// progression.mjs -- l'écran de progression (étape 1h) dans un vrai // navigateur sans écran (Playwright), sur le site servi par l'image de // production (charte, ecrans-etape-1.md, « 1h. Progression »). // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // // Usage : node progression.mjs // Lancé par tests/browser.sh. L'écran sans partie terminée, puis trois // courses courtes jouées jusqu'au bout par l'interface, depuis un lien de // position (celle des tests du domaine), dés d'une graine fixe : le joueur // joue toujours le premier pion jouable et la première case d'arrivée, et // prend les doubles, ce qui donne plusieurs décisions par partie et des // taux d'erreur réalistes. Vérifie que la ligne de l'accueil (44 px au // moins) mène à #/progression, que la carte de la partie ne montre plus la // partie finie, que la courbe a trois points égaux, dans l'ordre, aux taux // d'erreur des trois bilans, le dernier point en vert seulement si le taux // baisse, la phrase du maître, la liste (un lien par partie, vers son // bilan) et « Demander des exercices à une IA » (feuille du prompt). Rien // d'interactif sous // une barre d'action, aucune erreur ni violation de la politique de // sécurité. Captures (Chromium) dans /progression/, // en 412×915 et 915×412. import { mkdirSync } from "node:fs"; import { chromium, firefox } from "playwright"; import { hiddenUnderActionBar, tallButtons } from "./recette.mjs"; const [base, browserName, shots] = process.argv.slice(2); const browserType = { chromium, firefox }[browserName]; if (!base || !browserType || !shots) { console.error("usage : node progression.mjs "); process.exit(2); } const full = browserName === "chromium"; const dir = `${shots}/progression`; mkdirSync(dir, { recursive: true }); const PORTRAIT = { width: 412, height: 915 }; const LANDSCAPE = { width: 915, height: 412 }; const SIZES = [PORTRAIT, LANDSCAPE]; // Une course courte (Position ID de gnubg, app/tests/progression.test.ts). const RACE = "trsXAADs7mwAAA"; 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", reducedMotion: "reduce", }); await context.addInitScript(() => { // Les dés imposés d'abord (un dé par valeur), puis une graine fixe. window.__dice = []; let state = 5; 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}`), ); }); 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 = 120_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), * après la règle commune ; settle replace l'écran après le changement de * taille. */ async function capture(name, settle = async () => {}) { if (!full) { return; } // Le bandeau « Reprendre l'ancienne » (10 s) d'une partie commencée. await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000); for (const size of SIZES) { await page.setViewportSize(size); await sleep(300); await settle(); const hidden = await hiddenUnderActionBar(page); check( hidden.length === 0, `${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`, ); const tall = await tallButtons(page); check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.join(", ")}`); await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png`, fullPage: true }); captures++; } await page.setViewportSize(PORTRAIT); await sleep(300); } /** Une nouvelle partie à partir d'une position, joueur au trait. */ async function startFrom(position) { 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, ); } /** Joue la partie jusqu'au bout : lancer, le premier pion jouable et sa * première case d'arrivée, valider ; prendre les doubles ; passer quand * aucun coup n'est possible. */ async function playOut() { for (let step = 0; step < 2000; step++) { const s = await page.evaluate(() => { const shown = (id) => { const el = document.getElementById(id); return el !== null && !el.hidden && el.closest("[hidden]") === null && !el.disabled; }; return { over: !document.getElementById("panel-veil").hidden || /\/bilan$/.test(location.hash), busy: document.body.classList.contains("busy"), take: shown("take"), confirm: shown("confirm"), skip: shown("skip"), roll: shown("roll"), land: document.querySelector("#board .hit.can-land") !== null, move: document.querySelector("#board .hit.can-move") !== null, }; }); if (s.over) { return; } if (s.busy) { await sleep(100); } else if (s.take) { await page.click("#take"); } else if (s.confirm) { await page.click("#confirm"); } else if (s.skip) { await page.click("#skip"); } else if (s.land) { await page.locator("#board .hit.can-land").first().click(); } else if (s.move) { await page.locator("#board .hit.can-move").first().click(); } else if (s.roll) { await page.click("#roll"); } else { await sleep(100); } } throw new Error("la partie ne finit pas"); } /** Le bilan de la partie qui vient de finir : son identifiant et son taux * d'erreur (celui de la jauge « Votre jeu »). */ async function summaryRate() { await until( "bilan prêt", () => /^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) && document.getElementById("summary")?.dataset.ready === "true", ); return page.evaluate(() => ({ id: location.hash.split("/")[2], rate: document.getElementById("gauge-play").dataset.errorRate, })); } /** Une course jouée jusqu'au bout, et le taux de son bilan. */ async function oneGame() { await startFrom(RACE); await playOut(); return summaryRate(); } /** L'écran de progression lu, prêt (toutes ses parties analysées). */ async function progressionReady() { await until( "progression prête", () => location.hash === "#/progression" && !document.getElementById("progression").hidden && document.getElementById("progression").dataset.ready === "true", null, 300_000, ); } // --------------------------------------------------- Aucune partie terminée await page.goto(`${base}#/progression`); await progressionReady(); const empty = await page.evaluate(() => ({ summary: document.getElementById("progression-summary").textContent, start: document.getElementById("progression-new-game")?.getAttribute("href") ?? null, curve: document.getElementById("progression-curve") !== null, list: document.getElementById("progression-list") !== null, })); check( empty.summary.startsWith("Aucune partie terminée pour l'instant") && empty.start === "#/nouvelle-partie" && !empty.curve && !empty.list, `cas vide : ${JSON.stringify(empty)}`, ); await capture("1-vide"); log("progression sans partie terminée : phrase et « Nouvelle partie »"); // ----------------------------------------------------- Trois parties jouées const games = []; for (let i = 0; i < 3; i++) { games.push(await oneGame()); } check( games.every((g) => g.id && g.rate !== undefined && !Number.isNaN(Number(g.rate))), `bilans : ${JSON.stringify(games)}`, ); log(`trois parties jouées, taux des bilans : ${games.map((g) => g.rate).join(", ")}`); // La ligne de l'accueil mène à la progression. await page.goto(`${base}#/accueil`); await until("accueil", () => { const line = document.getElementById("home-progress"); return ( !document.getElementById("home").hidden && line !== null && !line.hidden && line.textContent.includes("3 parties jouées") ); }); const line = await page.evaluate(() => { const a = document.getElementById("home-progress"); return { tag: a.tagName, href: a.getAttribute("href"), text: a.textContent, height: a.getBoundingClientRect().height, // La carte de la partie, sans partie en cours : ni miniature ni // compte de la partie finie (recette 1h). mini: !document.querySelector("#home-game .home-mini").hidden, pips: !document.getElementById("home-pips").hidden, resume: !document.getElementById("home-resume").hidden, }; }); check( line.tag === "A" && line.href === "#/progression" && line.height >= 44, `ligne de l'accueil : ${JSON.stringify(line)}`, ); check(!line.mini && !line.pips && !line.resume, `carte de la partie finie : ${JSON.stringify(line)}`); await capture("3-accueil-ligne", async () => { await page.locator("#home-progress").scrollIntoViewIfNeeded(); }); log(`accueil : « ${line.text} » mène à #/progression`); await page.click("#home-progress"); await progressionReady(); // La courbe : trois points, dans l'ordre des parties, égaux aux taux des // bilans. const shown = await page.evaluate(() => ({ summary: document.getElementById("progression-summary").textContent, dots: [...document.querySelectorAll("#progression-curve .progression-dot")].map((c) => ({ id: c.getAttribute("data-id"), rate: c.getAttribute("data-rate"), last: c.classList.contains("last"), better: c.classList.contains("better"), r: Number(c.getAttribute("r")), })), values: [...document.querySelectorAll("#progression-curve .progression-value")].length, axisLabels: [...document.querySelectorAll("#progression-curve .curve-label")].length, items: [...document.querySelectorAll("#progression-list .progression-item")].map((li) => ({ id: li.dataset.id, summary: li.querySelector(".progression-game").getAttribute("href"), links: li.querySelectorAll("a, button").length, chevron: li.querySelector(".progression-game > svg.icon") !== null, rate: li.querySelector(".progression-rate")?.dataset.rate, })), glossary: document.getElementById("progression-glossary")?.getAttribute("href"), exercises: document.getElementById("progression-exercises")?.getAttribute("href"), exercisesText: document.getElementById("progression-exercises")?.textContent, back: document.getElementById("progression-back") !== null, title: document.getElementById("progression-title")?.tagName, })); check(shown.dots.length === 3, `courbe : ${shown.dots.length} points`); for (const [i, game] of games.entries()) { const dot = shown.dots[i]; check(dot.id === game.id && dot.rate === game.rate, `point ${i + 1} : ${JSON.stringify(dot)} contre ${JSON.stringify(game)}`); } check(shown.dots[2].last && !shown.dots[0].last && shown.dots[2].r > shown.dots[0].r, "le dernier point n'est pas plus gros"); // Vert seulement si le taux baisse (progression.baisse). const down = shown.summary.includes("vous faites moins d'erreurs"); check(shown.dots[2].better === down && !shown.dots[0].better, `vert du dernier point : ${shown.summary}`); check(shown.values === 2 && shown.axisLabels === 0, `valeurs de la courbe : ${shown.values}, axe : ${shown.axisLabels}`); check(!shown.back && shown.title === "H1", `en-tête : ${JSON.stringify({ back: shown.back, title: shown.title })}`); check(shown.summary.startsWith("Sur vos 3 dernières parties"), `phrase : ${shown.summary}`); check(!shown.summary.includes("!"), `phrase : ${shown.summary}`); check(shown.items.length === 3 && shown.items[0].id === games[2].id, `liste : ${JSON.stringify(shown.items)}`); for (const item of shown.items) { const game = games.find((g) => g.id === item.id); check( item.summary === `#/parties/${item.id}/bilan` && item.links === 1 && item.chevron && item.rate === game.rate, `ligne : ${JSON.stringify(item)}`, ); } check(shown.glossary === "#/glossaire/taux-d-erreur", `lien du glossaire : ${shown.glossary}`); check( shown.exercises === "#/progression/ia" && shown.exercisesText === "Demander des exercices à une IA", `exercices : ${shown.exercises} « ${shown.exercisesText} »`, ); await capture("2-trois-parties"); log(`courbe : ${shown.dots.map((d) => d.rate).join(", ")} ; « ${shown.summary} »`); // Une ligne ouvre le bilan ; le fermer ramène à la progression. await page.click(`#progression-list .progression-item[data-id="${games[0].id}"] .progression-game`); await until( "bilan depuis la progression", ([id]) => location.hash === `#/parties/${id}/bilan` && document.getElementById("summary")?.dataset.ready === "true", [games[0].id], ); await page.click("#summary-close"); await progressionReady(); log("une ligne ouvre le bilan, la croix ramène à la progression"); // « Demander des exercices à une IA » : la feuille du prompt. await page.click("#progression-exercises"); await until( "prompt des exercices", () => location.hash === "#/progression/ia" && document.getElementById("prompt-page")?.dataset.ready === "true", ); const prompt = await page.textContent("#prompt-text"); check(prompt.includes("Voici mes 3 dernières parties"), `prompt : ${prompt}`); check(prompt.includes("Ce qui revient dans ces erreurs"), `prompt : ${prompt}`); check(/Je débute au backgammon\.|Je suis un joueur de niveau /.test(prompt), `prompt : ${prompt}`); check(!/[{}]/.test(prompt), `repère non rempli : ${prompt}`); await page.keyboard.press("Escape"); await until("retour à la progression", () => location.hash === "#/progression"); log("« Demander des exercices à une IA » : prompt avec les erreurs et leurs thèmes"); 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(`progression : tout est conforme (${captures} captures)`);