Recette design du match : Crawford en paysage, « Nouvelle partie » en deux colonnes, « Rejouer »
- Paysage : pendant la partie Crawford, la consigne de la bande centrale commence par « Partie Crawford · » ; la police se resserre si besoin. - Écran « Nouvelle partie » en paysage : deux colonnes au-dessus de la barre d'action (type de partie et longueur à gauche, niveau et conseil du maître à droite), tout tient en 915x412 et en 844x320. - Fin de partie : « Rejouer » relance tout de suite une partie aux mêmes réglages (un match de même longueur après un match), « Partie suivante » dans un match qui continue, lien « Changer les réglages ». - Recette automatique : aucun élément interactif visible sous une barre d'action collée, vérifié sur chaque écran capturé (tests/browser/recette.mjs). - Scénario « Comment rejouer avec les mêmes réglages ? ». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
67bd207337
commit
74693c4277
+1
-1
@@ -59,7 +59,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 "$tools/"
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -114,6 +115,8 @@ async function capture(page, name, settle = async () => {}, sizes = SIZES) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await sleep(250);
|
||||
await settle();
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
|
||||
captures++;
|
||||
}
|
||||
|
||||
+92
-12
@@ -13,10 +13,13 @@
|
||||
// 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.
|
||||
// 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];
|
||||
@@ -29,6 +32,7 @@ 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;
|
||||
|
||||
@@ -83,14 +87,18 @@ async function until(what, fn, arg, ms = 60_000) {
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
/** 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 [PORTRAIT, LANDSCAPE]) {
|
||||
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++;
|
||||
}
|
||||
@@ -189,7 +197,45 @@ check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire
|
||||
"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");
|
||||
// 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");
|
||||
@@ -240,7 +286,13 @@ for (;;) {
|
||||
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") {
|
||||
const settingsLink = await page.evaluate(() => {
|
||||
const a = document.getElementById("panel-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) ?? [];
|
||||
@@ -254,20 +306,30 @@ for (;;) {
|
||||
`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");
|
||||
// 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("#panel-settings");
|
||||
await until("écran « Nouvelle partie » après le match", () =>
|
||||
location.hash === "#/nouvelle-partie");
|
||||
location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden);
|
||||
await page.click("#new-game-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;
|
||||
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),
|
||||
@@ -281,6 +343,24 @@ for (;;) {
|
||||
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) {
|
||||
|
||||
+25
-10
@@ -21,6 +21,7 @@
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } 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];
|
||||
@@ -343,6 +344,8 @@ async function checkView(width, height, when) {
|
||||
const where = `${width}x${height} (${when})`;
|
||||
const wrapped = await wrappedButtons(page);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
const seen = await page.evaluate(() => {
|
||||
const visible = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
@@ -809,6 +812,8 @@ async function checkAddresses() {
|
||||
const where = `${name} ${width}x${height}`;
|
||||
const wrapped = await wrappedButtons(tab);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
const hidden = await hiddenUnderActionBar(tab);
|
||||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
if (name === "question" || name === "terme") {
|
||||
// La barre haute de l'aide reste en vue, et ce qu'on a
|
||||
// ouvert s'arrête juste sous elle.
|
||||
@@ -996,6 +1001,15 @@ async function startChosenGame() {
|
||||
"« Commencer » ne ramène pas à la partie");
|
||||
}
|
||||
|
||||
/** « Rejouer » après une partie finie : une nouvelle partie aux mêmes
|
||||
* réglages commence tout de suite, sans passer par l'écran « Nouvelle
|
||||
* partie ». */
|
||||
async function rematch() {
|
||||
await page.click("#again");
|
||||
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
|
||||
"« Rejouer » ouvre un autre écran que 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() {
|
||||
@@ -1525,8 +1539,10 @@ const won = journal.events.at(-1).winner === "player";
|
||||
check((await vibrations()).at(-1) === (won ? "30,50,30,50,60" : "60"),
|
||||
`fin de partie : vibration ${(await vibrations()).at(-1)}`);
|
||||
check((await page.textContent("#panel-secondary")) === "Revoir la partie" &&
|
||||
(await page.textContent("#panel-primary")) === "Nouvelle partie",
|
||||
"fin de partie : panneau sans « Revoir la partie » et « Nouvelle partie »");
|
||||
(await page.textContent("#panel-primary")) === "Rejouer" &&
|
||||
(await page.textContent("#panel-settings")) === "Changer les réglages" &&
|
||||
(await page.getAttribute("#panel-settings", "href")) === "#/nouvelle-partie",
|
||||
"fin de partie : panneau sans « Revoir la partie », « Rejouer » et « Changer les réglages »");
|
||||
await review("6-fin-de-partie");
|
||||
await page.setViewportSize({ width: 844, height: 320 });
|
||||
await sleep(150);
|
||||
@@ -1557,11 +1573,12 @@ log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant l
|
||||
log(`vibrations demandées : ${(await vibrations()).length}`);
|
||||
await shot("4-fin-de-partie");
|
||||
|
||||
// « Revoir la partie » ferme le panneau ; « Nouvelle partie » reste dans la
|
||||
// barre d'actions.
|
||||
// « Revoir la partie » ferme le panneau ; « Rejouer » reste dans la barre
|
||||
// d'actions.
|
||||
await page.click("#panel-secondary");
|
||||
check(!(await page.isVisible("#panel")) && (await page.isVisible("#again")),
|
||||
"« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »");
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
|
||||
(await page.isVisible("#again")),
|
||||
"« Revoir la partie » : panneau toujours là, ou pas de « Rejouer »");
|
||||
|
||||
step("mes parties");
|
||||
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
|
||||
@@ -1569,8 +1586,7 @@ step("mes parties");
|
||||
// recharger la page.
|
||||
{
|
||||
const finished = await savedJournal();
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await rematch();
|
||||
await waitForPlayer();
|
||||
const second = await savedJournal();
|
||||
check(second.head !== finished.head || second.events.length !== finished.events.length,
|
||||
@@ -1651,8 +1667,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 rematch();
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// recette.mjs -- règles de la recette design communes aux tests dans un
|
||||
// navigateur (play.mjs, accueil.mjs, match.mjs), vérifiées sur chaque écran
|
||||
// capturé.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/**
|
||||
* Les éléments interactifs visibles cachés, même en partie, sous une barre
|
||||
* d'action collée en bas de l'écran (position: sticky, ancrée par bottom) :
|
||||
* un bouton, un lien ou un interrupteur que la barre recouvre ne se voit
|
||||
* pas et ne se touche pas. Un élément entièrement sous le bas de l'écran
|
||||
* n'est pas visible : il n'est pas compté. Ce qui passe devant la barre (le
|
||||
* menu, un panneau) n'est pas caché : seul compte ce que la barre recouvre
|
||||
* vraiment, au point du recouvrement.
|
||||
*
|
||||
* Renvoie la liste des éléments cachés, décrits (vide si tout va bien).
|
||||
*/
|
||||
export async function hiddenUnderActionBar(target) {
|
||||
return target.evaluate(() => {
|
||||
const shown = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
};
|
||||
const bars = [...document.querySelectorAll("body *")].filter((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return style.position === "sticky" && style.bottom !== "auto" && shown(el);
|
||||
});
|
||||
const describe = (el) =>
|
||||
el.id
|
||||
? `#${el.id}`
|
||||
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")} « ${
|
||||
el.getAttribute("aria-label") ?? el.textContent.trim().slice(0, 40)
|
||||
} »`;
|
||||
const hidden = [];
|
||||
const interactive = document.querySelectorAll(
|
||||
"button, a[href], input, select, textarea, [role=switch], [role=radio], " +
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
);
|
||||
for (const bar of bars) {
|
||||
const b = bar.getBoundingClientRect();
|
||||
for (const el of interactive) {
|
||||
if (bar.contains(el) || el.contains(bar) || !shown(el)) {
|
||||
continue;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top >= innerHeight) {
|
||||
continue;
|
||||
}
|
||||
const top = Math.max(r.top, b.top);
|
||||
const bottom = Math.min(r.bottom, b.bottom, innerHeight);
|
||||
const left = Math.max(r.left, b.left);
|
||||
const right = Math.min(r.right, b.right);
|
||||
if (bottom - top <= 1 || right - left <= 1) {
|
||||
continue;
|
||||
}
|
||||
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
||||
if (hit !== null && bar.contains(hit)) {
|
||||
hidden.push(
|
||||
`${describe(el)} sous ${describe(bar)} (${Math.round(bottom - top)} px)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user