// 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 // Lancé par tests/browser.sh. Captures (Chromium) dans // /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 "); 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` : ""}`, );