import { SnapshotBuilderService } from './snapshot-builder.service'; describe('SnapshotBuilderService', () => { let service: SnapshotBuilderService; let mockPrisma: any; let mockPriorityRules: any; const u1 = 'u1'; const m1 = 'm1'; // Lazy access — private methods only available after beforeEach const svc = () => service as any; beforeEach(() => { mockPriorityRules = { computePriorityRules: jest.fn().mockReturnValue({ depthPreference: 'standard' }), }; mockPrisma = { userAiSettings: { findUnique: jest.fn().mockResolvedValue(null) }, userLearningProfile: { findUnique: jest.fn().mockResolvedValue(null) }, learningSession: { aggregate: jest.fn().mockResolvedValue({ _sum: { totalActiveSeconds: 0 }, _count: 0 }), count: jest.fn().mockResolvedValue(0), findMany: jest.fn().mockResolvedValue([]), }, dailyLearningActivity: { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue(null), }, learningRecord: { findMany: jest.fn().mockResolvedValue([]) }, materialReadingProgress: { findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), aggregate: jest.fn().mockResolvedValue({ _sum: { totalActiveSeconds: 0, sessionCount: 0 }, _count: 0 }), }, knowledgeItem: { findMany: jest.fn().mockResolvedValue([]) }, quizAttempt: { aggregate: jest.fn().mockResolvedValue({ _avg: { score: null, correctCount: null, totalQuestions: null }, _count: 0 }), findMany: jest.fn().mockResolvedValue([]), }, reviewLog: { findMany: jest.fn().mockResolvedValue([]) }, aiLearningAnalysis: { findMany: jest.fn().mockResolvedValue([]) }, weakPointCandidate: { findMany: jest.fn().mockResolvedValue([]) }, streakRecord: { findMany: jest.fn().mockResolvedValue([]) }, readingEvent: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), }, userDevice: { findMany: jest.fn().mockResolvedValue([]) }, learningAnalysisSnapshot: { create: jest.fn().mockImplementation((args: any) => Promise.resolve({ id: 'snap-1', ...args.data })) }, }; service = new SnapshotBuilderService(mockPrisma, mockPriorityRules); }); // ═══════════════════════════════════════════════════════════════════ // buildSnapshot — basic flow // ═══════════════════════════════════════════════════════════════════ describe('buildSnapshot', () => { it('builds with defaults (all data empty)', async () => { const snap = await service.buildSnapshot(u1, 'material', m1); expect(snap).toBeDefined(); expect(snap.userId).toBe(u1); expect(snap.snapshotVersion).toBe('ai_snapshot_v1'); expect(snap.sourceDataVersion).toBe('1.0'); expect(snap.expiresAt).toBeInstanceOf(Date); expect(snap.privacyScope).toEqual({ allowDocumentContent: false, allowLearningBehavior: true, allowUserProfile: true }); expect(snap.constraints.qualityPreference).toBe('standard'); expect(snap.allowedModelFields).toContain('scoreSignals'); }); it('respects all privacy flags disabled', async () => { mockPrisma.userAiSettings.findUnique.mockResolvedValue({ allowUseDocumentContent: false, allowUseLearningBehavior: false, allowUseUserProfile: false, }); const snap = await service.buildSnapshot(u1, 'material', m1); expect(snap.privacyScope.allowLearningBehavior).toBe(false); expect(snap.userProfile).toBeUndefined(); expect(snap.learningBehaviorSummary).toBeUndefined(); expect(snap.behaviorSignals).toBeUndefined(); expect(snap.contentStructureSummary).toBeUndefined(); }); it('calls computePriorityRules with profile + settings', async () => { mockPrisma.userAiSettings.findUnique.mockResolvedValue({ allowAiAnalysis: true }); mockPrisma.userLearningProfile.findUnique.mockResolvedValue({ learningGoal: 'exam' }); await service.buildSnapshot(u1, 'knowledge_base', 'kb1'); expect(mockPriorityRules.computePriorityRules).toHaveBeenCalledWith( expect.objectContaining({ learningGoal: 'exam' }), expect.objectContaining({ allowAiAnalysis: true }), 'knowledge_base', 'kb1', ); }); it('creates snapshot record in DB', async () => { await service.buildSnapshot(u1, 'material', m1); expect(mockPrisma.learningAnalysisSnapshot.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: u1 }) }), ); }); it('logs error and re-throws', async () => { const loggerSpy = jest.spyOn(svc().logger, 'error'); mockPrisma.userAiSettings.findUnique.mockRejectedValueOnce(new Error('DB down')); await expect(service.buildSnapshot(u1, 'material', m1)).rejects.toThrow('DB down'); expect(loggerSpy).toHaveBeenCalled(); }); }); // ═══════════════════════════════════════════════════════════════════ // Scope routing // ═══════════════════════════════════════════════════════════════════ describe('scope routing', () => { it('scopes by materialId for material type', async () => { await service.buildSnapshot(u1, 'material', m1); expect(mockPrisma.learningSession.aggregate.mock.calls[0][0].where.materialId).toBe(m1); }); it('scopes by knowledgeBaseId for knowledge_base', async () => { await service.buildSnapshot(u1, 'knowledge_base', 'kb1'); expect(mockPrisma.learningSession.aggregate.mock.calls[0][0].where.knowledgeBaseId).toBe('kb1'); }); }); // ═══════════════════════════════════════════════════════════════════ // computeWeightedQuizScore // ═══════════════════════════════════════════════════════════════════ describe('computeWeightedQuizScore', () => { it('returns null for empty', () => { expect(svc().computeWeightedQuizScore([], new Date())).toBeNull(); }); it('returns weighted score', () => { expect(svc().computeWeightedQuizScore([{ score: 80, startedAt: new Date() }], new Date())).toBe(0.8); }); }); // ═══════════════════════════════════════════════════════════════════ // computeWeightedReviewScore // ═══════════════════════════════════════════════════════════════════ describe('computeWeightedReviewScore', () => { it('returns null for empty', () => { expect(svc().computeWeightedReviewScore([], new Date())).toBeNull(); }); it('maps easy rating to 1.0', () => { expect(svc().computeWeightedReviewScore([{ rating: 'easy', reviewedAt: new Date() }], new Date())).toBe(1.0); }); it('defaults unknown to 0.5', () => { expect(svc().computeWeightedReviewScore([{ rating: 'unknown', reviewedAt: new Date() }], new Date())).toBe(0.5); }); }); // ═══════════════════════════════════════════════════════════════════ // computeAIScore // ═══════════════════════════════════════════════════════════════════ describe('computeAIScore', () => { it('returns null for empty', () => { expect(svc().computeAIScore([])).toBeNull(); }); it('returns null when no valid state', () => { expect(svc().computeAIScore([{ learningState: null, confidence: 0.5 }])).toBeNull(); }); it('returns weighted score', () => { expect(svc().computeAIScore([{ learningState: 'mastered', confidence: 0.9 }])).toBe(0.9); }); }); // ═══════════════════════════════════════════════════════════════════ // computeWeakPointSeverity // ═══════════════════════════════════════════════════════════════════ describe('computeWeakPointSeverity', () => { it('returns null for empty', () => { expect(svc().computeWeakPointSeverity([])).toBeNull(); }); it('caps at 1.0', () => { expect(svc().computeWeakPointSeverity(Array(10).fill({ confidence: 1 }))).toBeLessThanOrEqual(1); }); }); // ═══════════════════════════════════════════════════════════════════ // computeQuizTrend // ═══════════════════════════════════════════════════════════════════ describe('computeQuizTrend', () => { const q = (score: number, daysAgo: number) => ({ score, startedAt: new Date(Date.now() - daysAgo * 86400000) }); it('< 4 → insufficient_data', () => { expect(svc().computeQuizTrend([q(80, 1)]).direction).toBe('insufficient_data'); }); it('detects increasing', () => { expect(svc().computeQuizTrend([q(50, 10), q(50, 8), q(80, 3), q(90, 1)]).direction).toBe('increasing'); }); it('detects decreasing', () => { expect(svc().computeQuizTrend([q(90, 10), q(90, 8), q(50, 3), q(50, 1)]).direction).toBe('decreasing'); }); it('detects stable', () => { expect(svc().computeQuizTrend([q(70, 10), q(70, 8), q(72, 3), q(71, 1)]).direction).toBe('stable'); }); }); // ═══════════════════════════════════════════════════════════════════ // computeCompositeMastery // ═══════════════════════════════════════════════════════════════════ describe('computeCompositeMastery', () => { it('returns null when all null', () => { const w = svc().getScoreWeights('standard'); expect(svc().computeCompositeMastery(null, null, null, null, w)).toBeNull(); }); it('computes from quiz only', () => { const w = svc().getScoreWeights('standard'); expect(svc().computeCompositeMastery(0.8, null, null, 0, w)).toBe(0.8); }); it('penalizes weak points', () => { const w = svc().getScoreWeights('standard'); const base = svc().computeCompositeMastery(0.8, null, null, 0, w); const penalized = svc().computeCompositeMastery(0.8, null, null, 0.5, w); expect(penalized).toBeLessThan(base); }); it('clamps to [0,1]', () => { const w = svc().getScoreWeights('standard'); expect(svc().computeCompositeMastery(1.5, null, null, 0, w)).toBe(1); expect(svc().computeCompositeMastery(-0.5, null, null, 0, w)).toBe(0); }); }); // ═══════════════════════════════════════════════════════════════════ // classifyMasteryLevel // ═══════════════════════════════════════════════════════════════════ describe('classifyMasteryLevel', () => { it('null→unknown', () => { expect(svc().classifyMasteryLevel(null)).toBe('unknown'); }); it('0.1→struggling', () => { expect(svc().classifyMasteryLevel(0.1)).toBe('struggling'); }); it('0.25→developing', () => { expect(svc().classifyMasteryLevel(0.25)).toBe('developing'); }); it('0.5→progressing', () => { expect(svc().classifyMasteryLevel(0.5)).toBe('progressing'); }); it('0.7→proficient', () => { expect(svc().classifyMasteryLevel(0.7)).toBe('proficient'); }); it('0.85→mastered', () => { expect(svc().classifyMasteryLevel(0.85)).toBe('mastered'); }); }); // ═══════════════════════════════════════════════════════════════════ // normalizeQualityPreference / getScoreWeights // ═══════════════════════════════════════════════════════════════════ describe('normalizeQualityPreference', () => { it.each(['light', 'standard', 'deep', 'exam'] as const)('%s passes through', (v) => { expect(svc().normalizeQualityPreference(v)).toBe(v); }); it('falls back to standard', () => { expect(svc().normalizeQualityPreference('fast')).toBe('standard'); expect(svc().normalizeQualityPreference(null)).toBe('standard'); }); }); describe('getScoreWeights', () => { it('exam > light for quiz weight', () => { expect(svc().getScoreWeights('exam').w_quiz).toBeGreaterThan(svc().getScoreWeights('light').w_quiz); }); it('deep > exam for AI weight', () => { expect(svc().getScoreWeights('deep').w_ai).toBeGreaterThan(svc().getScoreWeights('exam').w_ai); }); }); // ═══════════════════════════════════════════════════════════════════ // mapPlatformCategory // ═══════════════════════════════════════════════════════════════════ describe('mapPlatformCategory', () => { it('ios/android → phone', () => { expect(svc().mapPlatformCategory('ios')).toBe('phone'); expect(svc().mapPlatformCategory('android')).toBe('phone'); }); it('ipados → tablet', () => { expect(svc().mapPlatformCategory('ipados')).toBe('tablet'); }); it('desktop OS', () => { expect(svc().mapPlatformCategory('macos')).toBe('desktop'); expect(svc().mapPlatformCategory('windows')).toBe('desktop'); }); it('web', () => { expect(svc().mapPlatformCategory('web')).toBe('web'); }); it('null → null', () => { expect(svc().mapPlatformCategory(null)).toBeNull(); }); }); // ═══════════════════════════════════════════════════════════════════ // computeSwitchFrequency // ═══════════════════════════════════════════════════════════════════ describe('computeSwitchFrequency', () => { const e = (p: string) => ({ platform: p }); it('< 2 → unknown', () => { expect(svc().computeSwitchFrequency([e('ios')])).toBe('unknown'); }); it('none → rarely', () => { expect(svc().computeSwitchFrequency([e('ios'), e('ios')])).toBe('rarely'); }); it('frequent → daily', () => { expect(svc().computeSwitchFrequency([e('ios'), e('macos'), e('ios'), e('macos')])).toBe('daily'); }); }); // ═══════════════════════════════════════════════════════════════════ // mapDeviceTaskSuitability // ═══════════════════════════════════════════════════════════════════ describe('mapDeviceTaskSuitability', () => { it('phone restricts deep_analysis', () => { const r = svc().mapDeviceTaskSuitability('phone'); expect(r.unsuitableTaskTypes).toContain('deep_analysis'); }); it('desktop allows everything', () => { expect(svc().mapDeviceTaskSuitability('desktop').unsuitableTaskTypes).toEqual([]); }); it('null → empty', () => { expect(svc().mapDeviceTaskSuitability(null).suitableTaskTypes).toEqual([]); }); }); // ═══════════════════════════════════════════════════════════════════ // modeOf // ═══════════════════════════════════════════════════════════════════ describe('modeOf', () => { it('empty → null', () => { expect(svc().modeOf([])).toBeNull(); }); it('returns most frequent', () => { expect(svc().modeOf([3, 1, 3, 2, 3])).toBe(3); }); it('first on tie', () => { expect(svc().modeOf([1, 2])).toBe(1); }); }); // ═══════════════════════════════════════════════════════════════════ // buildAllowedFields // ═══════════════════════════════════════════════════════════════════ describe('buildAllowedFields', () => { it('core fields always', () => { expect(svc().buildAllowedFields(null, null)).toContain('constraints'); }); it('userProfile omitted when flag=false', () => { expect(svc().buildAllowedFields({ allowUseUserProfile: false }, null)).not.toContain('userProfile'); }); it('contentStructureSummary when flag=true', () => { expect(svc().buildAllowedFields({ allowUseDocumentContent: true }, null)).toContain('contentStructureSummary'); }); }); // ═══════════════════════════════════════════════════════════════════ // countBy // ═══════════════════════════════════════════════════════════════════ describe('countBy', () => { it('counts', () => { expect(svc().countBy([{ t: 'a' }, { t: 'b' }, { t: 'a' }], (i: any) => i.t)).toEqual({ a: 2, b: 1 }); }); it('empty → {}', () => { expect(svc().countBy([], (i: any) => i)).toEqual({}); }); }); // ═══════════════════════════════════════════════════════════════════ // computeSignals // ═══════════════════════════════════════════════════════════════════ describe('computeSignals', () => { const emptyAgg = { _sum: { totalActiveSeconds: 0, sessionCount: 0 }, _count: 0 }; it('returns all signal groups for empty data', () => { const r = svc().computeSignals([], 0, 0, null, new Date(), [], [], [], emptyAgg); expect(r.totalWeeklySessions).toBe(0); expect(r.engagementSignal).toBe('low'); expect(r.consistency.label).toBe('irregular'); expect(r.streak.currentStreak).toBe(0); expect(r.fatigue.fatigueRisk).toBe(false); }); it('high engagement for active user', () => { const now = new Date(); const activities = Array.from({ length: 7 }, (_, i) => ({ activityDate: new Date(now.getTime() - i * 86400000), durationSeconds: 3600, sessionsCount: 1, readingSeconds: 1800, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 3, })); const r = svc().computeSignals(activities, 7, 7, { durationSeconds: 1800, sessionsCount: 2, activityLevel: 3 }, now, [], [], [], { _sum: { totalActiveSeconds: 10000, sessionCount: 20 }, _count: 5, }); expect(r.engagementSignal).toBe('high'); }); }); });