diff --git a/tests/browser.sh b/tests/browser.sh index 8ca14e9..f232419 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -15,10 +15,13 @@ # captures dans build/browser-shots/prompts/. « Nouvelle partie » après une # partie finie et son bilan, par chaque entrée # (tests/browser/nouvelle-partie.mjs), captures dans -# build/browser-shots/nouvelle-partie/. +# build/browser-shots/nouvelle-partie/. Les retours des premiers joueurs +# (tests/browser/retours-joueurs.mjs : pions sur la barre, numéros sur le +# cadre, flèches du tutoriel, en thème clair et sombre), captures dans +# build/browser-shots/retours-joueurs/. # # BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan, -# prompts, nouvelle-partie) restreignent le test. +# prompts, nouvelle-partie, retours-joueurs) restreignent le test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, # installé une fois par npm dans build/browser-tools/ (réseau nécessaire la @@ -31,7 +34,7 @@ cd "$(dirname "$0")/.." image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" browsers="${BROWSERS:-chromium firefox}" # Les scripts à lancer (tests/browser/.mjs), tous par défaut. -scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie}" +scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -70,7 +73,7 @@ rm -rf build/browser-shots mkdir -p build/browser-shots cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \ tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \ - "$tools/" + tests/browser/retours-joueurs.mjs "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker @@ -125,5 +128,13 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" retours-joueurs "* ]]; then + # Retours des premiers joueurs : pions sur la barre, numéros sur le + # cadre, flèches du tutoriel ; captures dans + # build/browser-shots/retours-joueurs/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node retours-joueurs.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs index 1e305dd..361b881 100644 --- a/tests/browser/accueil.mjs +++ b/tests/browser/accueil.mjs @@ -274,17 +274,16 @@ async function playTouches(page, touches) { await tutorialReady(page, hash); } if (step === 2) { - check(await page.locator("#tutorial-board .tutorial-path").count() === 1, - "étape 2 : pas de chemin en pointillés"); - // Sous les pions et sous les numéros des flèches. + check(await page.locator("#tutorial-board .tutorial-path .direction-line").count() === 2, + "étape 2 : pas les deux flèches du sens du jeu"); + // Sous les pions (détail : retours-joueurs.mjs). const order = await page.evaluate(() => { const children = [...document.getElementById("tutorial-board").children]; const at = (selector) => children.findIndex((c) => c.matches(selector)); - const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c)); - return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) }; + return { path: at(".tutorial-path"), pieces: at("g.pieces") }; }); - check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber, - `étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`); + check(order.path >= 0 && order.path < order.pieces, + `étape 2 : les flèches passent au-dessus des pions ${JSON.stringify(order)}`); } await tutorialCapture(page, `tutoriel-${step}`); screens++; diff --git a/tests/browser/recette.mjs b/tests/browser/recette.mjs index ddc2658..c860809 100644 --- a/tests/browser/recette.mjs +++ b/tests/browser/recette.mjs @@ -167,3 +167,96 @@ export function tutorialWidthProblems(views) { } return problems; } + +/** + * Les contrastes des pions dessinés sur les plateaux visibles de la page + * (svg.board), mesurés sur le fond qui porte chacun : la barre, une flèche, + * le fond du plateau ou la sortie, trouvé au centre du pion (le dernier + * dessiné qui le contient). Pour chaque pion : son camp, ce fond, le + * contraste de son contour (stroke) et celui de sa couleur (fill) avec ce + * fond. Les pions entourés de brique (jouable, sélectionné) sont laissés : + * leur contour est celui du pion jouable, pas celui de son camp. + */ +export async function checkerContrasts(target) { + return target.evaluate(() => { + const rgb = (color) => (color.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number); + const luminance = (color) => { + const [r, g, b] = rgb(color).map((v) => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const ratio = (a, b) => { + const [dark, light] = [luminance(a), luminance(b)].sort((x, y) => x - y); + return (light + 0.05) / (dark + 0.05); + }; + const BACKGROUNDS = [ + ["board-bar", "barre"], + ["board-tray", "sortie"], + ["point-a", "flèche impaire"], + ["point-b", "flèche paire"], + ["board-bed", "fond"], + ]; + const selector = BACKGROUNDS.map(([c]) => `.${c}`).join(", "); + const found = []; + for (const svg of document.querySelectorAll("svg.board")) { + if (svg.closest("[hidden]") !== null || svg.getClientRects().length === 0) { + continue; + } + const shapes = [...svg.querySelectorAll(selector)].reverse(); + for (const checker of svg.querySelectorAll("circle.checker")) { + if (checker.matches(".movable, .selected")) { + continue; + } + const point = svg.createSVGPoint(); + point.x = Number(checker.getAttribute("cx")); + point.y = Number(checker.getAttribute("cy")); + const under = shapes.find((s) => s.isPointInFill(point)); + if (under === undefined) { + continue; + } + const ground = getComputedStyle(under).fill; + const style = getComputedStyle(checker); + found.push({ + board: svg.id || "plateau", + side: checker.matches(".checker-computer") ? "computer" : "player", + on: BACKGROUNDS.find(([c]) => under.classList.contains(c))[1], + edge: ratio(style.stroke, ground), + fill: ratio(style.fill, ground), + }); + } + } + return found; + }); +} + +/** + * Règle de la recette (recette-ux.md, « Pions de l'ordinateur lisibles + * partout ») : là où un pion de l'ordinateur ne se détache pas de son fond + * par sa couleur (moins de 3:1 : sur la barre, les flèches sombres, le fond + * sombre), son contour s'en détache à 3:1 au moins (guide de marque : + * contours porteurs de sens à 3:1). contrasts : le résultat de + * checkerContrasts. Renvoie la liste des écarts, décrits (vide si tout va + * bien). + */ +export function checkerContrastProblems(contrasts) { + const problems = new Set(); + for (const c of contrasts) { + if (c.side === "computer" && c.fill < 3 && c.edge < 3) { + problems.add(`pion de l'ordinateur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`); + } + } + return [...problems]; +} + +/** Le plus faible contraste du contour, par camp et par fond, pour le + * journal du test (« ordinateur sur barre 6.79:1, … »). */ +export function checkerContrastSummary(contrasts) { + const lowest = new Map(); + for (const c of contrasts) { + const key = `${c.side === "computer" ? "ordinateur" : "joueur"} sur ${c.on}`; + lowest.set(key, Math.min(lowest.get(key) ?? Number.POSITIVE_INFINITY, c.edge)); + } + return [...lowest].map(([key, edge]) => `${key} ${edge.toFixed(2)}:1`).join(", "); +} diff --git a/tests/browser/retours-joueurs.mjs b/tests/browser/retours-joueurs.mjs new file mode 100644 index 0000000..4934dd7 --- /dev/null +++ b/tests/browser/retours-joueurs.mjs @@ -0,0 +1,258 @@ +// 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"); + 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 () => {}) { + 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); + 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` : ""}`, +);