feat: on-demand topic tests with shared question bank
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:
194
src/lib/__tests__/onDemandTest.test.js
Normal file
194
src/lib/__tests__/onDemandTest.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
105
src/lib/db.js
105
src/lib/db.js
@@ -105,14 +105,105 @@ export async function deleteContent(topicId) {
|
||||
} catch { /* no record, nothing to do */ }
|
||||
}
|
||||
|
||||
// ── Quiz Banks (DEPRECATED — collection dropped) ────────────────────────────
|
||||
// ── Question Bank (shared, admin-curated, used by on-demand tests) ──────────
|
||||
// One PB record per topic, with `questions: json[]`. The legacy aliases
|
||||
// (getQuizBank etc.) are kept so existing admin UI code keeps working.
|
||||
|
||||
/** @deprecated quiz_banks collection has been dropped. */
|
||||
export async function getQuizBank() { return []; }
|
||||
/** @deprecated quiz_banks collection has been dropped. */
|
||||
export async function setQuizBank() { return null; }
|
||||
/** @deprecated quiz_banks collection has been dropped. */
|
||||
export async function deleteQuestionFromBank() { return null; }
|
||||
export async function getQuestionBank(topicId) {
|
||||
try {
|
||||
const r = await pb.collection('question_bank').getFirstListItem(`topic_id="${topicId}"`);
|
||||
return Array.isArray(r.questions) ? r.questions : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function setQuestionBank(topicId, questions) {
|
||||
return pbUpsert(
|
||||
'question_bank',
|
||||
`topic_id="${topicId}"`,
|
||||
{ questions },
|
||||
{ topic_id: topicId, questions },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteQuestionFromBank(topicId, questionId) {
|
||||
try {
|
||||
const r = await pb.collection('question_bank').getFirstListItem(`topic_id="${topicId}"`);
|
||||
const next = (r.questions || []).filter(q => q.id !== questionId);
|
||||
return pb.collection('question_bank').update(r.id, { questions: next });
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Legacy aliases — the admin TestManager UI and forceGenerateTopicQuestions
|
||||
// call these. Routing them at the new collection un-breaks the panel.
|
||||
export const getQuizBank = getQuestionBank;
|
||||
export const setQuizBank = setQuestionBank;
|
||||
|
||||
// ── On-demand attempts ──────────────────────────────────────────────────────
|
||||
|
||||
function isoWeekKey(date = new Date()) {
|
||||
// Returns "YYYY-Www" for the ISO week containing `date`. Used for the
|
||||
// per-(user, topic) weekly point cap aggregation.
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const day = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - day);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export { isoWeekKey };
|
||||
|
||||
export async function saveOnDemandAttempt(attempt) {
|
||||
return pb.collection('on_demand_attempts').create({
|
||||
user_id: attempt.userId,
|
||||
topic_id: attempt.topicId,
|
||||
topic_label: attempt.topicLabel || '',
|
||||
question_ids: attempt.questionIds || [],
|
||||
score: attempt.score,
|
||||
total: attempt.total,
|
||||
percentage: attempt.percentage,
|
||||
time_used: attempt.timeUsed,
|
||||
unseen_correct: attempt.unseenCorrect,
|
||||
points_earned: attempt.pointsEarned,
|
||||
capped: !!attempt.capped,
|
||||
iso_week: attempt.isoWeek,
|
||||
completed_at: attempt.completedAt,
|
||||
breakdown: attempt.breakdown || [],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOnDemandPointsThisWeek(userId, topicId) {
|
||||
try {
|
||||
const week = isoWeekKey();
|
||||
const rows = await pb.collection('on_demand_attempts').getFullList({
|
||||
filter: `user_id="${userId}" && topic_id="${topicId}" && iso_week="${week}"`,
|
||||
});
|
||||
return rows.reduce((sum, r) => sum + (r.points_earned || 0), 0);
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
export async function getUserSeenQuestionIds(userId, topicId) {
|
||||
// Returns the Set of question_bank IDs this user has already been served
|
||||
// for `topicId`. Used by the draw routine to prefer unseen questions and
|
||||
// by finishOnDemandTest to zero out points for repeats.
|
||||
try {
|
||||
const rows = await pb.collection('on_demand_attempts').getFullList({
|
||||
filter: `user_id="${userId}" && topic_id="${topicId}"`,
|
||||
fields: 'question_ids',
|
||||
});
|
||||
const seen = new Set();
|
||||
for (const r of rows) {
|
||||
for (const id of (r.question_ids || [])) seen.add(id);
|
||||
}
|
||||
return seen;
|
||||
} catch { return new Set(); }
|
||||
}
|
||||
|
||||
export async function getAllOnDemandAttempts() {
|
||||
try {
|
||||
return await pb.collection('on_demand_attempts').getFullList({ sort: '-created' });
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── Sources ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -357,3 +357,146 @@ export async function saveTestResult(userId, weekNumber, result) {
|
||||
export async function getTestResult(userId, weekNumber) {
|
||||
return db.getQuizResult(userId, weekNumber);
|
||||
}
|
||||
|
||||
// ── On-demand topic tests ───────────────────────────────────────────────────
|
||||
// A medewerker can take a 5-question test on any eligible topic at any time.
|
||||
// Questions come from the shared, admin-curated question_bank collection — the
|
||||
// LLM is NOT called from the user path. Points feed the same leaderboard as the
|
||||
// weekly test, but are capped at WEEKLY_POINT_CAP_PER_TOPIC per ISO week per
|
||||
// (user, topic) so the shared bank can't be farmed.
|
||||
|
||||
export const ON_DEMAND_TEST_SIZE = 5;
|
||||
export const WEEKLY_POINT_CAP_PER_TOPIC = 10;
|
||||
|
||||
export async function getEligibleTopicsForOnDemand() {
|
||||
// Topics a learner may pick from: not a fact, not excluded by relevance,
|
||||
// and with at least ON_DEMAND_TEST_SIZE questions in the shared bank.
|
||||
const allTopics = await db.getTopics();
|
||||
const candidates = allTopics.filter(
|
||||
t => t.type !== 'fact' && t.learning_relevance !== 'exclude',
|
||||
);
|
||||
|
||||
const out = [];
|
||||
for (const t of candidates) {
|
||||
const bank = await db.getQuestionBank(t.id);
|
||||
if (bank.length >= ON_DEMAND_TEST_SIZE) {
|
||||
out.push({ ...t, bankSize: bank.length });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function startOnDemandTest(userId, topicId) {
|
||||
const allTopics = await db.getTopics();
|
||||
const topic = allTopics.find(t => t.id === topicId);
|
||||
if (!topic) throw new Error('Unknown topic.');
|
||||
|
||||
const bank = await db.getQuestionBank(topicId);
|
||||
if (bank.length < ON_DEMAND_TEST_SIZE) {
|
||||
throw new Error(`Not enough questions in this topic's bank yet (have ${bank.length}, need ${ON_DEMAND_TEST_SIZE}). Ask an admin to expand it.`);
|
||||
}
|
||||
|
||||
const seen = await db.getUserSeenQuestionIds(userId, topicId);
|
||||
const unseen = bank.filter(q => !seen.has(q.id));
|
||||
const seenList = bank.filter(q => seen.has(q.id));
|
||||
|
||||
// Prefer unseen; fall back to seen if the bank is thin. Repeated questions
|
||||
// are marked isUnseen=false so finishOnDemandTest can deny them points.
|
||||
const picked = [
|
||||
...shuffle(unseen).slice(0, ON_DEMAND_TEST_SIZE).map(q => ({ ...q, isUnseen: true })),
|
||||
];
|
||||
if (picked.length < ON_DEMAND_TEST_SIZE) {
|
||||
const fill = shuffle(seenList).slice(0, ON_DEMAND_TEST_SIZE - picked.length)
|
||||
.map(q => ({ ...q, isUnseen: false }));
|
||||
picked.push(...fill);
|
||||
}
|
||||
|
||||
return {
|
||||
questions: shuffle(picked),
|
||||
meta: {
|
||||
userId,
|
||||
topicId,
|
||||
topicLabel: topic.label,
|
||||
startedAt: new Date().toISOString(),
|
||||
bankSize: bank.length,
|
||||
unseenAvailable: unseen.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function finishOnDemandTest(userId, topicId, topicLabel, quiz, answers, timeUsed) {
|
||||
const questions = quiz.questions || [];
|
||||
|
||||
let score = 0;
|
||||
let unseenCorrect = 0;
|
||||
const questionIds = [];
|
||||
const breakdown = questions.map((q) => {
|
||||
questionIds.push(q.id);
|
||||
const selected = answers[q.id];
|
||||
const correct = selected === q.correctIndex;
|
||||
if (correct) {
|
||||
score++;
|
||||
if (q.isUnseen) unseenCorrect++;
|
||||
}
|
||||
return {
|
||||
questionId: q.id,
|
||||
question: q.question,
|
||||
topicLabel: q.topicLabel || topicLabel,
|
||||
selected,
|
||||
correctIndex: q.correctIndex,
|
||||
correct,
|
||||
isUnseen: !!q.isUnseen,
|
||||
explanation: q.explanation,
|
||||
options: q.options,
|
||||
};
|
||||
});
|
||||
|
||||
const total = questions.length;
|
||||
const percentage = total ? Math.round((score / total) * 100) : 0;
|
||||
|
||||
// Points: 2 per correctly-answered UNSEEN question, capped weekly per topic.
|
||||
const rawPoints = unseenCorrect * 2;
|
||||
const alreadyEarned = await db.getOnDemandPointsThisWeek(userId, topicId);
|
||||
const remainingBudget = Math.max(0, WEEKLY_POINT_CAP_PER_TOPIC - alreadyEarned);
|
||||
const pointsEarned = Math.min(rawPoints, remainingBudget);
|
||||
const capped = rawPoints > pointsEarned;
|
||||
|
||||
const completedAt = new Date().toISOString();
|
||||
const isoWeek = db.isoWeekKey();
|
||||
|
||||
await db.saveOnDemandAttempt({
|
||||
userId,
|
||||
topicId,
|
||||
topicLabel,
|
||||
questionIds,
|
||||
score,
|
||||
total,
|
||||
percentage,
|
||||
timeUsed,
|
||||
unseenCorrect,
|
||||
pointsEarned,
|
||||
capped,
|
||||
isoWeek,
|
||||
completedAt,
|
||||
breakdown,
|
||||
});
|
||||
|
||||
if (pointsEarned > 0) {
|
||||
const members = await db.getTeamMembers();
|
||||
const member = members.find(m => m.id === userId);
|
||||
await db.upsertLeaderboardEntry(userId, member?.name || 'Unknown', pointsEarned, 1);
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
total,
|
||||
percentage,
|
||||
timeUsed,
|
||||
completedAt,
|
||||
breakdown,
|
||||
unseenCorrect,
|
||||
pointsEarned,
|
||||
capped,
|
||||
rawPoints,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user