Une adresse pour chaque écran, et un lien pour chaque position (0e.4) #43
+1
-1
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 ` +
|
||||
|
||||
Reference in New Issue
Block a user