Files
Pierre MARTINandClaude Opus 5.5 351482fa9e Recette 1g : textes corrigés, boutons en bas, barre haute en paysage
Leçon 1 §2 (flèche 7 vide, 5 déjà bloqué), leçon 2 « Faire des points »,
flèches 7 à 12, reformulations des leçons 3 et 4. Écran des leçons et du
tutoriel : « Retour » et « Continuer » en bas en portrait, « Revoir » sur
la ligne du titre, ligne « Leçon n · … » gardée en paysage. Barre haute
visible hors de l'écran de jeu en paysage (défaut antérieur à 1g), avec
un contrôle dans la recette automatique. Rappel des règles haut de
44 px ; « Aucune leçon terminée sur 5 · … » sur l'accueil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:55:17 +01:00

441 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",
"Faire des points",
"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);
});
// Couvre aussi le « nettement devant tous les autres » de la leçon 1 (étape
// 1) : chaque coup illustré a plus de MIN_GAP d'avance sur le deuxième.
test("chaque coup illustré est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", 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 });
});