385 lines
16 KiB
JavaScript
385 lines
16 KiB
JavaScript
// 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)`);
|