259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
// 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<number, number>, extra: Partial<BoardSide> = {}): 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<string>();
|
|
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/camp.md"));
|
|
assert.ok(entry !== null);
|
|
assert.equal(entry.english, "Home board");
|
|
assert.deepEqual(
|
|
entry.seeAlso.map((s) => s.name),
|
|
["sortie", "barre", "point"],
|
|
);
|
|
assert.ok(matchesSearch(entry, "camp"));
|
|
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.",
|
|
);
|
|
});
|