// Tests des leçons (étape 1g) : le texte des cinq leçons (docs/lecons/) se // lit ; chaque position est valide pour le vrai moteur ; chaque coup attendu // d'un exercice est le meilleur coup du moteur à 2 plis, avec plus de 0,04 // d'avance sur le deuxième, et seul accepté ; chaque coup illustré est aussi // le meilleur coup ; chaque compte de points cité est celui du moteur. Puis // la progression : sa lecture (formats durables, ADR 0007), son // enregistrement, les états affichés et la leçon proposée. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). import assert from "node:assert/strict"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { test } from "node:test"; import type { Engine, EngineCube } from "../src/domain/game/engine.js"; import type { Roll } from "../src/domain/game/roll.js"; import { finishedLessons, LESSON_END, LESSON_PROGRESS_VERSION, type LessonOutline, lessonRecord, lessonState, NO_PROGRESS, nextLesson, readLessonProgress, resumeStep, withSolved, withVisit, } from "../src/domain/learning/lessons.js"; import { MovePractice, tutorialBoard } from "../src/domain/learning/tutorial.js"; import { createKeyValueBackend } from "../src/storage/key-value-backend.js"; import { createLessonStore } from "../src/storage/lesson-store.js"; import { memoryStorage } from "../src/storage/open-library.js"; import { LESSON_COUNT, type Lesson, outlineOf, parseLesson } from "../src/ui/lesson-text.js"; import { LESSON_TEXTS } from "../src/ui/lesson-texts.js"; import { lessonCards, nextLessonCard } from "../src/ui/lessons-model.js"; import { lessonPlaceOf, lessonPlaceRoute } from "../src/ui/tutorial-nav.js"; import type { TutorialScreen } from "../src/ui/tutorial-text.js"; import { loadNodeEngine } from "./support/node-engine.js"; const root = new URL("../../../", import.meta.url); const lessons: Lesson[] = Array.from({ length: LESSON_COUNT }, (_, i) => parseLesson(i + 1, readFileSync(new URL(`docs/lecons/lecon-${i + 1}.md`, root), "utf8")), ); /** Niveau d'analyse des réponses attendues (décision de Pierre). */ const PLY = 2; /** Avance minimale du coup attendu sur le deuxième : un exercice n'a * qu'une bonne réponse nette. */ const MIN_GAP = 0.04; const CENTERED: EngineCube = { value: 1, owner: "centered" }; /** Les écrans d'une leçon, avec leur nom pour les messages. */ function screens(lesson: Lesson): [string, TutorialScreen][] { return lesson.steps.flatMap((s) => [ [`leçon ${lesson.number}, étape ${s.number}`, s.explain] as [string, TutorialScreen], ...(s.practice === null ? [] : [ [`leçon ${lesson.number}, étape ${s.number}, à vous`, s.practice] as [ string, TutorialScreen, ], ]), ]); } const allScreens = () => lessons.flatMap(screens); /** Le classement du moteur à 2 plis pour un coup : son rang, et l'écart * avec le meilleur coup suivant (positif : le coup a cette avance). */ async function rankOf( engine: Engine, position: string, roll: Roll, notation: string, ): Promise<{ rank: number; best: string; gap: number; second: string }> { const legal = await engine.legalMoves(position, roll); const move = legal.find((m) => m.notation === notation); assert.ok(move !== undefined, `${notation} n'est pas un coup légal de ${roll.join("-")}`); const analysis = await engine.analyseMove(position, CENTERED, roll, move.position, PLY); assert.ok(analysis !== null, `${notation} : aucune analyse`); const second = analysis.ranking.find((m) => m.position !== analysis.best.position); assert.ok(second !== undefined, `${notation} : un seul coup possible, rien à choisir`); // Le deuxième coup analysé à son tour : l'analyse le réévalue au même // niveau que le meilleur, ce qui donne l'écart exact. const other = await engine.analyseMove(position, CENTERED, roll, second.position, PLY); assert.ok(other !== null); return { rank: analysis.rank, best: analysis.best.notation, gap: -other.equityLoss, second: second.notation, }; } test("cinq leçons, de deux à quatre exercices chacune, avec un titre et une fin", () => { assert.equal(lessons.length, 5); assert.deepEqual( readdirSync(new URL("docs/lecons/", root)) .filter((f) => f.startsWith("lecon-")) .sort(), ["lecon-1.md", "lecon-2.md", "lecon-3.md", "lecon-4.md", "lecon-5.md"], ); assert.deepEqual( lessons.map((l) => l.title), [ "Les ouvertures", "Construire ses cases", "Frapper ou ne pas frapper", "La course et le compte de points", "Sortir sans se faire frapper", ], ); for (const lesson of lessons) { const exercises = outlineOf(lesson).exercises.length; assert.ok( exercises >= 2 && exercises <= 4, `leçon ${lesson.number} : ${exercises} exercices`, ); assert.ok(lesson.end.action !== "" && lesson.end.paragraphs.length > 0); for (const step of lesson.steps) { assert.ok( step.explain.paragraphs.length > 0, `leçon ${lesson.number}, étape ${step.number} sans texte`, ); const practice = step.practice; if (practice !== null) { assert.ok( practice.paragraphs.length > 0, `leçon ${lesson.number} : consigne absente`, ); assert.ok(practice.right.length > 0 && practice.wrong.length > 0); assert.equal(practice.expected.length, 1, "un seul coup attendu par exercice"); assert.ok(practice.roll !== null); } } } }); test("le texte des leçons garde le ton du maître et les mots du glossaire", () => { for (const lesson of lessons) { const text = readFileSync(new URL(`docs/lecons/lecon-${lesson.number}.md`, root), "utf8"); // Pas de point d'exclamation, pas de « camp » (remplacé par les // jans), pas de reproche. assert.ok(!text.includes("!"), `leçon ${lesson.number} : point d'exclamation`); assert.ok(!/\bcamp\b/i.test(text), `leçon ${lesson.number} : « camp »`); assert.ok( !/vous auriez dû|il fallait|erreur/i.test(text), `leçon ${lesson.number} : reproche`, ); assert.ok(text.includes("CC BY-SA 4.0"), `leçon ${lesson.number} : licence`); const inlines = screens(lesson).flatMap(([, s]) => [ ...s.paragraphs.flat(), ...s.right, ...s.wrong, ]); for (const term of inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]))) { assert.ok( existsSync(new URL(`docs/glossaire/${term}.md`, root)), `fiche ${term}.md absente`, ); } } const terms = new Set( lessons .flatMap((l) => screens(l).flatMap(([, s]) => s.paragraphs.flat())) .flatMap((i) => (i.term === undefined ? [] : [i.term])), ); for (const term of ["jan-interieur", "jan-exterieur", "point", "blot", "compte-de-points"]) { assert.ok(terms.has(term), `aucun lien vers ${term}.md`); } }); test("chaque position des leçons est valide, et ses comptes de points sont ceux du moteur", async () => { const engine = await loadNodeEngine(); let counts = 0; for (const [name, screen] of allScreens()) { const board = await tutorialBoard(engine, screen.position, screen.onRoll); for (const side of ["player", "computer"] as const) { const s = board.sides[side]; assert.equal( s.points.reduce((a, b) => a + b, 0) + s.bar + s.off, 15, `${name} : ${side}`, ); } assert.equal(board.result, null, `${name} : partie finie`); if (screen.pips !== null) { counts++; assert.deepEqual( [board.sides.player.pipCount, board.sides.computer.pipCount], screen.pips, `${name} : comptes de points`, ); } } assert.ok(counts >= 4, "la leçon 4 cite les comptes de points"); }); test("chaque coup attendu est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", async () => { const engine = await loadNodeEngine(); const report: string[] = []; for (const [name, screen] of allScreens()) { const notation = screen.expected[0]; if (notation === undefined || screen.roll === null) { continue; } const ranked = await rankOf(engine, screen.position, screen.roll, notation); assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${notation}`); assert.ok( ranked.gap > MIN_GAP, `${name} : ${notation} devant ${ranked.second} de ${ranked.gap.toFixed(3)} seulement`, ); report.push(`${name} : ${notation} (+${ranked.gap.toFixed(3)} sur ${ranked.second})`); } assert.equal(report.length, 16); }); test("chaque coup illustré est le meilleur coup du moteur à 2 plis", async () => { const engine = await loadNodeEngine(); let illustrated = 0; for (const [name, screen] of allScreens()) { if (screen.move === null || screen.roll === null) { continue; } illustrated++; const ranked = await rankOf(engine, screen.position, screen.roll, screen.move); assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${screen.move}`); assert.ok( ranked.gap > MIN_GAP, `${name} : ${screen.move} seulement +${ranked.gap.toFixed(3)}`, ); } assert.ok(illustrated >= 5); }); test("chaque exercice accepte le coup du moteur et refuse tous les autres", async () => { const engine = await loadNodeEngine(); for (const [name, screen] of allScreens()) { if (screen.expected.length === 0 || screen.roll === null) { continue; } const legal = await engine.legalMoves(screen.position, screen.roll); assert.ok(legal.length > 1, `${name} : rien à refuser`); for (const move of legal) { const practice = await MovePractice.start( engine, screen.position, screen.roll, screen.expected, ); let verdict = "continue"; for (const halfMove of move.halfMoves) { verdict = await practice.play(halfMove); if (verdict !== "continue") { break; } } const right = screen.expected.includes(move.notation); assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`); } } }); // ------------------------------------------------------- Progression const outlines: LessonOutline[] = [ { number: 1, exercises: [1, 2, 3, 4] }, { number: 2, exercises: [1, 2, 3] }, { number: 3, exercises: [1, 2] }, ]; test("la progression enregistrée se relit, même absente, abîmée ou d'une autre version", () => { assert.deepEqual(readLessonProgress(undefined), NO_PROGRESS); assert.deepEqual(readLessonProgress("texte"), NO_PROGRESS); assert.deepEqual(readLessonProgress({ version: 99, lessons: {} }), NO_PROGRESS); assert.deepEqual(readLessonProgress({ version: 1, lessons: null }), NO_PROGRESS); const saved = { version: LESSON_PROGRESS_VERSION, lessons: { 1: { last: 3, reached: 3, solved: [2, 1, 2, -1, 1.5] }, 2: { last: "x", reached: 1, solved: [] }, x: { last: 1, reached: 1, solved: [] }, 4: { last: LESSON_END, reached: LESSON_END, solved: [1] }, }, }; assert.deepEqual(readLessonProgress(JSON.parse(JSON.stringify(saved))), { version: 1, lessons: { 1: { last: 3, reached: 3, solved: [1, 2] }, 4: { last: LESSON_END, reached: LESSON_END, solved: [1] }, }, }); }); test("les états d'une leçon : à commencer, « 2 exercices sur 4 », terminée", () => { let progress = NO_PROGRESS; const first = outlines[0] as LessonOutline; assert.deepEqual(lessonState(progress, first), { kind: "toStart", total: 4 }); assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "À commencer · 4 exercices"); progress = withVisit(progress, 1, 1); assert.deepEqual(lessonState(progress, first), { kind: "inProgress", solved: 0, total: 4 }); progress = withSolved(withSolved(withVisit(progress, 1, 2), 1, 1), 1, 2); progress = withSolved(progress, 1, 2); assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "2 exercices sur 4"); assert.deepEqual(lessonRecord(progress, 1), { last: 2, reached: 2, solved: [1, 2] }); // Revenir en arrière garde l'étape la plus loin vue. progress = withVisit(progress, 1, 1); assert.deepEqual(lessonRecord(progress, 1), { last: 1, reached: 2, solved: [1, 2] }); progress = withSolved(withSolved(progress, 1, 3), 1, 4); assert.deepEqual(lessonState(progress, first), { kind: "done", total: 4 }); assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "Terminée"); assert.equal( LESSON_TEXTS.state({ kind: "inProgress", solved: 1, total: 3 }), "1 exercice sur 3", ); }); test("la leçon proposée : la première en cours, sinon la première à commencer", () => { assert.equal(nextLesson(NO_PROGRESS, outlines)?.number, 1); let progress = withVisit(NO_PROGRESS, 2, 1); assert.equal(nextLesson(progress, outlines)?.number, 2); for (const step of [1, 2, 3]) { progress = withSolved(progress, 2, step); } assert.equal(nextLesson(progress, outlines)?.number, 1, "leçon 2 terminée"); assert.equal(finishedLessons(progress, outlines), 1); for (const [number, steps] of [ [1, [1, 2, 3, 4]], [3, [1, 2]], ] as const) { for (const step of steps) { progress = withSolved(progress, number, step); } } assert.equal(nextLesson(progress, outlines), null, "toutes terminées"); assert.equal(finishedLessons(progress, outlines), 3); }); test("« Continuer » reprend là où l'on a quitté la leçon", () => { const first = outlines[0] as LessonOutline; assert.equal(resumeStep(NO_PROGRESS, first), 1); let progress = withVisit(NO_PROGRESS, 1, 3); assert.equal(resumeStep(progress, first), 3); // Après la fin, le premier exercice pas encore réussi. progress = withSolved(withVisit(progress, 1, LESSON_END), 1, 1); assert.equal(resumeStep(progress, first), 2); }); test("la progression est enregistrée dans le stockage des parties et relue", async () => { const storage = memoryStorage(); const store = createLessonStore(createKeyValueBackend(storage)); assert.deepEqual(await store.load(), NO_PROGRESS); const progress = withSolved(withVisit(NO_PROGRESS, 3, 2), 3, 2); void store.save(progress); // Les enregistrements passent avant la lecture qui suit. assert.deepEqual(await store.load(), progress); assert.deepEqual(JSON.parse(storage.getItem("ustagammon.lessons") ?? "null"), progress); const again = createLessonStore(createKeyValueBackend(storage)); assert.deepEqual(await again.load(), progress); // Une valeur illisible n'empêche rien. storage.setItem("ustagammon.lessons", "{abîmé"); assert.deepEqual(await again.load(), NO_PROGRESS); // Un stockage qui refuse l'écriture non plus. const refused = createLessonStore({ lessonProgress: async () => { throw new Error("refusé"); }, saveLessonProgress: async () => { throw new Error("refusé"); }, }); await refused.save(progress); assert.deepEqual(await refused.load(), NO_PROGRESS); }); test("la liste des leçons et la carte de l'accueil", () => { const empty = lessonCards(lessons, NO_PROGRESS); assert.deepEqual( empty.map((c) => [c.number, c.state.kind, c.current, c.href]), [ [1, "toStart", true, "#/lecons/1/1"], [2, "toStart", false, "#/lecons/2/1"], [3, "toStart", false, "#/lecons/3/1"], [4, "toStart", false, "#/lecons/4/1"], [5, "toStart", false, "#/lecons/5/1"], ], ); let progress = withSolved(withVisit(NO_PROGRESS, 2, 2), 2, 1); const card = nextLessonCard(lessons, progress); assert.equal(card?.number, 2); assert.equal(card?.href, "#/lecons/2/2"); assert.deepEqual(card?.exercises, [true, false, false]); assert.equal( LESSON_TEXTS.state(card?.state ?? { kind: "toStart", total: 0 }), "1 exercice sur 3", ); for (const lesson of lessons) { for (const step of outlineOf(lesson).exercises) { progress = withSolved(progress, lesson.number, step); } } assert.equal(nextLessonCard(lessons, progress), null); }); test("les écrans d'une leçon ont leur adresse, ramenée à un écran qui existe", () => { const lesson = lessons[0] as Lesson; assert.deepEqual( lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 2, practice: true }), { step: 2, practice: true, }, ); assert.deepEqual( lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: null, practice: false }), { step: 1, practice: false, }, ); assert.deepEqual( lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 9, practice: false }), { step: 1, practice: false, }, ); assert.equal(lessonPlaceOf(lesson, { screen: "lessonEnd", lesson: 1 }), "end"); assert.deepEqual(lessonPlaceRoute(3, { step: 2, practice: true }), { screen: "lesson", lesson: 3, step: 2, practice: true, }); assert.deepEqual(lessonPlaceRoute(3, "end"), { screen: "lessonEnd", lesson: 3 }); });