Plateau : glisser-déposer, double toucher, clavier, raccourcis et filet des pions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-12 09:59:34 +01:00
co-authored by Claude Opus 5.5
parent 6dd0e93d3d
commit 835a907b9c
11 changed files with 1127 additions and 42 deletions
+13 -4
View File
@@ -18,10 +18,12 @@
# 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/.
# build/browser-shots/retours-joueurs/. Les gestes du plateau soigné
# (tests/browser/plateau.mjs : glisser-déposer, pion lâché hors cible, double
# toucher, clavier, filet des pions), captures dans build/browser-shots/plateau/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
# prompts, nouvelle-partie, retours-joueurs) restreignent le test.
# prompts, nouvelle-partie, retours-joueurs, plateau) 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
@@ -34,7 +36,7 @@ cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs}"
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs plateau}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -73,7 +75,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 \
tests/browser/retours-joueurs.mjs "$tools/"
tests/browser/retours-joueurs.mjs tests/browser/plateau.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
@@ -136,5 +138,12 @@ for browser in $browsers; do
-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
if [[ " $scripts " == *" plateau "* ]]; then
# Gestes du plateau : glisser-déposer, lâcher hors cible, double
# toucher, clavier ; captures dans build/browser-shots/plateau/.
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 plateau.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+2 -2
View File
@@ -329,8 +329,8 @@ for (;;) {
await page.click("#summary-close");
await until("retour au résultat du match", () => location.hash === "#/" &&
!document.getElementById("again").hidden);
check((await page.textContent("#again")) === "Rejouer",
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
check((await page.textContent("#again > span")) === "Rejouer",
`après le match, barre d'actions : « ${await page.textContent("#again > span")} »`);
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
await page.click("#again");
await until("match rejoué", () => location.hash === "#/" &&
+328
View File
@@ -0,0 +1,328 @@
// 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)`);
+2 -2
View File
@@ -1420,7 +1420,7 @@ for (let actions = 0; ; actions++) {
const confirm = document.getElementById("confirm");
const cancel = document.getElementById("cancel");
return {
text: confirm.textContent,
text: confirm.firstChild.textContent,
primary: !confirm.classList.contains("secondary"),
right: confirm.getBoundingClientRect().x > cancel.getBoundingClientRect().x,
inBar: confirm.closest("#actions .slot.primary") !== null,
@@ -1673,7 +1673,7 @@ await shot("4-fin-de-partie");
// reste dans la barre d'actions.
await page.click("#summary-close");
await page.waitForSelector("#summary-veil", { state: "hidden" });
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
check(!(await page.isVisible("#panel")) && (await page.textContent("#again > span")) === "Rejouer" &&
(await page.isVisible("#again")),
"bilan fermé : panneau toujours là, ou pas de « Rejouer »");