SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
19 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
5.8 KB · 129 lines typescript
Raw Blame History
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