298 lines
12 KiB
JavaScript
298 lines
12 KiB
JavaScript
// 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)`);
|