feat: on-demand topic tests with shared question bank
All checks were successful
On Push to Main / test (push) Successful in 1m1s
On Push to Main / publish (push) Successful in 1m22s
On Push to Main / deploy-dev (push) Successful in 2m18s

Adds a /topic-test route where learners can take a 5-question test on any
eligible topic at any time. Questions come from a shared, admin-curated
question_bank — no LLM calls on the user path. Points feed the existing
leaderboard with a 10pt/topic/week cap (per ISO week) so the bank can't be
farmed, and repeats from a thin bank yield 0 points.

- New PB collections: question_bank, on_demand_attempts (+ migrations and
  setup-pb-collections.mjs entries).
- db.js: un-deprecates getQuizBank/setQuizBank against question_bank so the
  existing admin TestManager panel becomes functional again; adds
  saveOnDemandAttempt, getOnDemandPointsThisWeek, getUserSeenQuestionIds,
  getAllOnDemandAttempts, isoWeekKey.
- testService.js: getEligibleTopicsForOnDemand, startOnDemandTest (unseen-
  first draw), finishOnDemandTest (cap enforcement + leaderboard upsert).
- New TopicTest page, route, and nav entry; weekly test flow untouched.
- Leaderboard folds on-demand 100%s into perfect-score count and merges
  on-demand attempts into the recent activity feed.
- Spec: docs/on-demand-tests-spec.md with ADRs and extension points.
- Tests: 5 new vitest cases covering eligibility, draw fallback, scoring,
  partial cap, and exhausted cap (85/85 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-06-02 15:06:43 +02:00
parent d1a1cc913c
commit 43a71e2110
10 changed files with 1394 additions and 18 deletions

View File

@@ -0,0 +1,194 @@
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();
});
});