Bilan et analyse : scénario, fiches du glossaire, test dans le navigateur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 10:30:17 +01:00
co-authored by Claude Opus 5.5
parent 006fd62704
commit 2c5ae08f46
8 changed files with 748 additions and 5 deletions
+14 -5
View File
@@ -9,10 +9,12 @@
# captures dans build/browser-shots/match/.
#
# Le conseil du maître (tests/browser/maitre.mjs), captures dans
# build/browser-shots/maitre/.
# build/browser-shots/maitre/. Le bilan de fin de partie et l'écran
# d'analyse (tests/browser/bilan.mjs), captures dans
# build/browser-shots/bilan/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre) restreignent le
# test.
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan)
# restreignent le test.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
@@ -25,7 +27,7 @@ cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
scripts="${SCRIPTS:-play accueil match maitre}"
scripts="${SCRIPTS:-play accueil match maitre bilan}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -63,7 +65,7 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
tests/browser/maitre.mjs "$tools/"
tests/browser/maitre.mjs tests/browser/bilan.mjs "$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
@@ -96,5 +98,12 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node maitre.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" bilan "* ]]; then
# Bilan de fin de partie et écran d'analyse ; captures de la recette
# dans build/browser-shots/bilan/.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node bilan.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+384
View File
@@ -0,0 +1,384 @@
// bilan.mjs -- le bilan de fin de partie et l'écran d'analyse (étape 1e)
// joués par l'interface, dans un vrai navigateur sans écran (Playwright),
// sur le site servi par l'image de production (charte,
// ecrans-etape-1.md, « 1e. Bilan et analyse »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node bilan.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Une fin de course courte, partie depuis un
// lien de position avec des dés imposés : le joueur a 6-1 et deux pions,
// sur 6 et 1 ; 6/off 1/off gagne, 6/5/off perd (l'ordinateur sortira ses
// deux derniers pions au coup suivant : il double, et le joueur passe). Le
// joueur joue 6/5/off. Vérifie que le
// bilan remplace le panneau de fin de partie et nomme ce coup comme le coup
// à revoir, que l'analyse s'ouvre par son adresse (courbe, point touché,
// liste filtrée, visualiseur), que « Rejouer d'ici » crée une branche, la
// règle de la recette (rien d'interactif sous une barre d'action), et
// l'absence d'erreur et de violation de la politique de sécurité. Captures
// (Chromium) dans <dossier des captures>/bilan/, en 412×915, 915×412 et
// 844×320.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node bilan.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/bilan`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
const SMALL_LANDSCAPE = { width: 844, height: 320 };
const SIZES = [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE];
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
// un sur 2 (positions de gnubg, chacun vu de lui-même).
const BEAROFF = "BQAACAIAAAAAAA";
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const browser = await browserType.launch();
const context = await browser.newContext({
viewport: PORTRAIT,
deviceScaleFactor: 1,
locale: "fr-FR",
// Sans animation : les captures montrent les coups posés, avec leurs
// traces.
reducedMotion: "reduce",
});
await context.addInitScript(() => {
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
window.__dice = [];
let state = 5;
Math.random = () => {
const die = window.__dice.shift();
if (die !== undefined) {
return (die - 0.5) / 6;
}
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
});
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
* par eval, interdit par la politique de sécurité du site). */
async function until(what, fn, arg, ms = 120_000) {
for (let waited = 0; waited < ms; waited += 100) {
if (await page.evaluate(fn, arg)) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`attente trop longue : ${what}`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture de la recette dans les trois tailles (Chromium seulement),
* après la règle commune : rien d'interactif sous une barre d'action. */
async function capture(name) {
if (!full) {
return;
}
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
for (const size of SIZES) {
await page.setViewportSize(size);
await sleep(300);
const hidden = await hiddenUnderActionBar(page);
check(
hidden.length === 0,
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`,
);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
/** Une nouvelle partie à partir d'une position, joueur au trait. */
async function startFrom(position) {
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
await until("position lue", () => {
const play = document.getElementById("position-play");
return !play.hidden && !play.disabled;
});
await page.click("#position-play");
await until(
"partie commencée",
() =>
location.hash === "#/" &&
!document.body.classList.contains("busy") &&
!document.getElementById("roll").hidden,
);
}
/** Une case du plateau de jeu : « off » pour la sortie du joueur. */
const place = (point) => (point === 0 ? "off-player" : `p${point}`);
/** Joue un coup déjà lancé, demi-coup par demi-coup, puis le valide. */
async function playMove(halfMoves) {
for (const [from, to] of halfMoves) {
await page.click(`#board .hit[data-place="${place(from)}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="${place(to)}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
}
}
check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`);
await page.click("#confirm");
}
/** Lance les dés imposés et joue un coup. */
async function play(dice, halfMoves) {
await page.evaluate((d) => {
window.__dice.push(...d);
}, dice);
await page.click("#roll");
await idle();
await playMove(halfMoves);
}
// --------------------------------------------- Une partie, puis son bilan
await startFrom(BEAROFF);
await play([6, 1], [[6, 5], [5, 0]]);
// L'ordinateur, sûr de gagner, double : le joueur passe.
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await page.click("#drop");
// Le panneau de fin de partie, puis le bilan à sa place, à son adresse.
await until(
"bilan prêt",
() =>
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true",
);
await until("mini-plateau du coup à revoir", () =>
document.querySelector("#summary-board .board-frame") !== null,
);
const summary = await page.evaluate(() => ({
hash: location.hash,
panelHidden: document.getElementById("panel-veil").hidden,
title: document.getElementById("summary-title").textContent,
reading: document.getElementById("summary-reading").textContent,
counts: [...document.querySelectorAll(".summary-count .summary-number")].map(
(n) => n.textContent,
),
verdict: document.querySelector(".summary-verdict")?.textContent ?? "",
played: document.querySelector(".summary-played")?.textContent ?? "",
master: document.querySelector(".summary-master")?.textContent ?? "",
board: document.querySelector("#summary-board .board-frame") !== null,
play: document.querySelector("#gauge-play .gauge-value")?.textContent ?? "",
dice: document.querySelector("#gauge-dice .gauge-value")?.textContent ?? "",
slot: document.querySelector("#summary-review .ask-ai-slot") !== null,
}));
const id = summary.hash.split("/")[2];
check(summary.panelHidden, "le panneau de fin de partie reste sous le bilan");
check(summary.title === "Vous passez", `titre : ${summary.title}`);
check(summary.reading.startsWith("1 point pour lui."), `lecture : ${summary.reading}`);
check(summary.reading.includes("D'après mon analyse"), `lecture : ${summary.reading}`);
check(!summary.reading.includes("!"), `lecture : ${summary.reading}`);
// Le refus du double, juste ; le coup 6/5/off, grosse erreur ; le jet
// sans doubler, compté seulement s'il était une faute.
check(
summary.counts[0] === "1" && summary.counts[2] !== "0",
`décisions : ${summary.counts.join()}`,
);
check(summary.verdict.startsWith("Grosse erreur"), `coup à revoir : ${summary.verdict}`);
check(summary.played === "Votre coup : 6/off", `coup joué : ${summary.played}`);
check(summary.master === "J'aurais joué 6/off 1/off", `coup du maître : ${summary.master}`);
check(summary.board, "pas de mini-plateau dans le coup à revoir");
check(summary.play !== "" && summary.dice !== "", "jauges vides");
check(summary.slot, "pas d'emplacement pour « Demander à une IA »");
await capture("1-bilan");
log(`bilan : « ${summary.reading} » ; coup à revoir « ${summary.played} » contre « ${summary.master} »`);
// Le bilan se recharge par son adresse.
await page.reload();
await until(
"bilan rouvert",
() => document.getElementById("summary")?.dataset.ready === "true",
);
// ------------------------------------------------------- Écran d'analyse
await page.click("#summary-revisit");
await until(
"analyse ouverte sur le coup à revoir",
(game) =>
location.hash === `#/parties/${game}/analyse/1` &&
!document.getElementById("analysis").hidden &&
document.querySelector("#analysis-board .board-frame") !== null &&
document.querySelectorAll("#analysis-curve .curve-band").length === 2 &&
document.querySelector("#analysis-curve .curve-line") !== null,
id,
);
const viewer = await page.evaluate(() => ({
turn: document.getElementById("analysis-turn").textContent,
phrase: document.getElementById("analysis-phrase").textContent,
mine: document.getElementById("analysis-mine").getAttribute("aria-checked"),
choice: !document.getElementById("analysis-choice").hidden,
replay: !document.getElementById("analysis-replay").hidden,
slot: document.querySelector("#analysis-viewer .ask-ai-slot") !== null,
}));
check(viewer.turn === "Coup 1 sur 2 · 6-1", `visualiseur : ${viewer.turn}`);
check(viewer.phrase.startsWith("Grosse erreur."), `phrase : ${viewer.phrase}`);
check(viewer.choice && viewer.mine === "true", "votre coup n'est pas montré d'abord");
check(viewer.replay && viewer.slot, "« Rejouer d'ici » ou l'emplacement de l'IA manquent");
await capture("2-analyse-courbe");
// Toucher un point de la courbe : la bande du coup 2 (le double de
// l'ordinateur, que le joueur a passé).
const band = await page.locator('#analysis-curve .curve-band[data-turn="2"]').boundingBox();
await page.mouse.click(band.x + band.width / 2, band.y + band.height / 3);
await until(
"coup 2 choisi",
(game) =>
location.hash === `#/parties/${game}/analyse/2` &&
document.getElementById("analysis-turn").textContent.startsWith("Coup 2 sur 2"),
id,
);
const touched = await page.evaluate(() => ({
phrase: document.getElementById("analysis-phrase").textContent,
ring: document.querySelector("#analysis-curve .curve-ring") !== null,
replay: document.getElementById("analysis-replay").hidden,
}));
check(touched.phrase.startsWith("Bonne décision."), `coup 2 : ${touched.phrase}`);
check(touched.ring, "le point choisi n'est pas cerclé");
check(!touched.replay, "pas de « Rejouer d'ici » sur une décision du joueur");
await capture("3-point-touche");
// Au clavier, ← revient au coup 1.
await page.focus("#analysis-curve");
await page.keyboard.press("ArrowLeft");
await until("coup 1 au clavier", (game) => location.hash === `#/parties/${game}/analyse/1`, id);
// La liste, filtrée : une erreur, aucune imprécision.
await page.click('#analysis-filters .filter[data-filter="errors"]');
const errors = await page.evaluate(() => ({
rows: [...document.querySelectorAll("#analysis-list .analysis-row")].map((r) =>
r.getAttribute("aria-label"),
),
filters: [...document.querySelectorAll("#analysis-filters .filter")].map((f) => f.textContent),
}));
check(errors.rows.length === 1 && errors.rows[0].includes("Grosse erreur"), `liste : ${errors.rows}`);
check(errors.filters.join() === "Tout · 2,Erreurs · 1,Imprécisions · 0", `filtres : ${errors.filters}`);
await page.locator("#analysis-list").scrollIntoViewIfNeeded();
await capture("4-liste-filtree");
await page.click('#analysis-filters .filter[data-filter="doubtful"]');
check(
(await page.locator("#analysis-list .analysis-row").count()) === 0,
"une imprécision dans la liste",
);
await page.click('#analysis-filters .filter[data-filter="errors"]');
await page.click("#analysis-list .analysis-row");
// Le visualiseur : le coup du maître sur la position d'avant le coup.
await page.locator("#analysis-viewer").scrollIntoViewIfNeeded();
await page.click("#analysis-best");
const best = await page.evaluate(() => ({
legend: document.getElementById("analysis-legend").textContent,
move: document.getElementById("analysis-board").dataset.move,
}));
check(best.move === "best" && best.legend.includes("6/off 1/off"), `maître : ${best.legend}`);
await capture("5-visualiseur");
log("analyse : courbe, point touché, clavier, liste filtrée, visualiseur");
// L'analyse s'ouvre aussi par son adresse, dans un nouvel onglet.
const other = await context.newPage();
await other.goto(`${base}#/parties/${id}/analyse/2`);
for (let waited = 0; ; waited += 100) {
const ready = await other.evaluate(
() =>
!document.getElementById("analysis").hidden &&
document.getElementById("analysis-turn")?.textContent.startsWith("Coup 2 sur 2"),
);
if (ready) {
break;
}
check(waited < 60_000, "l'analyse ne s'ouvre pas par son adresse");
await sleep(100);
}
await other.close();
// ------------------------------------------------------- Rejouer d'ici
await page.click("#analysis-replay");
await until(
"partie reprise avant le coup",
() =>
location.hash === "#/" &&
!document.body.classList.contains("busy") &&
document.querySelector("#board .hit.can-move") !== null,
);
await capture("6-rejouer-d-ici");
// Le pion du point 1 d'abord : chacun n'a alors qu'une arrivée, la sortie.
await playMove([
[1, 0],
[6, 0],
]);
await until(
"partie gagnée, son bilan",
() =>
/\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true",
);
const won = await page.evaluate(() => ({
title: document.getElementById("summary-title").textContent,
none: document.querySelector(".summary-none")?.textContent ?? "",
}));
check(won.title === "Vous gagnez la partie simple", `branche : ${won.title}`);
check(won.none.startsWith("Aucun coup à revoir"), `branche : ${won.none}`);
// Deux branches dans « Mes parties », l'originale intacte.
await page.goto(`${base}#/parties/${id}`);
await until(
"branches dépliées",
() => document.querySelectorAll(".games-branch").length > 0,
);
const branches = await page.evaluate(() =>
[...document.querySelectorAll(".games-branch span")].map((s) => s.textContent),
);
check(branches.length === 2, `branches : ${branches.join(" | ")}`);
check(branches[0].startsWith("Partie d'origine : 1 coup, finie"), `origine : ${branches[0]}`);
log(`rejouer d'ici : ${branches.join(" | ")}`);
const csp = await page.evaluate(() => window.__csp);
for (const violation of csp) {
problems.push(`politique de sécurité : ${violation}`);
}
await browser.close();
if (problems.length > 0) {
for (const problem of problems) {
console.error(`[${browserName}] ${problem}`);
}
process.exit(1);
}
log(`bilan et analyse : tout est conforme (${captures} captures)`);