1320 lines
61 KiB
JavaScript
1320 lines
61 KiB
JavaScript
// 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 : 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).
|
||
//
|
||
// Usage : node play.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||
// Lancé par tests/browser.sh, dans l'image de Playwright. Sort en erreur au
|
||
// premier problème, ou si la page signale une erreur (dont une violation de
|
||
// la politique de sécurité du contenu).
|
||
|
||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||
import { chromium, firefox } from "playwright";
|
||
|
||
const [base, browserName, shots] = process.argv.slice(2);
|
||
const browserType = { chromium, firefox }[browserName];
|
||
if (!base || !browserType || !shots) {
|
||
console.error("usage : node play.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||
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: PORTRAIT,
|
||
deviceScaleFactor: 1,
|
||
locale: "fr-FR",
|
||
});
|
||
// Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur
|
||
// (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;
|
||
Worker.prototype.postMessage = function (message, ...rest) {
|
||
window.__engine.sent++;
|
||
window.__engine.last = message?.args ?? null;
|
||
if (!this.__counted) {
|
||
this.__counted = true;
|
||
this.addEventListener("message", () => window.__engine.received++);
|
||
}
|
||
return post.call(this, message, ...rest);
|
||
};
|
||
window.__csp = [];
|
||
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);
|
||
});
|
||
// Vrai pendant le contrôle final de la politique de sécurité, qui provoque
|
||
// exprès une violation.
|
||
let cspProbe = false;
|
||
page.on("console", (message) => {
|
||
if (message.type() === "error" && !(cspProbe && /Content.Security.Policy/i.test(message.text()))) {
|
||
problems.push(`console : ${message.text()}`);
|
||
}
|
||
});
|
||
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), et les boîtes natives. */
|
||
const violations = [];
|
||
const dialogs = [];
|
||
async function collectViolations() {
|
||
const seen = await page.evaluate(() => ({ csp: window.__csp, dialogs: window.__dialogs }));
|
||
violations.push(...seen.csp);
|
||
dialogs.push(...seen.dialogs);
|
||
}
|
||
|
||
function check(condition, message) {
|
||
if (!condition) {
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
/** Le journal de la partie en cours, tel qu'enregistré dans IndexedDB
|
||
* (app/src/storage/indexeddb-backend.ts), ou null. */
|
||
async function readJournal() {
|
||
return page.evaluate(
|
||
() =>
|
||
new Promise((resolve, reject) => {
|
||
const open = indexedDB.open("ustagammon");
|
||
open.onerror = () => reject(open.error);
|
||
open.onsuccess = () => {
|
||
const db = open.result;
|
||
const tx = db.transaction(["settings", "journals"]);
|
||
const current = tx.objectStore("settings").get("current");
|
||
current.onsuccess = () => {
|
||
const id = current.result?.value;
|
||
const journal = id === undefined ? null : tx.objectStore("journals").get(id);
|
||
if (journal === null) {
|
||
db.close();
|
||
resolve(null);
|
||
return;
|
||
}
|
||
journal.onsuccess = () => {
|
||
db.close();
|
||
resolve(journal.result?.journal ?? null);
|
||
};
|
||
};
|
||
};
|
||
}),
|
||
);
|
||
}
|
||
|
||
/** Le nombre de parties enregistrées dans IndexedDB. */
|
||
async function savedGameCount() {
|
||
await savedJournal(); // les enregistrements en cours terminés
|
||
return page.evaluate(
|
||
() =>
|
||
new Promise((resolve, reject) => {
|
||
const open = indexedDB.open("ustagammon");
|
||
open.onerror = () => reject(open.error);
|
||
open.onsuccess = () => {
|
||
const db = open.result;
|
||
const count = db.transaction("games").objectStore("games").count();
|
||
count.onsuccess = () => {
|
||
db.close();
|
||
resolve(count.result);
|
||
};
|
||
};
|
||
}),
|
||
);
|
||
}
|
||
|
||
/** Le journal enregistré, une fois les enregistrements en cours terminés
|
||
* (l'application enregistre sans attendre) : deux lectures identiques. */
|
||
async function savedJournal() {
|
||
let last = await readJournal();
|
||
for (let i = 0; i < 50; i++) {
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
const next = await readJournal();
|
||
if (JSON.stringify(next) === JSON.stringify(last)) {
|
||
return next;
|
||
}
|
||
last = next;
|
||
}
|
||
throw new Error("le journal enregistré ne se stabilise pas");
|
||
}
|
||
|
||
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
|
||
* sécurité du site interdit, à raison.)
|
||
*/
|
||
async function waitForPlayer() {
|
||
for (let i = 0; i < 600; i++) {
|
||
const todo = await page.evaluate(() => {
|
||
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;
|
||
};
|
||
for (const id of ["again", "take", "skip", "confirm", "roll"]) {
|
||
if (usable(id)) {
|
||
return id;
|
||
}
|
||
}
|
||
return document.querySelector(".hit.can-move") ? "move" : null;
|
||
});
|
||
if (todo !== null) {
|
||
return todo;
|
||
}
|
||
await sleep(100);
|
||
}
|
||
await shot("bloque").catch(() => {});
|
||
const screen = await page
|
||
.evaluate(() => {
|
||
const text = (id) => document.getElementById(id)?.textContent;
|
||
const busy = document.body.classList.contains("busy") ? "occupé" : "libre";
|
||
const engine = window.__engine;
|
||
return (
|
||
`${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` +
|
||
`${engine.sent} commandes, ${engine.received} réponses, dernière ` +
|
||
`${JSON.stringify(engine.last)}`
|
||
);
|
||
})
|
||
.catch((error) => `page illisible (${error.message})`);
|
||
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,
|
||
promptBackground: getComputedStyle(document.getElementById("prompt")).backgroundColor,
|
||
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}`);
|
||
check(/^(transparent|rgba\(0, 0, 0, 0\))$/.test(seen.promptBackground),
|
||
`${where} : la consigne a un fond (${seen.promptBackground})`);
|
||
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() {
|
||
const head = (await savedJournal())?.head ?? null;
|
||
return page.evaluate((head) => ({
|
||
head,
|
||
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,
|
||
}), head);
|
||
}
|
||
|
||
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) {
|
||
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
|
||
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length > 0
|
||
? last
|
||
: null;
|
||
}
|
||
|
||
/** Ce que montre le plateau du coup de l'ordinateur. */
|
||
async function shownTraces() {
|
||
return page.evaluate(() => ({
|
||
arrows: document.querySelectorAll(".trace-arrow").length,
|
||
landed: document.querySelectorAll(".trace-landed").length,
|
||
ghosts: document.querySelectorAll(".trace-ghost").length,
|
||
hits: document.querySelectorAll(".trace-hit").length,
|
||
replay: !document.getElementById("replay").hidden,
|
||
}));
|
||
}
|
||
|
||
/** Après un tour de l'ordinateur : ses traces sont là si son coup est la
|
||
* dernière chose jouée, et rien sinon. */
|
||
async function checkTraces() {
|
||
const journal = await savedJournal();
|
||
const move = computerMoveToShow(journal);
|
||
const traces = await shownTraces();
|
||
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
|
||
if (last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0) {
|
||
// L'ordinateur n'a pas pu jouer : son jet reste barré, avec un message.
|
||
const info = await page.textContent("#info");
|
||
const blocked = await page.locator(".die-blocked").count();
|
||
check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked >= 2 &&
|
||
blocked === (await shownDice()).dice,
|
||
`jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`);
|
||
log(`l'ordinateur ne peut pas jouer : « ${info} »`);
|
||
}
|
||
if (move === null) {
|
||
check(traces.arrows + traces.landed + traces.ghosts === 0 && !traces.replay,
|
||
`traces sans coup de l'ordinateur à montrer : ${JSON.stringify(traces)}`);
|
||
return null;
|
||
}
|
||
check(traces.arrows === move.halfMoves.length && traces.landed > 0 && traces.ghosts > 0 &&
|
||
traces.replay, `coup de l'ordinateur mal montré : ${JSON.stringify(traces)}`);
|
||
// Flèches de 2,5 px, sous les pions.
|
||
const arrows = await page.evaluate(() => {
|
||
const firstChecker = document.querySelector("#board .checker");
|
||
return [...document.querySelectorAll("#board .trace-arrow")].map((line) => {
|
||
const m = line.getScreenCTM();
|
||
return {
|
||
px: parseFloat(getComputedStyle(line).strokeWidth) * Math.hypot(m.a, m.b),
|
||
under: Boolean(line.compareDocumentPosition(firstChecker) &
|
||
Node.DOCUMENT_POSITION_FOLLOWING),
|
||
};
|
||
});
|
||
});
|
||
check(arrows.every((a) => Math.abs(a.px - 2.5) < 0.2 && a.under),
|
||
`flèches du coup de l'ordinateur : ${JSON.stringify(arrows)}`);
|
||
arrowsChecked++;
|
||
return traces;
|
||
}
|
||
|
||
/** Une zone à toucher d'une action du plateau (dés ou videau) : count zones
|
||
* (les dés en ont une de chaque côté du plateau), chacune un bouton
|
||
* accessible d'au moins 44 px de côté. Renvoie le locator de la zone index. */
|
||
async function touchTarget(action, label, count = 1, index = 0) {
|
||
const zones = page.locator(`[data-action="${action}"]`);
|
||
check((await zones.count()) === count,
|
||
`zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`);
|
||
for (const zone of await zones.all()) {
|
||
check((await zone.getAttribute("role")) === "button" &&
|
||
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
|
||
const box = await zone.boundingBox();
|
||
check(box.width >= 44 && box.height >= 44,
|
||
`zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`);
|
||
}
|
||
return zones.nth(index);
|
||
}
|
||
|
||
/** 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("#board g.die[data-value]").count(),
|
||
used: await page.locator("#board g.die-used").count(),
|
||
};
|
||
}
|
||
|
||
/** Le dernier jet du joueur, d'après le journal. */
|
||
function lastPlayerRoll(journal) {
|
||
const rolled = journal.events.filter((e) => e.type === "DiceRolled").at(-1);
|
||
return rolled?.roll ?? [];
|
||
}
|
||
|
||
/** Observe la page pendant ms millisecondes : le plus grand nombre
|
||
* d'animations en cours à la fois, et le plus petit nombre de flèches. */
|
||
async function watchReplay(ms) {
|
||
let animations = 0;
|
||
let arrows = Infinity;
|
||
for (const end = Date.now() + ms; Date.now() < end; ) {
|
||
const seen = await page.evaluate(() => ({
|
||
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 sleep(30);
|
||
}
|
||
return { animations, arrows };
|
||
}
|
||
|
||
/** « Revoir » : rejoue le coup (sans animation en mouvement réduit), puis
|
||
* remet les mêmes traces. */
|
||
async function checkReplay(traces, reducedMotion) {
|
||
await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" });
|
||
await page.click("#replay");
|
||
const seen = await watchReplay(250);
|
||
if (reducedMotion) {
|
||
check(seen.animations === 0, "Revoir en mouvement réduit : des pions glissent quand même");
|
||
} else {
|
||
check(seen.animations > 0 && seen.arrows === 0,
|
||
`Revoir : pas d'animation sans traces (${JSON.stringify(seen)})`);
|
||
}
|
||
await waitForPlayer();
|
||
const after = await shownTraces();
|
||
check(JSON.stringify(after) === JSON.stringify(traces),
|
||
`traces différentes après Revoir : ${JSON.stringify(after)}`);
|
||
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();
|
||
const started = () =>
|
||
page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").gamesStarted);
|
||
const startedBefore = await started();
|
||
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();
|
||
// (Les identifiants d'événements se suivent : une partie neuve peut avoir
|
||
// la même tête que la précédente ; on compte les parties commencées.)
|
||
check((await started()) === startedBefore + 1 &&
|
||
!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();
|
||
let stepName = "chargement";
|
||
function step(name) {
|
||
lastStep = Date.now();
|
||
stepName = name;
|
||
}
|
||
const watchdog = setInterval(async () => {
|
||
if (Date.now() - lastStep > 180_000) {
|
||
clearInterval(watchdog);
|
||
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;
|
||
});
|
||
// (Au niveau du contexte : le Web Worker du moteur fait la demande.)
|
||
await context.route("**/wasm/engine.data", async (route) => {
|
||
await engineHeld;
|
||
await route.continue();
|
||
});
|
||
const start = Date.now();
|
||
await page.goto(base);
|
||
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,
|
||
prompt: document.getElementById("prompt").textContent,
|
||
}));
|
||
check(loading.prompt === "", `chargement : consigne affichée « ${loading.prompt} »`);
|
||
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");
|
||
// Firefox ne laisse pas toujours retenir la demande du Worker : la revue de
|
||
// l'écran de chargement (six vues) se fait dans Chromium.
|
||
if (fullReview) {
|
||
await review("1-chargement");
|
||
}
|
||
releaseEngine();
|
||
await context.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 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;
|
||
let keyboardRolls = 0;
|
||
let halfMoves = 0;
|
||
let singleTouch = 0;
|
||
let twoTouches = 0;
|
||
let confirms = 0;
|
||
let undone = 0;
|
||
let doubles = 0;
|
||
let responseMs = 0;
|
||
let confirmButtonSeen = 0;
|
||
let landingsSeen = 0;
|
||
let arrowsChecked = 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");
|
||
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);
|
||
if (replays === 1) {
|
||
await shot("coup-adverse");
|
||
}
|
||
log(`coup de l'ordinateur revu${replays === 2 ? " en mouvement réduit" : ""} : ` +
|
||
`${traces.arrows} flèches, ${traces.landed} pions entourés, ` +
|
||
`${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} : ${info} / ${await page.textContent("#prompt")}`);
|
||
}
|
||
if (todo === "over") {
|
||
break;
|
||
}
|
||
if (todo === "skip") {
|
||
// Aucun coup possible : le jet reste affiché, barré, avec un
|
||
// 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 &&
|
||
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++;
|
||
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 (!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 question = "Doubler à 2 ? L'ordinateur pourra prendre ou passer.";
|
||
await (await touchTarget("double", "Doubler")).click();
|
||
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 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 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;
|
||
}
|
||
rolls++;
|
||
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");
|
||
for (let i = 0; i < 40; i++) {
|
||
if (await page.evaluate(() => document.documentElement.classList.contains("landscape"))) {
|
||
break;
|
||
}
|
||
await sleep(100);
|
||
}
|
||
await sleep(200);
|
||
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 (${await page.evaluate(() =>
|
||
`${document.documentElement.className} ; plein écran ${document.fullscreenElement !== null} ; ` +
|
||
`portrait ${matchMedia("(orientation: portrait)").matches}`)})`);
|
||
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, 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(/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,
|
||
`dés estompés : ${dice.used} pour ${halfMoves} demi-coups`);
|
||
// « Jouer ce coup » : l'action principale de la barre, à droite.
|
||
const bar = await page.evaluate(() => {
|
||
const confirm = document.getElementById("confirm");
|
||
const cancel = document.getElementById("cancel");
|
||
return {
|
||
text: confirm.textContent,
|
||
primary: !confirm.classList.contains("secondary"),
|
||
right: confirm.getBoundingClientRect().x > cancel.getBoundingClientRect().x,
|
||
inBar: confirm.closest("#actions .slot.primary") !== null,
|
||
};
|
||
});
|
||
check(await page.isVisible("#confirm") && bar.text === "Jouer ce coup" && bar.primary &&
|
||
bar.right && bar.inBar, `coup complet : « Jouer ce coup » mal placé ${JSON.stringify(bar)}`);
|
||
confirmButtonSeen++;
|
||
if (undone === 0 && halfMoves >= 2) {
|
||
// Une fois : « Annuler » défait les demi-coups un par un.
|
||
if (confirms === 0) {
|
||
await shot("2b-coup-a-valider");
|
||
}
|
||
for (let left = halfMoves - 1; left >= 0; left--) {
|
||
await page.click("#cancel");
|
||
await waitForPlayer();
|
||
const after = await shownDice();
|
||
check(after.used === left && (await page.isHidden("#confirm")),
|
||
`Annuler : ${after.used} dés estompés, ${left} attendus`);
|
||
undone++;
|
||
}
|
||
check(await page.isDisabled("#cancel"), "Annuler reste actif sans demi-coup à défaire");
|
||
check((await savedJournal()).head === journal.head, "Annuler a touché au journal");
|
||
log(`Annuler : ${undone} demi-coups défaits un par un, journal intact`);
|
||
halfMoves = 0;
|
||
continue;
|
||
}
|
||
confirms++;
|
||
// Une fois sur trois avec le bouton, sinon en touchant les dés
|
||
// (d'un côté ou de l'autre).
|
||
if (confirms % 3 === 0) {
|
||
await page.click("#confirm");
|
||
} else {
|
||
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 joué n'est pas dans le journal");
|
||
halfMoves = 0;
|
||
touched = false;
|
||
} else {
|
||
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());
|
||
const dice = await shownDice();
|
||
check(dice.dice === (a === b ? 4 : 2) && dice.used === 0,
|
||
`jet ${a}-${b} : ${dice.dice} dés montrés, ${dice.used} estompés`);
|
||
if (a === b) {
|
||
doubles++;
|
||
}
|
||
}
|
||
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. 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) {
|
||
// Cases d'arrivée : le triangle de la flèche voilé, et un anneau
|
||
// pointillé là où le pion se posera.
|
||
const marks = await page.evaluate(() => ({
|
||
lands: document.querySelectorAll(".hit.can-land").length,
|
||
rings: document.querySelectorAll("#board .landing").length,
|
||
zones: [...document.querySelectorAll("#board .target-zone")].map((z) => z.tagName),
|
||
}));
|
||
check(marks.rings === marks.lands &&
|
||
marks.zones.filter((t) => t === "polygon").length >= marks.lands - 1,
|
||
`cases d'arrivée mal marquées : ${JSON.stringify(marks)}`);
|
||
landingsSeen++;
|
||
twoTouches++;
|
||
if (!selectionShot) {
|
||
selectionShot = true;
|
||
await shot("2-pion-touche");
|
||
await review("3-coup-en-cours");
|
||
}
|
||
await page.click(".hit.can-land");
|
||
} else {
|
||
singleTouch++;
|
||
}
|
||
halfMoves++;
|
||
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 ; 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("#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("#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 && (await waitForPlayer()) === "roll") {
|
||
// Au moment de lancer les dés : un double de l'ordinateur en attente
|
||
// s'affiche en panneau en paysage (et dans la barre d'actions en
|
||
// portrait), ce qui changerait l'écran à la rotation, à raison.
|
||
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 games = await savedGameCount();
|
||
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");
|
||
check((await savedGameCount()) === games,
|
||
"« Reprendre l'ancienne » : la nouvelle partie n'a pas été supprimée");
|
||
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("#panel-text")}, ${await page.textContent("#panel-detail")}`;
|
||
check((await page.textContent("#panel-overline")) === "Fin de partie" &&
|
||
/^(Vous gagnez|L'ordinateur gagne|Vous passez|L'ordinateur passe)/.test(
|
||
await page.textContent("#panel-text")) &&
|
||
/^\d+ points? pour (vous|lui)(, videau à \d+)?\.$/.test(await page.textContent("#panel-detail")),
|
||
`panneau de fin de partie : ${result}`);
|
||
check((await page.textContent("#prompt")) === "", "fin de partie : la consigne répète le panneau");
|
||
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");
|
||
await page.setViewportSize({ width: 844, height: 320 });
|
||
await sleep(150);
|
||
check((await page.locator("#band-text").count()) === 0, "fin de partie : consigne sous le panneau en paysage");
|
||
await page.setViewportSize(PORTRAIT);
|
||
await sleep(150);
|
||
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
|
||
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 joués pour essayer dés et bouton");
|
||
check(moments.size === 2 && selectionShot, `moments de la recette manquants : ${[...moments]}`);
|
||
check(confirmButtonSeen >= 3 && landingsSeen > 0 && arrowsChecked > 0,
|
||
"« Jouer ce coup », cases d'arrivée ou flèches non vérifiés");
|
||
log(`« Jouer ce coup » vu ${confirmButtonSeen} fois à droite de la barre d'actions ; ` +
|
||
`cases d'arrivée marquées ${landingsSeen} fois ; flèches vérifiées sur ${arrowsChecked} coups`);
|
||
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 ? `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("mes parties");
|
||
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
|
||
// les deux listées, la première exportée en SGF puis reprise sans
|
||
// recharger la page.
|
||
{
|
||
const finished = await savedJournal();
|
||
await page.click("#again");
|
||
await waitForPlayer();
|
||
const second = await savedJournal();
|
||
check(second.head !== finished.head || second.events.length !== finished.events.length,
|
||
"la nouvelle partie n'a pas été enregistrée");
|
||
await openMenu();
|
||
await page.click("#menu-games");
|
||
await page.waitForSelector("#games-veil:not([hidden]) .games-item");
|
||
check(!(await page.isVisible("#menu")), "le menu reste ouvert sous « Mes parties »");
|
||
const items = page.locator("#games .games-item");
|
||
// Toutes les parties enregistrées, la dernière jouée d'abord : la
|
||
// nouvelle, puis celle qui vient de finir.
|
||
const listed = await items.count();
|
||
check(listed >= 2 && listed === (await savedGameCount()), `${listed} parties listées`);
|
||
check(!(await items.nth(0).locator('[data-action="remove"]').isVisible()),
|
||
"la partie à l'écran peut être supprimée");
|
||
const oldest = items.nth(1);
|
||
await oldest.locator('[data-action="branches"]').click();
|
||
await oldest.locator(".games-branch").first().waitFor();
|
||
const branchCount = await oldest.locator(".games-branch").count();
|
||
const [download] = await Promise.all([
|
||
page.waitForEvent("download"),
|
||
oldest.locator('[data-action="export"]').click(),
|
||
]);
|
||
const sgf = readFileSync(await download.path(), "utf8");
|
||
writeFileSync(`${shots}/${browserName}-partie.sgf`, sgf);
|
||
check(sgf.startsWith("(;FF[4]GM[6]"), `export SGF inattendu : ${sgf.slice(0, 40)}`);
|
||
const moves = countMoves(finished, "player") + countMoves(finished, "computer");
|
||
check((sgf.match(/;[BW]\[\d\d/g) ?? []).length >= moves,
|
||
"export SGF : moins de coups que dans le journal");
|
||
await shot("4b-mes-parties");
|
||
const loads = await page.evaluate(() => performance.getEntriesByType("navigation").length);
|
||
await oldest.locator('[data-action="resume"]').click();
|
||
await page.waitForSelector("#games-veil", { state: "hidden" });
|
||
await page.waitForSelector("#panel");
|
||
const resumed = await savedJournal();
|
||
check(JSON.stringify(resumed) === JSON.stringify(finished), "la première partie n'a pas été reprise");
|
||
const shown = `${await page.textContent("#panel-text")}, ${await page.textContent("#panel-detail")}`;
|
||
check(shown === result, `écran différent après la reprise : ${shown}`);
|
||
check((await page.evaluate(() => performance.getEntriesByType("navigation").length)) === loads,
|
||
"la page a été rechargée");
|
||
await page.click("#panel-secondary");
|
||
log(
|
||
`mes parties : ${listed} parties listées (${branchCount} branche(s) pour la première), ` +
|
||
`SGF exporté (${sgf.length} octets), première partie reprise sans rechargement`,
|
||
);
|
||
}
|
||
|
||
step("aide");
|
||
// 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 >= 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");
|
||
await page.emulateMedia({ colorScheme: "dark" });
|
||
await page.click("#again");
|
||
await waitForPlayer();
|
||
await shot("6-sombre");
|
||
await page.emulateMedia({ colorScheme: "light" });
|
||
|
||
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.
|
||
const cached = await page.evaluate(async () => {
|
||
// Le service worker est enregistré une fois le moteur chargé ; s'il ne
|
||
// s'installe pas, l'attente s'arrête au bout d'une minute.
|
||
for (let i = 0; i < 600; i++) {
|
||
const names = await caches.keys();
|
||
if (names.length === 1) {
|
||
const cache = await caches.open(names[0]);
|
||
const keys = await cache.keys();
|
||
const active = (await navigator.serviceWorker.getRegistration())?.active;
|
||
if (active && keys.some((r) => r.url.endsWith("/wasm/engine.data"))) {
|
||
return keys.length;
|
||
}
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
}
|
||
return 0;
|
||
});
|
||
check(cached > 0, "le service worker n'a pas mis le site en cache");
|
||
// Firefox sous Playwright refuse toute navigation une fois le contexte
|
||
// « hors ligne », même servie par le service worker (NS_ERROR_OFFLINE) : on
|
||
// y vérifie seulement que la page rechargée est contrôlée par le service
|
||
// worker, dont le cache est complet.
|
||
const offlineMode = browserName === "chromium";
|
||
if (offlineMode) {
|
||
await context.setOffline(true);
|
||
}
|
||
step("rechargement hors ligne");
|
||
await collectViolations();
|
||
await page.reload();
|
||
const controlled = await page.evaluate(() => navigator.serviceWorker.controller !== null);
|
||
check(controlled, "la page rechargée n'est pas contrôlée par le service worker");
|
||
const offline = await waitForPlayer();
|
||
if (offline === "roll") {
|
||
await page.click("#roll");
|
||
await waitForPlayer();
|
||
}
|
||
await page.click("#help-button");
|
||
await page.waitForSelector(".glossary dt");
|
||
await page.click("#help-back");
|
||
log(
|
||
offlineMode
|
||
? `hors ligne : page rechargée sans réseau depuis le cache (${cached} adresses), moteur et aide disponibles`
|
||
: `cache du service worker rempli (${cached} adresses), page rechargée sous son contrôle ` +
|
||
"(le hors ligne lui-même n'est pas simulable dans Firefox sous Playwright)",
|
||
);
|
||
await shot("7-hors-ligne");
|
||
|
||
step("CSP");
|
||
// Politique de sécurité du contenu : aucune violation pendant toute la
|
||
// partie (moteur WebAssembly, Web Worker, service worker, aide compris) ; et
|
||
// 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");
|
||
script.textContent = "window.__inline = true;";
|
||
document.head.append(script);
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
return { ran: window.__inline === true, violations: window.__csp };
|
||
});
|
||
check(!probe.ran, "CSP non appliquée : un script en ligne a été exécuté");
|
||
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é ; 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();
|
||
if (problems.length > 0) {
|
||
console.error(problems.join("\n"));
|
||
process.exit(1);
|
||
}
|
||
log("aucune erreur dans la page");
|