Test navigateur : nouvelle partie après une partie finie et son bilan

Reproduit le défaut signalé : après une partie finie, l'application
rouverte, « Commencer » de l'écran « Nouvelle partie » ramenait au bilan
de l'ancienne partie. Vérifie l'accueil, le menu et « Changer les réglages ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-11 22:14:46 +01:00
co-authored by Claude Opus 5.5
parent 62ecff245f
commit 0bb41524bf
2 changed files with 336 additions and 4 deletions
+15 -4
View File
@@ -11,9 +11,12 @@
# Le conseil du maître (tests/browser/maitre.mjs), captures dans
# build/browser-shots/maitre/. Le bilan de fin de partie et l'écran
# d'analyse (tests/browser/bilan.mjs), captures dans
# build/browser-shots/bilan/.
# build/browser-shots/bilan/. « Nouvelle partie » après une partie finie et
# son bilan, par chaque entrée (tests/browser/nouvelle-partie.mjs), captures
# dans build/browser-shots/nouvelle-partie/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan)
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
# nouvelle-partie)
# restreignent le test.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
@@ -27,7 +30,7 @@ cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
scripts="${SCRIPTS:-play accueil match maitre bilan}"
scripts="${SCRIPTS:-play accueil match maitre bilan nouvelle-partie}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -65,7 +68,7 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
tests/browser/maitre.mjs tests/browser/bilan.mjs "$tools/"
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/nouvelle-partie.mjs "$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
@@ -105,5 +108,13 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node bilan.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" nouvelle-partie "* ]]; then
# « Nouvelle partie » après une partie finie et son bilan : la
# nouvelle partie tout de suite ; captures dans
# build/browser-shots/nouvelle-partie/.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+321
View File
@@ -0,0 +1,321 @@
// nouvelle-partie.mjs -- après une partie finie et son bilan, « Nouvelle
// partie » mène tout droit à la nouvelle partie, sur le plateau, avec les
// réglages choisis, par chaque entrée vers #/nouvelle-partie : l'accueil
// (l'application rouverte), le menu, « Changer les réglages » du bilan.
// Dans un vrai navigateur sans écran (Playwright), sur le site servi par
// l'image de production.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node nouvelle-partie.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Défaut signalé par un joueur : « tu fais
// nouvelle partie […] tu te retrouves sur le résultat du match précédent et
// tu dois cliquer à nouveau sur nouvelle partie ». Chaque partie finie est
// une fin de course courte, partie d'un lien de position avec des dés
// imposés, comme dans bilan.mjs. Vérifie aussi le libellé du bouton
// (« Commencer la partie », « Commencer le match ») et que le coup de
// l'ordinateur, sur la ligne d'information, mène à la fiche « Notation des
// coups ». Captures (Chromium) dans <dossier des captures>/nouvelle-partie/,
// en 412×915 et 915×412.
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 nouvelle-partie.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/nouvelle-partie`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
// un sur 2. Le joueur joue 6/5/off, l'ordinateur double, le joueur passe.
const BEAROFF = "BQAACAIAAAAAAA";
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 = 7;
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} (adresse ${await page.evaluate(() => location.hash)})`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture en portrait et en paysage (Chromium seulement), après la
* règle de la recette : rien d'interactif sous une barre d'action. */
async function capture(name) {
if (!full) {
return;
}
for (const size of [PORTRAIT, LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(300);
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(300);
}
/** Une partie finie et son bilan à l'écran : une fin de course depuis un
* lien de position, 6/5/off, puis le double de l'ordinateur passé. Renvoie
* l'identifiant de la partie. */
async function finishedGameWithSummary() {
await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`);
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,
);
await page.evaluate(() => window.__dice.push(6, 1));
await page.click("#roll");
await idle();
for (const [from, to] of [
["p6", "p5"],
["p5", "off-player"],
]) {
await page.click(`#board .hit[data-place="${from}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="${to}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
}
}
await page.click("#confirm");
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await page.click("#drop");
await until(
"bilan de la partie finie",
() =>
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true",
);
return page.evaluate(() => location.hash.split("/")[2]);
}
/** Choisit les réglages sur l'écran « Nouvelle partie », vérifie le libellé
* du bouton, puis commence. */
async function chooseAndStart(choice, shot) {
await until(
"écran « Nouvelle partie »",
() =>
location.hash === "#/nouvelle-partie" &&
!document.getElementById("new-game").hidden &&
!document.getElementById("new-game-start").disabled,
);
await page.click(choice.match ? "#new-game-match" : "#new-game-free");
if (choice.match) {
await page.click(`#new-game-length-${choice.length}`);
}
await page.click(`#new-game-level-${choice.level}`);
const label = await page.textContent("#new-game-start");
const expected = choice.match ? "Commencer le match" : "Commencer la partie";
check(label === expected, `bouton : « ${label} », attendu « ${expected} »`);
if (shot) {
await capture("1-nouvelle-partie");
}
await page.click("#new-game-start");
}
/** La nouvelle partie est à l'écran, sur le plateau, avec les réglages
* choisis ; ni le bilan ni le panneau de fin de l'ancienne partie, même un
* peu plus tard. */
async function checkNewGame(entry, oldId, choice) {
await until(
`${entry} : partie commencée`,
() => location.hash === "#/" && !document.body.classList.contains("busy"),
);
// Le défaut apparaissait juste après : l'adresse passait au bilan de
// l'ancienne partie. On regarde pendant deux secondes.
for (let waited = 0; waited < 2000; waited += 100) {
const s = await page.evaluate(() => ({
hash: location.hash,
summary: !document.getElementById("summary-veil").hidden,
panel: !document.getElementById("panel-veil").hidden,
board: !document.getElementById("game").hidden,
newGame: !document.getElementById("new-game").hidden,
}));
check(
s.hash === "#/" && !s.summary && !s.panel && s.board && !s.newGame,
`${entry} : la nouvelle partie n'est pas à l'écran (ancienne partie ${oldId}) : ${JSON.stringify(s)}`,
);
await sleep(100);
}
await idle();
const shown = await page.evaluate(() => ({
line: document.getElementById("match-line").textContent,
lineShown: !document.getElementById("match-line").hidden,
level: JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").level,
over: document.getElementById("again").hidden === false,
}));
check(!shown.over, `${entry} : la partie à l'écran est finie`);
check(shown.level === choice.level, `${entry} : niveau ${shown.level}`);
if (choice.match) {
check(
shown.lineShown && shown.line === `Match en ${choice.length} points`,
`${entry} : ligne du match « ${shown.line} »`,
);
} else {
check(!shown.lineShown, `${entry} : un match au lieu d'une partie libre`);
}
log(`${entry} : nouvelle partie directe (${choice.match ? `match en ${choice.length}` : "partie libre"}, ${choice.level})`);
}
// --------------------------- 1. L'application rouverte, puis l'accueil
let oldId = await finishedGameWithSummary();
// L'application rouverte : sans adresse, elle ouvre l'accueil.
await page.goto(base);
await until(
"accueil",
() =>
location.hash === "#/accueil" &&
!document.getElementById("home-new").hidden &&
!document.getElementById("home-new").disabled,
);
await page.click("#home-new");
let choice = { match: true, length: 3, level: "beginner" };
await chooseAndStart(choice, true);
await checkNewGame("accueil", oldId, choice);
await capture("2-partie-demarree");
// Le coup de l'ordinateur, sur la ligne d'information, mène à la fiche
// « Notation des coups ». On joue jusqu'à ce qu'il ait joué un coup.
for (let actions = 0; ; actions++) {
check(actions < 200, "l'ordinateur ne joue pas de coup");
const link = await page.evaluate(() => document.querySelector("#info a.notation")?.href ?? null);
if (link !== null) {
check(link.endsWith("#/glossaire/notation-des-coups"), `lien de la notation : ${link}`);
break;
}
const what = await page.evaluate(() => {
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}`;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "#board .hit.can-land";
}
return document.querySelector("#board .hit.can-move") ? "#board .hit.can-move" : null;
});
if (what === null) {
await sleep(100);
continue;
}
await page.locator(what).first().click();
await idle();
}
await page.click("#info a.notation");
await until(
"fiche « Notation des coups »",
() =>
location.hash === "#/glossaire/notation-des-coups" &&
document.getElementById("glossaire-notation-des-coups") !== null,
);
await page.goBack();
await until("retour à la partie", () => location.hash === "#/");
log("ligne de l'ordinateur : la notation mène à sa fiche");
// --------------------------------- 2. L'application rouverte sur le menu
oldId = await finishedGameWithSummary();
await page.goto(`${base}#/menu`);
await until("menu", () => !document.getElementById("menu-veil").hidden);
await page.click("#menu-new-game");
choice = { match: false, level: "amateur" };
await chooseAndStart(choice, false);
await checkNewGame("menu", oldId, choice);
// ------------------------------- 3. « Changer les réglages » du bilan
oldId = await finishedGameWithSummary();
await page.click("#summary-settings");
choice = { match: true, length: 5, level: "intermediate" };
await chooseAndStart(choice, false);
await checkNewGame("bilan, « Changer les réglages »", oldId, choice);
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(`nouvelle partie : tout est conforme (${captures} captures)`);