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:
co-authored by
Claude Opus 5.5
parent
006fd62704
commit
2c5ae08f46
+14
-5
@@ -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"
|
||||
|
||||
@@ -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)`);
|
||||
Reference in New Issue
Block a user