Fusion de main dans corrections-plis-firefox
This commit is contained in:
@@ -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++;
|
||||
|
||||
@@ -167,3 +167,106 @@ 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). Et (« Pions du joueur lisibles
|
||||
* partout ») : en thème clair, le contour d'un pion du joueur atteint 3:1 sur
|
||||
* les flèches en chêne et sur le fond. contrasts : le résultat de
|
||||
* checkerContrasts ; theme : « light » ou « dark ». Renvoie la liste des
|
||||
* écarts, décrits (vide si tout va bien).
|
||||
*/
|
||||
export function checkerContrastProblems(contrasts, theme) {
|
||||
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`);
|
||||
}
|
||||
if (
|
||||
c.side === "player" &&
|
||||
theme === "light" &&
|
||||
(c.on === "flèche paire" || c.on === "fond") &&
|
||||
c.edge < 3
|
||||
) {
|
||||
problems.add(`pion du joueur 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(", ");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// 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` : ""}`,
|
||||
);
|
||||
Reference in New Issue
Block a user