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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
339d699488
commit
85bdf4bb93
@@ -0,0 +1,326 @@
|
||||
// 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/` : ""}`);
|
||||
+18
-4
@@ -819,8 +819,7 @@ async function checkAddresses() {
|
||||
for (const selector of ["#menu-games", "#menu-help"]) {
|
||||
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
|
||||
}
|
||||
check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1,
|
||||
"« Nouvelle partie » n'est plus un bouton");
|
||||
check(await isLink(tab, "#menu-new-game"), "« Nouvelle partie » n'est pas un lien");
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour : menu fermé", playing);
|
||||
await tab.click("#menu-button");
|
||||
@@ -881,8 +880,20 @@ async function openMenu() {
|
||||
check(await page.isVisible("#menu"), "le menu ne s'ouvre pas");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu : effet immédiat, bandeau 10 s. Renvoie
|
||||
* le journal de la partie quittée. */
|
||||
/** L'écran « Nouvelle partie » est ouvert : « Commencer » lance la partie
|
||||
* choisie, et l'adresse redevient celle de la partie. */
|
||||
async function startChosenGame() {
|
||||
await page.waitForSelector("#new-game-start", { state: "visible", timeout: 5000 }).catch(() => {});
|
||||
check(await page.isVisible("#new-game"), "l'écran « Nouvelle partie » ne s'ouvre pas");
|
||||
const hash = await page.evaluate(() => location.hash);
|
||||
check(hash === "#/nouvelle-partie", `écran « Nouvelle partie » à l'adresse ${hash}`);
|
||||
await page.click("#new-game-start");
|
||||
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
|
||||
"« Commencer » ne ramène pas à la partie");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu, par l'écran « Nouvelle partie » : effet
|
||||
* immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */
|
||||
async function newGameFromMenu() {
|
||||
const before = await savedJournal();
|
||||
const started = () =>
|
||||
@@ -891,6 +902,7 @@ async function newGameFromMenu() {
|
||||
await openMenu();
|
||||
const start = Date.now();
|
||||
await page.click("#menu-new-game");
|
||||
await startChosenGame();
|
||||
check(await page.isVisible("#banner"), "pas de bandeau « Reprendre l'ancienne »");
|
||||
check((await page.textContent("#banner")).includes("Reprendre l'ancienne"),
|
||||
`bandeau : « ${await page.textContent("#banner")} »`);
|
||||
@@ -1454,6 +1466,7 @@ step("mes parties");
|
||||
{
|
||||
const finished = await savedJournal();
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await waitForPlayer();
|
||||
const second = await savedJournal();
|
||||
check(second.head !== finished.head || second.events.length !== finished.events.length,
|
||||
@@ -1535,6 +1548,7 @@ log("compte de points : bulle avec la fiche du glossaire");
|
||||
step("thème sombre");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
Reference in New Issue
Block a user