// Tests de ce qui, dans l'interface et le stockage, se vérifie sans // navigateur : le plateau à dessiner, les mises en évidence, la lecture de // l'aide. Le stockage des parties a ses tests (storage.test.ts). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; import type { BoardSide } from "../src/domain/game/engine.js"; import { parseHalfMoves } from "../src/domain/game/position.js"; import { boardModel, moveTargets, moveTrace, onlyHalfMove, placeOf, pointColor, pointSlot, } from "../src/ui/board-model.js"; import { FEATURES } from "../src/ui/help.js"; import { HELP_QUESTIONS, helpHash } from "../src/ui/help-context.js"; import { matchesSearch, parseFeature, parseGlossaryEntry, parseGlossaryIndex, } from "../src/ui/help-text.js"; import { slug } from "../src/ui/router.js"; import { TEXTS } from "../src/ui/texts.js"; // Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut. const root = new URL("../../../", import.meta.url); const read = (path: string) => readFileSync(new URL(path, root), "utf8"); function side(points: Record, extra: Partial = {}): BoardSide { const all = Array.from({ length: 24 }, (_, i) => points[i + 1] ?? 0); return { points: all, bar: 0, off: 0, pipCount: 0, ...extra }; } test("le plateau est vu du joueur : la case q de l'ordinateur est sa case 25 - q", () => { const model = boardModel({ player: side({ 6: 5, 8: 3 }, { bar: 1, pipCount: 100 }), computer: side({ 6: 5, 24: 2 }, { off: 3, pipCount: 90 }), }); assert.equal(model.points.length, 24); assert.deepEqual(model.points[5], { place: "p6", number: 6, owner: "player", count: 5 }); assert.deepEqual(model.points[18], { place: "p19", number: 19, owner: "computer", count: 5 }); assert.deepEqual(model.points[0], { place: "p1", number: 1, owner: "computer", count: 2 }); assert.deepEqual(model.points[1], { place: "p2", number: 2, owner: null, count: 0 }); assert.deepEqual(model.bar, { player: 1, computer: 0 }); assert.deepEqual(model.off, { player: 0, computer: 3 }); assert.deepEqual(model.pipCount, { player: 100, computer: 90 }); }); test("cases, barre et sortie de chaque camp", () => { assert.equal(placeOf("player", 8), "p8"); assert.equal(placeOf("computer", 8), "p17"); assert.equal(placeOf("player", 25), "bar-player"); assert.equal(placeOf("computer", 0), "off-computer"); }); test("couleur des flèches : impaires en point-a, paires en point-b, quelle que soit la colonne", () => { for (const number of [1, 13, 23]) { assert.equal(pointColor(number), "point-a", `flèche ${number}`); } for (const number of [2, 12, 24]) { assert.equal(pointColor(number), "point-b", `flèche ${number}`); } // 12 et 13 partagent la colonne 0, mais pas la couleur. assert.equal(pointSlot(12).column, pointSlot(13).column); assert.notEqual(pointColor(12), pointColor(13)); }); test("disposition : 1 à 12 en bas de droite à gauche, 13 à 24 en haut de gauche à droite", () => { assert.deepEqual(pointSlot(1), { top: false, column: 11 }); assert.deepEqual(pointSlot(7), { top: false, column: 5 }); assert.deepEqual(pointSlot(12), { top: false, column: 0 }); assert.deepEqual(pointSlot(13), { top: true, column: 0 }); assert.deepEqual(pointSlot(24), { top: true, column: 11 }); }); test("les demi-coups possibles donnent les pions jouables et leurs cases d'arrivée", () => { const targets = moveTargets([ { from: 8, to: 5 }, { from: 8, to: 7 }, { from: 25, to: 22 }, { from: 3, to: 0 }, ]); assert.deepEqual([...targets.keys()], ["p8", "bar-player", "p3"]); assert.deepEqual([...(targets.get("p8")?.keys() ?? [])], ["p5", "p7"]); assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 }); // Un seul toucher suffit pour un pion à destination unique. assert.deepEqual(onlyHalfMove(targets, "p3"), { from: 3, to: 0 }); assert.deepEqual(onlyHalfMove(targets, "bar-player"), { from: 25, to: 22 }); assert.equal(onlyHalfMove(targets, "p8"), null); assert.equal(onlyHalfMove(targets, "p13"), null); }); test("traces d'un coup simple : une flèche par demi-coup, départs et arrivées", () => { // L'ordinateur joue 13/9 13/7 dans sa numérotation : ses cases 13, 9 et // 7 sont les cases 12, 16 et 18 du joueur. const trace = moveTrace("computer", parseHalfMoves("13/9 13/7"), [false, false]); assert.deepEqual(trace.arrows, [ { from: "p12", to: "p16" }, { from: "p12", to: "p18" }, ]); assert.deepEqual([...trace.left], [["p12", 2]]); assert.deepEqual( [...trace.landed], [ ["p16", 1], ["p18", 1], ], ); assert.equal(trace.hits, 0); }); test("traces : un pion qui fait étape ne laisse ni fantôme ni cercle à l'étape", () => { const trace = moveTrace("computer", parseHalfMoves("24/18 18/14"), [false, true]); assert.equal(trace.arrows.length, 2); assert.deepEqual([...trace.left], [["p1", 1]]); assert.deepEqual([...trace.landed], [["p11", 1]]); assert.equal(trace.hits, 1); }); test("traces : rentrée depuis la barre avec un pion frappé, sortie de pions", () => { const entering = moveTrace("computer", parseHalfMoves("bar/22 6/4"), [true, false]); assert.deepEqual(entering.arrows[0], { from: "bar-computer", to: "p3" }); assert.deepEqual( [...entering.left], [ ["bar-computer", 1], ["p19", 1], ], ); assert.equal(entering.hits, 1); const bearingOff = moveTrace("computer", parseHalfMoves("6/off 5/off"), [false, false]); assert.deepEqual([...bearingOff.landed], [["off-computer", 2]]); assert.deepEqual( [...bearingOff.left], [ ["p19", 1], ["p20", 1], ], ); }); test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () => { const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature")); assert.equal(feature.title, "Jouer une partie contre l'ordinateur"); assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/); assert.equal(feature.scenarios[0]?.title, "Comment commence une partie ?"); assert.equal(feature.scenarios[0]?.position, "4HPwATDgc/ABMA"); assert.deepEqual(feature.scenarios[0]?.steps, [ "Quand vous commencez une nouvelle partie", "Alors les pions sont en position de départ", "Et le premier jet de dés désigne qui commence", ]); const refused = feature.scenarios.find( (s) => s.title === "Pourquoi je ne peux pas poser mon pion sur cette flèche ?", ); assert.match( refused?.description[0] ?? "", /^Une flèche où l'ordinateur a au moins deux pions/, ); assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1"); assert.equal(feature.scenarios[2]?.position, null); // Chaque scénario a des étapes : aucune n'a été prise pour du texte libre. for (const scenario of feature.scenarios) { assert.ok(scenario.steps.length >= 2, scenario.title); } }); test("aide : chaque scénario affiché est une question avec sa réponse", () => { const listed = read("docs/scenarios/README.md"); const ids = new Set(); for (const url of FEATURES) { assert.ok( listed.includes(`](${url.replace("docs/scenarios/", "")})`), `${url} hors de l'index`, ); const feature = parseFeature(read(url)); assert.ok(feature.description.length > 0, `${url} : pas d'introduction`); for (const scenario of feature.scenarios) { // La réponse se lit seule : pas de vocabulaire technique. assert.ok(scenario.description.length > 0, `${scenario.title} : pas de réponse`); for (const word of ["scénario", "événement", "moteur", "Étant donné"]) { assert.ok( !scenario.description.join(" ").includes(word), `${scenario.title} : « ${word} » dans la réponse`, ); } const id = slug(scenario.title); assert.ok(id !== "" && !ids.has(id), `${scenario.title} : identifiant ${id} en double`); ids.add(id); } } }); test("aide contextuelle : chaque question ouverte depuis la partie existe dans les scénarios", () => { const titles = FEATURES.flatMap((url) => parseFeature(read(url)).scenarios.map((s) => s.title)); for (const title of Object.values(HELP_QUESTIONS)) { assert.ok(titles.includes(title), `« ${title} » n'est plus le titre d'un scénario`); } assert.equal(helpHash("stuck"), "#/aide/je-ne-peux-rien-jouer-avec-mes-des"); assert.equal(helpHash(null), "#/aide"); }); test("glossaire : nom anglais, renvois, recherche sans accents", () => { const entry = parseGlossaryEntry(read("docs/glossaire/jan-interieur.md")); assert.ok(entry !== null); assert.equal(entry.english, "Home board"); assert.deepEqual( entry.seeAlso.map((s) => s.name), ["jan-exterieur", "sortie", "barre", "point"], ); assert.ok(matchesSearch(entry, "jan interieur")); assert.ok(matchesSearch(entry, "HOME")); assert.ok(matchesSearch(entry, "fleches sortir")); assert.ok(!matchesSearch(entry, "videau")); }); test("l'aide lit chaque fiche de l'index du glossaire", () => { const files = parseGlossaryIndex(read("docs/glossaire/README.md")); assert.ok(files.includes("videau.md")); assert.ok(!files.includes("README.md")); for (const file of files) { const entry = parseGlossaryEntry(read(`docs/glossaire/${file}`)); assert.ok(entry !== null, file); assert.ok(entry.brief.length > 10, file); } const videau = parseGlossaryEntry(read("docs/glossaire/videau.md")); assert.equal(videau?.term, "Videau"); assert.equal(videau?.english, "Cube"); assert.equal( videau?.brief, "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.", ); assert.equal(parseGlossaryEntry("pas une fiche"), null); }); test("les textes de fin de partie emploient les mots du glossaire", () => { assert.equal(TEXTS.endTitle("computer", "gammon"), "L'ordinateur gagne un gammon"); assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4."); assert.equal(TEXTS.endTitle("player", "single"), "Vous gagnez la partie simple"); assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous."); // Un double passé : le titre donne l'issue, le détail dit comment. assert.equal( `${TEXTS.droppedDouble("computer")}${TEXTS.endDetail("computer", 1, 1)}`, "Vous avez passé son double : 1 point pour lui.", ); assert.equal( TEXTS.computerMoved([6, 4], "24/18 13/9"), "L'ordinateur a joué 6-4 : 24/18 13/9.", ); });