Files
Ustagammon/tests/browser/plateau.mjs
T

329 lines
13 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.
// plateau.mjs -- les gestes du plateau soigné (étape 1i ; charte,
// ecrans-etape-1.md, 1i ; interaction.md), joués dans un vrai navigateur sans
// écran (Playwright), sur le site servi par l'image de production :
//
// - un coup joué entièrement au glisser-déposer (pion fantôme au départ, pion
// glissé agrandi, cases d'arrivée du moteur) ;
// - un pion lâché hors d'une case permise revient à sa place, sans rien
// jouer ;
// - le double toucher : rien quand plusieurs coups commencent par le pion,
// le coup évident sinon ;
// - un coup joué au clavier (flèches, Entrée, Espace), raccourcis en kbd
// affichés une fois le clavier détecté ;
// - le filet intérieur des pions ;
// - aucune erreur, aucune violation de la politique de sécurité.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node plateau.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Les dés viennent de Math.random, remplacé ici
// par une file de valeurs choisies (window.__dice), puis par un générateur à
// graine. Captures de la recette design (Chromium) dans
// <dossier des captures>/plateau/, 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 plateau.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/plateau`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
// Positions de gnubg, vues du joueur : le départ ; un pion sur la 6 et un
// sur la 1 (avec 6-1, seul le pion de la 1 a un coup évident).
const START = "4HPwATDgc/ABMA";
const SIX_AND_ONE = "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",
});
await context.addInitScript(() => {
window.__dice = [];
let state = 7;
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}`),
);
if (localStorage.getItem("ustagammon.settings") === null) {
localStorage.setItem("ustagammon.settings", JSON.stringify({ tutorialSeen: 1 }));
}
});
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 = 60_000) {
for (let waited = 0; waited < ms; waited += 100) {
if (await page.evaluate(fn, arg).catch(() => false)) {
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 nouvelle partie à partir d'une position, joueur au trait, puis les
* dés imposés lancés. */
async function startFrom(position, dice) {
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,
);
await page.evaluate((d) => window.__dice.push(...d), dice);
await page.click("#roll");
await idle();
}
/** L'état du plateau : pions par endroit (pion du dessus marqué), pion
* choisi, cases d'arrivée, glissement en cours, dés joués. */
function boardState() {
return page.evaluate(() => {
const board = document.getElementById("board");
const at = (place) => board.querySelector(`[data-checker="${place}"]`) !== null;
return {
at: Object.fromEntries(
["p1", "p5", "p6", "p7", "p8", "p13", "p24", "off-player"].map((p) => [p, at(p)]),
),
selected: board.querySelector(".checker.selected")?.closest("[data-checker]")
?.getAttribute("data-checker") ?? null,
landing: [...board.querySelectorAll(".hit.can-land")].map((e) =>
e.getAttribute("data-place"),
),
dragging: board.querySelector("#drag-piece") !== null,
ghost: board.querySelector(".drag-origin")?.getAttribute("data-checker") ?? null,
used: board.querySelectorAll(".die-used").length,
focus: board.querySelector(".key-focus") !== null,
rings: board.querySelectorAll(".checker-ring").length,
checkers: board.querySelectorAll("circle.checker").length,
confirm: !document.getElementById("confirm").hidden,
};
});
}
/** Le centre, à l'écran, de la zone à toucher d'un endroit. */
async function center(place) {
const box = await page.locator(`#board .hit[data-place="${place}"]`).boundingBox();
check(box !== null, `zone ${place} introuvable`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/** Le centre, à l'écran, du pion du dessus d'un endroit. */
async function checkerCenter(place) {
const box = await page.locator(`#board [data-checker="${place}"] circle.checker`).boundingBox();
check(box !== null, `pion ${place} introuvable`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/** Prend le pion de from et le fait glisser au-dessus de la zone de to,
* sans le lâcher ; renvoie l'état du plateau pendant le glissement. */
async function dragOver(from, to) {
const start = await checkerCenter(from);
const end = await center(to);
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 4, start.y + 12, { steps: 3 });
await page.mouse.move(end.x, end.y, { steps: 8 });
await sleep(100);
return boardState();
}
/** Lâche le pion glissé, puis attend que le plateau soit de nouveau libre
* et sans glissement (le pion rentré ou posé). */
async function release() {
await page.mouse.up();
await until("glissement fini", () => document.getElementById("drag-piece") === null);
await idle();
}
/** Une capture de la recette, à la taille du moment (Chromium seulement). */
async function capture(name) {
if (!full) {
return;
}
const size = page.viewportSize();
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
// ------------------------------------------------- Le filet des pions
await startFrom(START, [3, 1]);
let state = await boardState();
check(state.checkers >= 28 && state.rings === state.checkers,
`filet : ${state.rings} filets pour ${state.checkers} pions`);
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 capture("plateau-filet");
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
log(`filet intérieur sur chaque pion (${state.rings})`);
// ------------------------------------- Un coup entier au glisser-déposer
state = await dragOver("p8", "p5");
check(state.dragging && state.ghost === "p8" && state.selected === "p8",
`glissement de 8 : ${JSON.stringify(state)}`);
check(state.landing.includes("p5") && state.landing.includes("p7"),
`glissement de 8 : cases d'arrivée ${state.landing}`);
await capture("glissement");
await release();
state = await boardState();
check(state.used === 1 && state.at.p5 && !state.dragging && state.ghost === null,
`8/5 lâché sur la 5 : ${JSON.stringify(state)}`);
await dragOver("p6", "p5");
await release();
state = await boardState();
check(state.used === 2 && state.confirm, `6/5 lâché sur la 5 : ${JSON.stringify(state)}`);
await page.click("#confirm");
await idle();
log("un coup entier joué au glisser-déposer (8/5 6/5)");
// Le glissement en paysage, pour la recette.
if (full) {
await startFrom(START, [3, 1]);
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
await page.setViewportSize(LANDSCAPE);
await sleep(300);
state = await dragOver("p13", "p10");
check(state.dragging && state.ghost === "p13", `paysage : ${JSON.stringify(state)}`);
await capture("glissement");
await release();
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
// ------------------------------------------- Lâché hors d'une case permise
await startFrom(START, [3, 1]);
const before = await boardState();
// La 12 est tenue par l'ordinateur : 13/12 n'est pas permis.
state = await dragOver("p13", "p12");
check(state.dragging && !state.landing.includes("p12"), `13 vers 12 : ${JSON.stringify(state)}`);
await release();
state = await boardState();
check(state.used === 0 && state.at.p13 && state.selected === null && !state.dragging &&
state.checkers === before.checkers,
`lâché sur la 12 : le pion n'est pas revenu ${JSON.stringify(state)}`);
log("lâché hors d'une case permise : le pion revient à sa place, rien n'est joué");
// ----------------------------------------------------------- Double toucher
await startFrom(SIX_AND_ONE, [6, 1]);
// Depuis la 6 : 6/off 1/off ou 6/5/off, deux coups : rien.
await page.dblclick('#board .hit[data-place="p6"]');
await sleep(500);
await idle();
state = await boardState();
check(state.used === 0 && state.at.p6 && state.at.p1, `double toucher sur la 6 : ${JSON.stringify(state)}`);
// Depuis la 1 : 1/off puis 6/off, le seul coup.
await page.dblclick('#board .hit[data-place="p1"]');
await until("coup évident joué", () => !document.getElementById("confirm").hidden);
await idle();
state = await boardState();
check(state.used === 2 && !state.at.p6 && !state.at.p1 && state.at["off-player"],
`double toucher sur la 1 : ${JSON.stringify(state)}`);
log("double toucher : rien sur la 6 (deux coups), le coup évident sur la 1");
// ------------------------------------------------------------------ Clavier
await startFrom(START, [3, 1]);
await page.locator("body").focus().catch(() => {});
// Les pions jouables dans le sens de la course : 24, 13, 8, 6.
for (let i = 0; i < 3; i++) {
await page.keyboard.press("ArrowRight");
}
state = await boardState();
check(state.selected === "p8" && state.focus, `flèches : pion ${state.selected}`);
// Les cases d'arrivée de la 8 : la 7, puis la 5.
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await idle();
state = await boardState();
check(state.used === 1 && state.at.p5, `Entrée : 8/5 non joué ${JSON.stringify(state)}`);
// En arrière depuis la fin : 5, puis 6 ; sa seule case, la 5.
await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowLeft");
state = await boardState();
check(state.selected === "p6" && state.focus, `flèche gauche : pion ${state.selected}`);
await page.keyboard.press("Enter");
await idle();
state = await boardState();
check(state.used === 2 && state.confirm, `Entrée : 6/5 non joué ${JSON.stringify(state)}`);
const keys = await page.evaluate(() => {
const kbd = document.querySelector("#confirm kbd.shortcut");
return {
keyboard: document.documentElement.classList.contains("keyboard"),
shown: kbd !== null && kbd.getBoundingClientRect().width > 0,
text: kbd?.textContent ?? null,
};
});
check(keys.keyboard && keys.shown && keys.text === "Espace", `raccourci : ${JSON.stringify(keys)}`);
await capture("clavier");
await page.keyboard.press(" ");
await until("coup validé", () => document.getElementById("confirm").hidden);
await idle();
log("un coup joué au clavier (flèches, Entrée, Espace), raccourcis affichés");
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(`plateau soigné : tout est conforme (${captures} captures)`);