Files
Ustagammon/tests/browser/maitre.mjs
T

298 lines
12 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.
// 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 (étape 1d, docs/etape-1.md ; charte, ecrans-etape-1.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)`);