import { beforeAll, describe, expect, it } from "vitest"; import { db, run, get } from "../lib/db/index.ts"; import { recordMasteryEvent, levelFor, decayedScore, masteryForCourse } from "../lib/learning/mastery.ts"; import { gradeAnswer, nextDifficulty, startingDifficulty } from "../lib/learning/quiz.ts"; import { generatePlan } from "../lib/learning/plan.ts"; let userId: number; let conceptId: number; beforeAll(() => { db(); run("INSERT OR IGNORE INTO courses (code, full_code, title, session_label) VALUES ('IMM1003','IMM1003-20','Test','A2026')"); const u = run("INSERT INTO users (username, display_name, password_hash) VALUES ('test-learn','T','x')"); userId = Number(u.lastInsertRowid); const c = run( "INSERT INTO concepts (course_code, slug, name, week, importance) VALUES ('IMM1003','test-concept','Concept test', 4, 3)" ); conceptId = Number(c.lastInsertRowid); }); describe("Moteur de maîtrise", () => { it("monte avec des réussites difficiles, descend avec des échecs", () => { let score = 0; for (let i = 0; i < 6; i++) { score = recordMasteryEvent({ userId, conceptId, kind: "quiz", correct: true, difficulty: 4 }); } expect(score).toBeGreaterThan(0.5); const after = recordMasteryEvent({ userId, conceptId, kind: "quiz", correct: false, difficulty: 2 }); expect(after).toBeLessThan(score); }); it("un examen pèse plus qu'une flashcard", () => { const u2 = Number(run("INSERT INTO users (username, display_name, password_hash) VALUES ('t2','T','x')").lastInsertRowid); const u3 = Number(run("INSERT INTO users (username, display_name, password_hash) VALUES ('t3','T','x')").lastInsertRowid); const viaExam = recordMasteryEvent({ userId: u2, conceptId, kind: "exam", correct: true, difficulty: 3 }); const viaCard = recordMasteryEvent({ userId: u3, conceptId, kind: "flashcard", correct: true, difficulty: 3 }); expect(viaExam).toBeGreaterThan(viaCard); }); it("classe les niveaux correctement", () => { expect(levelFor(0.1)).toBe("a-decouvrir"); expect(levelFor(0.45)).toBe("en-construction"); expect(levelFor(0.7)).toBe("solide"); expect(levelFor(0.9)).toBe("maitrise"); }); it("applique une décroissance temporelle bornée", () => { const now = new Date().toISOString().slice(0, 19).replace("T", " "); expect(decayedScore(0.8, now)).toBeCloseTo(0.8, 1); const old = new Date(Date.now() - 60 * 86400_000).toISOString().slice(0, 19).replace("T", " "); const decayed = decayedScore(0.8, old); expect(decayed).toBeLessThan(0.5); expect(decayed).toBeGreaterThan(0.8 * 0.35); // plancher : jamais d'oubli total }); }); describe("Quiz adaptatif", () => { it("choisit la difficulté de départ selon la maîtrise", () => { expect(startingDifficulty(0.1)).toBe(1); expect(startingDifficulty(0.5)).toBe(2); expect(startingDifficulty(0.7)).toBe(3); expect(startingDifficulty(0.9)).toBe(4); }); it("monte après 2 bonnes réponses, descend après 1 échec", () => { expect(nextDifficulty(3, [true, true])).toBe(4); expect(nextDifficulty(3, [false])).toBe(2); expect(nextDifficulty(3, [true, false])).toBe(3); expect(nextDifficulty(5, [true, true])).toBe(5); expect(nextDifficulty(1, [false])).toBe(1); }); const base = { id: 1, course_code: "IMM1003", concept_id: 1, difficulty: 3, question: "", options: "[]", answer: "", explanation: "" }; it("corrige les choix multiples par correspondance exacte", () => { const q = { ...base, type: "mcq", answer: "La valeur marchande" }; expect(gradeAnswer(q, "la valeur marchande")).toBe(true); expect(gradeAnswer(q, "La valeur au rôle")).toBe(false); }); it("corrige les calculs avec tolérance de 1 %", () => { const q = { ...base, type: "calc", answer: "458 640 $" }; expect(gradeAnswer(q, "458640")).toBe(true); expect(gradeAnswer(q, "458 000 $")).toBe(true); // -0,14 % expect(gradeAnswer(q, "470 000")).toBe(false); }); it("corrige les réponses courtes par mots-clés", () => { const q = { ...base, type: "short", answer: "âge effectif, vie économique totale" }; expect(gradeAnswer(q, "Il faut l'âge effectif divisé par la vie économique totale")).toBe(true); expect(gradeAnswer(q, "aucune idée")).toBe(false); }); }); describe("Plan d'étude", () => { it("répartit les concepts sur les jours disponibles avec examens blancs", () => { recordMasteryEvent({ userId, conceptId, kind: "quiz", correct: false, difficulty: 3 }); const today = new Date(); const exam = new Date(today.getTime() + 14 * 86400_000).toISOString().slice(0, 10); const plan = generatePlan(userId, "IMM1003", { examDate: exam, weekdays: [1, 3, 5], minutesPerSession: 60, weeksScope: [1, 14], }); expect(plan.length).toBeGreaterThan(2); const allItems = plan.flatMap((d) => d.items); expect(allItems.some((i) => i.kind === "exam")).toBe(true); expect(allItems.some((i) => i.conceptSlug === "test-concept")).toBe(true); // entrelacement : pas deux blocs consécutifs du même concept dans une même journée for (const day of plan) { for (let i = 1; i < day.items.length; i++) { if (day.items[i].conceptSlug && day.items[i - 1].conceptSlug) { expect(day.items[i].conceptSlug === day.items[i - 1].conceptSlug).toBe(false); } } } }); }); describe("Isolation des cours (mastery)", () => { it("ne mélange jamais les concepts des deux cours", () => { run("INSERT OR IGNORE INTO courses (code, full_code, title, session_label) VALUES ('IMM1033','IMM1033-20','Test','A2026')"); run("INSERT INTO concepts (course_code, slug, name, week) VALUES ('IMM1033','autre-cours','Autre', 1)"); const list = masteryForCourse(userId, "IMM1003"); expect(list.every((c) => c.slug !== "autre-cours")).toBe(true); }); });