// 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, l'enregistrement de la partie. // // 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 { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js"; import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js"; import { createGameStore } from "../src/storage/game-store.js"; import { boardModel, moveTargets, moveTrace, onlyHalfMove, placeOf, pointColor, pointSlot, } from "../src/ui/board-model.js"; import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.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, "Commencer une partie"); 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 === "Un coup interdit est refusé"); assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/); assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1"); // 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("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); } assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), { term: "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("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => { const data = new Map(); const storage = { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => void data.set(key, value), }; const store = createGameStore(storage); assert.equal(store.load(), null); const journal = appendEvent(EMPTY_JOURNAL, { type: "GameStarted", position: START_POSITION, cube: { value: 1, owner: null }, firstTurn: null, }); store.save(journal); assert.deepEqual(store.load(), journal); for (const key of data.keys()) { data.set(key, "{pas du JSON"); } assert.equal(store.load(), null); // Un stockage plein ou interdit ne casse pas la partie. const full = createGameStore({ getItem: () => null, setItem: () => { throw new Error("plein"); }, }); assert.doesNotThrow(() => full.save(journal)); }); test("les textes de fin de partie emploient les mots du glossaire", () => { assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon"); assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4."); assert.equal(TEXTS.endTitle("player", "single", false), "Vous gagnez la partie simple"); assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous."); assert.equal(TEXTS.endTitle("player", "single", true), "L'ordinateur passe"); assert.equal( TEXTS.computerMoved([6, 4], "24/18 13/9"), "L'ordinateur a joué 6-4 : 24/18 13/9.", ); });