Recette automatisée de la révision UX dans make test-browser ; chiffres du plateau à 13 px au moins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c6649f94c0
commit
2c56b0a2b1
+600
-83
@@ -1,8 +1,15 @@
|
||||
// play.mjs -- joue une partie complète par l'interface, dans un vrai
|
||||
// navigateur sans écran (Playwright), sur le site servi par l'image de
|
||||
// production : dés, coups au toucher (validés, annulés), coups de
|
||||
// l'ordinateur, videau (avec sa confirmation), reprise
|
||||
// après rechargement, aide, puis hors ligne (service worker).
|
||||
// production : chargement (plateau dessiné avant le moteur), dés, coups au
|
||||
// toucher (joués, annulés), coups de l'ordinateur, videau (avec sa
|
||||
// confirmation), aucun coup possible, menu, nouvelle partie reprise,
|
||||
// vibrations, plein écran (repli par rotation), paysage, reprise après
|
||||
// rechargement, aide, thème sombre, puis hors ligne (service worker).
|
||||
//
|
||||
// Il vérifie en chemin les critères automatisables de la recette design
|
||||
// (docs/identite/charte/recette-ux.md) et, dans Chromium, range les
|
||||
// captures de la revue dans <dossier>/revision-ux/ : 412×915, 915×412 et
|
||||
// 844×320, thèmes clair et sombre, à six moments de la partie.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -12,6 +19,7 @@
|
||||
// premier problème, ou si la page signale une erreur (dont une violation de
|
||||
// la politique de sécurité du contenu).
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
@@ -21,15 +29,30 @@ if (!base || !browserType || !shots) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// La recette complète (six vues à chaque moment, captures de la revue) dans
|
||||
// Chromium ; Firefox joue la même partie et vérifie la vue courante.
|
||||
const fullReview = browserName === "chromium";
|
||||
const reviewDir = `${shots}/revision-ux`;
|
||||
if (fullReview) {
|
||||
mkdirSync(reviewDir, { recursive: true });
|
||||
}
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const VIEWS = [
|
||||
[412, 915],
|
||||
[915, 412],
|
||||
[844, 320],
|
||||
];
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 360, height: 740 },
|
||||
deviceScaleFactor: 2,
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
});
|
||||
// Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur
|
||||
// (Web Worker), et si elle a reçu sa réponse ; et, pour vérifier la
|
||||
// politique de sécurité du contenu, chaque violation signalée par la page.
|
||||
// (Web Worker). Pour la recette : chaque violation de la politique de
|
||||
// sécurité du contenu, chaque boîte native (confirm, alert, prompt) et
|
||||
// chaque vibration demandée.
|
||||
await context.addInitScript(() => {
|
||||
window.__engine = { sent: 0, received: 0, last: null };
|
||||
const post = Worker.prototype.postMessage;
|
||||
@@ -46,10 +69,29 @@ await context.addInitScript(() => {
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
window.__dialogs = [];
|
||||
for (const name of ["confirm", "alert", "prompt"]) {
|
||||
window[name] = (...args) => {
|
||||
window.__dialogs.push(`${name}(${args.join(", ")})`);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
window.__vibrations = [];
|
||||
Object.defineProperty(Navigator.prototype, "vibrate", {
|
||||
configurable: true,
|
||||
value: (pattern) => {
|
||||
window.__vibrations.push(Array.isArray(pattern) ? pattern.join(",") : String(pattern));
|
||||
return true;
|
||||
},
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const problems = [];
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("dialog", (dialog) => {
|
||||
problems.push(`boîte native ouverte : ${dialog.type()} « ${dialog.message()} »`);
|
||||
void dialog.dismiss();
|
||||
});
|
||||
page.on("crash", () => {
|
||||
console.error(`[${browserName}] la page a planté`);
|
||||
process.exit(1);
|
||||
@@ -64,12 +106,16 @@ page.on("console", (message) => {
|
||||
});
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` });
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Relève les violations de la CSP signalées par la page (avant chaque
|
||||
* rechargement, qui les efface). */
|
||||
* rechargement, qui les efface), et les boîtes natives. */
|
||||
const violations = [];
|
||||
const dialogs = [];
|
||||
async function collectViolations() {
|
||||
violations.push(...(await page.evaluate(() => window.__csp)));
|
||||
const seen = await page.evaluate(() => ({ csp: window.__csp, dialogs: window.__dialogs }));
|
||||
violations.push(...seen.csp);
|
||||
dialogs.push(...seen.dialogs);
|
||||
}
|
||||
|
||||
function check(condition, message) {
|
||||
@@ -87,6 +133,10 @@ function countMoves(journal, side) {
|
||||
return journal.events.filter((e) => e.type === "MovePlayed" && e.side === side).length;
|
||||
}
|
||||
|
||||
async function vibrations() {
|
||||
return page.evaluate(() => [...window.__vibrations]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que le joueur ait quelque chose à faire, et dit quoi. (Pas de
|
||||
* page.waitForFunction : Playwright y passe par eval, que la politique de
|
||||
@@ -98,6 +148,10 @@ async function waitForPlayer() {
|
||||
if (document.body.classList.contains("busy")) {
|
||||
return null;
|
||||
}
|
||||
const panel = document.getElementById("panel-veil");
|
||||
if (!panel.hidden) {
|
||||
return panel.dataset.kind;
|
||||
}
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
@@ -112,7 +166,7 @@ async function waitForPlayer() {
|
||||
if (todo !== null) {
|
||||
return todo;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await sleep(100);
|
||||
}
|
||||
await shot("bloque").catch(() => {});
|
||||
const screen = await page
|
||||
@@ -131,6 +185,217 @@ async function waitForPlayer() {
|
||||
throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Recette design
|
||||
|
||||
/** Les boîtes des zones de l'écran de jeu en portrait (y et hauteur). */
|
||||
async function zoneBoxes() {
|
||||
return page.evaluate(() => {
|
||||
const box = (selector) => {
|
||||
const r = document.querySelector(selector).getBoundingClientRect();
|
||||
return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)];
|
||||
};
|
||||
return {
|
||||
topbar: box(".topbar"),
|
||||
computer: box("#strip-computer"),
|
||||
board: box("#board"),
|
||||
player: box("#strip-player"),
|
||||
info: box(".infoline"),
|
||||
prompt: box("#prompt"),
|
||||
actions: box("#actions"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let stableBoxes = null;
|
||||
let stableChecks = 0;
|
||||
/** Portrait : rien ne bouge sous le doigt, d'une étape à l'autre de la
|
||||
* partie (et dès le chargement). */
|
||||
async function checkStable(when) {
|
||||
const boxes = await zoneBoxes();
|
||||
if (stableBoxes === null) {
|
||||
stableBoxes = boxes;
|
||||
const order = ["topbar", "computer", "board", "player", "info", "prompt", "actions"];
|
||||
for (let i = 1; i < order.length; i++) {
|
||||
const above = boxes[order[i - 1]];
|
||||
check(above[1] + above[3] <= boxes[order[i]][1] + 0.5,
|
||||
`portrait : ${order[i - 1]} n'est pas au-dessus de ${order[i]}`);
|
||||
}
|
||||
const actions = boxes.actions;
|
||||
check(Math.abs(actions[1] + actions[3] - PORTRAIT.height) <= 1,
|
||||
`portrait : barre d'actions pas collée en bas (${JSON.stringify(actions)})`);
|
||||
}
|
||||
for (const [zone, box] of Object.entries(boxes)) {
|
||||
check(JSON.stringify(box) === JSON.stringify(stableBoxes[zone]),
|
||||
`portrait : ${zone} a bougé (${when}) : ${JSON.stringify(stableBoxes[zone])} -> ` +
|
||||
JSON.stringify(box));
|
||||
}
|
||||
stableChecks++;
|
||||
}
|
||||
|
||||
/** Les critères de la recette vérifiables sur la vue courante. Renvoie des
|
||||
* mesures pour le compte rendu. */
|
||||
async function checkView(width, height, when) {
|
||||
const where = `${width}x${height} (${when})`;
|
||||
const seen = await page.evaluate(() => {
|
||||
const visible = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
return false;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(el);
|
||||
return style.visibility !== "hidden" && style.display !== "none";
|
||||
};
|
||||
const describe = (el) =>
|
||||
el.id ? `#${el.id}` : el.getAttribute("data-action") ?? el.getAttribute("aria-label") ??
|
||||
el.className?.baseVal ?? el.className;
|
||||
// Cibles tactiles : boutons et zones d'action du plateau.
|
||||
const small = [];
|
||||
for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action]")) {
|
||||
if (!visible(el)) {
|
||||
continue;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 43.5 || r.height < 43.5) {
|
||||
small.push(`${describe(el)} ${Math.round(r.width)}x${Math.round(r.height)}`);
|
||||
}
|
||||
}
|
||||
// Textes du plateau : taille à l'écran.
|
||||
const scaleOf = (el) => {
|
||||
const m = el.getScreenCTM();
|
||||
return Math.hypot(m.a, m.b);
|
||||
};
|
||||
let minNumber = Infinity;
|
||||
const smallText = [];
|
||||
for (const text of document.querySelectorAll("#board text")) {
|
||||
const px = parseFloat(getComputedStyle(text).fontSize) * scaleOf(text);
|
||||
if (text.classList.contains("point-number")) {
|
||||
minNumber = Math.min(minNumber, px);
|
||||
} else if (px < 12.9) {
|
||||
smallText.push(`${text.getAttribute("class")} ${px.toFixed(1)} px`);
|
||||
}
|
||||
}
|
||||
// Textes de la page à lire : 13 px au moins.
|
||||
for (const el of document.querySelectorAll("body *:not(svg *)")) {
|
||||
const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
||||
if (own && visible(el) && parseFloat(getComputedStyle(el).fontSize) < 13) {
|
||||
smallText.push(`${describe(el)} ${getComputedStyle(el).fontSize}`);
|
||||
}
|
||||
}
|
||||
// Couleurs des flèches : impaires d'une couleur, paires d'une autre.
|
||||
const fills = {};
|
||||
for (const p of document.querySelectorAll("#board polygon[data-point]")) {
|
||||
fills[p.getAttribute("data-point")] = getComputedStyle(p).fill;
|
||||
}
|
||||
// Boutons principaux (brique) visibles.
|
||||
const primaries = [...document.querySelectorAll(
|
||||
"button:not(.secondary):not(.icon-button):not(.item):not(.count):not(.link-button)",
|
||||
)].filter(visible).map(describe);
|
||||
const board = document.getElementById("board").getBoundingClientRect();
|
||||
const frame = document.querySelector("#board .board-frame").getBoundingClientRect();
|
||||
const scroller = document.scrollingElement;
|
||||
return {
|
||||
small,
|
||||
minNumber,
|
||||
smallText,
|
||||
fills,
|
||||
primaries,
|
||||
landscape: document.documentElement.classList.contains("landscape"),
|
||||
board: [board.x, board.y, board.width, board.height],
|
||||
frame: [frame.x, frame.y, frame.width, frame.height],
|
||||
scroll: [scroller.scrollWidth, scroller.scrollHeight],
|
||||
topbar: visible(document.querySelector(".topbar")),
|
||||
overscroll: getComputedStyle(document.documentElement).overscrollBehaviorY,
|
||||
pointHits: [...document.querySelectorAll('#board [data-place^="p"]')].map(
|
||||
(el) => el.getBoundingClientRect().width,
|
||||
),
|
||||
};
|
||||
});
|
||||
check(seen.small.length === 0, `${where} : cibles sous 44 px : ${seen.small.join(", ")}`);
|
||||
check(seen.minNumber >= 12, `${where} : numéros des flèches à ${seen.minNumber.toFixed(1)} px`);
|
||||
check(seen.smallText.length === 0, `${where} : textes sous 13 px : ${seen.smallText.join(", ")}`);
|
||||
const odd = new Set();
|
||||
const even = new Set();
|
||||
for (const [n, fill] of Object.entries(seen.fills)) {
|
||||
(Number(n) % 2 ? odd : even).add(fill);
|
||||
}
|
||||
check(Object.keys(seen.fills).length === 24 && odd.size === 1 && even.size === 1 &&
|
||||
[...odd][0] !== [...even][0] && seen.fills[12] !== seen.fills[13],
|
||||
`${where} : couleurs des flèches ${JSON.stringify(seen.fills)}`);
|
||||
check(seen.primaries.length <= 1, `${where} : plusieurs actions principales : ${seen.primaries}`);
|
||||
check(seen.overscroll === "none", `${where} : overscroll-behavior ${seen.overscroll}`);
|
||||
const landscape = width > height && height <= 640;
|
||||
check(seen.landscape === landscape, `${where} : mise en page ${seen.landscape ? "paysage" : "portrait"}`);
|
||||
if (landscape) {
|
||||
const edge = (box) => box.every((v, i) => Math.abs(v - [0, 0, width, height][i]) <= 1);
|
||||
check(edge(seen.board) && edge(seen.frame),
|
||||
`${where} : cadre pas bord à bord : ${seen.board} / ${seen.frame}`);
|
||||
check(seen.scroll[0] <= width && seen.scroll[1] <= height, `${where} : la page défile`);
|
||||
check(!seen.topbar, `${where} : barre haute visible en paysage`);
|
||||
check(Math.min(...seen.pointHits) >= 44,
|
||||
`${where} : flèches de ${Math.min(...seen.pointHits).toFixed(1)} px`);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** Ce qui doit survivre à une rotation : partie, sélection, dés. */
|
||||
async function screenState() {
|
||||
return page.evaluate(() => ({
|
||||
head: JSON.parse(localStorage.getItem("ustagammon.current-game"))?.head ?? null,
|
||||
selected: document.querySelector("#board .checker.selected") !== null,
|
||||
landing: [...document.querySelectorAll("#board .hit.can-land")]
|
||||
.map((e) => e.getAttribute("data-place"))
|
||||
.join(" "),
|
||||
dice: [...document.querySelectorAll("#board g.die[data-value]")]
|
||||
.map((d) => `${d.getAttribute("data-value")}${d.classList.contains("die-used") ? "u" : ""}`)
|
||||
.join(" "),
|
||||
traces: document.querySelectorAll(".trace-arrow").length,
|
||||
menu: !document.getElementById("menu-veil").hidden,
|
||||
panel: !document.getElementById("panel-veil").hidden,
|
||||
}));
|
||||
}
|
||||
|
||||
const recette = { views: 0, captures: 0, minNumber: Infinity, rotations: 0 };
|
||||
|
||||
/**
|
||||
* Un moment de la partie : dans chaque vue (trois tailles, deux thèmes),
|
||||
* vérifie la recette et l'état conservé par la rotation, et range la
|
||||
* capture. Revient ensuite en portrait clair.
|
||||
*/
|
||||
async function review(moment) {
|
||||
const before = await screenState();
|
||||
const views = fullReview ? VIEWS : [[PORTRAIT.width, PORTRAIT.height], [844, 320]];
|
||||
const themes = fullReview ? ["light", "dark"] : ["light"];
|
||||
for (const [width, height] of views) {
|
||||
await page.setViewportSize({ width, height });
|
||||
for (const theme of themes) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await sleep(120);
|
||||
const seen = await checkView(width, height, moment);
|
||||
recette.views++;
|
||||
recette.minNumber = Math.min(recette.minNumber, seen.minNumber);
|
||||
const now = await screenState();
|
||||
check(JSON.stringify(now) === JSON.stringify(before),
|
||||
`${moment}, ${width}x${height} : état perdu à la rotation : ` +
|
||||
`${JSON.stringify(before)} -> ${JSON.stringify(now)}`);
|
||||
recette.rotations++;
|
||||
if (fullReview) {
|
||||
await page.screenshot({ path: `${reviewDir}/${moment}-${width}x${height}-${theme}.png` });
|
||||
recette.captures++;
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await sleep(120);
|
||||
check(JSON.stringify(await screenState()) === JSON.stringify(before),
|
||||
`${moment} : état perdu au retour en portrait`);
|
||||
}
|
||||
|
||||
// --------------------------------------------------- Coup de l'ordinateur
|
||||
|
||||
/** Le dernier coup de l'ordinateur, s'il est la dernière chose jouée (fin de
|
||||
* partie mise à part) et qu'il a déplacé des pions ; sinon null. */
|
||||
function computerMoveToShow(journal) {
|
||||
@@ -197,8 +462,8 @@ async function touchTarget(action, label, count = 1, index = 0) {
|
||||
/** Le nombre de dés montrés, et de dés estompés (déjà joués). */
|
||||
async function shownDice() {
|
||||
return {
|
||||
dice: await page.locator("g.die[data-value]").count(),
|
||||
used: await page.locator("g.die-used").count(),
|
||||
dice: await page.locator("#board g.die[data-value]").count(),
|
||||
used: await page.locator("#board g.die-used").count(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,12 +480,13 @@ async function watchReplay(ms) {
|
||||
let arrows = Infinity;
|
||||
for (const end = Date.now() + ms; Date.now() < end; ) {
|
||||
const seen = await page.evaluate(() => ({
|
||||
animations: document.getAnimations().length,
|
||||
animations: document.getAnimations().filter((a) => a.effect?.target?.closest?.("#board"))
|
||||
.length,
|
||||
arrows: document.querySelectorAll(".trace-arrow").length,
|
||||
}));
|
||||
animations = Math.max(animations, seen.animations);
|
||||
arrows = Math.min(arrows, seen.arrows);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await sleep(30);
|
||||
}
|
||||
return { animations, arrows };
|
||||
}
|
||||
@@ -230,7 +496,7 @@ async function watchReplay(ms) {
|
||||
async function checkReplay(traces, reducedMotion) {
|
||||
await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" });
|
||||
await page.click("#replay");
|
||||
const seen = await watchReplay(300);
|
||||
const seen = await watchReplay(250);
|
||||
if (reducedMotion) {
|
||||
check(seen.animations === 0, "Revoir en mouvement réduit : des pions glissent quand même");
|
||||
} else {
|
||||
@@ -244,6 +510,32 @@ async function checkReplay(traces, reducedMotion) {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Menu
|
||||
|
||||
async function openMenu() {
|
||||
await page.click("#menu-button");
|
||||
check(await page.isVisible("#menu"), "le menu ne s'ouvre pas");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu : effet immédiat, bandeau 10 s. Renvoie
|
||||
* le journal de la partie quittée. */
|
||||
async function newGameFromMenu() {
|
||||
const before = await savedJournal();
|
||||
await openMenu();
|
||||
const start = Date.now();
|
||||
await page.click("#menu-new-game");
|
||||
check(await page.isVisible("#banner"), "pas de bandeau « Reprendre l'ancienne »");
|
||||
check((await page.textContent("#banner")).includes("Reprendre l'ancienne"),
|
||||
`bandeau : « ${await page.textContent("#banner")} »`);
|
||||
check(!(await page.isVisible("#menu")), "le menu reste ouvert après « Nouvelle partie »");
|
||||
await waitForPlayer();
|
||||
const after = await savedJournal();
|
||||
check(after.head !== before.head && !after.events.some((e) => e.type === "MovePlayed" &&
|
||||
e.side === "player"), "« Nouvelle partie » n'a pas commencé de partie");
|
||||
log(`nouvelle partie commencée en ${Date.now() - start} ms, sans confirmation`);
|
||||
return before;
|
||||
}
|
||||
|
||||
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
|
||||
// capture et l'état de l'écran.
|
||||
let lastStep = Date.now();
|
||||
@@ -253,48 +545,132 @@ function step(name) {
|
||||
stepName = name;
|
||||
}
|
||||
const watchdog = setInterval(async () => {
|
||||
if (Date.now() - lastStep > 120_000) {
|
||||
if (Date.now() - lastStep > 180_000) {
|
||||
clearInterval(watchdog);
|
||||
console.error(`[${browserName}] bloqué depuis 2 minutes (étape : ${stepName})`);
|
||||
console.error(`[${browserName}] bloqué depuis 3 minutes (étape : ${stepName})`);
|
||||
await shot("bloque").catch(() => {});
|
||||
process.exit(1);
|
||||
}
|
||||
}, 5_000);
|
||||
|
||||
// ------------------------------------------------------------- Chargement
|
||||
|
||||
// Le moteur est retenu le temps de regarder l'écran de chargement : le
|
||||
// plateau doit être là sans lui (réponse immédiate).
|
||||
let releaseEngine;
|
||||
const engineHeld = new Promise((resolve) => {
|
||||
releaseEngine = resolve;
|
||||
});
|
||||
await page.route("**/wasm/engine.data", async (route) => {
|
||||
await engineHeld;
|
||||
await route.continue();
|
||||
});
|
||||
const start = Date.now();
|
||||
await page.goto(base);
|
||||
const first = await waitForPlayer();
|
||||
log(`moteur chargé, partie prête en ${Date.now() - start} ms (${first})`);
|
||||
await page.waitForSelector("#board polygon[data-point]");
|
||||
{
|
||||
const loading = await page.evaluate(() => ({
|
||||
busy: document.body.classList.contains("busy"),
|
||||
points: document.querySelectorAll("#board polygon[data-point]").length,
|
||||
checkers: document.querySelectorAll("#board .checker").length,
|
||||
info: document.getElementById("info").textContent,
|
||||
moves: document.querySelectorAll(".hit.can-move, [data-action=roll]").length,
|
||||
}));
|
||||
check(loading.busy && loading.points === 24 && loading.checkers === 30,
|
||||
`chargement : plateau incomplet ${JSON.stringify(loading)}`);
|
||||
check(loading.info === "Chargement du moteur…", `chargement : « ${loading.info} »`);
|
||||
check(loading.moves === 0, "chargement : le plateau est jouable avant le moteur");
|
||||
log(`plateau dessiné en ${Date.now() - start} ms, avant le moteur (${loading.checkers} pions)`);
|
||||
}
|
||||
await checkStable("chargement");
|
||||
await review("1-chargement");
|
||||
releaseEngine();
|
||||
await page.unroute("**/wasm/engine.data");
|
||||
let todo = await waitForPlayer();
|
||||
log(`moteur chargé, partie prête en ${Date.now() - start} ms (${todo})`);
|
||||
await checkStable("moteur chargé");
|
||||
await shot("1-debut");
|
||||
|
||||
// Nouvelle partie dès le début : le bandeau disparaît après 10 s, et
|
||||
// l'ancienne partie n'est alors plus proposée.
|
||||
step("bandeau de 10 s");
|
||||
await newGameFromMenu();
|
||||
await sleep(10_300);
|
||||
check(!(await page.isVisible("#banner")), "le bandeau « Reprendre l'ancienne » reste après 10 s");
|
||||
log("bandeau « Reprendre l'ancienne » retiré après 10 s");
|
||||
|
||||
let doubled = false;
|
||||
let selectionShot = false;
|
||||
let reloaded = false;
|
||||
let computerMs = [];
|
||||
let resumed = false;
|
||||
let landscapeTurn = false;
|
||||
let rotatedChecked = false;
|
||||
let vibrationsChecked = false;
|
||||
let tracesSeen = 0;
|
||||
let replays = 0;
|
||||
let skips = 0;
|
||||
let skipsByDice = 0;
|
||||
let rolls = 0;
|
||||
// Saisie du coup : demi-coups du coup en cours, pions avancés d'un seul
|
||||
// toucher ou en deux (pion, puis case), coups validés et annulations.
|
||||
let keyboardRolls = 0;
|
||||
let halfMoves = 0;
|
||||
let singleTouch = 0;
|
||||
let twoTouches = 0;
|
||||
let confirms = 0;
|
||||
let undone = 0;
|
||||
let doubles = 0;
|
||||
let responseMs = 0;
|
||||
const moments = new Set();
|
||||
// Vrai dès que le joueur a touché un pion ou « Annuler » pendant son coup :
|
||||
// le coup de l'ordinateur n'est plus montré.
|
||||
let touched = false;
|
||||
// Vrai juste après le rechargement : la partie reprise ne revibre pas.
|
||||
let justReloaded = false;
|
||||
|
||||
/** Un tour complet joué en paysage (844 × 320), par les zones du plateau :
|
||||
* dés, pions, dés pour jouer le coup. */
|
||||
async function landscapeTurnPlay() {
|
||||
await page.setViewportSize({ width: 844, height: 320 });
|
||||
await sleep(150);
|
||||
await checkView(844, 320, "tour en paysage");
|
||||
await (await touchTarget("roll", "Lancer les dés", 2, 1)).click();
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const now = await waitForPlayer();
|
||||
if (now === "confirm") {
|
||||
await (await touchTarget("confirm", "Jouer ce coup", 2, 0)).click();
|
||||
break;
|
||||
}
|
||||
if (now === "skip") {
|
||||
await (await touchTarget("skip", "Passer votre tour", 2, 0)).click();
|
||||
break;
|
||||
}
|
||||
check(now === "move", `paysage : ${now} pendant le coup`);
|
||||
await page.click(".hit.can-move");
|
||||
if ((await page.locator(".hit.can-land").count()) > 0) {
|
||||
await page.click(".hit.can-land");
|
||||
}
|
||||
}
|
||||
await waitForPlayer();
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(150);
|
||||
log("un tour joué en paysage (844 x 320) par les zones du plateau");
|
||||
}
|
||||
|
||||
for (let actions = 0; ; actions++) {
|
||||
check(actions < 1500, "la partie ne finit pas");
|
||||
const todo = await waitForPlayer();
|
||||
todo = await waitForPlayer();
|
||||
step(`partie, action ${actions}`);
|
||||
const afterReload = justReloaded;
|
||||
justReloaded = false;
|
||||
await checkStable(`action ${actions} (${todo})`);
|
||||
// Le coup de l'ordinateur est montré jusqu'à l'action suivante du
|
||||
// joueur ; deux fois, on le revoit (la seconde en mouvement réduit).
|
||||
const traces = touched ? null : await checkTraces();
|
||||
if (traces !== null) {
|
||||
tracesSeen++;
|
||||
if (!moments.has("coup-ordinateur")) {
|
||||
moments.add("coup-ordinateur");
|
||||
await review("4-coup-ordinateur");
|
||||
}
|
||||
if (replays < 2) {
|
||||
replays++;
|
||||
await checkReplay(traces, replays === 2);
|
||||
@@ -306,71 +682,131 @@ for (let actions = 0; ; actions++) {
|
||||
`${traces.ghosts} fantômes, ${traces.hits} pions frappés signalés`);
|
||||
}
|
||||
}
|
||||
const info = (await page.textContent("#info")) ?? "";
|
||||
check(!/\(\d+,\d s\)/.test(info), `durée de calcul affichée : « ${info} »`);
|
||||
if (process.env.TRACE) {
|
||||
log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`);
|
||||
log(`${todo} : ${info} / ${await page.textContent("#prompt")}`);
|
||||
}
|
||||
const info = await page.textContent("#info");
|
||||
const timing = /\((\d+),(\d) s\)/.exec(info ?? "");
|
||||
if (timing) {
|
||||
computerMs.push(Number(timing[1]) * 1000 + Number(timing[2]) * 100);
|
||||
}
|
||||
if (todo === "again") {
|
||||
if (todo === "over") {
|
||||
break;
|
||||
}
|
||||
if (todo === "skip") {
|
||||
// Aucun coup possible : le jet reste affiché, barré, avec un
|
||||
// message, et le tour ne passe qu'en touchant « Continuer ».
|
||||
const info = await page.textContent("#info");
|
||||
// message ; le tour ne passe qu'en touchant les dés (ou Continuer).
|
||||
const blocked = await page.locator(".die-blocked").count();
|
||||
check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked >= 2 &&
|
||||
check(/^Aucun coup possible avec \d-\d\.$/.test(info) && blocked >= 2 &&
|
||||
blocked === (await shownDice()).dice,
|
||||
`aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`);
|
||||
await sleep(400);
|
||||
check(await page.isVisible("#skip"), "aucun coup possible : le tour est passé tout seul");
|
||||
skips++;
|
||||
log(`aucun coup possible : « ${info} », vous continuez`);
|
||||
await page.click("#skip");
|
||||
if (skips % 2 === 1) {
|
||||
skipsByDice++;
|
||||
await (await touchTarget("skip", "Passer votre tour", 2, skips % 2)).click();
|
||||
} else {
|
||||
await page.click("#skip");
|
||||
}
|
||||
log(`aucun coup possible : « ${info} », vous passez votre tour`);
|
||||
} else if (todo === "take") {
|
||||
log("l'ordinateur double : vous prenez");
|
||||
await shot("videau-propose");
|
||||
check(afterReload || (await vibrations()).includes("40,60,40"),
|
||||
"l'ordinateur double sans vibration");
|
||||
await page.click("#take");
|
||||
} else if (todo === "roll") {
|
||||
if (!doubled && (await page.isEnabled("#double"))) {
|
||||
if (!moments.has("a-vous")) {
|
||||
moments.add("a-vous");
|
||||
await review("2-a-vous-de-lancer");
|
||||
}
|
||||
if (!doubled && (await page.isVisible("#double")) && (await page.isEnabled("#double"))) {
|
||||
doubled = true;
|
||||
// Toucher le videau demande confirmation ; « Annuler » ne
|
||||
// propose rien, puis « Doubler » (le bouton) et confirmer.
|
||||
const dialog = page.locator("#double-dialog");
|
||||
const question = "Doubler à 2 ? L'ordinateur pourra prendre ou passer.";
|
||||
await (await touchTarget("double", "Doubler")).click();
|
||||
check(await dialog.isVisible(), "videau touché : pas de confirmation");
|
||||
check(await page.isVisible("#double-dialog"), "videau touché : pas de confirmation");
|
||||
check((await page.textContent("#double-question")) === question,
|
||||
`confirmation du videau : « ${await page.textContent("#double-question")} »`);
|
||||
await shot("videau-confirmation");
|
||||
await page.click("#double-cancel");
|
||||
check(!(await dialog.isVisible()), "Annuler ne ferme pas la confirmation");
|
||||
check(!(await page.isVisible("#double-dialog")), "Annuler ne ferme pas la confirmation");
|
||||
const offered = (await savedJournal()).events.some((e) => e.type === "CubeOffered");
|
||||
check(!offered && (await page.isVisible("#roll")), "double proposé malgré Annuler");
|
||||
await page.click("#double");
|
||||
check(await dialog.isVisible(), "« Doubler » : pas de confirmation");
|
||||
check(await page.isVisible("#double-dialog"), "« Doubler » : pas de confirmation");
|
||||
await page.click("#double-confirm");
|
||||
await waitForPlayer();
|
||||
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
|
||||
log(`videau touché : confirmation demandée, annulée, puis double confirmé ; réponse : ${answer}`);
|
||||
continue;
|
||||
}
|
||||
// Une fois sur deux, en touchant les dés plutôt que le bouton, d'un
|
||||
// côté puis de l'autre du plateau.
|
||||
rolls++;
|
||||
if (rolls % 2 === 0) {
|
||||
if (rolls === 3 && !landscapeTurn) {
|
||||
landscapeTurn = true;
|
||||
await landscapeTurnPlay();
|
||||
continue;
|
||||
}
|
||||
if (rolls === 4 && !rotatedChecked) {
|
||||
// Plein écran : Chromium sans écran refuse de verrouiller
|
||||
// l'orientation, d'où le repli par rotation CSS de tout l'écran.
|
||||
rotatedChecked = true;
|
||||
await page.click("#fullscreen-button");
|
||||
await sleep(300);
|
||||
const view = await page.evaluate(() => {
|
||||
const r = document.getElementById("board").getBoundingClientRect();
|
||||
return {
|
||||
rotated: document.documentElement.classList.contains("rotated"),
|
||||
landscape: document.documentElement.classList.contains("landscape"),
|
||||
board: [r.x, r.y, r.width, r.height].map(Math.round),
|
||||
};
|
||||
});
|
||||
check(view.landscape, "plein écran : pas de vue paysage");
|
||||
if (view.rotated) {
|
||||
check(JSON.stringify(view.board) === JSON.stringify([0, 0, 412, 915]),
|
||||
`repli tourné : plateau ${view.board}`);
|
||||
}
|
||||
await shot("plein-ecran");
|
||||
if (fullReview) {
|
||||
await page.screenshot({ path: `${reviewDir}/plein-ecran-repli-412x915-light.png` });
|
||||
}
|
||||
// Les touchers suivent la rotation : lancer les dés par le plateau.
|
||||
await (await touchTarget("roll", "Lancer les dés", 2, 1)).click();
|
||||
await waitForPlayer();
|
||||
check((await savedJournal()).events.at(-1).type === "DiceRolled",
|
||||
"repli tourné : toucher les dés ne les lance pas");
|
||||
// Sortie par le menu (colonne de commandes), puis « Quitter ».
|
||||
await (await touchTarget("menu", "Menu")).click();
|
||||
check((await page.textContent("#menu-fullscreen")).includes("Quitter le plein écran"),
|
||||
"menu : pas de « Quitter le plein écran »");
|
||||
await page.click("#menu-fullscreen");
|
||||
await sleep(300);
|
||||
check(!(await page.evaluate(() => document.documentElement.classList.contains("landscape"))),
|
||||
"plein écran quitté : la vue paysage reste");
|
||||
log(`plein écran : ${view.rotated ? "repli par rotation CSS" : "paysage verrouillé"}, ` +
|
||||
"dés lancés en touchant le plateau tourné, sortie par le menu");
|
||||
continue;
|
||||
}
|
||||
// Dés : au clavier (Espace), en les touchant (d'un côté puis de
|
||||
// l'autre du plateau) ou par le bouton.
|
||||
const before = (await vibrations()).length;
|
||||
if (rolls % 5 === 0) {
|
||||
keyboardRolls++;
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await page.keyboard.press("Space");
|
||||
} else if (rolls % 2 === 0) {
|
||||
await (await touchTarget("roll", "Lancer les dés", 2, (rolls / 2) % 2)).click();
|
||||
} else {
|
||||
await page.click("#roll");
|
||||
}
|
||||
await waitForPlayer();
|
||||
check((await vibrations()).length === before, "le lancer des dés fait vibrer");
|
||||
} else if (todo === "confirm") {
|
||||
// Coup complet : rien n'est joué tant qu'il n'est pas validé.
|
||||
const journal = await savedJournal();
|
||||
const last = journal.events.at(-1);
|
||||
check(last?.type === "DiceRolled" && (await page.isEnabled("#cancel")),
|
||||
`coup complet déjà joué ou non annulable (dernier événement ${last?.type})`);
|
||||
check(/Valider/.test((await page.textContent("#prompt")) ?? ""),
|
||||
check(/jouer ce coup/i.test((await page.textContent("#prompt")) ?? ""),
|
||||
`consigne de validation absente : « ${await page.textContent("#prompt")} »`);
|
||||
const dice = await shownDice();
|
||||
check(dice.used === halfMoves && dice.used > 0,
|
||||
@@ -400,17 +836,17 @@ for (let actions = 0; ; actions++) {
|
||||
if (confirms % 3 === 0) {
|
||||
await page.click("#confirm");
|
||||
} else {
|
||||
await (await touchTarget("confirm", "Valider", 2, confirms % 2)).click();
|
||||
await (await touchTarget("confirm", "Jouer ce coup", 2, confirms % 2)).click();
|
||||
}
|
||||
await waitForPlayer();
|
||||
const played = await savedJournal();
|
||||
check(countMoves(played, "player") === countMoves(journal, "player") + 1,
|
||||
"le coup validé n'est pas dans le journal");
|
||||
"le coup joué n'est pas dans le journal");
|
||||
halfMoves = 0;
|
||||
touched = false;
|
||||
} else {
|
||||
check((await page.locator("[data-action]").count()) === 0,
|
||||
"dés ou videau touchables pendant que vous jouez votre coup");
|
||||
check((await page.locator("[data-action=roll], [data-action=confirm], [data-action=double]")
|
||||
.count()) === 0, "dés ou videau touchables pendant que vous jouez votre coup");
|
||||
if (halfMoves === 0) {
|
||||
// Quatre dés pour un double, deux sinon.
|
||||
const [a, b] = lastPlayerRoll(await savedJournal());
|
||||
@@ -422,14 +858,36 @@ for (let actions = 0; ; actions++) {
|
||||
}
|
||||
}
|
||||
touched = true;
|
||||
// Vibrations coupées dans le menu : le pion se pose sans vibrer.
|
||||
const silent = reloaded && !vibrationsChecked;
|
||||
if (silent) {
|
||||
await openMenu();
|
||||
await page.click("#menu-vibrations");
|
||||
check((await page.getAttribute("#menu-vibrations", "aria-checked")) === "false",
|
||||
"interrupteur des vibrations");
|
||||
await page.keyboard.press("Escape");
|
||||
check(!(await page.isVisible("#menu")), "Échap ne ferme pas le menu");
|
||||
}
|
||||
const vibrationsBefore = (await vibrations()).length;
|
||||
// Un pion à destination unique avance dès qu'on le touche ; sinon,
|
||||
// ses cases d'arrivée s'allument.
|
||||
await page.click(".hit.can-move");
|
||||
// ses cases d'arrivée s'allument. Le premier toucher est mesuré :
|
||||
// l'écran répond dans le gestionnaire du toucher.
|
||||
const ms = await page.evaluate(() => {
|
||||
const hit = document.querySelector(".hit.can-move");
|
||||
const t0 = performance.now();
|
||||
hit.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
const answered = document.querySelector("#board .checker.selected") !== null ||
|
||||
document.body.classList.contains("busy");
|
||||
return answered ? performance.now() - t0 : Infinity;
|
||||
});
|
||||
responseMs = Math.max(responseMs, ms);
|
||||
check(ms < 100, `toucher d'un pion : pas d'effet visible en moins de 100 ms (${ms})`);
|
||||
if ((await page.locator(".hit.can-land").count()) > 0) {
|
||||
twoTouches++;
|
||||
if (!selectionShot) {
|
||||
selectionShot = true;
|
||||
await shot("2-pion-touche");
|
||||
await review("3-coup-en-cours");
|
||||
}
|
||||
await page.click(".hit.can-land");
|
||||
} else {
|
||||
@@ -439,87 +897,140 @@ for (let actions = 0; ; actions++) {
|
||||
await waitForPlayer();
|
||||
const dice = await shownDice();
|
||||
check(dice.used === halfMoves, `${dice.used} dés estompés après ${halfMoves} demi-coups`);
|
||||
const vibrationsAfter = (await vibrations()).length;
|
||||
if (silent) {
|
||||
vibrationsChecked = true;
|
||||
check(vibrationsAfter === vibrationsBefore, "vibrations coupées : le pion vibre encore");
|
||||
await openMenu();
|
||||
await page.click("#menu-vibrations");
|
||||
await page.click("#menu-close");
|
||||
log("vibrations coupées dans le menu : le pion se pose sans vibrer");
|
||||
} else {
|
||||
const added = (await vibrations()).slice(vibrationsBefore);
|
||||
check(added.length === 1 && ["12", "25,40,25"].includes(added[0]),
|
||||
`pion posé : vibrations ${JSON.stringify(added)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Une fois, au milieu de la partie, entre deux coups : fermer et rouvrir
|
||||
// la page.
|
||||
// la page ; puis une nouvelle partie, et la reprise de l'ancienne.
|
||||
const journal = await savedJournal();
|
||||
if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) {
|
||||
reloaded = true;
|
||||
await waitForPlayer();
|
||||
await shot("3-milieu-de-partie");
|
||||
const before = await savedJournal();
|
||||
const pips = await page.textContent("#pips");
|
||||
const pips = await page.textContent("#strip-player .count");
|
||||
await collectViolations();
|
||||
await page.reload();
|
||||
// Avant le moteur, le plateau de la partie enregistrée est déjà là.
|
||||
await page.waitForSelector("#board polygon[data-point]");
|
||||
check((await page.textContent("#strip-player .count")) === pips,
|
||||
"chargement : le plateau dessiné n'est pas celui de la partie enregistrée");
|
||||
await waitForPlayer();
|
||||
const after = await savedJournal();
|
||||
check(after.head === before.head, "la partie n'a pas été reprise après rechargement");
|
||||
check((await page.textContent("#pips")) === pips, "plateau différent après rechargement");
|
||||
check((await page.textContent("#strip-player .count")) === pips,
|
||||
"plateau différent après rechargement");
|
||||
justReloaded = true;
|
||||
log(`partie reprise après rechargement (événement ${after.head})`);
|
||||
} else if (reloaded && !resumed) {
|
||||
resumed = true;
|
||||
step("menu");
|
||||
await openMenu();
|
||||
await review("5-menu-ouvert");
|
||||
await page.keyboard.press("Escape");
|
||||
// Le clavier ouvre aussi le menu (M) et Échap le ferme.
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await page.keyboard.press("m");
|
||||
check(await page.isVisible("#menu"), "M n'ouvre pas le menu");
|
||||
await page.keyboard.press("Escape");
|
||||
const pips = await page.textContent("#strip-player .count");
|
||||
const old = await newGameFromMenu();
|
||||
await page.click("#resume-old");
|
||||
await waitForPlayer();
|
||||
const back = await savedJournal();
|
||||
check(JSON.stringify(back) === JSON.stringify(old),
|
||||
"« Reprendre l'ancienne » ne restaure pas la partie exacte");
|
||||
check((await page.textContent("#strip-player .count")) === pips,
|
||||
"« Reprendre l'ancienne » : plateau différent");
|
||||
check(!(await page.isVisible("#banner")), "le bandeau reste après la reprise");
|
||||
log(`nouvelle partie puis « Reprendre l'ancienne » : journal identique (${old.events.length} événements)`);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- Fin de partie
|
||||
|
||||
const journal = await savedJournal();
|
||||
const result = await page.textContent("#prompt");
|
||||
const result = await page.textContent("#panel-text");
|
||||
log(
|
||||
`partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` +
|
||||
`${countMoves(journal, "computer")} de l'ordinateur, ${journal.events.length} événements)`,
|
||||
);
|
||||
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 review("6-fin-de-partie");
|
||||
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
|
||||
check(reloaded, "la reprise après rechargement n'a pas été essayée");
|
||||
check(reloaded && resumed, "rechargement ou reprise de l'ancienne partie non essayés");
|
||||
check(replays === 2, "le coup de l'ordinateur n'a pas été revu");
|
||||
check(doubled && rolls >= 2, "dés ou videau jamais touchés sur le plateau");
|
||||
check(landscapeTurn && rotatedChecked, "paysage ou plein écran non essayés");
|
||||
check(vibrationsChecked, "interrupteur des vibrations non essayé");
|
||||
check(singleTouch > 0 && twoTouches > 0,
|
||||
`saisie : ${singleTouch} pions avancés d'un toucher, ${twoTouches} en deux touchers`);
|
||||
check(undone >= 2, "Annuler n'a pas été essayé sur un coup complet");
|
||||
check(confirms >= 3, "trop peu de coups validés pour essayer dés et bouton");
|
||||
log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, des deux côtés du plateau`);
|
||||
log(`demi-coups : ${singleTouch} d'un seul toucher (destination unique), ${twoTouches} en deux ` +
|
||||
`touchers ; ${confirms} coups validés (${Math.floor(confirms / 3)} par le bouton, les autres ` +
|
||||
`en touchant les dés) ; ${doubles} doubles montrés en quatre dés`);
|
||||
check(confirms >= 3, "trop peu de coups joués pour essayer dés et bouton");
|
||||
check(moments.size === 2 && selectionShot, `moments de la recette manquants : ${[...moments]}`);
|
||||
log(`dés lancés ${rolls} fois (${keyboardRolls} au clavier), dont la moitié en les touchant`);
|
||||
log(`demi-coups : ${singleTouch} d'un seul toucher, ${twoTouches} en deux touchers ; ` +
|
||||
`${confirms} coups joués ; ${doubles} doubles montrés en quatre dés`);
|
||||
log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`);
|
||||
log(skips > 0 ? `« Continuer » demandé ${skips} fois` : "aucun jet sans coup possible dans cette partie");
|
||||
if (computerMs.length > 0) {
|
||||
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
|
||||
`${computerMs.length} tours`);
|
||||
}
|
||||
log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant les dés)`
|
||||
: "aucun jet sans coup possible dans cette partie");
|
||||
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.
|
||||
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 »");
|
||||
|
||||
step("aide");
|
||||
// Aide : scénario et glossaire lus dans les copies de docs/.
|
||||
// Aide : scénarios et glossaire lus dans les copies de docs/.
|
||||
await page.click("#help-button");
|
||||
await page.waitForSelector(".glossary dt");
|
||||
const terms = await page.locator(".glossary dt").count();
|
||||
const scenarios = await page.locator("#help-content h3").count();
|
||||
check(terms >= 20 && scenarios >= 5, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
|
||||
check(terms >= 20 && scenarios >= 9, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
|
||||
log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`);
|
||||
await shot("5-aide");
|
||||
await page.click("#help-back");
|
||||
|
||||
step("compte de points");
|
||||
// Toucher le compte de points : la bulle donne la fiche du glossaire.
|
||||
await page.click("#strip-player .count");
|
||||
await page.waitForSelector("#pip-bubble:not([hidden]) .bubble-text");
|
||||
const bubble = await page.textContent("#pip-bubble .bubble-text");
|
||||
check(/^nombre total de cases que vos pions doivent encore parcourir/.test(bubble),
|
||||
`bulle du compte de points : « ${bubble} »`);
|
||||
await shot("compte-de-points");
|
||||
await page.keyboard.press("Escape");
|
||||
check(!(await page.isVisible("#pip-bubble")), "Échap ne ferme pas la bulle");
|
||||
log("compte de points : bulle avec la fiche du glossaire");
|
||||
|
||||
step("thème sombre");
|
||||
// Thème sombre.
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.click("#again");
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
step("paysage");
|
||||
// Téléphone tourné : le plateau et les boutons tiennent dans l'écran.
|
||||
for (const [width, height] of [[915, 412], [740, 360]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
const board = await page.locator("#board").boundingBox();
|
||||
const actions = await page.locator(".actions").boundingBox();
|
||||
check(board.y + board.height <= height && board.x + board.width <= width,
|
||||
`paysage ${width}x${height} : plateau hors de l'écran`);
|
||||
check(actions.y + actions.height <= height, `paysage ${width}x${height} : boutons hors de l'écran`);
|
||||
}
|
||||
log("paysage : plateau et boutons visibles en entier");
|
||||
await shot("6-paysage");
|
||||
await page.setViewportSize({ width: 360, height: 740 });
|
||||
|
||||
step("mise en cache");
|
||||
// Hors ligne : une fois le cache du service worker rempli, la page se
|
||||
// recharge sans réseau, et le moteur répond.
|
||||
@@ -576,6 +1087,7 @@ step("CSP");
|
||||
// elle est bien appliquée : un script en ligne ajouté à la page est bloqué.
|
||||
await collectViolations();
|
||||
check(violations.length === 0, `violations de la CSP : ${violations.join(", ")}`);
|
||||
check(dialogs.length === 0, `boîtes natives appelées : ${dialogs.join(", ")}`);
|
||||
cspProbe = true;
|
||||
const probe = await page.evaluate(async () => {
|
||||
const script = document.createElement("script");
|
||||
@@ -589,7 +1101,12 @@ check(
|
||||
probe.violations.some((v) => v.startsWith("script-src")),
|
||||
"CSP : le script en ligne bloqué n'a pas été signalé",
|
||||
);
|
||||
log("CSP : aucune violation pendant la partie, script en ligne bloqué");
|
||||
log("CSP : aucune violation pendant la partie, script en ligne bloqué ; aucune boîte native");
|
||||
|
||||
log(`recette : ${recette.views} vues vérifiées (${stableChecks} contrôles de stabilité du ` +
|
||||
`portrait, ${recette.rotations} rotations sans perte d'état), numéros des flèches à ` +
|
||||
`${recette.minNumber.toFixed(1)} px au moins, réponse au toucher en ${responseMs.toFixed(1)} ms ` +
|
||||
`au plus${fullReview ? `, ${recette.captures} captures dans revision-ux/` : ""}`);
|
||||
|
||||
clearInterval(watchdog);
|
||||
await browser.close();
|
||||
|
||||
Reference in New Issue
Block a user