TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1import { beforeAll, describe, expect, it } from "vitest";2import { db, run, get } from "../lib/db/index.ts";3import { recordMasteryEvent, levelFor, decayedScore, masteryForCourse } from "../lib/learning/mastery.ts";4import { gradeAnswer, nextDifficulty, startingDifficulty } from "../lib/learning/quiz.ts";5import { generatePlan } from "../lib/learning/plan.ts";67let userId: number;8let conceptId: number;910beforeAll(() => {11 db();12 run("INSERT OR IGNORE INTO courses (code, full_code, title, session_label) VALUES ('IMM1003','IMM1003-20','Test','A2026')");13 const u = run("INSERT INTO users (username, display_name, password_hash) VALUES ('test-learn','T','x')");14 userId = Number(u.lastInsertRowid);15 const c = run(16 "INSERT INTO concepts (course_code, slug, name, week, importance) VALUES ('IMM1003','test-concept','Concept test', 4, 3)"17 );18 conceptId = Number(c.lastInsertRowid);19});2021describe("Moteur de maîtrise", () => {22 it("monte avec des réussites difficiles, descend avec des échecs", () => {23 let score = 0;24 for (let i = 0; i < 6; i++) {25 score = recordMasteryEvent({ userId, conceptId, kind: "quiz", correct: true, difficulty: 4 });26 }27 expect(score).toBeGreaterThan(0.5);28 const after = recordMasteryEvent({ userId, conceptId, kind: "quiz", correct: false, difficulty: 2 });29 expect(after).toBeLessThan(score);30 });3132 it("un examen pèse plus qu'une flashcard", () => {33 const u2 = Number(run("INSERT INTO users (username, display_name, password_hash) VALUES ('t2','T','x')").lastInsertRowid);34 const u3 = Number(run("INSERT INTO users (username, display_name, password_hash) VALUES ('t3','T','x')").lastInsertRowid);35 const viaExam = recordMasteryEvent({ userId: u2, conceptId, kind: "exam", correct: true, difficulty: 3 });36 const viaCard = recordMasteryEvent({ userId: u3, conceptId, kind: "flashcard", correct: true, difficulty: 3 });37 expect(viaExam).toBeGreaterThan(viaCard);38 });3940 it("classe les niveaux correctement", () => {41 expect(levelFor(0.1)).toBe("a-decouvrir");42 expect(levelFor(0.45)).toBe("en-construction");43 expect(levelFor(0.7)).toBe("solide");44 expect(levelFor(0.9)).toBe("maitrise");45 });4647 it("applique une décroissance temporelle bornée", () => {48 const now = new Date().toISOString().slice(0, 19).replace("T", " ");49 expect(decayedScore(0.8, now)).toBeCloseTo(0.8, 1);50 const old = new Date(Date.now() - 60 * 86400_000).toISOString().slice(0, 19).replace("T", " ");51 const decayed = decayedScore(0.8, old);52 expect(decayed).toBeLessThan(0.5);53 expect(decayed).toBeGreaterThan(0.8 * 0.35); // plancher : jamais d'oubli total54 });55});5657describe("Quiz adaptatif", () => {58 it("choisit la difficulté de départ selon la maîtrise", () => {59 expect(startingDifficulty(0.1)).toBe(1);60 expect(startingDifficulty(0.5)).toBe(2);61 expect(startingDifficulty(0.7)).toBe(3);62 expect(startingDifficulty(0.9)).toBe(4);63 });6465 it("monte après 2 bonnes réponses, descend après 1 échec", () => {66 expect(nextDifficulty(3, [true, true])).toBe(4);67 expect(nextDifficulty(3, [false])).toBe(2);68 expect(nextDifficulty(3, [true, false])).toBe(3);69 expect(nextDifficulty(5, [true, true])).toBe(5);70 expect(nextDifficulty(1, [false])).toBe(1);71 });7273 const base = { id: 1, course_code: "IMM1003", concept_id: 1, difficulty: 3, question: "", options: "[]", answer: "", explanation: "" };7475 it("corrige les choix multiples par correspondance exacte", () => {76 const q = { ...base, type: "mcq", answer: "La valeur marchande" };77 expect(gradeAnswer(q, "la valeur marchande")).toBe(true);78 expect(gradeAnswer(q, "La valeur au rôle")).toBe(false);79 });8081 it("corrige les calculs avec tolérance de 1 %", () => {82 const q = { ...base, type: "calc", answer: "458 640 $" };83 expect(gradeAnswer(q, "458640")).toBe(true);84 expect(gradeAnswer(q, "458 000 $")).toBe(true); // -0,14 %85 expect(gradeAnswer(q, "470 000")).toBe(false);86 });8788 it("corrige les réponses courtes par mots-clés", () => {89 const q = { ...base, type: "short", answer: "âge effectif, vie économique totale" };90 expect(gradeAnswer(q, "Il faut l'âge effectif divisé par la vie économique totale")).toBe(true);91 expect(gradeAnswer(q, "aucune idée")).toBe(false);92 });93});9495describe("Plan d'étude", () => {96 it("répartit les concepts sur les jours disponibles avec examens blancs", () => {97 recordMasteryEvent({ userId, conceptId, kind: "quiz", correct: false, difficulty: 3 });98 const today = new Date();99 const exam = new Date(today.getTime() + 14 * 86400_000).toISOString().slice(0, 10);100 const plan = generatePlan(userId, "IMM1003", {101 examDate: exam,102 weekdays: [1, 3, 5],103 minutesPerSession: 60,104 weeksScope: [1, 14],105 });106 expect(plan.length).toBeGreaterThan(2);107 const allItems = plan.flatMap((d) => d.items);108 expect(allItems.some((i) => i.kind === "exam")).toBe(true);109 expect(allItems.some((i) => i.conceptSlug === "test-concept")).toBe(true);110 // entrelacement : pas deux blocs consécutifs du même concept dans une même journée111 for (const day of plan) {112 for (let i = 1; i < day.items.length; i++) {113 if (day.items[i].conceptSlug && day.items[i - 1].conceptSlug) {114 expect(day.items[i].conceptSlug === day.items[i - 1].conceptSlug).toBe(false);115 }116 }117 }118 });119});120121describe("Isolation des cours (mastery)", () => {122 it("ne mélange jamais les concepts des deux cours", () => {123 run("INSERT OR IGNORE INTO courses (code, full_code, title, session_label) VALUES ('IMM1033','IMM1033-20','Test','A2026')");124 run("INSERT INTO concepts (course_code, slug, name, week) VALUES ('IMM1033','autre-cours','Autre', 1)");125 const list = masteryForCourse(userId, "IMM1003");126 expect(list.every((c) => c.slug !== "autre-cours")).toBe(true);127 });128});129