Files
Ustagammon/tests/browser/match.mjs
T
Pierre MARTINandClaude Opus 5.5 85bdf4bb93 Interface : écran Nouvelle partie, score du match et partie Crawford
Écran #/nouvelle-partie (type de partie, niveau, conseil du maître,
choix gardés), score du match en portrait et en paysage, videau caché
pendant la partie Crawford, « Partie suivante » ; test navigateur d'un
match en 3 points jusqu'au bout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:31:26 +01:00

327 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// match.mjs -- l'écran « Nouvelle partie » et un match en 3 points joué
// jusqu'au bout par l'interface, dans un vrai navigateur sans écran
// (Playwright), sur le site servi par l'image de production
// (docs/plans/2026-11-03-match-et-reglages.md, point 6).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node match.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Les dés de l'application viennent de
// Math.random : il est remplacé ici par un générateur à graine, pour que le
// match soit le même à chaque fois (il passe par une partie Crawford). Le
// joueur joue toujours le premier pion jouable vers sa première case
// d'arrivée, prend les doubles et ne double jamais. Captures de la recette
// design (Chromium) dans <dossier des captures>/match/ : écran « Nouvelle
// partie », score du match et partie Crawford, 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 match.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/match`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
// La graine des dés : un match qui passe par une partie Crawford.
const SEED = 7;
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",
// Sans animation : le match va plus vite, rien d'autre ne change.
reducedMotion: "reduce",
});
await context.addInitScript((seed) => {
let state = seed;
Math.random = () => {
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
}, SEED);
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)) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`attente trop longue : ${what}`);
}
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
async function capture(name) {
if (!full) {
return;
}
for (const size of [PORTRAIT, LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(200);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
await page.setViewportSize(PORTRAIT);
await sleep(200);
}
/** Ce que le joueur a à faire (comme tests/browser/play.mjs). */
async function todo() {
for (let i = 0; i < 600; i++) {
const what = await page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return null;
}
const panel = document.getElementById("panel-veil");
if (!panel.hidden) {
return panel.dataset.kind;
}
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["take", "skip", "confirm", "roll"]) {
if (usable(id)) {
return id;
}
}
return document.querySelector(".hit.can-move") ? "move" : null;
});
if (what !== null) {
return what;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`rien à faire depuis 60 s : « ${await page.textContent("#info")} »`);
}
/** Le match tel que l'écran le montre (portrait). */
async function shownMatch() {
return page.evaluate(() => ({
line: document.getElementById("match-line").textContent,
lineShown: !document.getElementById("match-line").hidden,
spoken: document.getElementById("match-line").getAttribute("aria-label"),
computer: document.querySelector("#strip-computer .match-score").textContent,
player: document.querySelector("#strip-player .match-score").textContent,
cube: document.querySelectorAll("#board .cube").length,
double: !document.getElementById("double").hidden,
}));
}
// ---------------------------------------------------- Écran « Nouvelle partie »
await page.goto(`${base}#/nouvelle-partie`);
await until("moteur chargé", () => !document.getElementById("new-game-start").disabled);
const screen = () =>
page.evaluate(() => ({
free: document.getElementById("new-game-free").getAttribute("aria-checked"),
match: document.getElementById("new-game-match").getAttribute("aria-checked"),
lengthsShown: !document.getElementById("new-game-match-options").hidden,
jacobyShown: !document.getElementById("new-game-free-options").hidden,
level: document.querySelector(".level-bars [aria-checked=true]")?.dataset.level,
reached: document.querySelectorAll(".level-bars .reached").length,
name: document.getElementById("new-game-level-name").textContent,
advice: document.getElementById("new-game-advice").getAttribute("aria-checked"),
start: document.getElementById("new-game-start").textContent,
topbar: document.querySelector(".topbar").getClientRects().length > 0,
game: !document.getElementById("game").hidden,
}));
let s = await screen();
check(s.free === "true" && s.match === "false" && !s.lengthsShown && s.jacobyShown,
`par défaut, partie libre : ${JSON.stringify(s)}`);
check(s.level === "intermediate" && s.reached === 3 && s.name === "Intermédiaire",
`par défaut, Intermédiaire : ${JSON.stringify(s)}`);
check(s.advice === "true" && s.start === "Commencer la partie", `conseil, bouton : ${JSON.stringify(s)}`);
check(!s.topbar && !s.game, "l'écran « Nouvelle partie » laisse voir la barre haute ou la partie");
check((await page.getAttribute("#new-game-jacoby a", "href")) === "#/glossaire/regle-de-jacoby",
"lien vers la règle de Jacoby");
// Les cibles tactiles : 44 px au moins.
const small = await page.evaluate(() =>
[...document.querySelectorAll("#new-game button, #new-game a")]
.filter((el) => el.getClientRects().length > 0 && el.closest(".new-game-rule") === null)
.map((el) => el.getBoundingClientRect())
.filter((r) => r.width < 44 || r.height < 44).length,
);
check(small === 0, `${small} cibles tactiles de moins de 44 px sur l'écran « Nouvelle partie »`);
await page.click("#new-game-match");
await page.click("#new-game-length-3");
await page.click("#new-game-level-beginner");
s = await screen();
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points",
`match en 3 points : ${JSON.stringify(s)}`);
check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`);
check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford",
"lien vers la règle de Crawford");
check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"),
"description du niveau Débutant");
await capture("1-nouvelle-partie");
// Fermer ne change rien : on revient à la partie, et les choix sont gardés.
const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings"));
await page.click("#new-game-close");
await until("retour à la partie", () => location.hash === "#/" && !document.getElementById("game").hidden);
const settings = JSON.parse(before);
check(settings.gameKind === "match" && settings.matchLength === 3 && settings.level === "beginner",
`choix gardés : ${before}`);
// Le menu y ramène, avec les choix de la dernière fois.
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-new-game");
await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" &&
!document.getElementById("new-game").hidden);
s = await screen();
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points",
`choix retrouvés : ${JSON.stringify(s)}`);
await page.click("#new-game-start");
await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy"));
log("écran « Nouvelle partie » : réglages par défaut, match en 3 points, Débutant, choix gardés");
// ------------------------------------------------------------- Le match
let m = await shownMatch();
check(m.lineShown && m.line === "Match en 3 points" && m.computer === "0" && m.player === "0",
`début du match : ${JSON.stringify(m)}`);
check(m.spoken === "Match en 3 points : l'ordinateur 0, vous 0", `lecteur d'écran : « ${m.spoken} »`);
// Un match ne passe pas toujours par une partie Crawford (un gammon peut
// sauter le point d'avant la victoire) : on enchaîne les matchs, avec les
// dés de la même graine, jusqu'à en avoir joué une (cinq matchs au plus).
let games = 1;
let crawfordGames = 0;
let scoreShot = false;
let crawfordShot = false;
let reloaded = false;
let actions = 0;
let matches = 1;
for (;;) {
check(actions++ < 20000, "le match ne finit pas");
const what = await todo();
const now = await shownMatch();
const crawford = now.line.endsWith("· partie Crawford");
if (crawford && what !== "over") {
check(now.cube === 0 && !now.double, `partie Crawford : videau visible ${JSON.stringify(now)}`);
}
if (what === "over") {
const title = await page.textContent("#panel-text");
const detail = await page.textContent("#panel-detail");
const primary = await page.textContent("#panel-primary");
log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`);
if (primary === "Nouvelle partie") {
check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"),
`fin du match : ${title} ; ${detail}`);
const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? [];
check(Math.max(Number(player), Number(computer)) >= 3, `score final : ${detail}`);
// Paysage : le score dans la colonne de commandes.
await page.setViewportSize(LANDSCAPE);
await sleep(200);
const box = await page.evaluate(() =>
document.querySelector("#board .match-box")?.getAttribute("aria-label"));
check(/^Match en 3 points : l'ordinateur \d+, vous \d+$/.test(box ?? ""),
`score en paysage : « ${box} »`);
await page.setViewportSize(PORTRAIT);
await sleep(200);
// Après le match, « Nouvelle partie » mène à l'écran des choix.
await page.click("#panel-primary");
await until("écran « Nouvelle partie » après le match", () =>
location.hash === "#/nouvelle-partie");
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
if (crawfordGames > 0 && scoreShot) {
break;
}
check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`);
matches++;
games = 1;
await page.click("#new-game-start");
await until("match suivant", () => location.hash === "#/" &&
!document.body.classList.contains("busy"));
continue;
}
check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail),
`fin de partie dans un match : ${primary} ; ${detail}`);
await page.click("#panel-primary");
games++;
await until("partie suivante", () => !document.body.classList.contains("busy"));
const next = await shownMatch();
if (next.line.endsWith("· partie Crawford")) {
crawfordGames++;
check(next.cube === 0, "partie Crawford : le videau est dessiné");
if (!crawfordShot) {
crawfordShot = true;
await capture("3-partie-crawford");
}
} else if (!scoreShot) {
scoreShot = true;
await capture("2-score-du-match");
}
if (!reloaded) {
// Le match continue après un rechargement de la page.
reloaded = true;
await page.reload();
await until("partie reprise", () => !document.body.classList.contains("busy"));
const again = await shownMatch();
check(again.line === next.line && again.player === next.player && again.computer === next.computer,
`après rechargement : ${JSON.stringify(again)} au lieu de ${JSON.stringify(next)}`);
}
continue;
}
// Les doubles de l'ordinateur sont passés : le match avance d'un point
// à la fois, ce qui rend la partie Crawford probable.
if (what === "doubled") {
await page.click("#panel-secondary");
} else if (what === "take") {
await page.click("#drop");
} else if (what === "skip" || what === "confirm" || what === "roll") {
await page.click(`#${what}`);
} else if (what === "move") {
await page.click(".hit.can-move");
if ((await page.locator(".hit.can-land").count()) > 0) {
await page.click(".hit.can-land");
}
}
}
log(`${matches} match(s) en 3 points joués jusqu'au bout : ${crawfordGames} partie(s) Crawford, ` +
`${actions} actions`);
const csp = await page.evaluate(() => window.__csp);
check(csp.length === 0, `violations de la politique de sécurité : ${csp.join(", ")}`);
await browser.close();
if (problems.length > 0) {
console.error(problems.join("\n"));
process.exit(1);
}
log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans match/` : ""}`);