Files
Ustagammon/tests/browser/bilan.mjs
T
Pierre MARTINandClaude Opus 5.5 4050517904 Glossaire : fiche « Notation des coups », touchable depuis la partie
La fiche explique 13/7, 24/22*, 6/off, bar/22 et 8/4(2), dans la notation
de gnubg. Le type MoveNotation la relie au code. Le coup de la ligne
« L'ordinateur a joué » et celui de la feuille du maître mènent à la fiche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00

492 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, tallButtons } 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(", ")}`,
);
const tall = await tallButtons(page);
check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.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. La ligne du maître commente le
// coup : sa feuille d'explication écrit le coup joué dans la notation du
// moteur, comme le bilan et l'analyse plus bas.
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await until("ligne du maître", () => !document.getElementById("advice-line").hidden);
await page.click("#advice-line");
await until("feuille du maître", () => document.getElementById("explain-mine") !== null);
await page.click("#explain-mine");
const sheetLegend = await page.textContent("#explain-legend");
check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`);
// Le coup y est un lien vers la fiche « Notation des coups ».
const notationLink = await page.evaluate(() => {
const a = document.querySelector("#explain-legend a.notation");
return a === null ? null : { text: a.textContent, href: a.getAttribute("href") };
});
check(
notationLink?.text === "6/off" && notationLink.href === "#/glossaire/notation-des-coups",
`feuille du maître, lien de la notation : ${JSON.stringify(notationLink)}`,
);
await page.click("#explain-done");
await until("feuille fermée", () => document.getElementById("explain-veil").hidden);
// Le joueur passe.
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 === "L'ordinateur gagne la partie simple", `titre : ${summary.title}`);
check(
summary.reading.startsWith("Vous avez passé son double : 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}`);
// Une seule notation, celle du moteur : le même coup s'écrit pareil dans
// la feuille du maître, le bilan, la liste et le visualiseur.
await page.click("#analysis-mine");
const notations = await page.evaluate(() => ({
legend: document.getElementById("analysis-legend").textContent,
row: document.querySelector('#analysis-list .analysis-row[data-turn="1"] .row-move')
?.textContent,
}));
check(
notations.legend.includes(" 6/off,") && notations.row === "6/off",
`notation : visualiseur « ${notations.legend} », liste « ${notations.row} »`,
);
await page.click("#analysis-best");
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(" | ")}`);
// ------------------------------------------- Une partie plus longue
// Pour juger la courbe en usage réel (Chromium seulement) : une longue
// course (tests/reference/positions.json, race-2) jouée jusqu'au bout,
// le joueur prenant à chaque fois le premier pion jouable et sa première
// arrivée, puis son analyse complète.
if (full) {
await startFrom("cLezAQBwN2cGAA");
for (let actions = 0; ; actions++) {
check(actions < 2000, "la longue partie ne finit pas");
const what = await page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return null;
}
if (!document.getElementById("summary-veil").hidden) {
return "over";
}
if (!document.getElementById("panel-veil").hidden) {
return "panel";
}
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["take", "skip", "confirm", "roll"]) {
if (usable(id)) {
return id;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "land";
}
return document.querySelector("#board .hit.can-move") ? "move" : null;
});
if (what === "over") {
break;
}
if (what === null || what === "panel") {
await sleep(100);
continue;
}
const target = {
take: "#take",
skip: "#skip",
confirm: "#confirm",
roll: "#roll",
land: "#board .hit.can-land",
move: "#board .hit.can-move",
}[what];
await page.locator(target).first().click();
await idle();
}
await until("bilan de la longue partie", () =>
document.getElementById("summary")?.dataset.ready === "true");
await page.click("#summary-analysis");
await until("analyse complète de la longue partie", () => {
const bands = document.querySelectorAll("#analysis-curve .curve-band").length;
return (
!document.getElementById("analysis").hidden &&
bands >= 20 &&
document.querySelectorAll("#analysis-curve .curve-dot").length === bands
);
}, null, 600_000);
const turns = await page.locator("#analysis-curve .curve-band").count();
await page.evaluate(() => window.scrollTo(0, 0));
await capture("7-analyse-partie-longue");
log(`longue partie : ${turns} coups, courbe complète`);
}
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)`);