diff --git a/ops/README.md b/ops/README.md index 07d9c5a..bba6c68 100644 --- a/ops/README.md +++ b/ops/README.md @@ -325,3 +325,13 @@ regeneration (must print 0): ```bash grep -c '"correct"\|"feedback"' fixtures/quizbank-3.json ``` + +### Grist table `QuizAttempts` (doc `bPfitC7Poc4r2xGRx7FgHL`) + +Created one-shot via the Grist API (`/apply` AddTable) on 2026-06-10 — the +app never creates tables. Columns: `user_sub` Text, `email` Text, +`course_slug` Text, `quiz_n` Int, `score` Int, `total` Int, `reponses` +Text (JSON `{questionId: [choiceIds]}`), `date` DateTime (epoch seconds), +`moodle_synced` Bool. One row per submitted attempt (retakes unlimited, +best wins); same Bool-filter caveat as Progression — filter `quiz_n` / +`moodle_synced` in app code, only Text equality filters via the API. diff --git a/src/lib/grist.ts b/src/lib/grist.ts index 07c3a90..25f1b10 100644 --- a/src/lib/grist.ts +++ b/src/lib/grist.ts @@ -10,7 +10,7 @@ export interface ReadMark { id: number; } -async function grist(path: string, init?: RequestInit): Promise { +export async function grist(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE()}/api/docs/${DOC()}${path}`, { ...init, headers: { Authorization: `Bearer ${KEY()}`, 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, diff --git a/src/lib/quiz.test.ts b/src/lib/quiz.test.ts new file mode 100644 index 0000000..46b7b3d --- /dev/null +++ b/src/lib/quiz.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { + mapQuizRecords, mapAttemptRecords, toPublic, gradeQuiz, isPassed, bestByQuiz, + type Quiz, type QuizQuestion, type QuizChoice, type QuizAttempt, +} from './quiz'; + +// --- helpers ------------------------------------------------------------- + +const choice = (id: number, correct = false, feedback = ''): QuizChoice => + ({ id, text: `

choice ${id}

`, correct, feedback }); + +const question = (id: number, choices: QuizChoice[], single = 1): QuizQuestion => + ({ id, text: `

Q${id} ?

