Fusion de main (révision UX) ; Mes parties dans le menu, reprise sans rechargement
« Reprendre l'ancienne » relit l'ancienne partie dans le stockage par son identifiant et supprime la nouvelle (GameSession). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
// Tests de la révision UX qui se vérifient sans navigateur : géométrie du
|
||||
// plateau selon l'écran, vibrations, réglages, compte de points, plateau
|
||||
// gardé pour le chargement, et règles de la charte vérifiables dans le code.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { createSettingsStore } from "../src/storage/settings-store.js";
|
||||
import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js";
|
||||
import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js";
|
||||
import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js";
|
||||
import { ICON_PATHS } from "../src/ui/icons.js";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
LONG_PROMPT_GAMES,
|
||||
longPrompts,
|
||||
nextTheme,
|
||||
parseSettings,
|
||||
} from "../src/ui/settings.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
|
||||
|
||||
// --------------------------------------------------------------- Géométrie
|
||||
|
||||
/** Les écrans en paysage de la recette : largeur et hauteur en pixels,
|
||||
* dont 844 × 320 (Chrome garde sa barre d'adresse) et un rapport plus large
|
||||
* encore. */
|
||||
const LANDSCAPES: [number, number][] = [
|
||||
[915, 412],
|
||||
[844, 390],
|
||||
[844, 320],
|
||||
[740, 360],
|
||||
[800, 308],
|
||||
];
|
||||
|
||||
test("géométrie en portrait : les mesures du plateau d'origine", () => {
|
||||
const g = boardGeometry("portrait", 640 / 520, 396 / 640);
|
||||
assert.equal(g.width, 640);
|
||||
assert.equal(g.pointWidth, 44);
|
||||
assert.equal(g.radius, 20);
|
||||
assert.equal(g.step, 42);
|
||||
assert.equal(g.trayLeft + g.tray + g.frame, 640);
|
||||
// Numéros d'au moins 12 px à l'écran, à 412 px de large.
|
||||
assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`);
|
||||
});
|
||||
|
||||
test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => {
|
||||
for (const [width, height] of LANDSCAPES) {
|
||||
const px = height / BOARD_HEIGHT;
|
||||
const g = boardGeometry("landscape", width / height, px);
|
||||
const where = `${width}x${height}`;
|
||||
assert.ok(Math.abs(g.width / g.height - width / height) < 0.01, `${where} : proportions`);
|
||||
// Les colonnes, la barre et les flèches occupent toute la largeur.
|
||||
assert.ok(Math.abs(g.trayLeft + g.tray + g.frame - g.width) < 1e-9, `${where} : largeur`);
|
||||
assert.ok(g.pointWidth * px >= 44, `${where} : flèches de ${g.pointWidth * px} px`);
|
||||
assert.ok(2 * g.radius * px >= 22, `${where} : pions de ${2 * g.radius * px} px`);
|
||||
assert.ok(g.numberSize * px >= 12, `${where} : numéros de ${g.numberSize * px} px`);
|
||||
assert.ok(g.bandFont * px >= 13, `${where} : consigne de ${g.bandFont * px} px`);
|
||||
assert.ok(g.chipFont * px >= 13, `${where} : comptes de ${g.chipFont * px} px`);
|
||||
assert.ok(g.target * px >= 44, `${where} : cibles de ${g.target * px} px`);
|
||||
assert.ok(g.cubeFont * px >= 13, `${where} : videau en ${g.cubeFont * px} px`);
|
||||
assert.ok(g.countFont * px >= 13, `${where} : nombres des piles en ${g.countFont * px} px`);
|
||||
assert.ok(g.cube <= g.left - 8, `${where} : videau plus large que sa colonne`);
|
||||
assert.ok(g.left - 4 >= g.target - 4, `${where} : colonne de commandes trop étroite`);
|
||||
// Les numéros tiennent dans le cadre.
|
||||
assert.ok(g.numberSize < g.edge, `${where} : numéros hors du cadre`);
|
||||
}
|
||||
});
|
||||
|
||||
test("une pile de cinq pions ne déborde pas dans la bande centrale (dés, consigne)", () => {
|
||||
const cases = [
|
||||
boardGeometry("portrait", 640 / 520, 0.62),
|
||||
...[1.9, 2.16, 2.6].map((aspect) => boardGeometry("landscape", aspect, 0.615)),
|
||||
];
|
||||
for (const g of cases) {
|
||||
const band = g.height / 2 - g.die / 2;
|
||||
const last = stackY(g, true, MAX_STACK - 1) + g.radius;
|
||||
assert.ok(last <= band, `${g.layout} ${g.width} : pion jusqu'à ${last}, dés dès ${band}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comptes", () => {
|
||||
for (const [width, height] of LANDSCAPES) {
|
||||
const g = boardGeometry("landscape", width / height, height / BOARD_HEIGHT);
|
||||
const lastOffset = g.radius + 4 + (g.barStack - 1) * g.step + g.radius;
|
||||
const chipEdge = g.height / 2 - (g.edge + 4 + g.chipHeight);
|
||||
assert.ok(g.barStack >= 1);
|
||||
assert.ok(lastOffset <= chipEdge, `${width}x${height} : ${lastOffset} > ${chipEdge}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- Vibrations
|
||||
|
||||
test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => {
|
||||
const calls: number[][] = [];
|
||||
let on = true;
|
||||
const haptic = createHaptics(
|
||||
(pattern) => {
|
||||
calls.push(pattern);
|
||||
return true;
|
||||
},
|
||||
() => on,
|
||||
);
|
||||
haptic("checkerPlaced");
|
||||
haptic("checkerHit");
|
||||
haptic("computerDoubles");
|
||||
haptic("gameWon");
|
||||
haptic("gameLost");
|
||||
assert.deepEqual(calls, [[12], [25, 40, 25], [40, 60, 40], [30, 50, 30, 50, 60], [60]]);
|
||||
on = false;
|
||||
haptic("checkerHit");
|
||||
assert.equal(calls.length, 5);
|
||||
// Sans navigator.vibrate, ou si le navigateur refuse : aucune erreur.
|
||||
assert.doesNotThrow(() => createHaptics(undefined, () => true)("gameWon"));
|
||||
const refusing = createHaptics(
|
||||
() => {
|
||||
throw new Error("refusé");
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
assert.doesNotThrow(() => refusing("gameWon"));
|
||||
// Aucun motif ne dépasse 60 ms par impulsion (interaction.md).
|
||||
for (const pattern of Object.values(HAPTIC_PATTERNS)) {
|
||||
assert.ok(pattern.every((ms) => ms > 0 && ms <= 60));
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Réglages
|
||||
|
||||
test("réglages : valeurs par défaut, lecture tolérante, thème en boucle", () => {
|
||||
assert.deepEqual(parseSettings(null), DEFAULT_SETTINGS);
|
||||
assert.equal(DEFAULT_SETTINGS.vibrations, true);
|
||||
assert.equal(DEFAULT_SETTINGS.keepScreenOn, true);
|
||||
assert.deepEqual(parseSettings("pas du JSON"), DEFAULT_SETTINGS);
|
||||
assert.deepEqual(parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}'), {
|
||||
...DEFAULT_SETTINGS,
|
||||
vibrations: false,
|
||||
theme: "dark",
|
||||
gamesStarted: 4,
|
||||
});
|
||||
assert.deepEqual(parseSettings('{"vibrations": "non", "theme": "rose", "gamesStarted": -1}'), {
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
assert.equal(nextTheme("auto"), "light");
|
||||
assert.equal(nextTheme("light"), "dark");
|
||||
assert.equal(nextTheme("dark"), "auto");
|
||||
});
|
||||
|
||||
test("consigne longue pendant les trois premières parties, courte ensuite", () => {
|
||||
assert.equal(LONG_PROMPT_GAMES, 3);
|
||||
for (const games of [0, 1, 2, 3]) {
|
||||
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: games }), true);
|
||||
}
|
||||
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: 4 }), false);
|
||||
});
|
||||
|
||||
test("stockage des réglages et du dernier plateau affiché", () => {
|
||||
const data = new Map<string, string>();
|
||||
const store = createSettingsStore({
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => void data.set(key, value),
|
||||
});
|
||||
assert.equal(store.loadSettings(), null);
|
||||
store.saveSettings('{"vibrations":false}');
|
||||
assert.equal(store.loadSettings(), '{"vibrations":false}');
|
||||
assert.equal(store.loadBoard(), null);
|
||||
const board = { sides: OPENING_BOARDS, cube: { value: 2, owner: "player" as const } };
|
||||
store.saveBoard(board);
|
||||
assert.deepEqual(store.loadBoard(), board);
|
||||
// Un plateau abîmé est ignoré : la position de départ sera dessinée.
|
||||
data.set("ustagammon.last-board", '{"sides": {"player": {"points": [1]}}}');
|
||||
assert.equal(store.loadBoard(), null);
|
||||
const full = createSettingsStore({
|
||||
getItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
});
|
||||
assert.doesNotThrow(() => full.saveBoard(board));
|
||||
assert.equal(full.loadBoard(), null);
|
||||
assert.equal(full.loadSettings(), null);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Compte de points
|
||||
|
||||
test("compte de points : le plus petit mène, de l'écart", () => {
|
||||
assert.deepEqual(pipLead({ player: 136, computer: 128 }), { leader: "computer", by: 8 });
|
||||
assert.deepEqual(pipLead({ player: 100, computer: 128 }), { leader: "player", by: 28 });
|
||||
assert.deepEqual(pipLead({ player: 167, computer: 167 }), { leader: null, by: 0 });
|
||||
// La position de départ : 15 pions, 167 points chacun.
|
||||
for (const side of [OPENING_BOARDS.player, OPENING_BOARDS.computer]) {
|
||||
assert.equal(
|
||||
side.points.reduce((a, b) => a + b, 0),
|
||||
15,
|
||||
);
|
||||
assert.equal(
|
||||
side.points.reduce((sum, n, i) => sum + n * (i + 1), 0),
|
||||
side.pipCount,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Charte
|
||||
|
||||
/** Les fichiers .ts d'un dossier, récursivement. */
|
||||
function sources(dir: string): string[] {
|
||||
const url = new URL(dir, root);
|
||||
return readdirSync(url).flatMap((name) => {
|
||||
const path = `${dir}${name}`;
|
||||
return statSync(new URL(path, root)).isDirectory() ? sources(`${path}/`) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => {
|
||||
for (const path of sources("app/src/")) {
|
||||
const text = read(path);
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
|
||||
`${path} appelle une boîte native`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("les deux blocs du thème sombre de app.css sont identiques", () => {
|
||||
const css = read("web/app.css");
|
||||
const block = (start: string) => {
|
||||
const from = css.indexOf(start);
|
||||
assert.ok(from >= 0, `bloc ${start} absent`);
|
||||
const body = css.slice(css.indexOf("{", from + start.length - 1) + 1);
|
||||
return body
|
||||
.slice(0, body.indexOf("}"))
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
assert.deepEqual(
|
||||
block(':root:not([data-theme="light"]) {'),
|
||||
block(':root[data-theme="dark"] {'),
|
||||
);
|
||||
});
|
||||
|
||||
test("aucun style en ligne ni attribut style dans la page et le code (CSP)", () => {
|
||||
assert.doesNotMatch(read("web/index.html"), /\sstyle=|<style/);
|
||||
for (const path of sources("app/src/")) {
|
||||
assert.doesNotMatch(read(path), /setAttribute\(\s*"style"|\.style\./, path);
|
||||
}
|
||||
});
|
||||
|
||||
test("pictogrammes : le jeu de la charte, en tracés SVG", () => {
|
||||
for (const name of [
|
||||
"menu",
|
||||
"help",
|
||||
"fullscreen",
|
||||
"exitFullscreen",
|
||||
"undo",
|
||||
"replay",
|
||||
"close",
|
||||
]) {
|
||||
assert.ok(name in ICON_PATHS, `pictogramme ${name} absent`);
|
||||
}
|
||||
for (const d of Object.values(ICON_PATHS)) {
|
||||
assert.match(d, /^M[\d.\s,a-zA-Z-]+$/);
|
||||
}
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import type { GameState } from "../../src/domain/game/state.js";
|
||||
import { boardModel, pipLead } from "../../src/ui/board-model.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
// Le chargement du moteur prend une à deux secondes sur le PC de 2011.
|
||||
@@ -283,7 +284,7 @@ Then("c'est toujours à vous", () => {
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
When("vous continuez", async () => {
|
||||
When("vous laissez passer votre tour", async () => {
|
||||
await game.skipTurn();
|
||||
const event = lastEvent();
|
||||
assert.ok(event.type === "MovePlayed" && event.side === "player");
|
||||
@@ -344,6 +345,27 @@ Then("vous pouvez revoir son coup", async () => {
|
||||
assert.deepEqual(await game.lastMoveSteps(), computerMove);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Compte de points
|
||||
|
||||
// Les bandeaux lisent le compte de points du plateau donné par le moteur ;
|
||||
// qui mène se calcule dans l'interface (board-model.ts).
|
||||
|
||||
async function pips() {
|
||||
return boardModel(await game.displayedBoard()).pipCount;
|
||||
}
|
||||
|
||||
Then("chaque camp a un compte de points de {int}", async (n: number) => {
|
||||
assert.deepEqual(await pips(), { player: n, computer: n });
|
||||
});
|
||||
|
||||
Then("personne ne mène la course", async () => {
|
||||
assert.equal(pipLead(await pips()).leader, null);
|
||||
});
|
||||
|
||||
Then("vous menez la course de {int}", async (n: number) => {
|
||||
assert.deepEqual(pipLead(await pips()), { leader: "player", by: n });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Videau
|
||||
|
||||
// La confirmation elle-même est une question de l'interface (une fenêtre
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Étapes du scénario docs/scenarios/regler-l-application.feature (ADR 0008) :
|
||||
// la partie reprise passe par le domaine et le vrai moteur, et par le même
|
||||
// enregistrement que l'application ; les réglages et les vibrations par les
|
||||
// modules de l'interface qui les portent (settings.ts, haptics.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { GameJournal } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import { createGameLibrary, type GameLibrary } from "../../src/storage/game-library.js";
|
||||
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
|
||||
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { BANNER_MS } from "../../src/ui/app.js";
|
||||
import { createHaptics } from "../../src/ui/haptics.js";
|
||||
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
|
||||
let engine: Engine;
|
||||
let game: Game;
|
||||
let oldJournal: GameJournal;
|
||||
let oldBoard: unknown;
|
||||
let settings: Settings;
|
||||
let vibrations: number[][] = [];
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
// -------------------------------------------------- Nouvelle partie annulable
|
||||
|
||||
// Comme dans l'application : chaque partie est enregistrée par la session
|
||||
// (ici sur un stockage en mémoire), et l'ancienne est relue dans le
|
||||
// stockage par son identifiant.
|
||||
let library: GameLibrary;
|
||||
let session: GameSession;
|
||||
let oldId: string | null;
|
||||
|
||||
Given("une partie est en cours depuis quelques coups", async () => {
|
||||
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
let dice = fixedDice([3, 1, 6, 4, 5, 2]);
|
||||
session = createGameSession(
|
||||
library,
|
||||
{
|
||||
start: () => Game.start(engine, { dice }),
|
||||
resume: (journal) => Game.resume(engine, journal, { dice }),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
game = session.newGame();
|
||||
dice = () => 1 + Math.floor(Math.random() * 6);
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
session.save(game.journal);
|
||||
oldId = session.shownId;
|
||||
assert.equal(game.state.turn, "player");
|
||||
});
|
||||
|
||||
When("vous commencez une nouvelle partie depuis le menu", async () => {
|
||||
oldJournal = game.journal;
|
||||
oldBoard = await game.displayedBoard();
|
||||
game = session.newGame();
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("une nouvelle partie commence aussitôt", () => {
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.ok(game.events.every((e) => e.type === "GameStarted" || e.type === "DiceRolled"));
|
||||
});
|
||||
|
||||
Then("l'ancienne partie peut être reprise pendant {int} secondes", (seconds: number) => {
|
||||
assert.equal(BANNER_MS, seconds * 1000);
|
||||
});
|
||||
|
||||
When("vous reprenez l'ancienne partie", async () => {
|
||||
const old = await session.resumePrevious();
|
||||
assert.ok(old !== null);
|
||||
game = old;
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("vous retrouvez exactement la partie quittée", async () => {
|
||||
assert.equal(session.shownId, oldId);
|
||||
assert.deepEqual((await library.current())?.journal, oldJournal);
|
||||
// La nouvelle partie, abandonnée, est supprimée.
|
||||
assert.deepEqual(
|
||||
(await library.list()).map((s) => s.id),
|
||||
[oldId],
|
||||
);
|
||||
assert.deepEqual(game.journal, oldJournal);
|
||||
assert.deepEqual(await game.displayedBoard(), oldBoard);
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- Réglages
|
||||
|
||||
Given("vous ouvrez l'application pour la première fois", () => {
|
||||
settings = parseSettings(null);
|
||||
vibrations = [];
|
||||
});
|
||||
|
||||
const haptic = createHaptics(
|
||||
(pattern) => {
|
||||
vibrations.push(pattern);
|
||||
return true;
|
||||
},
|
||||
() => settings.vibrations,
|
||||
);
|
||||
|
||||
When("un pion est frappé", () => {
|
||||
vibrations = [];
|
||||
haptic("checkerHit");
|
||||
});
|
||||
|
||||
Then("le téléphone vibre deux fois, brièvement", () => {
|
||||
assert.deepEqual(vibrations, [[25, 40, 25]]);
|
||||
});
|
||||
|
||||
When("vous coupez les vibrations dans le menu", () => {
|
||||
settings = { ...settings, vibrations: false };
|
||||
});
|
||||
|
||||
Then("le téléphone ne vibre pas", () => {
|
||||
assert.deepEqual(vibrations, []);
|
||||
});
|
||||
|
||||
Then("l'écran reste allumé pendant une partie", () => {
|
||||
assert.equal(settings.keepScreenOn, true);
|
||||
});
|
||||
|
||||
When("vous coupez « Écran allumé pendant la partie » dans le menu", () => {
|
||||
settings = parseSettings(JSON.stringify({ ...settings, keepScreenOn: false }));
|
||||
});
|
||||
|
||||
Then("l'écran peut se mettre en veille comme d'habitude", () => {
|
||||
assert.equal(settings.keepScreenOn, false);
|
||||
});
|
||||
|
||||
Then("le thème suit celui du téléphone", () => {
|
||||
assert.equal(settings.theme, "auto");
|
||||
});
|
||||
|
||||
When("vous changez de thème dans le menu", () => {
|
||||
// Rien : l'étape suivante fait le tour des thèmes.
|
||||
});
|
||||
|
||||
Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
|
||||
const seen = [settings.theme];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
seen.push(nextTheme(seen.at(-1) ?? "auto"));
|
||||
}
|
||||
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
|
||||
});
|
||||
@@ -9,14 +9,19 @@
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { branches } from "../src/domain/game/branches.js";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { createGameLibrary, isJournal, newGameId } from "../src/storage/game-library.js";
|
||||
import { createGameSession } from "../src/storage/game-session.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
@@ -216,3 +221,47 @@ test("les identifiants de partie sont distincts", () => {
|
||||
assert.ok(ids.size > 95);
|
||||
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
|
||||
});
|
||||
|
||||
test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => {
|
||||
const games = library();
|
||||
// Game.start et Game.resume n'interrogent pas le moteur.
|
||||
const engine = {} as Engine;
|
||||
const session = createGameSession(
|
||||
games,
|
||||
{
|
||||
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
|
||||
resume: (j) => Game.resume(engine, j),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
assert.equal(await session.restore(), null);
|
||||
assert.equal(await session.resumePrevious(), null);
|
||||
|
||||
const first = session.newGame();
|
||||
const firstId = session.shownId as string;
|
||||
session.save(first.journal);
|
||||
const second = session.newGame();
|
||||
session.save(second.journal);
|
||||
assert.notEqual(session.shownId, firstId);
|
||||
|
||||
// L'ancienne revient, relue dans le stockage ; la nouvelle disparaît.
|
||||
const back = await session.resumePrevious();
|
||||
assert.deepEqual(back?.journal, first.journal);
|
||||
assert.equal(session.shownId, firstId);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
[firstId],
|
||||
);
|
||||
assert.equal(await session.resumePrevious(), null);
|
||||
|
||||
// Une branche reprise devient la ligne courante enregistrée.
|
||||
let j = goBackTo(first.journal, "1");
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [6, 1] });
|
||||
await games.save(firstId, j, { level: "2ply" });
|
||||
const original = branches(j).find((b) => b.forkedFrom === null);
|
||||
assert.ok(original !== undefined);
|
||||
const opened = await session.open(firstId, original.head);
|
||||
assert.equal(opened.journal.head, original.head);
|
||||
assert.equal((await games.current())?.journal.head, original.head);
|
||||
await assert.rejects(session.open("inconnue"), /introuvable/);
|
||||
});
|
||||
|
||||
@@ -175,13 +175,13 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
});
|
||||
|
||||
test("les textes de fin de partie emploient les mots du glossaire", () => {
|
||||
assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points.");
|
||||
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.youLose("single", 1),
|
||||
"L'ordinateur gagne la partie simple : 1 point pour lui.",
|
||||
);
|
||||
assert.equal(
|
||||
TEXTS.computerMoved([6, 4], "24/18 13/9", 312),
|
||||
"L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s).",
|
||||
TEXTS.computerMoved([6, 4], "24/18 13/9"),
|
||||
"L'ordinateur a joué 6-4 : 24/18 13/9.",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user