Étape 0d.1 : domaine « partie » en TypeScript, scénario exécuté ; ADR 0009 accepté #11
@@ -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" },
|
||||
},
|
||||
};
|
||||
@@ -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<boolean> {
|
||||
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 });
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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é
|
||||
|
||||
|
||||
@@ -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 ]
|
||||
Reference in New Issue
Block a user