`, single, choices }); + +const quiz = (questions: QuizQuestion[], gradepass = 60): Quiz => + ({ quizN: 1, cmid: 51, name: 'Quiz 1 : Découverte', gradepass, questions }); + +const fixture = JSON.parse( + readFileSync(new URL('../../fixtures/quizbank-3.json', import.meta.url), 'utf8'), +) as { courses: Record }; + +// --- mapQuizRecords ------------------------------------------------------ + +describe('mapQuizRecords', () => { + it('parses real QuizBank rows (sanitized fixture) and sorts by quiz_n', () => { + // Reverse order on purpose: Grist row order is not guaranteed. + const records = [...fixture.courses['3'].quizzes].reverse().map((q, i) => ({ + id: i + 1, + fields: { + course_slug: 'kicad-makers', quiz_n: q.quiz_n, cmid: q.cmid, + name: q.name, gradepass: q.gradepass, + payload: JSON.stringify({ questions: q.questions }), + }, + })); + const quizzes = mapQuizRecords({ records }); + expect(quizzes.map((q) => q.quizN)).toEqual([1, 2, 3, 4]); + expect(quizzes[0]).toMatchObject({ cmid: 51, name: 'Quiz 1 : Découverte', gradepass: 60 }); + for (const q of quizzes) { + expect(q.questions).toHaveLength(5); + for (const qu of q.questions) expect(qu.choices).toHaveLength(4); + } + }); + + it('skips rows whose payload is not valid JSON', () => { + const records = [ + { id: 1, fields: { course_slug: 's', quiz_n: 2, cmid: 9, name: 'Quiz 2', gradepass: 60, payload: '{broken' } }, + { id: 2, fields: { course_slug: 's', quiz_n: 1, cmid: 8, name: 'Quiz 1', gradepass: 60, payload: '{"questions":[]}' } }, + ]; + const quizzes = mapQuizRecords({ records }); + expect(quizzes).toHaveLength(1); + expect(quizzes[0].quizN).toBe(1); + }); +}); + +// --- toPublic ------------------------------------------------------------ + +describe('toPublic', () => { + it('strips every correct flag and feedback (stringify scan)', () => { + const full = quiz([ + question(106, [choice(421, true, 'Bravo !'), choice(422, false, 'Non : relisez le chapitre 2.')]), + question(107, [choice(425, false, 'Raté'), choice(426, true, 'Oui')], 0), + ]); + const pub = toPublic(full); + const dump = JSON.stringify(pub); + expect(dump).not.toContain('correct'); + expect(dump).not.toContain('feedback'); + expect(dump).not.toContain('Bravo'); + // structure preserved + expect(pub.questions).toHaveLength(2); + expect(pub.questions[0].choices.map((c) => c.id)).toEqual([421, 422]); + expect(pub.questions[0].text).toBe('

Q106 ?

'); + // and the source quiz is untouched + expect(full.questions[0].choices[0].correct).toBe(true); + }); +}); + +// --- gradeQuiz ----------------------------------------------------------- + +describe('gradeQuiz', () => { + const single = quiz([ + question(1, [choice(11, true, 'Exact.'), choice(12, false, 'Faux : voir module 1.')]), + question(2, [choice(21, false, 'Non.'), choice(22, true, 'Oui.')]), + ]); + + it('scores a fully correct single-choice submission', () => { + const r = gradeQuiz(single, { '1': ['11'], '2': ['22'] }); + expect(r.score).toBe(2); + expect(r.total).toBe(2); + expect(r.detail.every((d) => d.ok)).toBe(true); + expect(r.detail[0].correctIds).toEqual([11]); + }); + + it('scores 0 on a wrong choice and reports the chosen feedback', () => { + const r = gradeQuiz(single, { '1': ['12'], '2': ['22'] }); + expect(r.score).toBe(1); + expect(r.detail[0]).toMatchObject({ questionId: 1, ok: false, chosenIds: [12], feedback: 'Faux : voir module 1.' }); + }); + + it('is all-or-nothing on multi-answer questions', () => { + const multi = quiz([question(3, [choice(31, true), choice(32, true), choice(33, false)], 0)]); + expect(gradeQuiz(multi, { '3': ['31', '32'] }).score).toBe(1); // exact set + expect(gradeQuiz(multi, { '3': ['31'] }).score).toBe(0); // partial + expect(gradeQuiz(multi, { '3': ['31', '32', '33'] }).score).toBe(0); // superset + }); + + it('treats empty or missing answers as 0 points, never throws', () => { + const r = gradeQuiz(single, {}); + expect(r.score).toBe(0); + expect(r.total).toBe(2); + expect(r.detail.map((d) => d.ok)).toEqual([false, false]); + expect(gradeQuiz(single, { '1': [] }).score).toBe(0); + }); +}); + +// --- isPassed ------------------------------------------------------------ + +describe('isPassed', () => { + const five = quiz([1, 2, 3, 4, 5].map((i) => question(i, [choice(i * 10, true), choice(i * 10 + 1)])), 60); + + it('passes at the 60 % boundary (3/5)', () => { + expect(isPassed(five, 3)).toBe(true); + }); + + it('fails below 60 % (2/5)', () => { + expect(isPassed(five, 2)).toBe(false); + }); + + it('defaults to 50 % when gradepass is 0', () => { + const noGate = quiz(five.questions, 0); + expect(isPassed(noGate, 3)).toBe(true); // 60 % ≥ 50 % + expect(isPassed(noGate, 2)).toBe(false); // 40 % < 50 % + }); + + it('honours an explicit attempt total over the question count', () => { + expect(isPassed(five, 3, 5)).toBe(true); + expect(isPassed(five, 3, 6)).toBe(false); // 50 % < 60 % + }); +}); + +// --- attempts ------------------------------------------------------------ + +describe('mapAttemptRecords / bestByQuiz', () => { + it('maps raw Grist attempt rows with safe coercions', () => { + const raw = { + records: [ + { id: 4, fields: { user_sub: 'kc-1', email: 'a@b.c', course_slug: 'kicad-makers', quiz_n: 1, score: 4, total: 5, reponses: '{"106":["421"]}', date: 1760000000, moodle_synced: 0 } }, + { id: 5, fields: { quiz_n: '2', score: null, total: undefined, moodle_synced: true } }, + ], + }; + expect(mapAttemptRecords(raw)).toEqual([ + { id: 4, quiz_n: 1, score: 4, total: 5, reponses: '{"106":["421"]}', date: 1760000000, moodle_synced: false }, + { id: 5, quiz_n: 2, score: 0, total: 0, reponses: '', date: 0, moodle_synced: true }, + ]); + }); + + it('keeps the best attempt per quiz, newest on tie', () => { + const att = (id: number, quizN: number, score: number, date: number): QuizAttempt => + ({ id, quiz_n: quizN, score, total: 5, reponses: '', date, moodle_synced: false }); + const best = bestByQuiz([att(1, 1, 2, 100), att(2, 1, 4, 200), att(3, 2, 3, 300), att(4, 2, 3, 400)]); + expect(best.get(1)?.id).toBe(2); // higher score wins + expect(best.get(2)?.id).toBe(4); // tie → newest + }); +}); diff --git a/src/lib/quiz.ts b/src/lib/quiz.ts new file mode 100644 index 0000000..0d4b6cc --- /dev/null +++ b/src/lib/quiz.ts @@ -0,0 +1,163 @@ +import { swrCache } from './cache'; +import { grist } from './grist'; + +export interface QuizChoice { id: number; text: string; correct?: boolean; feedback?: string; } +export interface QuizQuestion { id: number; text: string; single: number; choices: QuizChoice[]; } +export interface Quiz { quizN: number; cmid: number; name: string; gradepass: number; questions: QuizQuestion[]; } + +export interface PublicChoice { id: number; text: string; } +export interface PublicQuestion { id: number; text: string; single: number; choices: PublicChoice[]; } +export interface PublicQuiz { quizN: number; cmid: number; name: string; gradepass: number; questions: PublicQuestion[]; } + +type RawRecords = { records: { id: number; fields: Record }[] }; + +const TTL = Number(process.env.GRIST_CACHE_TTL_MS || 5 * 60_000); + +/** Map Grist QuizBank rows → Quiz[] sorted by quiz_n. Rows whose payload + * does not parse are skipped (logged) rather than poisoning the course. */ +export function mapQuizRecords(raw: RawRecords): Quiz[] { + const out: Quiz[] = []; + for (const r of raw.records) { + try { + const payload = JSON.parse(String(r.fields.payload ?? '')) as { questions?: QuizQuestion[] }; + out.push({ + quizN: Number(r.fields.quiz_n ?? 0), + cmid: Number(r.fields.cmid ?? 0), + name: String(r.fields.name ?? ''), + gradepass: Number(r.fields.gradepass ?? 0), + questions: Array.isArray(payload.questions) ? payload.questions : [], + }); + } catch (err) { + console.error(`[quiz] bad QuizBank payload (row ${r.id}): ${(err as Error).message}`); + } + } + return out.sort((a, b) => a.quizN - b.quizN); +} + +const fetchQuizzes = async (slug: string): Promise => { + const filter = encodeURIComponent(JSON.stringify({ course_slug: [slug] })); + return mapQuizRecords(await grist(`/tables/QuizBank/records?filter=${filter}`)); +}; +const cachedQuizzes = swrCache(fetchQuizzes, TTL); + +/** Full quizzes of a course (WITH correct flags + feedback — server-side only). */ +export const getQuizzes = (slug: string): Promise => cachedQuizzes(slug); + +/** Deep-strip correct flags and feedback before anything reaches the client. */ +export function toPublic(quiz: Quiz): PublicQuiz { + return { + quizN: quiz.quizN, cmid: quiz.cmid, name: quiz.name, gradepass: quiz.gradepass, + questions: quiz.questions.map((q) => ({ + id: q.id, text: q.text, single: q.single, + choices: q.choices.map((c) => ({ id: c.id, text: c.text })), + })), + }; +} + +export interface QuestionResult { + questionId: number; + ok: boolean; + chosenIds: number[]; + correctIds: number[]; + /** Feedback of the chosen choice(s), '' when none. */ + feedback: string; +} + +/** All-or-nothing per question: chosen id set must equal the correct id set. + * `answers` keys are question ids (string), values are choice ids (string). */ +export function gradeQuiz(quiz: Quiz, answers: Record): { score: number; total: number; detail: QuestionResult[] } { + const detail = quiz.questions.map((q) => { + const chosenIds = (answers[String(q.id)] ?? []).map(Number).filter(Number.isInteger); + const correctIds = q.choices.filter((c) => c.correct).map((c) => c.id); + const chosen = new Set(chosenIds); + const ok = correctIds.length > 0 && chosen.size === correctIds.length && correctIds.every((id) => chosen.has(id)); + const feedback = q.choices + .filter((c) => chosen.has(c.id) && c.feedback) + .map((c) => c.feedback as string) + .join(' '); + return { questionId: q.id, ok, chosenIds, correctIds, feedback }; + }); + return { score: detail.filter((d) => d.ok).length, total: quiz.questions.length, detail }; +} + +/** gradepass is a percentage (Moodle grade_items); 0/unset → 50 % default. */ +export function isPassed(quiz: Pick, score: number, total = quiz.questions.length): boolean { + if (total <= 0) return false; + const pct = (score / total) * 100; + return quiz.gradepass > 0 ? pct >= quiz.gradepass : pct >= 50; +} + +// --- QuizAttempts (Grist) -------------------------------------------------- + +export interface QuizAttempt { + id: number; + quiz_n: number; + score: number; + total: number; + reponses: string; // JSON Record + date: number; // epoch seconds + moodle_synced: boolean; +} + +export function mapAttemptRecords(raw: RawRecords): QuizAttempt[] { + return raw.records.map((r) => ({ + id: r.id, + quiz_n: Number(r.fields.quiz_n ?? 0), + score: Number(r.fields.score ?? 0), + total: Number(r.fields.total ?? 0), + reponses: String(r.fields.reponses ?? ''), + date: Number(r.fields.date ?? 0), + moodle_synced: Boolean(r.fields.moodle_synced), + })); +} + +/** All attempts of a user on a course. Only Text equality filters are + * reliable in the Grist API (see ops/README.md) — quiz_n and moodle_synced + * are filtered in app code. */ +export async function getAttempts(sub: string, courseSlug: string): Promise { + const filter = encodeURIComponent(JSON.stringify({ user_sub: [sub], course_slug: [courseSlug] })); + return mapAttemptRecords(await grist(`/tables/QuizAttempts/records?filter=${filter}`)); +} + +export async function addAttempt( + user: { sub: string; email: string }, + courseSlug: string, + quizN: number, + score: number, + total: number, + answers: Record, +): Promise { + await grist('/tables/QuizAttempts/records', { + method: 'POST', + body: JSON.stringify({ + records: [{ + fields: { + user_sub: user.sub, email: user.email, course_slug: courseSlug, + quiz_n: quizN, score, total, + reponses: JSON.stringify(answers), + date: Math.floor(Date.now() / 1000), // Grist DateTime accepts epoch seconds + moodle_synced: false, + }, + }], + }), + }); +} + +/** Best attempt per quiz_n (highest score, newest on tie). */ +export function bestByQuiz(attempts: QuizAttempt[]): Map { + const best = new Map(); + for (const a of attempts) { + const cur = best.get(a.quiz_n); + if (!cur || a.score > cur.score || (a.score === cur.score && (a.date > cur.date || (a.date === cur.date && a.id > cur.id)))) + best.set(a.quiz_n, a); + } + return best; +} + +export async function markAttemptsSynced(ids: number[]): Promise { + if (!ids.length) return; + await grist('/tables/QuizAttempts/records', { + method: 'PATCH', + body: JSON.stringify({ records: ids.map((id) => ({ id, fields: { moodle_synced: true } })) }), + }); +}