diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 6f218b2..b5fc7a4 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -1007,7 +1007,7 @@ export function startApp(options: AppOptions): void { // Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son // lien) ; le refermer, revenir à l'écran d'avant. menu: () => { - if (navigation.route.screen === "menu") { + if (!ui.menuVeil.hidden) { closeOverlays(); } else { closeTransient(); diff --git a/app/src/ui/navigation.ts b/app/src/ui/navigation.ts index 67e1ac5..c47d913 100644 --- a/app/src/ui/navigation.ts +++ b/app/src/ui/navigation.ts @@ -50,6 +50,10 @@ function currentHash(): string { */ export function startNavigation(onChange: (route: Route) => void): Navigation { let trail: readonly string[] = []; + // Un retour dans l'historique est en cours (history.go est asynchrone) : + // une adresse demandée entre-temps attend son arrivée. + let leaving: ReturnType | null = null; + let queued: string | null = null; const remember = (next: readonly string[]) => { trail = next.slice(-TRAIL_MAX); @@ -76,8 +80,25 @@ export function startNavigation(onChange: (route: Route) => void): Navigation { remember([...trail, hash]); } onChange(parseRoute(hash)); + if (leaving !== null) { + clearTimeout(leaving); + leaving = null; + const next = queued; + queued = null; + if (next !== null) { + go(next); + } + } }); + function go(hash: string): void { + if (leaving !== null) { + queued = hash; + } else if (hash !== currentHash()) { + location.hash = hash; + } + } + function replace(hash: string): void { if (hash === currentHash()) { return; @@ -100,19 +121,24 @@ export function startNavigation(onChange: (route: Route) => void): Navigation { get route() { return parseRoute(currentHash()); }, - go(hash) { - if (hash !== currentHash()) { - location.hash = hash; - } - }, + go, replace, leave(stay) { + if (leaving !== null) { + return; // déjà en route + } const steps = stepsBack(trail, stay); if (steps === null) { replace("#/"); - } else { - history.go(steps); + return; } + // Au cas où le navigateur ne reviendrait pas (historique + // indisponible), on n'attend pas plus d'une seconde. + leaving = setTimeout(() => { + leaving = null; + queued = null; + }, 1000); + history.go(steps); }, }; } diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index f0973ea..954307e 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -76,6 +76,15 @@ await context.addInitScript(() => { return null; }; } + // Les liens copiés (« Copier le lien de la position ») : le + // presse-papiers du navigateur sans écran n'est pas lisible partout. + window.__copied = []; + if (window.Clipboard) { + Clipboard.prototype.writeText = function (text) { + window.__copied.push(String(text)); + return Promise.resolve(); + }; + } window.__vibrations = []; Object.defineProperty(Navigator.prototype, "vibrate", { configurable: true, @@ -312,7 +321,7 @@ async function checkView(width, height, when) { 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]")) { + for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action], a.icon-button, a.item, a.button")) { if (!visible(el)) { continue; } @@ -528,7 +537,7 @@ async function touchTarget(action, label, count = 1, index = 0) { 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" && + check((await zone.getAttribute("role")) === (action === "menu" ? "link" : "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, @@ -588,10 +597,270 @@ async function checkReplay(traces, reducedMotion) { await page.emulateMedia({ reducedMotion: "no-preference" }); } +// ----------------------------------------------------------- Adresses + +/** Un nouvel onglet ouvert directement sur une adresse (fragment ou URL + * complète), ses erreurs comptées comme celles de la page principale. */ +async function openTab(address, viewport = PORTRAIT) { + const tab = await context.newPage(); + await tab.setViewportSize(viewport); + tab.on("pageerror", (error) => problems.push(`erreur de la page (${address}) : ${error.message}`)); + tab.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console (${address}) : ${message.text()}`); + } + }); + tab.on("dialog", (dialog) => { + problems.push(`boîte native ouverte (${address}) : ${dialog.type()}`); + void dialog.dismiss(); + }); + await tab.goto(new URL(address, base).href); + return tab; +} + +/** Ce que montre un onglet : écran, question, terme, feuille, message. */ +async function screenOf(tab) { + return tab.evaluate(() => { + const shown = (id) => { + const el = document.getElementById(id); + return el !== null && !el.hasAttribute("hidden") && el.getClientRects().length > 0; + }; + const text = (id) => document.getElementById(id)?.textContent ?? ""; + return { + hash: location.hash, + busy: document.body.classList.contains("busy"), + game: shown("game"), + help: shown("help"), + play: shown("help-play"), + glossary: shown("help-glossary"), + about: shown("help-about"), + question: document.querySelector("#help-play .question.open")?.id ?? null, + term: document.querySelector("#help-glossary dt.current")?.id ?? null, + menu: shown("menu"), + games: shown("games"), + listed: document.querySelectorAll("#games .games-item").length, + expanded: + document + .querySelector('#games [data-action="branches"][aria-expanded="true"]') + ?.closest(".games-item")?.dataset.id ?? null, + position: shown("position-view"), + positionBoard: shown("position-board"), + positionTitle: text("position-title"), + positionMessage: text("position-message"), + positionPlay: shown("position-play"), + info: text("info"), + }; + }); +} + +/** Attend que l'onglet montre l'écran attendu (expected : test sur + * screenOf), au plus 30 s ; sinon, échoue avec ce qu'il montre. */ +async function expectScreen(tab, what, expected) { + let state = null; + for (let i = 0; i < 300; i++) { + state = await screenOf(tab); + if (expected(state)) { + return state; + } + await sleep(100); + } + await tab.screenshot({ path: `${shots}/${browserName}-adresse-echec.png` }).catch(() => {}); + throw new Error(`${what} : écran inattendu ${JSON.stringify(state)}`); +} + +/** Le dernier lien copié par l'application dans cet onglet. */ +async function lastCopied(tab) { + for (let i = 0; i < 50; i++) { + const copied = await tab.evaluate(() => window.__copied.at(-1) ?? null); + if (copied !== null) { + return copied; + } + await sleep(100); + } + throw new Error("aucun lien copié"); +} + +/** L'élément est-il exposé comme un lien (rôle link) ? */ +async function isLink(tab, selector) { + const all = tab.locator(selector); + const links = all.and(tab.getByRole("link")); + return (await all.count()) > 0 && (await links.count()) === (await all.count()); +} + +async function checkAddresses() { + const dir = `${shots}/adresses`; + mkdirSync(dir, { recursive: true }); + const playing = (s) => s.hash === "#/" && s.game && !s.help && !s.position && !s.menu && !s.games; + + // Le lien de la position en cours, copié depuis le menu de la partie. + await openMenu(); + await page.evaluate(() => { + window.__copied = []; + }); + await page.click("#menu-copy-link"); + const positionUrl = await lastCopied(page); + const positionHash = new URL(positionUrl).hash; + check(/^#\/position\/[^/]{14,}\/[^/]+$/.test(positionHash), `lien de position copié : ${positionUrl}`); + check(!(await page.isVisible("#menu")), "le menu reste ouvert après la copie du lien"); + + // Chaque adresse du tableau de l'ADR, dans un nouvel onglet. Les + // identifiants d'une question et d'une partie sont lus en chemin. + let question = null; + let gameId = null; + let gamesBefore = 0; + const notYet = (s) => s.hash === "#/" && s.game && s.info.includes("n'existe pas encore"); + const addresses = [ + ["#/", playing], + ["#/accueil", notYet], + ["#/aide", (s) => s.help && s.play && !s.glossary && !s.about && s.question === null], + [() => `#/aide/${question}`, (s) => s.help && s.play && s.question === `aide-${question}`], + ["#/glossaire", (s) => s.help && s.glossary && !s.play && s.term === null], + ["#/glossaire/lien-de-position", (s) => s.help && s.glossary && s.term === "glossaire-lien-de-position"], + ["#/a-propos", (s) => s.help && s.about && !s.play && !s.glossary], + ["#/tutoriel", notYet], + ["#/tutoriel/2", notYet], + ["#/parties", (s) => s.games && s.listed >= 2 && !s.menu], + [() => `#/parties/${gameId}`, (s) => s.games && s.expanded === gameId], + ["#/menu", (s) => s.menu && !s.games], + [positionHash, (s) => s.position && s.positionBoard && !s.game && s.positionPlay], + [`#/position/${positionHash.split("/")[2]}`, (s) => s.position && s.positionBoard], + ["#/position/pas-une-position", (s) => + s.position && !s.positionBoard && s.positionMessage.includes("ne décrit pas une position")], + ["#/nulle-part", (s) => s.hash === "#/" && s.game && s.info.includes("ne mène à aucun écran")], + ]; + for (const [address, expected] of addresses) { + const hash = typeof address === "function" ? address() : address; + const tab = await openTab(hash); + await expectScreen(tab, `adresse ${hash}`, expected); + if (hash === "#/aide") { + question = await tab.evaluate(() => + document.querySelector("#help-play .question")?.id.replace(/^aide-/, "")); + check(typeof question === "string" && question !== "", "aide : aucune question"); + } + if (hash === "#/parties") { + await tab.waitForSelector("#games .games-item"); + gameId = await tab.evaluate(() => document.querySelector("#games .games-item")?.dataset.id); + gamesBefore = (await screenOf(tab)).listed; + } + await tab.close(); + } + log(`adresses : ${addresses.length} adresses ouvertes directement, chacune sur son écran`); + + // Captures : l'aide ouverte par son adresse, une question, un terme, la + // vue d'une position, un lien illisible ; en portrait et en paysage. + const captures = [ + ["aide", "#/aide", (s) => s.help && s.play], + ["question", `#/aide/${question}`, (s) => s.question === `aide-${question}`], + ["terme", "#/glossaire/lien-de-position", (s) => s.term === "glossaire-lien-de-position"], + ["position", positionHash, (s) => s.position && s.positionBoard], + ["lien-invalide", "#/position/pas-une-position", (s) => s.position && + s.positionMessage.includes("ne décrit pas une position")], + ]; + for (const [width, height] of [[412, 915], [915, 412]]) { + for (const [name, hash, expected] of captures) { + const tab = await openTab(hash, { width, height }); + await expectScreen(tab, `capture ${name}`, expected); + await sleep(300); + await tab.screenshot({ path: `${dir}/${browserName}-${name}-${width}x${height}.png` }); + await tab.close(); + } + } + + // Retour du navigateur et rôle des liens, dans un même onglet. + { + const tab = await openTab("#/"); + await expectScreen(tab, "partie", playing); + for (const selector of ["#help-button", "#menu-button"]) { + check(await isLink(tab, selector), `${selector} n'est pas un lien`); + } + for (const selector of ["#roll, #confirm, #again, #double", "#fullscreen-button"]) { + check((await tab.locator(selector).and(tab.getByRole("link")).count()) === 0, + `${selector} : une action de jeu exposée comme un lien`); + } + await tab.click("#help-button"); + await expectScreen(tab, "aide par son lien", (s) => s.hash === "#/aide" && s.help && s.play); + for (const selector of ["#help-back", "#help-tabs a", "#help-play .question h3 a"]) { + check(await isLink(tab, selector), `${selector} : pas des liens`); + } + await tab.click(`#aide-${question} h3 a`); + await expectScreen(tab, "question par son lien", (s) => s.hash === `#/aide/${question}` && + s.question === `aide-${question}`); + await tab.goBack(); + await expectScreen(tab, "retour : question refermée", (s) => s.hash === "#/aide" && s.help && + s.question === null); + await tab.click('#help-tabs a[data-screen="glossary"]'); + await expectScreen(tab, "onglet glossaire", (s) => s.hash === "#/glossaire" && s.glossary); + check(await isLink(tab, "#help-glossary dt a"), "les termes du glossaire ne sont pas des liens"); + await tab.goBack(); + await expectScreen(tab, "retour : onglet Jouer", (s) => s.hash === "#/aide" && s.play); + await tab.goBack(); + await expectScreen(tab, "retour : la partie", playing); + // Le menu, puis « Mes parties » depuis le menu : le retour ferme la + // feuille et ramène à la partie, sans rouvrir le menu. + await tab.click("#menu-button"); + await expectScreen(tab, "menu par son lien", (s) => s.hash === "#/menu" && s.menu); + for (const selector of ["#menu-games", "#menu-help"]) { + check(await isLink(tab, selector), `${selector} n'est pas un lien`); + } + check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1, + "« Nouvelle partie » n'est plus un bouton"); + await tab.goBack(); + await expectScreen(tab, "retour : menu fermé", playing); + await tab.click("#menu-button"); + await expectScreen(tab, "menu", (s) => s.menu); + await tab.click("#menu-games"); + await expectScreen(tab, "mes parties depuis le menu", (s) => s.hash === "#/parties" && s.games && + !s.menu); + await tab.goBack(); + await expectScreen(tab, "retour : mes parties fermé", playing); + // Échap quitte l'aide comme « Retour ». + await tab.click("#help-button"); + await expectScreen(tab, "aide", (s) => s.help); + await tab.keyboard.press("Escape"); + await expectScreen(tab, "Échap : la partie", playing); + await tab.close(); + } + log("adresses : retour du navigateur (question, onglet, menu, mes parties), Échap, rôle link des liens"); + + // Aller-retour d'un lien de position : le lien copié depuis la vue + // ouverte par ce lien est le même ; « Retour à ma partie » ; « Jouer à + // partir d'ici » commence une partie de plus, l'ancienne restant + // reprenable. + { + const tab = await openTab(positionUrl); + await expectScreen(tab, "lien de position", (s) => s.position && s.positionBoard && s.positionPlay); + await tab.click("#position-copy"); + const again = await lastCopied(tab); + check(again === positionUrl, `aller-retour du lien : ${again} au lieu de ${positionUrl}`); + check(await isLink(tab, "#position-back"), "« Retour à ma partie » n'est pas un lien"); + await tab.click("#position-back"); + await expectScreen(tab, "retour à ma partie (nouvel onglet)", playing); + // Depuis la partie : le lien suivi, puis « Retour à ma partie ». + await tab.evaluate((hash) => { + location.hash = hash; + }, positionHash); + await expectScreen(tab, "lien suivi depuis la partie", (s) => s.position && s.positionBoard); + await tab.click("#position-back"); + await expectScreen(tab, "retour à ma partie", playing); + await tab.goForward(); + await expectScreen(tab, "avance : la position", (s) => s.position && s.positionPlay); + await tab.click("#position-play"); + await expectScreen(tab, "jouer à partir d'ici", (s) => playing(s) && !s.busy); + await tab.goto(new URL("#/parties", base).href); + const after = await expectScreen(tab, "mes parties", (s) => s.games && s.listed > 0); + check(after.listed === gamesBefore + 1, + `jouer à partir d'ici : ${after.listed} parties, ${gamesBefore + 1} attendues`); + await tab.close(); + } + log("adresses : lien de position copié, rouvert à l'identique, retour à la partie, partie jouée depuis la position"); +} + // ------------------------------------------------------------- Menu async function openMenu() { await page.click("#menu-button"); + // Le menu a son adresse (#/menu) : il s'ouvre au changement d'adresse. + await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {}); check(await page.isVisible("#menu"), "le menu ne s'ouvre pas"); } @@ -1074,6 +1343,7 @@ for (let actions = 0; ; actions++) { // Le clavier ouvre aussi le menu (M) et Échap le ferme. await page.evaluate(() => document.activeElement?.blur()); await page.keyboard.press("m"); + await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {}); check(await page.isVisible("#menu"), "M n'ouvre pas le menu"); await page.keyboard.press("Escape"); const pips = await page.textContent("#strip-player .count"); @@ -1214,7 +1484,7 @@ step("mes parties"); step("aide"); // Aide : scénarios et glossaire lus dans les copies de docs/. await page.click("#help-button"); -await page.waitForSelector(".glossary dt"); +await page.waitForSelector(".glossary dt", { state: "attached" }); 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`); @@ -1281,7 +1551,7 @@ if (offline === "roll") { await waitForPlayer(); } await page.click("#help-button"); -await page.waitForSelector(".glossary dt"); +await page.waitForSelector(".glossary dt", { state: "attached" }); await page.click("#help-back"); log( offlineMode @@ -1313,6 +1583,16 @@ check( ); log("CSP : aucune violation pendant la partie, script en ligne bloqué ; aucune boîte native"); +step("adresses"); +// Chaque écran a son adresse (ADR 0013, amendement du 2026-11-03) : ouverte +// directement dans un nouvel onglet, elle montre le bon écran ; le retour du +// navigateur referme d'abord la question ou la feuille ouverte ; les liens +// sont des liens (rôle link) ; un lien de position fait l'aller-retour. +if (offlineMode) { + await context.setOffline(false); +} +await checkAddresses(); + 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 ` +