Une adresse pour chaque écran, et un lien pour chaque position (0e.4) #43

Manually merged
pierre merged 9 commits from adresses-ecrans into main 2026-11-09 18:06:52 +00:00
3 changed files with 318 additions and 12 deletions
Showing only changes of commit 97a098e98f - Show all commits
+1 -1
View File
@@ -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();
+33 -7
View File
@@ -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<typeof setTimeout> | 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);
},
};
}
+284 -4
View File
@@ -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 ` +