// 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 // 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 /match/ : écran « Nouvelle // partie » (aussi en 844×320), score du match et partie Crawford, en // 412×915 et 915×412. Sur chaque capture, rien d'interactif ne doit être // caché sous la barre d'action collée (recette.mjs). import { mkdirSync } from "node:fs"; import { chromium, firefox } from "playwright"; import { 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 match.mjs "); 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 }; const SMALL_LANDSCAPE = { width: 844, height: 320 }; // 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 chaque taille (Chromium seulement), * après la règle commune : rien d'interactif sous la barre d'action. */ async function capture(name, sizes = [PORTRAIT, LANDSCAPE]) { if (!full) { return; } for (const size of sizes) { await page.setViewportSize(size); await sleep(200); const hidden = await hiddenUnderActionBar(page); check(hidden.length === 0, `${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`); 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; } // Le bilan remplace le panneau de fin de partie (étape 1e). if (!document.getElementById("summary-veil").hidden) { return "over"; } 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"); // Paysage : deux colonnes au-dessus de la barre d'action, tout tient sans // défiler, jusqu'en 844×320 ; le type de partie à gauche, le niveau et le // conseil du maître à droite. for (const size of [LANDSCAPE, SMALL_LANDSCAPE]) { await page.setViewportSize(size); await sleep(200); const where = `${size.width}x${size.height}`; const layout = await page.evaluate(() => { const box = (selector) => { const r = document.querySelector(selector).getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; }; return { scroll: document.scrollingElement.scrollHeight, height: innerHeight, bar: box(".new-game-actions"), kinds: box(".segmented"), lengths: box(".lengths"), rule: box("#new-game-crawford"), bars: box(".level-bars"), name: box("#new-game-level-name"), text: box("#new-game-level-text"), advice: box("#new-game-advice"), }; }); check(layout.scroll <= layout.height + 1, `${where} : l'écran « Nouvelle partie » défile (${layout.scroll} px pour ${layout.height})`); for (const part of ["kinds", "lengths", "rule", "bars", "name", "text", "advice"]) { check(layout[part].top >= 0 && layout[part].bottom <= layout.bar.top + 1, `${where} : ${part} n'est pas au-dessus de la barre d'action : ${JSON.stringify(layout)}`); } for (const part of ["bars", "name", "text", "advice"]) { check(layout[part].left >= layout.kinds.right, `${where} : ${part} n'est pas dans la colonne de droite : ${JSON.stringify(layout)}`); } } await page.setViewportSize(PORTRAIT); await sleep(200); await capture("1-nouvelle-partie", [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE]); // 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") { // Le bilan, une fois l'analyse prête, à la place du panneau. await until("bilan prêt", () => !document.getElementById("summary-veil").hidden && document.getElementById("summary")?.dataset.ready === "true", null, 120_000); const title = await page.textContent("#summary-title"); const reading = await page.textContent("#summary-reading"); const detail = reading.split(" D'après mon analyse")[0]; const primary = await page.textContent("#summary-primary"); log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`); const settingsLink = await page.evaluate(() => { const a = document.getElementById("summary-settings"); return a.hidden ? null : { text: a.textContent, href: a.getAttribute("href") }; }); check(settingsLink?.text === "Changer les réglages" && settingsLink.href === "#/nouvelle-partie", `lien « Changer les réglages » : ${JSON.stringify(settingsLink)}`); if (primary === "Rejouer") { 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, « Changer les réglages » mène à l'écran des // choix ; le fermer ramène au résultat, où « Rejouer » relance // tout de suite un match de même longueur. await page.click("#summary-settings"); await until("écran « Nouvelle partie » après le match", () => location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden); await page.click("#new-game-close"); // Retour au bilan ; le fermer laisse le résultat sur le plateau. await until("retour au bilan du match", () => /\/bilan$/.test(location.hash) && document.getElementById("summary")?.dataset.ready === "true"); 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")} »`); log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`); await page.click("#again"); await until("match rejoué", () => location.hash === "#/" && !document.body.classList.contains("busy")); const again = await shownMatch(); check(again.line === "Match en 3 points" && again.computer === "0" && again.player === "0", `« Rejouer » après le match : ${JSON.stringify(again)}`); if (crawfordGames > 0 && scoreShot) { break; } check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`); matches++; games = 1; 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("#summary-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; check(next.line === "Match en 3 points · partie Crawford", `partie Crawford, portrait : « ${next.line} »`); await page.setViewportSize(LANDSCAPE); await sleep(200); const band = await page.evaluate(() => { const text = document.getElementById("band-text"); const pill = document.querySelector("#board .band-pill"); return { text: text?.textContent ?? "", fits: text !== null && pill !== null && text.getBBox().width <= pill.getBBox().width - 16, }; }); check(band.text.startsWith("Partie Crawford · "), `partie Crawford, paysage : « ${band.text} »`); check(band.fits, `partie Crawford, paysage : la consigne déborde (« ${band.text} »)`); await page.setViewportSize(PORTRAIT); await sleep(200); 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; // L'application enregistre sans attendre : on laisse IndexedDB // finir d'écrire la nouvelle partie (Firefox peut être lent). await until("partie suivante enregistrée", (score) => new Promise((resolve) => { const open = indexedDB.open("ustagammon"); open.onerror = () => resolve(false); open.onsuccess = () => { const tx = open.result.transaction(["settings", "games"]); const current = tx.objectStore("settings").get("current"); current.onsuccess = () => { const id = current.result?.value; if (typeof id !== "string") { resolve(false); return; } const summary = tx.objectStore("games").get(id); summary.onsuccess = () => { const s = summary.result; resolve(s?.status === "playing" && `${s.match?.score.computer}-${s.match?.score.player}` === score); open.result.close(); }; }; }; }), `${next.computer}-${next.player}`).catch(async (error) => { const stored = await page.evaluate(() => new Promise((resolve) => { const open = indexedDB.open("ustagammon"); open.onerror = () => resolve(`IndexedDB : ${open.error}`); open.onsuccess = () => { const all = open.result.transaction(["settings", "games"]); const current = all.objectStore("settings").get("current"); const games = all.objectStore("games").getAll(); games.onsuccess = () => resolve(JSON.stringify({ current: current.result, games: games.result.map((g) => [g.id, g.status, g.match, g.updatedAt]), local: Object.keys(localStorage), })); }; })); throw new Error(`${error.message} ; enregistré : ${stored}`); }); 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/` : ""}`);