import { describe, expect, it, vi, beforeEach } from 'vitest'; // vi.mock factories are hoisted, so any state they close over must come from // vi.hoisted() — top-level `const`s aren't initialised yet at that point. const ctx = vi.hoisted(() => { return { bankByTopic: new Map(), attempts: [], // { user_id, topic_id, points_earned, iso_week, question_ids } topics: [], isoWeekValue: '2026-W23', saveOnDemandAttemptMock: null, upsertLeaderboardEntryMock: null, }; }); vi.mock('../pb', () => ({ pb: { collection: () => ({}) } })); vi.mock('../db', async () => { const { vi: viInner } = await import('vitest'); ctx.saveOnDemandAttemptMock = viInner.fn(async (a) => { ctx.attempts.push({ user_id: a.userId, topic_id: a.topicId, points_earned: a.pointsEarned, iso_week: a.isoWeek, question_ids: a.questionIds, }); }); ctx.upsertLeaderboardEntryMock = viInner.fn(); return { getTopics: viInner.fn(async () => ctx.topics), getQuizBank: viInner.fn(async (topicId) => ctx.bankByTopic.get(topicId) || []), getQuestionBank: viInner.fn(async (topicId) => ctx.bankByTopic.get(topicId) || []), setQuizBank: viInner.fn(), deleteQuestionFromBank: viInner.fn(), getCachedQuiz: viInner.fn(), setCachedQuiz: viInner.fn(), getQuizResult: viInner.fn(), saveQuizResult: viInner.fn(), getTeamMembers: viInner.fn(async () => [{ id: 'u1', name: 'Alice' }]), upsertLeaderboardEntry: ctx.upsertLeaderboardEntryMock, getUserSeenQuestionIds: viInner.fn(async (userId, topicId) => { const seen = new Set(); for (const a of ctx.attempts) { if (a.user_id === userId && a.topic_id === topicId) { for (const qid of a.question_ids || []) seen.add(qid); } } return seen; }), getOnDemandPointsThisWeek: viInner.fn(async (userId, topicId) => { return ctx.attempts .filter(a => a.user_id === userId && a.topic_id === topicId && a.iso_week === ctx.isoWeekValue) .reduce((s, a) => s + (a.points_earned || 0), 0); }), saveOnDemandAttempt: ctx.saveOnDemandAttemptMock, isoWeekKey: () => ctx.isoWeekValue, getCurriculum: viInner.fn(), }; }); vi.mock('../llm', () => ({ callLLM: vi.fn(), cachedSystem: (t) => [{ type: 'text', text: t }], })); vi.mock('../curriculumService', () => ({ getCurrentWeekContent: vi.fn(), getCurriculumTopic: vi.fn(), getQuarterForWeek: vi.fn(() => 1), })); import { getEligibleTopicsForOnDemand, startOnDemandTest, finishOnDemandTest, WEEKLY_POINT_CAP_PER_TOPIC, } from '../testService'; function makeQ(id) { return { id, question: `Q ${id}?`, topicLabel: 'Onboarding', options: ['A) a', 'B) b', 'C) c', 'D) d'], correctIndex: 0, explanation: 'because A is correct, this is a longer explanation.', difficulty: 'medium', }; } beforeEach(() => { ctx.bankByTopic.clear(); ctx.attempts.length = 0; ctx.topics.length = 0; ctx.isoWeekValue = '2026-W23'; ctx.saveOnDemandAttemptMock?.mockClear(); ctx.upsertLeaderboardEntryMock?.mockClear(); }); describe('on-demand test eligibility', () => { it('only lists topics with ≥ 5 questions and acceptable type/relevance', async () => { ctx.topics.push( { id: 'a', label: 'A', type: 'concept', learning_relevance: 'standard' }, { id: 'b', label: 'B', type: 'fact', learning_relevance: 'standard' }, // facts excluded { id: 'c', label: 'C', type: 'concept', learning_relevance: 'exclude' }, // excluded { id: 'd', label: 'D', type: 'concept', learning_relevance: 'standard' }, // bank too small ); ctx.bankByTopic.set('a', [makeQ('a1'), makeQ('a2'), makeQ('a3'), makeQ('a4'), makeQ('a5'), makeQ('a6')]); ctx.bankByTopic.set('b', Array.from({ length: 6 }, (_, i) => makeQ(`b${i}`))); ctx.bankByTopic.set('c', Array.from({ length: 6 }, (_, i) => makeQ(`c${i}`))); ctx.bankByTopic.set('d', [makeQ('d1'), makeQ('d2'), makeQ('d3')]); const eligible = await getEligibleTopicsForOnDemand(); expect(eligible.map(t => t.id)).toEqual(['a']); expect(eligible[0].bankSize).toBe(6); }); }); describe('on-demand draw strategy', () => { it('prefers unseen questions and marks repeats with isUnseen=false', async () => { ctx.topics.push({ id: 't', label: 'T', type: 'concept', learning_relevance: 'standard' }); const bank = Array.from({ length: 5 }, (_, i) => makeQ(`q${i}`)); ctx.bankByTopic.set('t', bank); // Simulate the user already saw q0 and q1 in a prior attempt. ctx.attempts.push({ user_id: 'u1', topic_id: 't', points_earned: 0, iso_week: 'prev', question_ids: ['q0', 'q1'] }); const { questions } = await startOnDemandTest('u1', 't'); expect(questions).toHaveLength(5); const unseenCount = questions.filter(q => q.isUnseen).length; const seenCount = questions.filter(q => !q.isUnseen).length; expect(unseenCount).toBe(3); // q2, q3, q4 expect(seenCount).toBe(2); // q0 and q1 reappear as repeats }); }); describe('on-demand cap enforcement', () => { it('awards 2 points per correct unseen answer, gives 0 for repeats', async () => { const quiz = { questions: [ { ...makeQ('q0'), isUnseen: true }, { ...makeQ('q1'), isUnseen: true }, { ...makeQ('q2'), isUnseen: false }, // repeat — no points even if correct { ...makeQ('q3'), isUnseen: true }, { ...makeQ('q4'), isUnseen: true }, ], }; // All correct (correctIndex is 0 for every makeQ). const answers = { q0: 0, q1: 0, q2: 0, q3: 0, q4: 0 }; const r = await finishOnDemandTest('u1', 't', 'Topic T', quiz, answers, 120); expect(r.score).toBe(5); expect(r.unseenCorrect).toBe(4); expect(r.rawPoints).toBe(8); expect(r.pointsEarned).toBe(8); expect(r.capped).toBe(false); expect(ctx.upsertLeaderboardEntryMock).toHaveBeenCalledWith('u1', 'Alice', 8, 1); }); it('caps points at WEEKLY_POINT_CAP_PER_TOPIC per (user, topic, week)', async () => { // Pre-seed: user already earned 8 of the 10 weekly points on topic t. ctx.attempts.push({ user_id: 'u1', topic_id: 't', points_earned: 8, iso_week: ctx.isoWeekValue, question_ids: ['old1','old2'] }); const quiz = { questions: Array.from({ length: 5 }, (_, i) => ({ ...makeQ(`q${i}`), isUnseen: true })), }; const answers = { q0: 0, q1: 0, q2: 0, q3: 0, q4: 0 }; // perfect const r = await finishOnDemandTest('u1', 't', 'Topic T', quiz, answers, 100); expect(r.rawPoints).toBe(10); // Only 10 - 8 = 2 points remain in the weekly budget. expect(r.pointsEarned).toBe(2); expect(r.capped).toBe(true); expect(ctx.upsertLeaderboardEntryMock).toHaveBeenCalledWith('u1', 'Alice', 2, 1); expect(WEEKLY_POINT_CAP_PER_TOPIC).toBe(10); }); it('awards zero (no leaderboard call) when the cap is already exhausted', async () => { ctx.attempts.push({ user_id: 'u1', topic_id: 't', points_earned: 10, iso_week: ctx.isoWeekValue, question_ids: ['old1'] }); const quiz = { questions: Array.from({ length: 5 }, (_, i) => ({ ...makeQ(`q${i}`), isUnseen: true })), }; const answers = { q0: 0, q1: 0, q2: 0, q3: 0, q4: 0 }; const r = await finishOnDemandTest('u1', 't', 'Topic T', quiz, answers, 80); expect(r.pointsEarned).toBe(0); expect(r.capped).toBe(true); expect(ctx.upsertLeaderboardEntryMock).not.toHaveBeenCalled(); // Still persists the attempt so it shows in Contributions / practice history. expect(ctx.saveOnDemandAttemptMock).toHaveBeenCalled(); }); });