From 1f9981dc634a92b2bf8d779a07202464a69824a4 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 20:42:08 +0100 Subject: [PATCH] =?UTF-8?q?Sc=C3=A9nario=20=C2=AB=20Jouer=20une=20partie?= =?UTF-8?q?=20=C2=BB=20ex=C3=A9cut=C3=A9=20;=20garde-fous=20d'architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les étapes de docs/scenarios/jouer-une-partie.feature pilotent le domaine sur le vrai moteur WebAssembly, avec des dés imposés (Cucumber, sous Node). Deux retouches du scénario : 8/4 avec 3-1 est un coup permis (8/5/4), remplacé par 13/12 (case tenue par l'ordinateur) ; « c'est ensuite au tour de l'ordinateur » distingue le constat de la situation de départ du même nom. dependency-cruiser vérifie que le domaine n'importe que le domaine, que l'interface n'appelle pas le moteur et qu'il n'y a pas de cycle ; tests/test_web.sh prouve aussi que des violations volontaires sont détectées. Co-Authored-By: Claude Opus 5.5 --- app/.dependency-cruiser.cjs | 43 ++++ app/tests/steps/jouer-une-partie.steps.ts | 242 ++++++++++++++++++++++ docs/scenarios/README.md | 6 +- docs/scenarios/jouer-une-partie.feature | 7 +- tests/test_web.sh | 65 ++++++ 5 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 app/.dependency-cruiser.cjs create mode 100644 app/tests/steps/jouer-une-partie.steps.ts create mode 100644 tests/test_web.sh diff --git a/app/.dependency-cruiser.cjs b/app/.dependency-cruiser.cjs new file mode 100644 index 0000000..3b59596 --- /dev/null +++ b/app/.dependency-cruiser.cjs @@ -0,0 +1,43 @@ +// Règles d'architecture de l'application (ADR 0007, point 2 ; ADR 0009), +// vérifiées par dependency-cruiser dans make test (tests/test_web.sh). +// Les chemins sont relatifs au dossier app/. + +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + { + name: "domain-is-pure", + comment: + "Le domaine n'importe que le domaine : ni le moteur (src/engine), ni " + + "l'interface (src/ui), ni Node, ni bibliothèque, ni code du navigateur. " + + "Il décrit ce dont il a besoin (interface Engine) ; les adaptateurs " + + "l'implémentent.", + severity: "error", + from: { path: "^src/domain/" }, + to: { pathNot: "^src/domain/" }, + }, + { + name: "ui-does-not-call-engine", + comment: + "Aucun écran n'appelle le moteur directement : l'interface passe par le domaine.", + severity: "error", + from: { path: "^src/ui/" }, + to: { path: "^src/engine/" }, + }, + { + name: "no-circular", + comment: "Pas de dépendance circulaire.", + severity: "error", + from: {}, + to: { circular: true }, + }, + ], + options: { + // Les imports de types comptent aussi : ils disparaissent à la + // compilation, mais créent bien une dépendance dans le code source. + tsPreCompilationDeps: true, + // Résolution des imports comme TypeScript : « ./events.js » désigne + // le fichier events.ts. Le tsconfig.json est celui du dossier courant. + tsConfig: { fileName: "tsconfig.json" }, + }, +}; diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts new file mode 100644 index 0000000..f1f3050 --- /dev/null +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -0,0 +1,242 @@ +// Étapes du scénario docs/scenarios/jouer-une-partie.feature (ADR 0008) : +// elles pilotent le domaine, branché sur le vrai moteur WebAssembly, avec des +// dés imposés pour que chaque exécution soit identique. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber"; +import type { Engine, Move } from "../../src/domain/game/engine.js"; +import type { GameEvent } from "../../src/domain/game/events.js"; +import { Game, STANDARD_PLY } from "../../src/domain/game/game.js"; +import { type PositionId, parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js"; +import { fixedDice } from "../../src/domain/game/roll.js"; +import type { GameState } from "../../src/domain/game/state.js"; +import { loadNodeEngine } from "../support/node-engine.js"; + +// Le chargement du moteur prend une à deux secondes sur le PC de 2011. +setDefaultTimeout(30_000); + +// Course où l'ordinateur, au trait, mène nettement : le moteur lui recommande +// de doubler, et au joueur de prendre (position de référence +// « cube-double-take », tests/reference/positions.json). +const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA"; + +// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5 ; +// l'ordinateur n'en a sorti aucun. +const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA"; + +let engine: Engine; +let game: Game; +let accepted: boolean; +let positionBefore: PositionId; +let computerTurn: { before: GameState; ms: number }; + +BeforeAll(async () => { + engine = await loadNodeEngine(); +}); + +Before(() => { + accepted = false; +}); + +/** Joue les demi-coups un par un ; au premier refus, le coup est abandonné. */ +async function play(text: string): Promise { + positionBefore = game.displayedPosition; + for (const halfMove of parseHalfMoves(text)) { + if (!(await game.playHalfMove(halfMove))) { + game.cancelHalfMoves(); + return false; + } + } + return true; +} + +function lastEvent(): GameEvent { + const event = game.events.at(-1); + assert.ok(event !== undefined, "aucun événement"); + return event; +} + +// ------------------------------------------------------- Commencer une partie + +When("vous commencez une nouvelle partie", () => { + // Égalité au premier jet d'ouverture, puis 5 contre 2. + game = Game.start(engine, { dice: fixedDice([4, 4, 5, 2]) }); +}); + +Then("les pions sont en position de départ", async () => { + assert.equal(game.state.position, START_POSITION); + const board = await engine.board(game.state.position, { value: 1, owner: "centered" }); + const start = Array.from({ length: 24 }, (_, i) => ({ 5: 5, 7: 3, 12: 5, 23: 2 })[i] ?? 0); + for (const side of [board.onRoll, board.opponent]) { + assert.deepEqual(side.points, start); + assert.equal(side.bar, 0); + assert.equal(side.off, 0); + } +}); + +Then("le premier jet de dés désigne qui commence", () => { + const opening = game.events.filter((e) => e.type === "DiceRolled" && e.side === null); + const last = opening.at(-1); + assert.ok(last?.type === "DiceRolled"); + // Les jets d'ouverture égaux ont été relancés. + for (const tie of opening.slice(0, -1)) { + assert.ok(tie.type === "DiceRolled" && tie.roll[0] === tie.roll[1]); + } + const [playerDie, computerDie] = last.roll; + assert.notEqual(playerDie, computerDie); + assert.equal(game.state.turn, playerDie > computerDie ? "player" : "computer"); + assert.equal(game.state.phase, "moving"); + assert.deepEqual(game.state.roll, last.roll); +}); + +// ------------------------------------------------------------ Jouer un coup + +Given("c'est à vous de jouer avec le jet {int}-{int}", (first: number, second: number) => { + // Le jet d'ouverture : votre dé, puis celui de l'ordinateur. + game = Game.start(engine, { dice: fixedDice([first, second]) }); + assert.equal(game.state.turn, "player"); + assert.deepEqual(game.state.roll, [first, second]); +}); + +When(/^vous jouez (.+)$/, async (text: string) => { + accepted = await play(text); +}); + +When(/^vous essayez de jouer (.+)$/, async (text: string) => { + accepted = await play(text); +}); + +Then("votre coup est accepté", () => { + assert.equal(accepted, true); + const event = lastEvent(); + assert.ok(event.type === "MovePlayed"); + assert.equal(event.side, "player"); +}); + +Then("c'est ensuite au tour de l'ordinateur", () => { + assert.equal(game.state.turn, "computer"); + assert.equal(game.state.phase, "rolling"); +}); + +Then("votre coup est refusé", () => { + assert.equal(accepted, false); +}); + +Then("la position n'a pas changé", () => { + assert.equal(game.displayedPosition, positionBefore); + assert.equal(game.state.position, positionBefore); + assert.equal(game.state.turn, "player"); + assert.equal(game.state.phase, "moving"); + assert.equal(lastEvent().type, "DiceRolled"); +}); + +// ------------------------------------------------------- L'ordinateur joue + +Given("c'est au tour de l'ordinateur", async () => { + // Vous commencez avec 3-1, puis l'ordinateur tirera 6-4. + game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) }); + assert.equal(await play("8/5 6/5"), true); + assert.equal(game.state.turn, "computer"); +}); + +When("ses dés sont tirés", async () => { + const before = game.state; + const start = performance.now(); + await game.playComputerTurn(); + computerTurn = { before, ms: performance.now() - start }; +}); + +Then( + "il joue le coup recommandé par le moteur en moins de {int} secondes", + async (seconds: number) => { + const rolled = game.events.findLast((e) => e.type === "DiceRolled"); + const played = lastEvent(); + assert.ok(rolled?.type === "DiceRolled" && rolled.side === "computer"); + assert.ok(played.type === "MovePlayed" && played.side === "computer"); + const best: Move | null = await engine.bestMove( + computerTurn.before.position, + { value: 1, owner: "centered" }, + rolled.roll, + STANDARD_PLY, + ); + assert.ok(best !== null); + assert.deepEqual(played.halfMoves, best.halfMoves); + assert.ok( + computerTurn.ms < seconds * 1000, + `tour de l'ordinateur en ${Math.round(computerTurn.ms)} ms`, + ); + }, +); + +// ------------------------------------------------------------------ Videau + +Given("l'ordinateur vous propose de doubler", async () => { + game = Game.start(engine, { setup: { position: COMPUTER_DOUBLES, firstTurn: "computer" } }); + await game.playComputerTurn(); + const event = lastEvent(); + assert.ok(event.type === "CubeOffered" && event.side === "computer"); + assert.equal(game.state.phase, "doubled"); +}); + +When("vous prenez", () => { + game.take(); +}); + +When("vous passez", () => { + game.drop(); +}); + +Then("la partie continue à valeur doublée", () => { + assert.equal(game.state.cube.value, 2); + assert.equal(game.state.result, null); + assert.equal(game.state.phase, "rolling"); +}); + +Then("le videau est de votre côté : vous seul pourrez redoubler", async () => { + const { cube, position } = game.state; + assert.equal(cube.owner, "player"); + // Le moteur le confirme : l'ordinateur, au trait, ne peut plus doubler… + const computer = await engine.cubeDecision( + position, + { value: 2, owner: "opponent" }, + STANDARD_PLY, + ); + assert.equal(computer.available, false); + // … alors que vous, à votre tour, le pourrez. + const board = await engine.board(position, { value: 2, owner: "opponent" }); + const player = await engine.cubeDecision( + board.swappedPosition, + { value: 2, owner: "onRoll" }, + STANDARD_PLY, + ); + assert.equal(player.available, true); +}); + +Then("la partie s'arrête", () => { + assert.equal(game.state.phase, "over"); + assert.equal(lastEvent().type, "GameEnded"); +}); + +Then("vous perdez la partie à sa valeur d'avant le double", () => { + assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 1 }); +}); + +// --------------------------------------------------------- Fin de partie + +Given("un joueur a sorti tous ses pions", async () => { + game = Game.start(engine, { + setup: { position: PLAYER_ALMOST_DONE, firstTurn: "player" }, + dice: fixedDice([6, 5]), + }); + await game.roll(); + assert.equal(await play("6/off 5/off"), true); +}); + +Then("le résultat indique le gagnant et les points gagnés", () => { + // Gammon (l'ordinateur n'a sorti aucun pion), mais 1 point seulement : + // règle Jacoby, le videau n'a pas bougé. + assert.deepEqual(game.state.result, { winner: "player", kind: "gammon", points: 1 }); +}); diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 481e511..6a9aea1 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -5,8 +5,10 @@ joueur, écrit pour lui. Ces fichiers sont la seule source (ADR 0008) : l'application les met en forme comme pages d'aide, et ils sont exécutés comme tests d'intégration sur le domaine et le moteur réel. -Tant que le domaine n'existe pas, les scénarios se déroulent à la main : ils -servent alors de guide de test. +`make test` les exécute avec Cucumber : les étapes, définies dans +`app/tests/steps/`, pilotent le domaine branché sur le vrai moteur +WebAssembly, avec des dés imposés pour que chaque exécution soit identique. Une +étape sans définition fait échouer les tests. ## Format diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 219946c..bd46470 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -14,11 +14,14 @@ Fonctionnalité: Jouer une partie contre l'ordinateur Étant donné que c'est à vous de jouer avec le jet 3-1 Quand vous jouez 8/5 6/5 Alors votre coup est accepté - Et c'est au tour de l'ordinateur + Et c'est ensuite au tour de l'ordinateur Scénario: Un coup interdit est refusé + La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne + pouvez pas vous y poser. + Étant donné que c'est à vous de jouer avec le jet 3-1 - Quand vous essayez de jouer 8/4 + Quand vous essayez de jouer 13/12 Alors votre coup est refusé Et la position n'a pas changé diff --git a/tests/test_web.sh b/tests/test_web.sh new file mode 100644 index 0000000..64baee1 --- /dev/null +++ b/tests/test_web.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Teste l'application web (app/, ADR 0009), dans l'image ustagammon/web : +# - tests unitaires du domaine (faux moteur) et partie complète sur le vrai +# moteur WebAssembly (node:test) ; +# - scénarios de docs/scenarios/ exécutés par Cucumber sur le domaine branché +# au vrai moteur (ADR 0008) ; +# - règles d'architecture (dependency-cruiser, ADR 0007), et preuve qu'une +# violation volontaire de chaque règle est bien détectée. +# +# Lance `make engine-wasm` et `make web` (sans effet si tout est à jour). + +set -euo pipefail +cd "$(dirname "$0")/.." + +image="${WEB_IMAGE:?lancer via make test}" + +# Lancé depuis `make test` : repartir d'un make indépendant du make parent. +unset MAKEFLAGS MFLAGS MAKELEVEL +make engine-wasm >/dev/null +make web >/dev/null + +errors=0 +report() { + echo " $1" + errors=$((errors + 1)) +} + +# Lance une commande des outils web, dépôt monté sous /src ; le premier +# argument est le dossier de travail, relatif au dépôt. +web() { + local dir="$1" + shift + docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src" -w "/src/$dir" "$image" "$@" +} + +web . node --test --test-reporter=dot 'build/web/tests/**/*.test.js' \ + || report "tests unitaires ou partie complète en échec (voir ci-dessus)" + +web . cucumber-js --format summary --import 'build/web/tests/steps/*.js' docs/scenarios/*.feature \ + || report "scénarios en échec (voir ci-dessus)" + +web app depcruise --config .dependency-cruiser.cjs src tests \ + || report "règles d'architecture non respectées (voir ci-dessus)" + +# Violations volontaires, dans un dossier jetable sous build/ : chaque règle +# doit être signalée par dependency-cruiser. +fixture=build/web-violations +rm -rf "$fixture" +mkdir -p "$fixture/src/domain" "$fixture/src/engine" "$fixture/src/ui" +cp app/tsconfig.json "$fixture/" +echo 'import { engine } from "../engine/adapter.js"; export const a = engine;' >"$fixture/src/domain/uses-engine.ts" +echo 'import { readFileSync } from "node:fs"; export const b = readFileSync;' >"$fixture/src/domain/uses-node.ts" +echo 'export const engine = 1;' >"$fixture/src/engine/adapter.ts" +echo 'import { engine } from "../engine/adapter.js"; export const c = engine;' >"$fixture/src/ui/uses-engine.ts" +echo 'import { e } from "./loop-b.js"; export const d = e;' >"$fixture/src/ui/loop-a.ts" +echo 'import { d } from "./loop-a.js"; export const e = d;' >"$fixture/src/ui/loop-b.ts" +output="$(web "$fixture" depcruise --config /src/app/.dependency-cruiser.cjs src 2>&1)" \ + && report "dependency-cruiser accepte des violations volontaires" +for rule in domain-is-pure ui-does-not-call-engine no-circular; do + grep -q "$rule" <<<"$output" || report "violation volontaire de $rule non détectée" +done +grep -q 'uses-node' <<<"$output" || report "import de Node dans le domaine non détecté" +rm -rf "$fixture" + +[ "$errors" -eq 0 ]