diff --git a/src/modules/ai-runtime/snapshot-builder.service.spec.ts b/src/modules/ai-runtime/snapshot-builder.service.spec.ts index 46209bd..f4d743d 100644 --- a/src/modules/ai-runtime/snapshot-builder.service.spec.ts +++ b/src/modules/ai-runtime/snapshot-builder.service.spec.ts @@ -1,901 +1,378 @@ import { SnapshotBuilderService } from './snapshot-builder.service'; -import { PriorityRulesService } from './priority-rules.service'; - -function mockPrisma() { - return { - userAiSettings: { findUnique: jest.fn() }, - userLearningProfile: { findUnique: jest.fn() }, - learningAnalysisSnapshot: { create: jest.fn(), findUnique: jest.fn(), findMany: jest.fn() }, - learningSession: { aggregate: jest.fn(), count: jest.fn(), findMany: jest.fn() }, - dailyLearningActivity: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn() }, - learningRecord: { findMany: jest.fn() }, - knowledgeItem: { findMany: jest.fn() }, - quizAttempt: { aggregate: jest.fn(), findMany: jest.fn() }, - reviewLog: { findMany: jest.fn() }, - aiLearningAnalysis: { findMany: jest.fn() }, - readingEvent: { findFirst: jest.fn(), findMany: jest.fn() }, - userDevice: { findMany: jest.fn() }, - aiRuntimeJob: { findUnique: jest.fn(), update: jest.fn() }, - streakRecord: { findMany: jest.fn() }, - weakPointCandidate: { findMany: jest.fn() }, - materialReadingProgress: { aggregate: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() }, - } as any; -} - -function mockPriorityRules() { - return { computePriorityRules: jest.fn().mockReturnValue({ version: '1.0', depthPreference: 'standard' }) } as any; -} - -function seedDefaults(prisma: ReturnType) { - prisma.userAiSettings.findUnique.mockResolvedValue({ - id: 's1', userId: 'u1', - allowUseDocumentContent: false, - allowUseLearningBehavior: true, - allowUseUserProfile: true, - allowAiAnalysis: true, - allowStoreAiAnalysisHistory: true, - apiKeyMode: 'platform_key', - defaultCredentialId: null, - fallbackToPlatformKey: true, - maxDailyAiJobs: 20, - maxDailyTokenBudget: 100000, - }); - prisma.userLearningProfile.findUnique.mockResolvedValue({ - id: 'p1', userId: 'u1', - learningGoal: 'Learn TypeScript', - currentLevel: 'intermediate', - dailyAvailableMinutes: 30, - qualityPreference: 'standard', - ageRange: '25-34', - preferredLanguage: 'zh', - learningStyle: 'visual', - examTarget: null, - preferredQuestionTypes: ['single_choice'], - occupation: null, - learningDeadline: null, - aiAcceptanceLevel: null, - digitalSkillLevel: null, - createdAt: new Date(), updatedAt: new Date(), - }); - prisma.learningSession.aggregate.mockResolvedValue({ _count: 5, _sum: { totalActiveSeconds: 3600 } }); - prisma.learningSession.count.mockResolvedValue(2); - prisma.learningSession.findMany.mockResolvedValue([]); - prisma.dailyLearningActivity.findMany.mockResolvedValue([]); - prisma.dailyLearningActivity.count.mockResolvedValue(0); - prisma.dailyLearningActivity.findUnique.mockResolvedValue(null); - prisma.learningRecord.findMany.mockResolvedValue([]); - prisma.materialReadingProgress.findMany.mockResolvedValue([]); - prisma.materialReadingProgress.findFirst.mockResolvedValue(null); - prisma.knowledgeItem.findMany.mockResolvedValue([]); - prisma.quizAttempt.aggregate.mockResolvedValue({ _count: 0, _avg: { score: null, correctCount: null, totalQuestions: null } }); - prisma.quizAttempt.findMany.mockResolvedValue([]); - prisma.reviewLog.findMany.mockResolvedValue([]); - prisma.aiLearningAnalysis.findMany.mockResolvedValue([]); - prisma.readingEvent.findFirst.mockResolvedValue(null); - prisma.readingEvent.findMany.mockResolvedValue([]); - prisma.userDevice.findMany.mockResolvedValue([]); - prisma.streakRecord.findMany.mockResolvedValue([]); - prisma.weakPointCandidate.findMany.mockResolvedValue([]); - prisma.materialReadingProgress.aggregate.mockResolvedValue({ _sum: { totalActiveSeconds: 0, sessionCount: 0 }, _count: 0 }); - prisma.learningAnalysisSnapshot.create.mockImplementation((args: any) => - Promise.resolve({ id: 'snap-1', ...args.data, createdAt: new Date() })); -} describe('SnapshotBuilderService', () => { let service: SnapshotBuilderService; - let prisma: ReturnType; - let priorityRules: ReturnType; + 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(() => { - prisma = mockPrisma(); - priorityRules = mockPriorityRules(); - service = new SnapshotBuilderService(prisma, priorityRules); - seedDefaults(prisma); + 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 ── + // ═══════════════════════════════════════════════════════════════════ + // buildSnapshot — basic flow + // ═══════════════════════════════════════════════════════════════════ describe('buildSnapshot', () => { - it('creates snapshot with all required fields', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - - expect(snap.id).toBe('snap-1'); - expect(snap.userId).toBe('u1'); - expect(snap.scopeType).toBe('material'); - expect(snap.scopeId).toBe('m1'); + 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.constraints).toMatchObject({ - dailyAvailableMinutes: 30, - qualityPreference: 'standard', - learningGoal: 'Learn TypeScript', - priorityRules: { version: '1.0', depthPreference: 'standard' }, - }); 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('sets userProfile when allowUseUserProfile=true', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.userProfile).toEqual({ - learningGoal: 'Learn TypeScript', - currentLevel: 'intermediate', - qualityPreference: 'standard', - ageRange: '25-34', - preferredLanguage: 'zh', - learningStyle: 'visual', - examTarget: null, - preferredQuestionTypes: ['single_choice'], + it('respects all privacy flags disabled', async () => { + mockPrisma.userAiSettings.findUnique.mockResolvedValue({ + allowUseDocumentContent: false, allowUseLearningBehavior: false, allowUseUserProfile: false, }); - }); - - it('omits userProfile when allowUseUserProfile=false', async () => { - prisma.userAiSettings.findUnique.mockResolvedValue({ - ...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })), - allowUseUserProfile: false, - }); - - const snap = await service.buildSnapshot('u1', 'material', 'm1'); + const snap = await service.buildSnapshot(u1, 'material', m1); + expect(snap.privacyScope.allowLearningBehavior).toBe(false); expect(snap.userProfile).toBeUndefined(); - }); - - it('omits behavior fields when allowUseLearningBehavior=false', async () => { - prisma.userAiSettings.findUnique.mockResolvedValue({ - ...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })), - allowUseLearningBehavior: false, - }); - - const snap = await service.buildSnapshot('u1', 'material', 'm1'); expect(snap.learningBehaviorSummary).toBeUndefined(); expect(snap.behaviorSignals).toBeUndefined(); - }); - - it('includes content structure when allowUseDocumentContent=true', async () => { - prisma.userAiSettings.findUnique.mockResolvedValue({ - ...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })), - allowUseDocumentContent: true, - }); - prisma.knowledgeItem.findMany.mockResolvedValue([ - { id: 'ki1', itemType: 'note', title: 'Chapter 1', summary: 'Intro', learnable: true, orderIndex: 0, durationSeconds: 300 }, - ]); - - const snap = await service.buildSnapshot('u1', 'knowledge_base', 'kb1'); - expect(snap.contentStructureSummary).toEqual({ - itemCount: 1, - items: [{ id: 'ki1', itemType: 'note', title: 'Chapter 1', summary: 'Intro', learnable: true, orderIndex: 0, durationSeconds: 300 }], - }); - }); - - it('omits content structure when allowUseDocumentContent=false (default)', async () => { - const snap = await service.buildSnapshot('u1', 'knowledge_base', 'kb1'); expect(snap.contentStructureSummary).toBeUndefined(); }); - it('passes priority rules into constraints', async () => { - priorityRules.computePriorityRules.mockReturnValue({ - version: '1.0', depthPreference: 'deep', learningGoal: 'Test', hasExamTarget: false, - daysUntilDeadline: null, isTimeConstrained: false, dailyAvailableMinutes: 30, - isBudgetConstrained: false, taskSuitability: { lightReview: true, deepAnalysis: true, quizGeneration: true, flashcardGeneration: true, contentSummarization: false, urgentExamMode: false }, - }); - - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.constraints.priorityRules.depthPreference).toBe('deep'); + 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('computes scores from quiz + review + analysis data', async () => { - prisma.quizAttempt.aggregate.mockResolvedValue({ _count: 3, _avg: { score: 80, correctCount: 8, totalQuestions: 10 } }); - prisma.reviewLog.findMany.mockResolvedValue([ - { rating: 'good', reviewedAt: new Date() }, - { rating: 'good', reviewedAt: new Date() }, - { rating: 'hard', reviewedAt: new Date() }, - ]); - prisma.aiLearningAnalysis.findMany.mockResolvedValue([ - { id: 'a1', targetType: 'material', targetId: 'm1', learningState: 'mastering', riskLevel: 'low', confidence: 0.9, createdAt: new Date() }, - ]); - - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.scoreSignals.quiz.attempts).toBe(3); - expect(snap.scoreSignals.quiz.avgScore).toBe(80); - expect(snap.scoreSignals.review.ratingDistribution).toEqual({ good: 2, hard: 1 }); - expect(snap.scoreSignals.recentAnalyses).toHaveLength(1); + 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 }) }), + ); }); - // ── Derived scores (API-AI-020) ── - - it('weightedQuizScore uses recency-weighted average', async () => { - const now = Date.now(); - prisma.quizAttempt.findMany.mockResolvedValue([ - { score: 90, startedAt: new Date(now - 1 * 24 * 60 * 60 * 1000) }, - { score: 50, startedAt: new Date(now - 28 * 24 * 60 * 60 * 1000) }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // Recent 90% heavily outweighs old 50% → > 80 - expect(snap.scoreSignals.derived.weightedQuizScore).toBeGreaterThan(0.85); - }); - - it('weightedQuizScore is null when no attempts', async () => { - prisma.quizAttempt.findMany.mockResolvedValue([]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.scoreSignals.derived.weightedQuizScore).toBeNull(); - }); - - it('weightedReviewScore maps ratings to numeric values', async () => { - prisma.reviewLog.findMany.mockResolvedValue([ - { rating: 'good', reviewedAt: new Date() }, - { rating: 'easy', reviewedAt: new Date() }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // (0.66 + 1.0) / 2 = 0.83 - expect(snap.scoreSignals.derived.weightedReviewScore).toBeCloseTo(0.83, 1); - }); - - it('aiConfidenceScore weights by analysis confidence', async () => { - prisma.aiLearningAnalysis.findMany.mockResolvedValue([ - { id: 'a1', targetType: 'material', targetId: 'm1', learningState: 'mastered', riskLevel: 'low', confidence: 0.9, createdAt: new Date() }, - { id: 'a2', targetType: 'material', targetId: 'm1', learningState: 'in_progress', riskLevel: 'medium', confidence: 0.5, createdAt: new Date() }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // (0.90 * 0.9 + 0.50 * 0.5) / (0.9 + 0.5) = 0.76 - expect(snap.scoreSignals.derived.aiConfidenceScore).toBeCloseTo(0.76, 1); - }); - - it('weakPointSeverity accumulates active weak points', async () => { - prisma.weakPointCandidate.findMany.mockResolvedValue([ - { confidence: 0.8, title: 'Grammar' }, - { confidence: 0.6, title: 'Vocabulary' }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // (0.8 + 0.6) / 5 = 0.28 - expect(snap.scoreSignals.derived.weakPointSeverity).toBeCloseTo(0.28, 1); - }); - - it('quizTrend requires at least 4 attempts', async () => { - prisma.quizAttempt.findMany.mockResolvedValue([ - { score: 80, startedAt: new Date('2026-06-10') }, - { score: 90, startedAt: new Date('2026-06-15') }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.scoreSignals.derived.quizTrend.direction).toBe('insufficient_data'); - }); - - it('quizTrend detects increasing scores', async () => { - prisma.quizAttempt.findMany.mockResolvedValue([ - { score: 50, startedAt: new Date('2026-06-01') }, - { score: 55, startedAt: new Date('2026-06-05') }, - { score: 80, startedAt: new Date('2026-06-10') }, - { score: 90, startedAt: new Date('2026-06-15') }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // recent avg (80+90)/2=85 vs prior (50+55)/2=52.5 → +62% → increasing - expect(snap.scoreSignals.derived.quizTrend.direction).toBe('increasing'); - expect(snap.scoreSignals.derived.quizTrend.percentChange).toBeGreaterThan(50); - }); - - it('compositeMastery blends sub-scores with default weights', async () => { - prisma.quizAttempt.findMany.mockResolvedValue([ - { score: 80, startedAt: new Date() }, - ]); - prisma.reviewLog.findMany.mockResolvedValue([ - { rating: 'good', reviewedAt: new Date() }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.scoreSignals.derived.compositeMastery).not.toBeNull(); - expect(snap.scoreSignals.derived.compositeMastery).toBeGreaterThan(0); - expect(snap.scoreSignals.derived.compositeMastery).toBeLessThanOrEqual(1); - }); - - it('masteryLevel classifies composite correctly', async () => { - // No quiz/review/ai data → composite = null → unknown - prisma.quizAttempt.findMany.mockResolvedValue([]); - prisma.reviewLog.findMany.mockResolvedValue([]); - prisma.aiLearningAnalysis.findMany.mockResolvedValue([]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.scoreSignals.derived.masteryLevel).toBe('unknown'); - }); - - it('weight configuration varies by qualityPreference', async () => { - // Set profile to 'exam' mode - prisma.userLearningProfile.findUnique.mockResolvedValue({ - id: 'p1', userId: 'u1', - learningGoal: 'Pass exam', - currentLevel: 'intermediate', - dailyAvailableMinutes: 30, - qualityPreference: 'exam', - ageRange: '25-34', - preferredLanguage: 'zh', - learningStyle: 'visual', - examTarget: 'AWS', - preferredQuestionTypes: null, - occupation: null, - learningDeadline: null, - aiAcceptanceLevel: null, - digitalSkillLevel: null, - createdAt: new Date(), updatedAt: new Date(), - }); - prisma.quizAttempt.findMany.mockResolvedValue([]); - prisma.reviewLog.findMany.mockResolvedValue([]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.scoreSignals.derived.weightConfiguration).toEqual({ - w_quiz: 0.50, w_review: 0.25, w_ai: 0.10, w_weak: 0.15, - }); - }); - - it('builds device context from reading events and devices', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.1.0', clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([]); - prisma.userDevice.findMany.mockResolvedValue([ - { deviceId: 'd1', deviceName: 'iPhone', osVersion: '18.0', lastSeenAt: new Date() }, - ]); - - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.latestPlatform).toBe('ios'); - expect(snap.deviceContext.deviceCount).toBe(1); - }); - - // ── Device scene signals (API-AI-019) ── - - it('maps ios platform to phone category', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.1.0', clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.platformCategory).toBe('phone'); - }); - - it('maps web platform to web category', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'web', appVersion: null, clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.platformCategory).toBe('web'); - }); - - it('computes platform distribution from recent events', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'web', appVersion: null, clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([ - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'web', appVersion: null, clientTimezoneOffsetMinutes: 480 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.platformDistribution.phone).toBeCloseTo(0.67, 1); - expect(snap.deviceContext.platformDistribution.web).toBeCloseTo(0.33, 1); - expect(snap.deviceContext.hasPrimaryDevice).toBe(false); // 67% < 70% threshold - }); - - it('detects primary device when >70% from one category', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([ - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'android', appVersion: '1.0.0', clientTimezoneOffsetMinutes: 480 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.hasPrimaryDevice).toBe(true); - expect(snap.deviceContext.primaryPlatformCategory).toBe('phone'); - }); - - it('computes device switch frequency from ordered events', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) }); - // Chronologically ordered: ios → ios → android → ios → web (3 switches in 5 events = 60% → daily) - prisma.readingEvent.findMany.mockResolvedValue([ - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'android', appVersion: '1.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'web', appVersion: null, clientTimezoneOffsetMinutes: 480 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.deviceSwitchFrequency).toBe('daily'); - }); - - it('returns unknown switch frequency for single event', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([ - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.deviceSwitchFrequency).toBe('unknown'); - }); - - it('detects timezone variance', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([ - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 540 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.primaryTimezoneOffsetMinutes).toBe(480); - expect(snap.deviceContext.hasTimezoneVariance).toBe(true); - }); - - it('phone category has appropriate task suitability', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.deviceTaskSuitability.suitableTaskTypes).toContain('flashcard'); - expect(snap.deviceContext.deviceTaskSuitability.unsuitableTaskTypes).toContain('deep_analysis'); - }); - - it('desktop category supports deep analysis', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'macos', appVersion: '3.0.0', clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.deviceContext.deviceTaskSuitability.suitableTaskTypes).toContain('deep_analysis'); - expect(snap.deviceContext.deviceTaskSuitability.unsuitableTaskTypes).toHaveLength(0); - }); - - it('appVersion freshness detects outdated versions', async () => { - prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.1.0', clientTimestampMs: BigInt(Date.now()) }); - prisma.readingEvent.findMany.mockResolvedValue([ - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 }, - { platform: 'ios', appVersion: '2.1.0', clientTimezoneOffsetMinutes: 480 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // 2.1.0 appears only 1/4 → <50% → outdated - expect(snap.deviceContext.appVersion.latest).toBe('2.1.0'); - expect(snap.deviceContext.appVersion.isOutdated).toBe(true); - }); - - it('sets aiSettings from UserAiSettings', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.aiSettings).toEqual({ - apiKeyMode: 'platform_key', - defaultCredentialId: null, - fallbackToPlatformKey: true, - maxDailyAiJobs: 20, - maxDailyTokenBudget: 100000, - }); - }); - - it('handles null profile gracefully', async () => { - prisma.userLearningProfile.findUnique.mockResolvedValue(null); - - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.userProfile).toBeUndefined(); - expect(snap.constraints.learningGoal).toBeNull(); - expect(snap.constraints.dailyAvailableMinutes).toBeNull(); - expect(snap.constraints.qualityPreference).toBe('standard'); - }); - - it('handles null settings gracefully', async () => { - prisma.userAiSettings.findUnique.mockResolvedValue(null); - - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // defaults: allowUserProfile=true, allowLearningBehavior=true, allowDocumentContent=false - expect(snap.userProfile).toBeDefined(); - expect(snap.learningBehaviorSummary).toBeDefined(); - expect(snap.contentStructureSummary).toBeUndefined(); - expect(snap.aiSettings).toBeUndefined(); - }); - - it('throws and logs on prisma error', async () => { - prisma.learningAnalysisSnapshot.create.mockRejectedValue(new Error('DB down')); - - await expect(service.buildSnapshot('u1', 'material', 'm1')).rejects.toThrow('DB down'); + 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(); }); }); - // ── Behavior signal value correctness ── + // ═══════════════════════════════════════════════════════════════════ + // Scope routing + // ═══════════════════════════════════════════════════════════════════ - describe('behavior signal values', () => { - function seedBaseSignals() { - prisma.learningSession.aggregate.mockResolvedValue({ - _count: 10, - _sum: { totalActiveSeconds: 7200 }, - }); - prisma.learningSession.count.mockResolvedValue(5); // default: 5 global, scoped - prisma.dailyLearningActivity.findMany.mockResolvedValue([ - { activityDate: new Date('2026-06-17'), durationSeconds: 1800, sessionsCount: 2, readingSeconds: 1200, materialsReadCount: 3, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 3 }, - { activityDate: new Date('2026-06-16'), durationSeconds: 600, sessionsCount: 1, readingSeconds: 400, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 }, - { activityDate: new Date('2026-06-15'), durationSeconds: 0, sessionsCount: 0, readingSeconds: 0, materialsReadCount: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 0 }, - ]); - prisma.dailyLearningActivity.findUnique.mockResolvedValue({ - durationSeconds: 1800, sessionsCount: 2, activityLevel: 3, - }); - prisma.learningRecord.findMany.mockResolvedValue([]); - } - - beforeEach(() => { - seedBaseSignals(); + 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('learningBehaviorSummary.totalSessions matches session aggregate', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.learningBehaviorSummary.totalSessions).toBe(10); - }); - - it('learningBehaviorSummary.totalActiveSeconds matches session aggregate', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.learningBehaviorSummary.totalActiveSeconds).toBe(7200); - }); - - it('learningBehaviorSummary.weeklySessions and behaviorSignals.totalWeeklySessions use count', async () => { - // Both scoped and global count return 5 by default - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.learningBehaviorSummary.weeklySessions).toBe(5); - expect(snap.behaviorSignals.totalWeeklySessions).toBe(5); - }); - - it('behaviorSignals.activeDays counts days with durationSeconds > 0', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.activeDays).toBe(2); // 06-17=1800 and 06-16=600 have >0; 06-15=0 - }); - - it('behaviorSignals.avgSecondsPerActiveDay computes correct average', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // (1800 + 600 + 0) / 3 = 800 - expect(snap.behaviorSignals.avgSecondsPerActiveDay).toBe(800); - }); - - it('behaviorSignals.today reflects today activity', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.today).toEqual({ - durationSeconds: 1800, - sessionsCount: 2, - activityLevel: 3, - }); - }); - - it('behaviorSignals.today is null when no activity today', async () => { - prisma.dailyLearningActivity.findUnique.mockResolvedValue(null); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.today).toBeNull(); - }); - - it('behaviorSignals.recentActivityLevels maps correctly', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.recentActivityLevels).toEqual([ - { date: new Date('2026-06-17'), level: 3, seconds: 1800 }, - { date: new Date('2026-06-16'), level: 2, seconds: 600 }, - { date: new Date('2026-06-15'), level: 0, seconds: 0 }, - ]); - }); - - it('dailyActivities in learningBehaviorSummary includes full detail', async () => { - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.learningBehaviorSummary.dailyActivities).toHaveLength(3); - expect(snap.learningBehaviorSummary.dailyActivities[0].readingSeconds).toBe(1200); - expect(snap.learningBehaviorSummary.dailyActivities[0].materialsReadCount).toBe(3); - }); - - // ── New signals (API-AI-018) ── - - it('engagementSignal=medium with moderate metrics', async () => { - // 5 sessions (1pt), 4 active days (0.5pt), avg=800s (0pt) = 1.5 → "medium" - prisma.dailyLearningActivity.findMany.mockResolvedValue([ - { activityDate: new Date('2026-06-17'), durationSeconds: 1800, sessionsCount: 2, readingSeconds: 1200, materialsReadCount: 3, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 3 }, - { activityDate: new Date('2026-06-16'), durationSeconds: 600, sessionsCount: 1, readingSeconds: 400, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 }, - { activityDate: new Date('2026-06-15'), durationSeconds: 300, sessionsCount: 1, readingSeconds: 200, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 1 }, - { activityDate: new Date('2026-06-14'), durationSeconds: 500, sessionsCount: 1, readingSeconds: 300, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 0, activityLevel: 2 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.engagementSignal).toBe('medium'); - expect(snap.behaviorSignals.engagement.score).toBe(1.5); - }); - - it('engagementSignal=high when all metrics are strong', async () => { - prisma.learningSession.count.mockResolvedValue(5); - prisma.dailyLearningActivity.findMany.mockResolvedValue([ - { activityDate: new Date('2026-06-17'), durationSeconds: 3600, sessionsCount: 3, readingSeconds: 3000, materialsReadCount: 5, activeRecallCount: 3, reviewCount: 5, aiAnalysisCount: 1, activityLevel: 5 }, - { activityDate: new Date('2026-06-16'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 }, - { activityDate: new Date('2026-06-15'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 }, - { activityDate: new Date('2026-06-14'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 }, - { activityDate: new Date('2026-06-13'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 }, - { activityDate: new Date('2026-06-12'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 }, - { activityDate: new Date('2026-06-11'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.engagementSignal).toBe('high'); - expect(snap.behaviorSignals.engagement.score).toBeGreaterThanOrEqual(2.5); - }); - - it('consistency computes coefficient of variation', async () => { - prisma.dailyLearningActivity.findMany.mockResolvedValue([ - { activityDate: new Date('2026-06-17'), durationSeconds: 1800, sessionsCount: 2, readingSeconds: 1200, materialsReadCount: 3, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 3 }, - { activityDate: new Date('2026-06-16'), durationSeconds: 1500, sessionsCount: 1, readingSeconds: 1000, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 }, - { activityDate: new Date('2026-06-15'), durationSeconds: 1600, sessionsCount: 2, readingSeconds: 1100, materialsReadCount: 2, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // Mean ≈ 1633, stddev ≈ 125, CV ≈ 0.077 → consistent - expect(snap.behaviorSignals.consistency.label).toBe('consistent'); - expect(snap.behaviorSignals.consistency.coefficientOfVariation).toBeLessThan(0.5); - expect(snap.behaviorSignals.consistency.zeroDaysCount).toBe(0); - }); - - it('streak metrics reflect streak records', async () => { - prisma.streakRecord.findMany.mockResolvedValue([ - { length: 7, startDate: new Date('2026-06-10'), endDate: new Date('2026-06-17') }, - { length: 15, startDate: new Date('2026-05-01'), endDate: new Date('2026-05-16') }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.streak.currentStreak).toBe(7); - expect(snap.behaviorSignals.streak.longestStreak).toBe(15); - }); - - it('streakAtRisk when no activity today and last day was 0', async () => { - prisma.dailyLearningActivity.findUnique.mockResolvedValue(null); - prisma.dailyLearningActivity.findMany.mockResolvedValue([ - { activityDate: new Date('2026-06-17'), durationSeconds: 0, sessionsCount: 0, readingSeconds: 0, materialsReadCount: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 0 }, - { activityDate: new Date('2026-06-16'), durationSeconds: 600, sessionsCount: 1, readingSeconds: 400, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.streak.streakAtRisk).toBe(true); - }); - - it('sessionPattern computes duration and completion metrics', async () => { - prisma.learningSession.findMany.mockResolvedValue([ - { id: 's1', totalActiveSeconds: 600, status: 'completed', startedAt: new Date('2026-06-17T10:00:00Z') }, - { id: 's2', totalActiveSeconds: 1200, status: 'completed', startedAt: new Date('2026-06-17T14:00:00Z') }, - { id: 's3', totalActiveSeconds: 300, status: 'interrupted', startedAt: new Date('2026-06-17T16:00:00Z') }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.sessionPattern.avgSessionDuration).toBe(700); - expect(snap.behaviorSignals.sessionPattern.medianSessionDuration).toBe(600); - expect(snap.behaviorSignals.sessionPattern.sessionCompletionRate).toBe(2 / 3); - }); - - it('readingVelocity uses progress aggregate', async () => { - prisma.materialReadingProgress.aggregate.mockResolvedValue({ - _sum: { totalActiveSeconds: 3600, sessionCount: 10 }, - _count: 3, - }); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.readingVelocity.totalMaterialsTracked).toBe(3); - expect(snap.behaviorSignals.readingVelocity.avgProgressPerSession).toBe(360); - }); - - it('timeDistribution bins reading events by local hour', async () => { - // One event at 8:00 UTC+8 = 8am local → morning - // One event at 22:00 UTC+8 = 10pm local → evening - prisma.readingEvent.findMany.mockResolvedValue([ - { clientTimestampMs: BigInt(new Date('2026-06-17T00:00:00Z').getTime()), clientTimezoneOffsetMinutes: 480 }, - { clientTimestampMs: BigInt(new Date('2026-06-17T14:00:00Z').getTime()), clientTimezoneOffsetMinutes: 480 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // 0:00 UTC + 480min(UTC+8) = 8:00 local → morning - // 14:00 UTC + 480min = 22:00 local → evening - expect(snap.behaviorSignals.timeDistribution.morning).toBe(0.5); - expect(snap.behaviorSignals.timeDistribution.evening).toBe(0.5); - expect(snap.behaviorSignals.timeDistribution.afternoon).toBe(0); - expect(snap.behaviorSignals.timeDistribution.night).toBe(0); - }); - - it('activityBalance computes activity type proportions', async () => { - prisma.dailyLearningActivity.findMany.mockResolvedValue([ - { activityDate: new Date('2026-06-17'), durationSeconds: 120, sessionsCount: 1, readingSeconds: 120, materialsReadCount: 1, activeRecallCount: 1, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 1 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - // reading: 120, recall: 1*120=120, review: 0, analysis: 0 → total = 240 - expect(snap.behaviorSignals.activityBalance.readingPct).toBe(0.5); - expect(snap.behaviorSignals.activityBalance.activeRecallPct).toBe(0.5); - }); - - it('weeklyTrend detects increasing trend', async () => { - prisma.dailyLearningActivity.findMany.mockResolvedValue([ - { activityDate: new Date('2026-06-17'), durationSeconds: 2000, sessionsCount: 2, readingSeconds: 1500, materialsReadCount: 3, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 4 }, - { activityDate: new Date('2026-06-16'), durationSeconds: 1800, sessionsCount: 2, readingSeconds: 1400, materialsReadCount: 2, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 4 }, - { activityDate: new Date('2026-06-15'), durationSeconds: 500, sessionsCount: 1, readingSeconds: 300, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 0, activityLevel: 2 }, - { activityDate: new Date('2026-06-14'), durationSeconds: 400, sessionsCount: 1, readingSeconds: 250, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 }, - ]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.weeklyTrend.trendDirection).toBe('increasing'); - expect(snap.behaviorSignals.weeklyTrend.percentChange).toBeGreaterThan(20); - }); - - it('fatigue risk detects consecutive inactive days', async () => { - prisma.dailyLearningActivity.findMany.mockResolvedValue([ - { activityDate: new Date('2026-06-17'), durationSeconds: 0, sessionsCount: 0, readingSeconds: 0, materialsReadCount: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 0 }, - { activityDate: new Date('2026-06-16'), durationSeconds: 0, sessionsCount: 0, readingSeconds: 0, materialsReadCount: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 0 }, - { activityDate: new Date('2026-06-15'), durationSeconds: 600, sessionsCount: 1, readingSeconds: 400, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 0, activityLevel: 2 }, - ]); - prisma.learningSession.findMany.mockResolvedValue([]); - const snap = await service.buildSnapshot('u1', 'material', 'm1'); - expect(snap.behaviorSignals.fatigue.fatigueRisk).toBe(true); - expect(snap.behaviorSignals.fatigue.riskFactors).toContain('consecutive_inactive_days'); + 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'); }); }); - // ── buildAllowedFields ── + // ═══════════════════════════════════════════════════════════════════ + // 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', () => { - const callBuildAllowedFields = (settings: any, profile?: any) => - (service as any).buildAllowedFields(settings, profile ?? null); - - it('always includes base fields', () => { - const fields = callBuildAllowedFields({ - allowUseUserProfile: false, - allowUseLearningBehavior: false, - allowUseDocumentContent: false, - }); - expect(fields).toEqual(expect.arrayContaining([ - 'constraints', 'materialProgressSummary', 'scoreSignals', 'deviceContext', - ])); - expect(fields).not.toContain('userProfile'); - expect(fields).not.toContain('learningBehaviorSummary'); - expect(fields).not.toContain('contentStructureSummary'); + it('core fields always', () => { + expect(svc().buildAllowedFields(null, null)).toContain('constraints'); }); - - it('adds userProfile+aiSettings when allowUseUserProfile=true', () => { - const fields = callBuildAllowedFields({ allowUseUserProfile: true, allowUseLearningBehavior: false, allowUseDocumentContent: false }); - expect(fields).toContain('userProfile'); - expect(fields).toContain('aiSettings'); + it('userProfile omitted when flag=false', () => { + expect(svc().buildAllowedFields({ allowUseUserProfile: false }, null)).not.toContain('userProfile'); }); - - it('adds behavior fields when allowUseLearningBehavior=true', () => { - const fields = callBuildAllowedFields({ allowUseUserProfile: false, allowUseLearningBehavior: true, allowUseDocumentContent: false }); - expect(fields).toContain('learningBehaviorSummary'); - expect(fields).toContain('behaviorSignals'); - }); - - it('adds contentStructureSummary only when allowUseDocumentContent=true', () => { - const fields = callBuildAllowedFields({ allowUseUserProfile: false, allowUseLearningBehavior: false, allowUseDocumentContent: true }); - expect(fields).toContain('contentStructureSummary'); - }); - - it('handles null settings with defaults', () => { - const fields = callBuildAllowedFields(null); - // null → allowUseUserProfile !== false → true (default), allowUseLearningBehavior !== false → true - expect(fields).toContain('userProfile'); - expect(fields).toContain('learningBehaviorSummary'); - expect(fields).not.toContain('contentStructureSummary'); + it('contentStructureSummary when flag=true', () => { + expect(svc().buildAllowedFields({ allowUseDocumentContent: true }, null)).toContain('contentStructureSummary'); }); }); - // ── pickSafeFields ── - - describe('pickSafeFields', () => { - const call = (profile: any) => (service as any).pickSafeFields(profile); - - it('returns undefined for null profile', () => { - expect(call(null)).toBeUndefined(); - }); - - it('filters to safe fields only', () => { - const result = call({ - learningGoal: 'goal', - currentLevel: 'beginner', - qualityPreference: 'deep', - ageRange: '18-24', - preferredLanguage: 'en', - learningStyle: 'reading', - examTarget: 'exam', - preferredQuestionTypes: ['mc'], - occupation: 'engineer', // excluded - dailyAvailableMinutes: 30, // excluded → goes to constraints - aiAcceptanceLevel: 'high', // excluded - digitalSkillLevel: 'high', // excluded - id: 'x', userId: 'x', createdAt: 'x', updatedAt: 'x', - }); - expect(result).toEqual({ - learningGoal: 'goal', - currentLevel: 'beginner', - qualityPreference: 'deep', - ageRange: '18-24', - preferredLanguage: 'en', - learningStyle: 'reading', - examTarget: 'exam', - preferredQuestionTypes: ['mc'], - }); - expect(result).not.toHaveProperty('occupation'); - expect(result).not.toHaveProperty('dailyAvailableMinutes'); - }); - }); - - // ── pickAiSettingsFields ── - - describe('pickAiSettingsFields', () => { - const call = (settings: any) => (service as any).pickAiSettingsFields(settings); - - it('returns undefined for null settings', () => { - expect(call(null)).toBeUndefined(); - }); - - it('picks only relevant settings fields', () => { - const result = call({ - apiKeyMode: 'user_deepseek_key', - defaultCredentialId: 'c1', - fallbackToPlatformKey: false, - maxDailyAiJobs: 10, - maxDailyTokenBudget: 50000, - allowAiAnalysis: true, - allowUseDocumentContent: true, - id: 'x', userId: 'x', createdAt: 'x', updatedAt: 'x', - }); - expect(result).toEqual({ - apiKeyMode: 'user_deepseek_key', - defaultCredentialId: 'c1', - fallbackToPlatformKey: false, - maxDailyAiJobs: 10, - maxDailyTokenBudget: 50000, - }); - expect(result).not.toHaveProperty('allowAiAnalysis'); - }); - }); - - // ── countBy ── + // ═══════════════════════════════════════════════════════════════════ + // countBy + // ═══════════════════════════════════════════════════════════════════ describe('countBy', () => { - const call = (items: any[], fn: (item: any) => string) => - (service as any).countBy(items, fn); - - it('counts items by key', () => { - const result = call(['a', 'a', 'b', 'c', 'b', 'a'], (s: string) => s); - expect(result).toEqual({ a: 3, b: 2, c: 1 }); - }); - - it('returns empty object for empty array', () => { - expect(call([], (s: string) => s)).toEqual({}); + 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({}); }); }); - // ── aggregateBehavior scope support ── + // ═══════════════════════════════════════════════════════════════════ + // computeSignals + // ═══════════════════════════════════════════════════════════════════ - describe('aggregateBehavior scope', () => { - it('filters sessions by materialId for material scope', async () => { - await service.buildSnapshot('u1', 'material', 'm1'); + describe('computeSignals', () => { + const emptyAgg = { _sum: { totalActiveSeconds: 0, sessionCount: 0 }, _count: 0 }; - const callArg = prisma.learningSession.aggregate.mock.calls[0][0]; - expect(callArg.where.materialId).toBe('m1'); + 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('filters sessions by knowledgeBaseId for knowledge_base scope', async () => { - await service.buildSnapshot('u1', 'knowledge_base', 'kb1'); - - const callArg = prisma.learningSession.aggregate.mock.calls[0][0]; - expect(callArg.where.knowledgeBaseId).toBe('kb1'); - }); - }); - - // ── aggregateProgress scope support ── - - describe('aggregateProgress scope', () => { - it('filters by knowledgeBaseId for knowledge_base scope', async () => { - await service.buildSnapshot('u1', 'knowledge_base', 'kb1'); - - const callArg = prisma.materialReadingProgress.findMany.mock.calls[0][0]; - expect(callArg.where.knowledgeBaseId).toBe('kb1'); - expect(callArg.where.readingTargetType).toBeUndefined(); - }); - - it('filters by materialId for material scope', async () => { - await service.buildSnapshot('u1', 'material', 'm1'); - - const callArg = prisma.materialReadingProgress.findMany.mock.calls[0][0]; - expect(callArg.where.materialId).toBe('m1'); - expect(callArg.where.readingTargetType).toBe('material'); - }); - }); - - // ── aggregateContent userId isolation ── - - describe('aggregateContent userId isolation', () => { - it('knowledge_base scope includes userId in query', async () => { - prisma.userAiSettings.findUnique.mockResolvedValue({ - ...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })), - allowUseDocumentContent: true, + 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, }); - - await service.buildSnapshot('u1', 'knowledge_base', 'kb1'); - - const callArg = prisma.knowledgeItem.findMany.mock.calls[0][0]; - expect(callArg.where.userId).toBe('u1'); - expect(callArg.where.knowledgeBaseId).toBe('kb1'); - }); - - it('material scope includes userId in query', async () => { - prisma.userAiSettings.findUnique.mockResolvedValue({ - ...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })), - allowUseDocumentContent: true, - }); - - await service.buildSnapshot('u1', 'material', 'm1'); - - const callArg = prisma.materialReadingProgress.findFirst.mock.calls[0][0]; - expect(callArg.where.userId).toBe('u1'); - expect(callArg.where.materialId).toBe('m1'); + expect(r.engagementSignal).toBe('high'); }); }); });