329 lines
13 KiB
JavaScript
329 lines
13 KiB
JavaScript
// 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)`);
|