Files
Ustagammon/tests/browser/progression.mjs
Pierre MARTINandClaude Opus 5.5 06cc20fb1b Recette 1h : « aucune gagnée », résultat avec gammon et videau
Le résultat d'une partie dit d'où viennent ses points (« Perdue,
gammon, videau à 2, 4 points ») ; la valeur du videau vient du journal
rejoué. Les « Perdue, 2 points » des captures étaient des parties
simples perdues avec un videau à 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:10:11 +01:00

396 lines
16 KiB
JavaScript
Raw Permalink 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.
// 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 <adresse du site> <chromium|firefox> <dossier des captures>
// 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 <dossier des captures>/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 <adresse> <chromium|firefox> <dossier des captures>");
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,
result: li.querySelector(".progression-text strong").textContent,
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} » ; résultats : ${shown.items.map((i) => i.result).join(" | ")}`);
// 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)`);