220 lines
9.3 KiB
TypeScript
220 lines
9.3 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, 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<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, "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<string, string>();
|
|
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.",
|
|
);
|
|
});
|