diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index ba7d944..9c74002 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -358,7 +358,7 @@ export function startApp(options: AppOptions): void { : moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits), diceAction: action, canDouble: !busy && canDouble, - band: landscape ? (text === null ? resultText(game) : text.short) : null, + band: landscape ? (text === null ? TEXTS.gameOver : text.short) : null, controls: landscape ? controls() : null, }); } diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 39ca8f9..688743d 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -85,7 +85,6 @@ export const NO_MARKS = { } as const; const OFF_SLAB = 12; // épaisseur d'un pion sorti -const CUBE = 36; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -340,7 +339,12 @@ function drawStack( add( svg, "text", - { class: `checker-count count-${side}`, x, y: y + g.radius * 0.3 }, + { + class: `checker-count count-${side}`, + x, + y: y + g.countFont * 0.35, + "font-size": g.countFont, + }, String(count), ); } @@ -721,14 +725,20 @@ function drawDice(svg: SVGSVGElement, g: Geometry, dice: readonly ShownDie[]): v * sinon du côté de son propriétaire. */ function drawCube(svg: SVGSVGElement, g: Geometry, cube: Cube): SVGElement { const center = cubeCenter(g, cube); - const x = center.x - CUBE / 2; - const y = center.y - CUBE / 2; + const size = g.cube; + const x = center.x - size / 2; + const y = center.y - size / 2; const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" }); - add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 }); + add(group, "rect", { class: "cube-face", x, y, width: size, height: size, rx: 4 }); add( group, "text", - { class: "cube-value", x: center.x, y: center.y + 6 }, + { + class: "cube-value", + x: center.x, + y: center.y + g.cubeFont * 0.35, + "font-size": g.cubeFont, + }, String(cube.owner === null ? 64 : cube.value), ); return group; diff --git a/app/src/ui/geometry.ts b/app/src/ui/geometry.ts index 22c9077..1fe5799 100644 --- a/app/src/ui/geometry.ts +++ b/app/src/ui/geometry.ts @@ -51,6 +51,11 @@ export interface Geometry { readonly numberSize: number; readonly bandFont: number; readonly chipFont: number; + /** Videau : côté et taille du chiffre ; nombre posé sur une pile trop + * haute (13 px au moins à l'écran). */ + readonly cube: number; + readonly cubeFont: number; + readonly countFont: number; readonly die: number; /** Nombre de pions dessinés au plus sur la barre, par camp (au-delà, un * nombre sur le dernier) : en paysage, ils s'arrêtent avant les @@ -80,6 +85,12 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number) const height = BOARD_HEIGHT; const target = atLeast(46, pxPerUnit, 40); const numberSize = atLeast(12.5, pxPerUnit, 13); + const text = atLeast(13.5, pxPerUnit, 17); + const sizes = { + cubeFont: text, + cube: Math.max(36, text + 20), + countFont: Math.max(18, text), + }; if (layout === "portrait") { // Les mesures du plateau d'origine : flèches de 44, pions de 20. const frame = 8; @@ -112,6 +123,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number) numberSize, bandFont: atLeast(13.5, pxPerUnit, 17), chipFont: atLeast(13.5, pxPerUnit, 17), + ...sizes, die: 36, barStack: MAX_STACK, chipHeight: 0, @@ -156,6 +168,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number) numberSize, bandFont: atLeast(13.5, pxPerUnit, 22), chipFont, + ...sizes, die: Math.min(40, band - 12), barStack, chipHeight, diff --git a/app/tests/layout.test.ts b/app/tests/layout.test.ts index c570a04..1cb6348 100644 --- a/app/tests/layout.test.ts +++ b/app/tests/layout.test.ts @@ -62,6 +62,9 @@ test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et assert.ok(g.bandFont * px >= 13, `${where} : consigne de ${g.bandFont * px} px`); assert.ok(g.chipFont * px >= 13, `${where} : comptes de ${g.chipFont * px} px`); assert.ok(g.target * px >= 44, `${where} : cibles de ${g.target * px} px`); + assert.ok(g.cubeFont * px >= 13, `${where} : videau en ${g.cubeFont * px} px`); + assert.ok(g.countFont * px >= 13, `${where} : nombres des piles en ${g.countFont * px} px`); + assert.ok(g.cube <= g.left - 8, `${where} : videau plus large que sa colonne`); assert.ok(g.left - 4 >= g.target - 4, `${where} : colonne de commandes trop étroite`); // Les numéros tiennent dans le cadre. assert.ok(g.numberSize < g.edge, `${where} : numéros hors du cadre`); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 19e99e2..7a469ab 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -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 /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(); diff --git a/web/app.css b/web/app.css index fbbb86c..870f146 100644 --- a/web/app.css +++ b/web/app.css @@ -965,8 +965,9 @@ body.busy .prompt { stroke-width: 6; } +/* Tailles des chiffres du plateau : attribut font-size, calculé par + * geometry.ts (13 px au moins à l'écran). */ .checker-count { - font-size: 18px; font-weight: 700; text-anchor: middle; } @@ -1038,7 +1039,6 @@ body.busy .prompt { .cube-value { fill: var(--cube-text); - font-size: 17px; font-weight: 800; text-anchor: middle; }