All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 44s
- 59 tests covering buildSnapshot full flow + all computational methods - buildSnapshot: defaults, privacy flags, priority rules, DB persistence, error logging - Scope routing: material vs knowledge_base session queries - Computational methods: weightedQuizScore, weightedReviewScore, AIScore, weakPointSeverity, quizTrend, compositeMastery, classifyMasteryLevel - Quality: normalizeQualityPreference, getScoreWeights presets - Device: mapPlatformCategory, computeSwitchFrequency, mapDeviceTaskSuitability - Helpers: modeOf, buildAllowedFields, countBy, computeSignals Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
379 lines
21 KiB
TypeScript
379 lines
21 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|