264 lines
11 KiB
JavaScript
264 lines
11 KiB
JavaScript
// retours-joueurs.mjs -- les règles tirées des retours des premiers joueurs
|
||
// (ADR 0010, amendement du 2026-11-11 ; recette-ux.md), dans un vrai
|
||
// navigateur sans écran (Playwright), sur le site servi par l'image de
|
||
// production, en thème clair et en thème sombre :
|
||
//
|
||
// - pions de l'ordinateur lisibles sur la barre : un pion de chaque camp sur
|
||
// la barre, contraste du contour de chaque pion sur son fond
|
||
// (recette.mjs, checkerContrasts) ;
|
||
// - numéros des flèches sur le cadre, en portrait comme en paysage, de
|
||
// 12 px au moins ;
|
||
// - étape 2 du tutoriel : deux flèches circulaires, claire et sombre, sous
|
||
// les pions, jamais en brique.
|
||
//
|
||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||
//
|
||
// Usage : node retours-joueurs.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||
// Lancé par tests/browser.sh. Captures (Chromium) dans
|
||
// <dossier des captures>/retours-joueurs/, en 412×915 et 915×412.
|
||
|
||
import { mkdirSync, writeFileSync } from "node:fs";
|
||
import { chromium, firefox } from "playwright";
|
||
import {
|
||
checkerContrastProblems,
|
||
checkerContrastSummary,
|
||
checkerContrasts,
|
||
hiddenUnderActionBar,
|
||
} from "./recette.mjs";
|
||
|
||
const [base, browserName, shots] = process.argv.slice(2);
|
||
const browserType = { chromium, firefox }[browserName];
|
||
if (!base || !browserType || !shots) {
|
||
console.error("usage : node retours-joueurs.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||
process.exit(2);
|
||
}
|
||
const full = browserName === "chromium";
|
||
const dir = `${shots}/retours-joueurs`;
|
||
mkdirSync(dir, { recursive: true });
|
||
const SIZES = [
|
||
{ width: 412, height: 915 },
|
||
{ width: 915, height: 412 },
|
||
];
|
||
// Positions de gnubg, chacun vu de lui-même : la position de départ, et la
|
||
// même avec un pion de chaque camp de sa flèche 24 sur la barre.
|
||
const OPENING = "4HPwATDgc/ABMA";
|
||
const BOTH_ON_BAR = "4HPwAVDgc/ABUA";
|
||
|
||
const problems = [];
|
||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
const contrasts = { light: [], dark: [] };
|
||
let captures = 0;
|
||
|
||
function check(condition, message) {
|
||
if (!condition) {
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
const browser = await browserType.launch();
|
||
|
||
/** Un contexte neuf dans un thème, tutoriel déjà vu (l'accueil ne propose
|
||
* plus le premier lancement). */
|
||
async function newPage(theme) {
|
||
const context = await browser.newContext({
|
||
viewport: SIZES[0],
|
||
deviceScaleFactor: 1,
|
||
locale: "fr-FR",
|
||
reducedMotion: "reduce",
|
||
});
|
||
await context.addInitScript((saved) => {
|
||
window.__csp = [];
|
||
document.addEventListener("securitypolicyviolation", (e) =>
|
||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||
);
|
||
if (localStorage.getItem("ustagammon.settings") === null) {
|
||
localStorage.setItem("ustagammon.settings", saved);
|
||
}
|
||
}, JSON.stringify({ theme, 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()}`);
|
||
}
|
||
});
|
||
return { context, page };
|
||
}
|
||
|
||
/** 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(page, 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}`);
|
||
}
|
||
|
||
/** Une partie à partir d'une position, joueur au trait, dés à lancer. */
|
||
async function startFrom(page, position) {
|
||
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
|
||
await until(page, "position lue", () => {
|
||
const play = document.getElementById("position-play");
|
||
return !play.hidden && !play.disabled;
|
||
});
|
||
await page.click("#position-play", { timeout: 15_000 }).catch(async (error) => {
|
||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||
throw error;
|
||
});
|
||
await until(
|
||
page,
|
||
"partie commencée",
|
||
() =>
|
||
location.hash === "#/" &&
|
||
!document.body.classList.contains("busy") &&
|
||
!document.getElementById("roll").hidden,
|
||
);
|
||
}
|
||
|
||
/** Les numéros des flèches du plateau visible : tous sur le cadre (hors du
|
||
* fond du plateau) et de 12 px au moins à l'écran. Renvoie les écarts. */
|
||
async function numberProblems(page, selector) {
|
||
return page.evaluate((selector) => {
|
||
const svg = document.querySelector(selector);
|
||
const box = svg.getBoundingClientRect();
|
||
const vb = svg.viewBox.baseVal;
|
||
const scale = Math.min(box.width / vb.width, box.height / vb.height);
|
||
const bed = svg.querySelector(".board-bed").getBBox();
|
||
const found = [];
|
||
const numbers = [...svg.querySelectorAll(".point-number")];
|
||
if (numbers.length !== 24) {
|
||
found.push(`${numbers.length} numéros`);
|
||
}
|
||
for (const text of numbers) {
|
||
const b = text.getBBox();
|
||
const px = Number(text.getAttribute("font-size")) * scale;
|
||
if (b.y + b.height > bed.y + 0.5 && b.y < bed.y + bed.height - 0.5) {
|
||
found.push(`flèche ${text.textContent} dans le fond du plateau`);
|
||
}
|
||
if (px < 12) {
|
||
found.push(`flèche ${text.textContent} en ${px.toFixed(1)} px`);
|
||
}
|
||
}
|
||
return found;
|
||
}, selector);
|
||
}
|
||
|
||
/** Les règles sur l'écran courant, dans chaque taille, et sa capture
|
||
* (Chromium). */
|
||
async function checkScreen(page, theme, name, selector, settle = async () => {}) {
|
||
// Le bandeau « Reprendre l'ancienne » (10 s) n'est pas le sujet.
|
||
await until(page, "bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||
for (const size of SIZES) {
|
||
await page.setViewportSize(size);
|
||
await sleep(300);
|
||
await settle();
|
||
const where = `${name} ${theme} ${size.width}x${size.height}`;
|
||
const numbers = await numberProblems(page, selector);
|
||
check(numbers.length === 0, `${where} : numéros : ${numbers.join(", ")}`);
|
||
const measured = await checkerContrasts(page);
|
||
check(measured.length > 0, `${where} : aucun pion mesuré`);
|
||
const low = checkerContrastProblems(measured, theme);
|
||
check(low.length === 0, `${where} : ${low.join(", ")}`);
|
||
contrasts[theme].push(...measured);
|
||
if (full) {
|
||
const hidden = await hiddenUnderActionBar(page);
|
||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||
await page.screenshot({ path: `${dir}/${name}-${theme}-${size.width}x${size.height}.png` });
|
||
captures++;
|
||
}
|
||
}
|
||
await page.setViewportSize(SIZES[0]);
|
||
await sleep(200);
|
||
}
|
||
|
||
for (const theme of ["light", "dark"]) {
|
||
const { context, page } = await newPage(theme);
|
||
|
||
// Un pion de chaque camp sur la barre.
|
||
await startFrom(page, BOTH_ON_BAR);
|
||
check(
|
||
(await page.evaluate(() => document.documentElement.dataset.theme)) === theme,
|
||
`thème ${theme} non appliqué`,
|
||
);
|
||
const onBar = await page.evaluate(() =>
|
||
["player", "computer"].map(
|
||
(side) => document.querySelectorAll(`#board [data-checker="bar-${side}"]`).length,
|
||
),
|
||
);
|
||
check(onBar[0] === 1 && onBar[1] === 1, `pions sur la barre : ${onBar}`);
|
||
await checkScreen(page, theme, "barre", "#board");
|
||
const bar = contrasts[theme].filter((c) => c.on === "barre" && c.side === "computer");
|
||
check(bar.length > 0, `${theme} : pion de l'ordinateur sur la barre non mesuré`);
|
||
|
||
// La position de départ : les numéros sur le cadre, en portrait aussi.
|
||
await startFrom(page, OPENING);
|
||
await checkScreen(page, theme, "numeros-sur-le-cadre", "#board");
|
||
|
||
// L'étape 2 du tutoriel : deux flèches circulaires sous les pions.
|
||
await page.goto(`${base}#/tutoriel/2`);
|
||
const ready = () =>
|
||
until(page, "tutoriel, étape 2", () => {
|
||
const section = document.getElementById("tutorial");
|
||
return (
|
||
location.hash === "#/tutoriel/2" &&
|
||
section.dataset.ready !== undefined &&
|
||
document.querySelectorAll("#tutorial-board .tutorial-path").length === 1
|
||
);
|
||
});
|
||
await ready();
|
||
const arrows = async () => {
|
||
const drawn = await page.evaluate(() => {
|
||
const svg = document.getElementById("tutorial-board");
|
||
const children = [...svg.children];
|
||
const path = svg.querySelector(".tutorial-path");
|
||
const colors = (selector) =>
|
||
[...path.querySelectorAll(selector)].map((el) => getComputedStyle(el).stroke);
|
||
return {
|
||
path: children.indexOf(path),
|
||
pieces: children.findIndex((c) => c.matches("g.pieces")),
|
||
player: colors(".direction-line.direction-player"),
|
||
computer: colors(".direction-line.direction-computer"),
|
||
heads: path.querySelectorAll(".direction-head").length,
|
||
pieceColors: ["player", "computer"].map(
|
||
(side) => getComputedStyle(svg.querySelector(`.checker-${side}`)).fill,
|
||
),
|
||
};
|
||
});
|
||
check(drawn.path >= 0 && drawn.path < drawn.pieces, `étape 2 : flèches au-dessus des pions ${JSON.stringify(drawn)}`);
|
||
check(drawn.player.length === 1 && drawn.computer.length === 1 && drawn.heads === 2,
|
||
`étape 2 : flèches ${JSON.stringify(drawn)}`);
|
||
// Aux couleurs des pions de chaque camp, donc jamais en brique.
|
||
check(drawn.player[0] === drawn.pieceColors[0] && drawn.computer[0] === drawn.pieceColors[1],
|
||
`étape 2 : flèches pas aux couleurs des pions ${JSON.stringify(drawn)}`);
|
||
};
|
||
await checkScreen(page, theme, "tutoriel-2", "#tutorial-board", async () => {
|
||
await ready();
|
||
await arrows();
|
||
});
|
||
|
||
const csp = await page.evaluate(() => window.__csp);
|
||
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
|
||
await context.close();
|
||
log(`thème ${theme} : contours ${checkerContrastSummary(contrasts[theme])}`);
|
||
}
|
||
|
||
await browser.close();
|
||
if (full) {
|
||
// Les mesures, à côté des captures.
|
||
writeFileSync(
|
||
`${dir}/contrastes.txt`,
|
||
["light", "dark"].map((theme) => `${theme}\t${checkerContrastSummary(contrasts[theme])}`).join("\n") + "\n",
|
||
);
|
||
}
|
||
check(problems.length === 0, `problèmes relevés :\n ${problems.join("\n ")}`);
|
||
log(
|
||
"retours des joueurs : pions des deux camps sur la barre, numéros sur le cadre, " +
|
||
`flèches circulaires du tutoriel, thèmes clair et sombre${full ? ` ; ${captures} captures` : ""}`,
|
||
);
|