Conseil du maître : test dans le navigateur, ADR 0007 et 0013 amendés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 04:49:03 +01:00
co-authored by Claude Opus 5.5
parent 5d6cbec89b
commit cd5f70e548
4 changed files with 350 additions and 3 deletions
+297
View File
@@ -0,0 +1,297 @@
// maitre.mjs -- le conseil du maître (étape 1d) joué par l'interface, dans
// un vrai navigateur sans écran (Playwright), sur le site servi par l'image
// de production (docs/plans/2026-11-03-conseil-du-maitre.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node maitre.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Chaque cas part d'une position connue
// (#/position/..., « Jouer à partir d'ici ») avec des dés imposés : les dés
// de l'application viennent de Math.random, remplacé ici par une file de
// valeurs choisies (window.__dice), puis par un générateur à graine.
// Vérifie la ligne du maître (bon coup, imprécision, erreur, grosse
// erreur), la feuille d'explication et son adresse, une décision de videau
// commentée, le conseil coupé dans le menu, l'absence d'erreur et de
// violation de la politique de sécurité. Captures de la recette design
// (Chromium) dans <dossier des captures>/maitre/, en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node maitre.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/maitre`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
const START = "4HPwATDgc/ABMA";
// Une position de double et prise (videau au centre) : doubler y est juste.
const DOUBLE_TAKE = "6O5uAAC4uzsAAA";
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 = 11;
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 = 90_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 deux tailles (Chromium seulement). */
async function capture(name) {
if (!full) {
return;
}
// Le bandeau « Reprendre l'ancienne » (10 s) ne gêne pas la lecture.
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
for (const size of [PORTRAIT, LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(300);
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);
}
/** Lance les dés imposés et joue un coup, demi-coup par demi-coup : le pion
* de départ, puis sa case d'arrivée si le pion ne s'y est pas déjà posé. */
async function play(dice, halfMoves) {
await page.evaluate((d) => {
window.__dice.push(...d);
}, dice);
await page.click("#roll");
await idle();
for (const [from, to] of halfMoves) {
await page.click(`#board .hit[data-place="p${from}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="p${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");
}
/** La ligne du maître telle qu'elle s'affiche. */
async function adviceLine() {
return page.evaluate(() => {
const line = document.getElementById("advice-line");
return {
shown: !line.hidden,
word: line.querySelector(".advice-word")?.textContent ?? "",
phrase: line.querySelector(".advice-phrase")?.textContent ?? "",
href: line.getAttribute("href"),
infoShown: !document.getElementById("info").hidden,
height: line.getBoundingClientRect().height,
chip: document.getElementById("advice-chip") !== null,
};
});
}
async function waitAdvice() {
await until("ligne du maître", () =>
!document.getElementById("advice-line").hidden &&
!document.body.classList.contains("busy"));
return adviceLine();
}
// ------------------------------------------------- Bon coup, et ses voisins
const cases = [
{ name: "1-bon-coup", dice: [3, 1], moves: [[8, 5], [6, 5]], word: "Bon coup." },
{ name: "2-imprecision", dice: [2, 1], moves: [[13, 11], [11, 10]], word: "Imprécision." },
{ name: "3-erreur", dice: [2, 1], moves: [[24, 22], [6, 5]], word: "Erreur." },
{ name: "4-grosse-erreur", dice: [3, 1], moves: [[24, 23], [13, 10]], word: "Grosse erreur." },
];
for (const c of cases) {
await startFrom(START);
// Rien pendant que le joueur réfléchit.
check(!(await adviceLine()).shown, `${c.name} : ligne avant le coup`);
await play(c.dice, c.moves);
const line = await waitAdvice();
check(line.word === c.word, `${c.name} : mot « ${line.word} », attendu « ${c.word} »`);
check(line.phrase.length > 0 && !line.phrase.includes("!"), `${c.name} : phrase « ${line.phrase} »`);
check(!line.infoShown, `${c.name} : la ligne du dernier coup reste visible`);
check(line.height <= 44, `${c.name} : ligne de ${line.height} px`);
check(/^#\/explication\/\d+$/.test(line.href), `${c.name} : lien ${line.href}`);
if (c.word !== "Bon coup.") {
check(line.phrase.startsWith("J'aurais joué") || line.phrase.startsWith("Tout près"),
`${c.name} : le coup du maître n'est pas nommé : « ${line.phrase} »`);
}
await capture(c.name);
if (full) {
// En paysage, la pastille du mot mène à la même feuille.
await page.setViewportSize(LANDSCAPE);
await sleep(200);
check((await adviceLine()).chip, `${c.name} : pas de pastille en paysage`);
await page.setViewportSize(PORTRAIT);
await sleep(200);
}
log(`${c.name} : « ${line.word} ${line.phrase} »`);
}
// La ligne s'efface à l'action suivante du joueur.
await page.click("#roll");
await idle();
check(!(await adviceLine()).shown, "la ligne reste après le lancer suivant");
// ------------------------------------------------- Feuille d'explication
await startFrom(START);
await play([3, 1], [[24, 23], [13, 10]]);
await waitAdvice();
await page.click("#advice-line");
await until("feuille ouverte", () =>
/^#\/explication\/\d+$/.test(location.hash) &&
!document.getElementById("explain-veil").hidden &&
document.getElementById("explain-board") !== null &&
document.querySelector("#explain-board .board-frame") !== null);
const sheet = await page.evaluate(() => ({
verdict: document.getElementById("explain-verdict").textContent,
title: document.getElementById("explain-title").textContent,
mine: document.getElementById("explain-mine")?.getAttribute("aria-checked"),
best: document.getElementById("explain-best")?.getAttribute("aria-checked"),
numbersOpen: document.getElementById("explain-numbers").open,
}));
check(sheet.verdict === "Grosse erreur · votre 3-1 d'ouverture", `feuille : ${JSON.stringify(sheet)}`);
check(sheet.title === "J'aurais joué 8/5 6/5", `titre : ${sheet.title}`);
check(sheet.best === "true" && sheet.mine === "false", "le coup du maître n'est pas montré d'abord");
check(!sheet.numbersOpen, "les chiffres du moteur ne sont pas repliés");
await capture("5-feuille-explication");
await page.click("#explain-mine");
await sleep(300);
await page.click("#explain-numbers summary");
const rows = await page.evaluate(() => document.querySelector(".explain-table .strong")?.textContent);
check(rows !== undefined, "votre coup n'est pas en gras dans les chiffres");
await capture("5b-feuille-votre-coup-chiffres");
// L'adresse se recharge : la feuille revient, le conseil vient du journal.
await page.reload();
await until("feuille rouverte après rechargement", () =>
!document.getElementById("explain-veil").hidden &&
document.getElementById("explain-title")?.textContent === "J'aurais joué 8/5 6/5");
await page.click("#explain-done");
await until("feuille fermée", () =>
location.hash === "#/" && document.getElementById("explain-veil").hidden);
log("feuille d'explication : mot, coup du maître, votre coup, chiffres repliés, adresse rechargée");
// ------------------------------------------------- Décision de videau
await startFrom(DOUBLE_TAKE);
check(!(await page.locator("#double").isHidden()), "doubler n'est pas proposé");
await page.click("#double");
await page.click("#double-confirm");
const cube = await waitAdvice();
check(cube.word === "Bonne décision." && cube.phrase === "Doubler était le bon choix.",
`videau : « ${cube.word} ${cube.phrase} »`);
await capture("6-decision-de-videau");
log(`décision de videau : « ${cube.word} ${cube.phrase} »`);
// ------------------------------------------------- Conseil coupé
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
check((await page.getAttribute("#menu-advice", "aria-checked")) === "true", "conseil coupé par défaut");
await page.click("#menu-advice");
check((await page.getAttribute("#menu-advice", "aria-checked")) === "false", "interrupteur sans effet");
await page.click("#menu-close");
await startFrom(START);
await play([3, 1], [[24, 23], [13, 10]]);
await idle();
// Laisse le temps à l'analyse : la ligne ne doit pas venir.
await sleep(3000);
const off = await adviceLine();
check(!off.shown && off.infoShown, `conseil coupé : ${JSON.stringify(off)}`);
await capture("7-conseil-desactive");
// On le rallume : le conseil déjà prêt réapparaît.
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-advice");
await page.click("#menu-close");
check((await waitAdvice()).word === "Grosse erreur.", "conseil rallumé : la ligne ne revient pas");
log("conseil coupé dans le menu : la ligne reste vide ; rallumé, elle revient");
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(`conseil du maître : tout est conforme (${captures} captures)`);