É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>
327 lines
15 KiB
JavaScript
327 lines
15 KiB
JavaScript
// 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/` : ""}`);
|