api-server/src/modules/ai-runtime/snapshot-builder.service.spec.ts
wangdl 4a69d14047
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 44s
test: add integration tests for snapshot-builder.service (API-AI-068)
- 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>
2026-06-18 12:23:52 +08:00

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');
});
});
});