Liste des leçons, écrans d'une leçon sur le moteur d'écrans du tutoriel, carte « prochaine leçon » de l'accueil, ligne du menu. Tests du contenu sur le vrai moteur, scénario Gherkin, test navigateur. Amendement de l'ADR 0013 ; 1g faite, plan clos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
439 lines
18 KiB
TypeScript
439 lines
18 KiB
TypeScript
// 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 });
|
|
});
|