From 31815134e5287d9d0cd34dbd60345d14e283cc53 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 15:36:54 +0100 Subject: [PATCH] =?UTF-8?q?Mes=20parties=20:=20sc=C3=A9narios,=20glossaire?= =?UTF-8?q?,=20test=20navigateur=20sur=20IndexedDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/domain/game/sgf.ts | 4 +- app/src/ui/help.ts | 2 +- app/tests/steps/mes-parties.steps.ts | 203 +++++++++++++++++++++++++++ app/tests/support/play.ts | 86 ++++++++++++ app/tests/tools/sgf-samples.ts | 73 +--------- docs/glossaire/README.md | 2 + docs/glossaire/branche.md | 7 +- docs/glossaire/partie-enregistree.md | 14 ++ docs/glossaire/sgf.md | 13 ++ docs/scenarios/README.md | 1 + docs/scenarios/mes-parties.feature | 36 +++++ tests/browser/play.mjs | 89 +++++++++++- 12 files changed, 449 insertions(+), 81 deletions(-) create mode 100644 app/tests/steps/mes-parties.steps.ts create mode 100644 app/tests/support/play.ts create mode 100644 docs/glossaire/partie-enregistree.md create mode 100644 docs/glossaire/sgf.md create mode 100644 docs/scenarios/mes-parties.feature diff --git a/app/src/domain/game/sgf.ts b/app/src/domain/game/sgf.ts index 215596f..b945ffb 100644 --- a/app/src/domain/game/sgf.ts +++ b/app/src/domain/game/sgf.ts @@ -46,9 +46,7 @@ interface SgfNode { /** * La partie au format SGF de GNU Backgammon, branches comprises (en * variantes, la branche courante d'abord). Le moteur ne sert qu'à décoder - * une position de départ particulière. - * - * Glossaire : docs/glossaire/sgf.md + * une position de départ particulière. (Le format : docs/glossaire/sgf.md.) */ export async function exportSgf( engine: Engine, diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index 67afdd5..ec14422 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -15,7 +15,7 @@ import { import { TEXTS } from "./texts.js"; /** Les scénarios montrés dans l'aide, dans l'ordre. */ -const FEATURES = ["docs/scenarios/jouer-une-partie.feature"]; +const FEATURES = ["docs/scenarios/jouer-une-partie.feature", "docs/scenarios/mes-parties.feature"]; const GLOSSARY = "docs/glossaire/"; async function fetchText(url: string): Promise { diff --git a/app/tests/steps/mes-parties.steps.ts b/app/tests/steps/mes-parties.steps.ts new file mode 100644 index 0000000..27db67f --- /dev/null +++ b/app/tests/steps/mes-parties.steps.ts @@ -0,0 +1,203 @@ +// Étapes du scénario docs/scenarios/mes-parties.feature (ADR 0008) : elles +// pilotent le domaine, branché sur le vrai moteur WebAssembly, et le +// stockage des parties sur un localStorage simulé en mémoire. Fermer puis +// rouvrir l'application revient à rouvrir ce stockage et à reprendre la +// partie en cours, comme main.ts au démarrage. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { Before, Given, Then, When } from "@cucumber/cucumber"; +import type { Engine } from "../../src/domain/game/engine.js"; +import type { GameJournal, MovePlayed } from "../../src/domain/game/events.js"; +import { Game } from "../../src/domain/game/game.js"; +import { exportSgf } from "../../src/domain/game/sgf.js"; +import type { GameLibrary } from "../../src/storage/game-library.js"; +import type { KeyValueStorage } from "../../src/storage/key-value-backend.js"; +import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js"; +import { loadNodeEngine } from "../support/node-engine.js"; +import { playMoves, seededDice } from "../support/play.js"; + +let engine: Engine; +let storage: KeyValueStorage; +let library: GameLibrary; +let game: Game; +let shownId: string; +/** Les parties jouées, dans l'ordre : identifiant et journal laissé. */ +let played: { id: string; journal: GameJournal }[]; +/** La partie d'origine, avant « Rejouer d'ici ». */ +let original: { head: string; ids: string[] }; +/** Le coup du joueur d'où l'on rejoue. */ +let replayed: MovePlayed; +let sgf: string; +let nextSeed = 1; + +Before(async () => { + engine = await loadNodeEngine(); + storage = memoryStorage(); + library = (await openGameLibrary({ localStorage: storage })).library; + played = []; +}); + +/** Commence une partie, comme l'application : un nouvel identifiant. */ +function startGame(): void { + game = Game.start(engine, { dice: seededDice(nextSeed), ply: 0 }); + shownId = `partie-${nextSeed++}`; +} + +/** Joue des coups, en enregistrant après chacun comme l'application. */ +async function play(moves: number): Promise { + for (let i = 0; i < moves; i++) { + await playMoves(game, engine, 1); + await library.save(shownId, game.journal, { level: "0ply" }); + } +} + +async function reopen(): Promise { + library = (await openGameLibrary({ localStorage: storage })).library; + const saved = await library.current(); + assert.ok(saved !== null, "aucune partie en cours"); + game = Game.resume(engine, saved.journal, { ply: 0 }); + shownId = saved.summary.id; +} + +/** « Rejouer d'ici » : revient juste avant le deuxième coup du joueur (ses + * dés déjà lancés). Renvoie ce coup. */ +function goBackToSecondMove(): MovePlayed { + original = { head: game.journal.head as string, ids: game.events.map((e) => e.id) }; + const second = game.events.filter( + (e): e is MovePlayed => e.type === "MovePlayed" && e.side === "player", + )[1]; + assert.ok(second?.parent != null, "moins de deux coups du joueur"); + game.goBackTo(second.parent); + return second; +} + +/** Joue, avec les mêmes dés, un coup légal qui mène ailleurs que first. */ +async function playAnotherMove(first: MovePlayed): Promise { + const state = game.state; + assert.ok(state.roll !== null); + const moves = await engine.legalMoves(state.position, state.roll); + // Move.position est vue de celui qui joue, MovePlayed.position de son + // adversaire : on compare les demi-coups. + const same = (m: (typeof moves)[number]) => + m.halfMoves.length === first.halfMoves.length && + m.halfMoves.every((h, i) => h.from === first.halfMoves[i]?.from); + const move = moves.find((m) => !same(m)); + assert.ok(move !== undefined, "un seul coup possible"); + for (const halfMove of move.halfMoves) { + assert.ok(await game.playHalfMove(halfMove)); + } + await game.confirmMove(); + await library.save(shownId, game.journal, { level: "0ply" }); + const last = game.events.at(-1); + assert.ok(last?.type === "MovePlayed" && last.position !== first.position); +} + +// ------------------------------------------- Reprendre une partie parmi plusieurs + +Given("vous avez joué deux parties, l'une après l'autre", async () => { + for (const moves of [5, 3]) { + startGame(); + await play(moves); + played.push({ id: shownId, journal: game.journal }); + } +}); + +When("vous fermez l'application puis la rouvrez", reopen); + +Then("la dernière partie jouée est à l'écran", () => { + const last = played.at(-1); + assert.equal(shownId, last?.id); + assert.deepEqual(game.journal, last?.journal); +}); + +Then("« Mes parties » montre vos deux parties, la dernière jouée en premier", async () => { + const ids = (await library.list()).map((s) => s.id); + assert.deepEqual(ids, played.map((p) => p.id).reverse()); +}); + +When("vous reprenez la première partie", async () => { + await library.setCurrent(played[0]?.id as string); + await reopen(); +}); + +Then("elle est à l'écran, telle que vous l'aviez laissée", () => { + assert.equal(shownId, played[0]?.id); + assert.deepEqual(game.journal, played[0]?.journal); +}); + +// ------------------------------------------- Rejouer d'ici sans perdre l'original + +Given("vous avez joué plusieurs coups", async () => { + startGame(); + await play(8); +}); + +When("vous rejouez d'ici à partir de votre deuxième coup", () => { + // Le retour seul : le coup suivant ouvrira la branche. + replayed = goBackToSecondMove(); +}); + +When("vous choisissez un autre coup", async () => { + await playAnotherMove(replayed); +}); + +Then("la partie a deux branches", () => { + assert.equal(game.branches.length, 2); +}); + +Then("la partie d'origine est intacte", () => { + const origin = game.branches.find((b) => b.forkedFrom === null); + assert.equal(origin?.head, original.head); + const copy = Game.resume(engine, game.journal); + copy.switchBranch(original.head); + assert.deepEqual( + copy.events.map((e) => e.id), + original.ids, + ); +}); + +Then("vous êtes sur la nouvelle branche", () => { + const current = game.branches.find((b) => b.containsHead); + assert.ok(current !== undefined && current.forkedFrom !== null); + assert.equal(current.head, game.journal.head); +}); + +Then("vous pouvez revenir à la partie d'origine, au coup près", () => { + const branch = game.branches.find((b) => b.forkedFrom === null); + assert.ok(branch !== undefined); + game.switchBranch(branch.head); + assert.deepEqual( + game.events.map((e) => e.id), + original.ids, + ); +}); + +// ------------------------------------------------------------ Exporter une partie + +Given("vous avez rejoué d'ici une autre suite", async () => { + await playAnotherMove(goBackToSecondMove()); + await play(2); +}); + +When("vous exportez la partie", async () => { + sgf = await exportSgf(engine, game.journal, { + playerName: "Vous", + computerName: "Ustagammon", + }); +}); + +Then("vous obtenez un fichier SGF de GNU Backgammon", () => { + assert.ok(sgf.startsWith("(;FF[4]GM[6]"), sgf.slice(0, 40)); + assert.ok(sgf.trimEnd().endsWith(")")); +}); + +Then("il contient vos coups, et l'autre branche en variante", () => { + // Un nœud par coup joué, toutes branches comprises (B ou W, puis les + // dés), et deux variantes à l'embranchement. + const moves = game.journal.events.filter((e) => e.type === "MovePlayed").length; + assert.equal(sgf.match(/;[BW]\[\d\d/g)?.length, moves); + assert.equal(sgf.match(/\(\n;/g)?.length, 2); +}); diff --git a/app/tests/support/play.ts b/app/tests/support/play.ts new file mode 100644 index 0000000..d938774 --- /dev/null +++ b/app/tests/support/play.ts @@ -0,0 +1,86 @@ +// Jouer une partie du domaine sans interface, pour les tests, les +// scénarios et les parties d'exemple : les deux camps, avec des dés +// reproductibles. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { Engine } from "../../src/domain/game/engine.js"; +import type { Game } from "../../src/domain/game/game.js"; +import type { DiceSource } from "../../src/domain/game/roll.js"; + +/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */ +export function seededDice(seed: number): DiceSource { + let state = seed; + return () => { + state = (state * 1103515245 + 12345) % 2147483648; + return 1 + (Math.floor(state / 65536) % 6); + }; +} + +/** + * Une action de la partie, quel que soit le camp qui a la main. Le joueur + * joue le choice-ième coup légal (le dernier s'il y en a moins), prend les + * doubles si take, et double quand il le peut si double. + */ +export async function step( + game: Game, + engine: Engine, + strategy: { choice?: number; take?: boolean; double?: boolean } = {}, +): Promise { + const state = game.state; + if (state.turn === "computer" && state.phase === "doubled") { + if (strategy.take ?? true) { + game.take(); + } else { + game.drop(); + } + } else if (state.turn === "computer") { + await game.playComputerTurn(); + } else if (state.phase === "rolling") { + if (strategy.double === true && (await game.canDouble())) { + await game.double(); + } else { + await game.roll(); + } + } else if (state.phase === "moving" && state.roll !== null) { + const moves = await engine.legalMoves(state.position, state.roll); + const move = moves[Math.min(strategy.choice ?? 0, moves.length - 1)]; + if (move === undefined) { + await game.skipTurn(); + return; + } + for (const halfMove of move.halfMoves) { + if (!(await game.playHalfMove(halfMove))) { + throw new Error("coup légal refusé"); + } + } + await game.confirmMove(); + } else { + throw new Error(`phase inattendue : ${state.phase}`); + } +} + +/** Joue jusqu'à count coups (des deux camps), ou jusqu'à la fin. */ +export async function playMoves( + game: Game, + engine: Engine, + count: number, + choice = 0, +): Promise { + let played = 0; + while (played < count && game.state.phase !== "over") { + const before = game.events.length; + await step(game, engine, { choice }); + played += game.events.slice(before).filter((event) => event.type === "MovePlayed").length; + } +} + +export async function playToTheEnd(game: Game, engine: Engine, choice = 0): Promise { + for (let i = 0; game.state.phase !== "over"; i++) { + if (i > 2000) { + throw new Error("la partie ne finit pas"); + } + await step(game, engine, { choice }); + } +} diff --git a/app/tests/tools/sgf-samples.ts b/app/tests/tools/sgf-samples.ts index eedc50f..dd8321b 100644 --- a/app/tests/tools/sgf-samples.ts +++ b/app/tests/tools/sgf-samples.ts @@ -15,81 +15,10 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import type { Engine } from "../../src/domain/game/engine.js"; import { Game } from "../../src/domain/game/game.js"; -import type { DiceSource } from "../../src/domain/game/roll.js"; import { exportSgf } from "../../src/domain/game/sgf.js"; import { loadNodeEngine } from "../support/node-engine.js"; - -/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */ -function seededDice(seed: number): DiceSource { - let state = seed; - return () => { - state = (state * 1103515245 + 12345) % 2147483648; - return 1 + (Math.floor(state / 65536) % 6); - }; -} - -/** - * Une action de la partie, quel que soit le camp qui a la main. Le joueur - * joue le choice-ième coup légal (le dernier s'il y en a moins), prend les - * doubles si take, et double quand il le peut si double. - */ -async function step( - game: Game, - engine: Engine, - strategy: { choice?: number; take?: boolean; double?: boolean } = {}, -): Promise { - const state = game.state; - if (state.turn === "computer" && state.phase === "doubled") { - if (strategy.take ?? true) { - game.take(); - } else { - game.drop(); - } - } else if (state.turn === "computer") { - await game.playComputerTurn(); - } else if (state.phase === "rolling") { - if (strategy.double === true && (await game.canDouble())) { - await game.double(); - } else { - await game.roll(); - } - } else if (state.phase === "moving" && state.roll !== null) { - const moves = await engine.legalMoves(state.position, state.roll); - const move = moves[Math.min(strategy.choice ?? 0, moves.length - 1)]; - if (move === undefined) { - await game.skipTurn(); - return; - } - for (const halfMove of move.halfMoves) { - if (!(await game.playHalfMove(halfMove))) { - throw new Error("coup légal refusé"); - } - } - await game.confirmMove(); - } else { - throw new Error(`phase inattendue : ${state.phase}`); - } -} - -async function playMoves(game: Game, engine: Engine, count: number, choice = 0): Promise { - let played = 0; - while (played < count && game.state.phase !== "over") { - const before = game.events.length; - await step(game, engine, { choice }); - played += game.events.slice(before).filter((event) => event.type === "MovePlayed").length; - } -} - -async function playToTheEnd(game: Game, engine: Engine, choice = 0): Promise { - for (let i = 0; game.state.phase !== "over"; i++) { - if (i > 2000) { - throw new Error("la partie ne finit pas"); - } - await step(game, engine, { choice }); - } -} +import { playMoves, playToTheEnd, seededDice, step } from "../support/play.js"; /** Le dernier événement « coup joué » de la ligne courante. */ function lastMoveId(game: Game, skip = 0): string { diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index f5e6a05..2036fc7 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -56,6 +56,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Moteur](moteur.md) | Engine | | [Niveau de jeu](niveau-de-jeu.md) | Level | | [Partie](partie.md) | Game | +| [Partie enregistrée](partie-enregistree.md) | Saved game | | [Passer](passer.md) | Drop | | [Pion](pion.md) | Checker | | [Plateau](plateau.md) | Board | @@ -64,5 +65,6 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Prendre](prendre.md) | Take | | [Règle de Crawford](regle-de-crawford.md) | Crawford rule | | [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule | +| [SGF](sgf.md) | SGF | | [Sortie](sortie.md) | Bear-off | | [Videau](videau.md) | Cube | diff --git a/docs/glossaire/branche.md b/docs/glossaire/branche.md index 11f0e33..0a2ecab 100644 --- a/docs/glossaire/branche.md +++ b/docs/glossaire/branche.md @@ -1,9 +1,14 @@ --- term: Branche english: Branch +code: Branch --- # Branche **En bref** : suite de coups rejouée à partir d'un moment d'une partie (« Rejouer d'ici ») ; la partie d'origine reste intacte. -Voir aussi : [Événement de partie](evenement-de-partie.md) +Une partie garde toutes ses branches : vous pouvez revenir à l'une ou à +l'autre depuis « Mes parties », et l'export SGF les contient toutes, en +variantes. + +Voir aussi : [Événement de partie](evenement-de-partie.md), [Partie enregistrée](partie-enregistree.md) diff --git a/docs/glossaire/partie-enregistree.md b/docs/glossaire/partie-enregistree.md new file mode 100644 index 0000000..d87f995 --- /dev/null +++ b/docs/glossaire/partie-enregistree.md @@ -0,0 +1,14 @@ +--- +term: Partie enregistrée +english: Saved game +code: SavedGameSummary +--- +# Partie enregistrée + +**En bref** : une partie gardée sur votre téléphone, finie ou en cours, avec toutes ses branches ; vous la retrouvez dans « Mes parties ». + +Chaque partie est enregistrée après chacune de vos actions : fermer +l'application ne perd rien. Rien ne quitte le téléphone, sauf si vous +exportez une partie. + +Voir aussi : [Partie](partie.md), [Branche](branche.md), [SGF](sgf.md) diff --git a/docs/glossaire/sgf.md b/docs/glossaire/sgf.md new file mode 100644 index 0000000..9910a8d --- /dev/null +++ b/docs/glossaire/sgf.md @@ -0,0 +1,13 @@ +--- +term: SGF +english: SGF +--- +# SGF + +**En bref** : format de fichier des parties de GNU Backgammon ; une partie exportée en SGF s'ouvre dans GNU Backgammon pour l'analyser. + +SGF (Smart Game Format) est un format commun à plusieurs jeux. Les branches +d'une partie y deviennent des variantes ; GNU Backgammon ouvre la branche +courante. + +Voir aussi : [Partie enregistrée](partie-enregistree.md), [Moteur](moteur.md) diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 61cfa29..bf7d640 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -47,3 +47,4 @@ Règles d'écriture : | Fichier | Tâche | Depuis | | --- | --- | --- | | [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d | +| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b | diff --git a/docs/scenarios/mes-parties.feature b/docs/scenarios/mes-parties.feature new file mode 100644 index 0000000..b353647 --- /dev/null +++ b/docs/scenarios/mes-parties.feature @@ -0,0 +1,36 @@ +# language: fr +Fonctionnalité: Retrouver mes parties + + Toutes vos parties restent sur votre téléphone, finies ou en cours, avec + leurs branches : chacune est enregistrée après chacune de vos actions, et + fermer l'application ne perd rien. Dans « Mes parties », vous en reprenez + une, vous passez d'une branche à l'autre, ou vous l'exportez au format SGF + pour l'ouvrir dans GNU Backgammon. + + Scénario: Reprendre une partie parmi plusieurs + Étant donné que vous avez joué deux parties, l'une après l'autre + Quand vous fermez l'application puis la rouvrez + Alors la dernière partie jouée est à l'écran + Et « Mes parties » montre vos deux parties, la dernière jouée en premier + Quand vous reprenez la première partie + Alors elle est à l'écran, telle que vous l'aviez laissée + + Scénario: Rejouer d'ici sans perdre l'original + Revenez à un moment passé de la partie et jouez autre chose : une + nouvelle branche commence là, et la partie d'origine reste entière. + + Étant donné que vous avez joué plusieurs coups + Quand vous rejouez d'ici à partir de votre deuxième coup + Et que vous choisissez un autre coup + Alors la partie a deux branches + Et la partie d'origine est intacte + Quand vous fermez l'application puis la rouvrez + Alors vous êtes sur la nouvelle branche + Et vous pouvez revenir à la partie d'origine, au coup près + + Scénario: Exporter une partie + Étant donné que vous avez joué plusieurs coups + Et que vous avez rejoué d'ici une autre suite + Quand vous exportez la partie + Alors vous obtenez un fichier SGF de GNU Backgammon + Et il contient vos coups, et l'autre branche en variante diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 19e99e2..5650b64 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -12,6 +12,7 @@ // premier problème, ou si la page signale une erreur (dont une violation de // la politique de sécurité du contenu). +import { readFileSync, writeFileSync } from "node:fs"; import { chromium, firefox } from "playwright"; const [base, browserName, shots] = process.argv.slice(2); @@ -78,9 +79,49 @@ function check(condition, message) { } } -/** Les coups joués de chaque camp, d'après le journal enregistré. */ +/** Le journal de la partie en cours, tel qu'enregistré dans IndexedDB + * (app/src/storage/indexeddb-backend.ts), ou null. */ +async function readJournal() { + return page.evaluate( + () => + new Promise((resolve, reject) => { + const open = indexedDB.open("ustagammon"); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction(["settings", "journals"]); + const current = tx.objectStore("settings").get("current"); + current.onsuccess = () => { + const id = current.result?.value; + const journal = id === undefined ? null : tx.objectStore("journals").get(id); + if (journal === null) { + db.close(); + resolve(null); + return; + } + journal.onsuccess = () => { + db.close(); + resolve(journal.result?.journal ?? null); + }; + }; + }; + }), + ); +} + +/** Le journal enregistré, une fois les enregistrements en cours terminés + * (l'application enregistre sans attendre) : deux lectures identiques. */ async function savedJournal() { - return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game"))); + let last = await readJournal(); + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const next = await readJournal(); + if (JSON.stringify(next) === JSON.stringify(last)) { + return next; + } + last = next; + } + throw new Error("le journal enregistré ne se stabilise pas"); } function countMoves(journal, side) { @@ -123,8 +164,7 @@ async function waitForPlayer() { return ( `${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` + `${engine.sent} commandes, ${engine.received} réponses, dernière ` + - `${JSON.stringify(engine.last)} ; journal ` + - localStorage.getItem("ustagammon.current-game") + `${JSON.stringify(engine.last)}` ); }) .catch((error) => `page illisible (${error.message})`); @@ -487,6 +527,47 @@ if (computerMs.length > 0) { } await shot("4-fin-de-partie"); +step("mes parties"); +// Mes parties (IndexedDB) : une deuxième partie commencée, les deux +// listées, la première exportée en SGF puis reprise, après rechargement. +{ + const finished = await savedJournal(); + await page.click("#again"); + await waitForPlayer(); + const second = await savedJournal(); + check(second.head !== finished.head, "la nouvelle partie n'a pas été enregistrée"); + await page.click("#games-button"); + await page.waitForSelector("#games-dialog[open] .games-item"); + const items = page.locator("#games-dialog .games-item"); + check((await items.count()) === 2, `${await items.count()} parties listées au lieu de 2`); + const oldest = items.nth(1); + await oldest.locator('[data-action="branches"]').click(); + await oldest.locator(".games-branch").first().waitFor(); + const branchCount = await oldest.locator(".games-branch").count(); + const [download] = await Promise.all([ + page.waitForEvent("download"), + oldest.locator('[data-action="export"]').click(), + ]); + const sgf = readFileSync(await download.path(), "utf8"); + writeFileSync(`${shots}/${browserName}-partie.sgf`, sgf); + check(sgf.startsWith("(;FF[4]GM[6]"), `export SGF inattendu : ${sgf.slice(0, 40)}`); + check( + (sgf.match(/;[BW]\[\d\d/g) ?? []).length === countMoves(finished, "player") + countMoves(finished, "computer"), + "export SGF : nombre de coups différent du journal", + ); + await shot("4b-mes-parties"); + await collectViolations(); + await Promise.all([page.waitForEvent("load"), oldest.locator('[data-action="resume"]').click()]); + await waitForPlayer(); + const resumed = await savedJournal(); + check(resumed.head === finished.head, "la première partie n'a pas été reprise"); + check((await page.textContent("#prompt")) === result, "écran différent après la reprise"); + log( + `mes parties : 2 parties listées (${branchCount} branche(s) pour la première), ` + + `SGF exporté (${sgf.length} octets), première partie reprise après rechargement`, + ); +} + step("aide"); // Aide : scénario et glossaire lus dans les copies de docs/. await page.click("#help-button");