diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 59f45a6..d347ff2 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -1289,13 +1289,21 @@ export function startApp(options: AppOptions): void { // ----------------------------------------------------------- Adresses let menuReturnFocus: HTMLElement | null = null; + // L'écran sous la feuille d'installation : le dernier écran affiché. + let underSheet: Route = { screen: "game" }; /** Dessine l'écran que désigne l'adresse (ADR 0013, amendement). */ function applyRoute(route: Route): void { - const help = isHelpScreen(route); - const position = route.screen === "position"; - const home = route.screen === "home"; - const tutorial = route.screen === "tutorial" || route.screen === "tutorialEnd"; + // La feuille d'installation s'ouvre par-dessus l'écran d'où l'on + // vient (l'accueil, le plus souvent), qui reste dessous. + if (route.screen !== "install") { + underSheet = route; + } + const below = underSheet; + const help = isHelpScreen(below); + const position = below.screen === "position"; + const home = below.screen === "home"; + const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd"; const page = help || position || home || tutorial; const wasPage = ui.game.hidden; const wasScreen = ui.root.dataset.screen; @@ -1307,12 +1315,14 @@ export function startApp(options: AppOptions): void { ui.root.classList.toggle("page-open", page); // Le tutoriel et le premier lancement ont leur propre en-tête. ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings))); - ui.root.dataset.screen = route.screen; - if ((wasPage && !ui.game.hidden) || (page && wasScreen !== route.screen)) { + ui.root.dataset.screen = below.screen; + if ((wasPage && !ui.game.hidden) || (page && wasScreen !== below.screen)) { window.scrollTo(0, 0); } if (tutorial) { - void tutorialView.show(route); + if (route.screen !== "install") { + void tutorialView.show(below); + } } else { tutorialView.hide(); } @@ -1361,11 +1371,14 @@ export function startApp(options: AppOptions): void { gamesView?.hide(); } - if (help) { - void helpView.show(route); + const sheet = route.screen === "install"; + if (isHelpScreen(below) && !sheet) { + void helpView.show(below); } - if (position) { - void showPosition(route); + if (below.screen === "position") { + if (!sheet) { + void showPosition(below); + } } else { positionRequest++; shownPosition = null; diff --git a/app/src/ui/tutorial-view.ts b/app/src/ui/tutorial-view.ts index 5ec28e6..815734d 100644 --- a/app/src/ui/tutorial-view.ts +++ b/app/src/ui/tutorial-view.ts @@ -252,7 +252,13 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { // à l'écran (le joueur est déjà ailleurs). let shown = 0; let autoplay: ReturnType | null = null; + // Un coup illustré se joue, ou un geste est jugé : les touchers + // attendent (data-busy, pour les tests dans un navigateur). let busy = false; + const setBusy = (value: boolean) => { + busy = value; + section.toggleAttribute("data-busy", value); + }; // L'écran affiché. let screen: TutorialScreen | null = null; @@ -349,7 +355,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { if (shownMove === null || id !== shown) { return; } - busy = true; + setBusy(true); replay.disabled = true; illustrated = false; try { @@ -376,7 +382,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { } } finally { if (id === shown) { - busy = false; + setBusy(false); illustrated = true; replay.disabled = false; draw(); @@ -419,7 +425,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { return; } const id = shown; - busy = true; + setBusy(true); selected = null; try { const verdict = await session.play(halfMove); @@ -445,7 +451,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { await refreshTargets(); } finally { if (id === shown) { - busy = false; + setBusy(false); draw(practice === null ? null : await practice.board()); } } @@ -487,7 +493,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { return; } const id = shown; - busy = true; + setBusy(true); try { const right = (await access.shouldTake(current.position)) === takes; if (id !== shown) { @@ -498,7 +504,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { } setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong); } finally { - busy = false; + setBusy(false); } } take.addEventListener("click", () => void answer(true)); @@ -535,8 +541,10 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { return { async show(route) { const id = ++shown; + // Prêt (pour les tests dans un navigateur) une fois ce plateau dessiné. + delete section.dataset.ready; clearTimer(); - busy = false; + setBusy(false); practice = null; illustration = null; illustrated = false; @@ -696,7 +704,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { hide() { shown++; clearTimer(); - busy = false; + setBusy(false); }, }; } diff --git a/app/tests/home.test.ts b/app/tests/home.test.ts index 12e0f31..a567185 100644 --- a/app/tests/home.test.ts +++ b/app/tests/home.test.ts @@ -6,6 +6,7 @@ // Licence : GPLv3 ou ultérieure (voir LICENSE). import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { test } from "node:test"; import { installPlatform } from "../src/ui/install.js"; import { @@ -15,7 +16,18 @@ import { isFirstLaunch, parseSettings, type Settings, + TUTORIAL_DONE, } from "../src/ui/settings.js"; +import { + nextPlace, + placeOf, + previousPlace, + progress, + seenAfter, +} from "../src/ui/tutorial-nav.js"; +import { parseTutorial } from "../src/ui/tutorial-text.js"; + +const root = new URL("../../../", import.meta.url); test("réglages d'une version précédente : les nouveaux champs prennent leur valeur par défaut", () => { const old = parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}'); @@ -66,6 +78,31 @@ test("invitation à installer : après la première partie terminée, une seule assert.ok(!at({ installDismissals: 2, installDismissedAt: 4, gamesFinished: 40 })); }); +test("tutoriel : écran suivant, précédent, adresse ramenée à un écran qui existe", () => { + const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8")); + const explain = (step: number) => ({ step, practice: false }); + const practice = (step: number) => ({ step, practice: true }); + assert.deepEqual(nextPlace(tutorial, explain(2)), explain(3)); + assert.deepEqual(nextPlace(tutorial, explain(3)), practice(3)); + assert.deepEqual(nextPlace(tutorial, practice(3)), explain(4)); + assert.equal(nextPlace(tutorial, practice(8)), "end"); + assert.equal(previousPlace(tutorial, explain(1)), null); + assert.deepEqual(previousPlace(tutorial, explain(4)), practice(3)); + assert.deepEqual(previousPlace(tutorial, explain(3)), explain(2)); + assert.deepEqual(previousPlace(tutorial, "end"), practice(8)); + assert.deepEqual(placeOf(tutorial, { screen: "tutorial", step: null, practice: false }), explain(1)); + assert.deepEqual(placeOf(tutorial, { screen: "tutorial", step: 2, practice: true }), explain(2)); + assert.deepEqual(placeOf(tutorial, { screen: "tutorial", step: 42, practice: false }), explain(1)); + assert.equal(placeOf(tutorial, { screen: "tutorialEnd" }), "end"); + assert.equal(seenAfter(5, explain(3)), 5); + assert.equal(seenAfter(0, "end"), TUTORIAL_DONE); + // La barre : des liens vers les étapes vues seulement, pas l'actuelle. + const bar = progress(8, explain(3), 5); + assert.deepEqual(bar.filter((s) => s.link).map((s) => s.step), [1, 2, 4, 5]); + assert.deepEqual(bar.filter((s) => s.done).map((s) => s.step), [1, 2, 3]); + assert.deepEqual(bar.filter((s) => s.current).map((s) => s.step), [3]); +}); + test("la marche à suivre pour installer dépend du navigateur", () => { const iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " + diff --git a/docs/plans/2026-11-03-accueil-tutoriel-aide.md b/docs/plans/2026-11-03-accueil-tutoriel-aide.md index b79a7eb..2936c62 100644 --- a/docs/plans/2026-11-03-accueil-tutoriel-aide.md +++ b/docs/plans/2026-11-03-accueil-tutoriel-aide.md @@ -73,4 +73,81 @@ Chaque écran (accueil, étapes du tutoriel, onglets et questions de l'aide) a s ## Résultat -À remplir à la clôture. +Exécuté le 2026-11-09 sur la branche `accueil-tutoriel-aide`, construite sur +`adresses-ecrans` (0e.4, en recette). + +1. **Accueil** (`#/accueil`, `app/src/ui/home-view.ts`, `home-model.ts`). + Lancée sans adresse d'écran (icône, adresse du site), l'application + s'ouvre sur l'accueil ; `#/` reste la partie. Premier lancement (rien de + fait : ni choix, ni partie terminée, ni étape du tutoriel) : logo, + plateau de départ, « Bienvenue à la table », « Je découvre » (lien + `#/tutoriel/1`) et « Je connais les règles » (lien `#/`), une seule fois. + Ensuite : la partie en cours (miniature, comptes, « Reprendre la partie » + en action principale, ou « Nouvelle partie » s'il n'y a rien à + reprendre), l'invitation à installer, une carte « Les règles du jeu » + (le tutoriel : seul contenu d'apprentissage qui existe), la progression + (« N parties jouées · M gagnées », sans les leçons) et la phrase de + l'Atelier du Huit. Le logo de la barre haute et le menu mènent à + l'accueil. +2. **Installation** (`install.ts`) : invite gardée de `beforeinstallprompt`, + sinon marche à suivre en trois étapes (Firefox, iPhone) dans une feuille + à son adresse, `#/installer`. Carte après la première partie terminée, + « Plus tard » une fois, retour après trois parties de plus ; ligne du + menu tant que l'application n'est pas installée ; plus rien en + `display-mode: standalone` ni après `appinstalled` (retenu dans les + réglages). +3. **Tutoriel** : texte unique `docs/tutoriel/regles.md` (format : + `docs/tutoriel/README.md`), copié par `make site`, CC BY-SA 4.0, texte de + la charte. Huit étapes, écrans « à vous » aux étapes 3, 4, 6 et 8, + adresses `#/tutoriel/`, `#/tutoriel//a-vous`, `#/tutoriel/fin`. + Positions par identifiants gnubg ; coup illustré joué seul après 1 s, + pion par pion, avec « Revoir » ; sens du jeu en pointillés sur liseré + ivoire (étape 2). Domaine : `app/src/domain/learning/tutorial.ts` + (`MovePractice`, `illustratedMove`, `shouldTake`) ; le moteur dit les + demi-coups permis, et un coup est juste s'il mène à la position d'un des + coups attendus. Refus : le pion revient, une phrase dit pourquoi ; succès : + coche, vibration « pion posé », « Continuer » principal. La barre de + progression ne lie que les étapes déjà vues. `app/tests/tutorial.test.ts` + joue chaque coup légal de chaque écran « à vous » sur le vrai moteur. + Fiche du glossaire `camp.md` ajoutée. +4. **Aide** : onglet « Jouer » en questions (titres des scénarios reformulés, + descriptions ajoutées là où il n'y en avait pas, étapes cachées, lien + « Voir sur le plateau » par l'étiquette `@position:`), carte du tutoriel en + tête, « Les mots du jeu » en cartes triées avec nom anglais, renvois et + recherche sans accents, « À propos » (licences, crédits, version publiée + lue dans `version.json`). Le bouton « Aide » de la partie ouvre la + question du moment (`help-context.ts`, titres vérifiés par un test). +5. **Mise à jour** (`updates.ts`, `web/sw.js`) : le service worker installe + la nouvelle version puis attend (plus de `skipWaiting()` à + l'installation, sauf à la première où rien n'attend) ; bandeau + « Nouvelle version disponible » en haut, jamais pendant un coup en cours ni + sur « Reprendre l'ancienne » ; « Mettre à jour » envoie `skip-waiting`, + rechargement au `controllerchange`, la partie reprend. Menu « Rechercher + une mise à jour » (`registration.update()`), qui répond « Vous avez la + dernière version. ». +6. **Scénarios** : `decouvrir-l-application.feature`, + `installer-l-application.feature`, `apprendre-les-regles.feature`, et + « Comment mettre l'application à jour ? » dans + `regler-l-application.feature`, exécutés par Cucumber. +7. **`make test-browser`** : `tests/browser/accueil.mjs` (premier lancement, + tutoriel complet avec un geste refusé puis juste sur chaque écran + « à vous », installation avec `beforeinstallprompt` simulé dans Chromium, + aide, mise à jour sur un second site dont le test publie une deuxième + version) ; `play.mjs` adapté (accueil, tutoriel et installation ont leur + adresse). Captures dans `build/browser-shots/accueil-tutoriel/`. + +**Écarts et points à trancher** + +- Les adresses `#/tutoriel//a-vous`, `#/tutoriel/fin` et `#/installer` + s'ajoutent au tableau de l'ADR 0013 (amendement à consigner). +- Étape 7 : avec la règle de Jacoby, active en partie libre, un gammon ne + vaut 2 points qu'une fois le videau doublé (le moteur compte 1 point + sinon). Le texte de la charte est gardé, avec une phrase ajoutée qui le + dit ; à valider par Pierre. +- Les boutons de l'écran « à vous » du videau s'appellent « Je prends » et + « Je passe », pour ne pas se confondre avec « Passer » (le tutoriel). +- « Toutes les leçons » et la prochaine leçon attendent 1g ; la carte + « Les règles du jeu » tient leur place. +- La partie commencée d'office au chargement compte dans « parties + commencées » : le premier lancement reste affiché jusqu'à une partie + commencée de plus. diff --git a/tests/browser.sh b/tests/browser.sh index 102b264..6b14e33 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -2,7 +2,11 @@ # Joue une partie complète par l'interface dans Chromium et Firefox sans # écran (tests/browser/play.mjs), sur le site servi par l'image de production # construite en local, comme tests/test_site.sh. Les captures d'écran vont -# dans build/browser-shots/. +# dans build/browser-shots/. Puis l'accueil, le tutoriel, l'aide, +# l'installation et la mise à jour (tests/browser/accueil.mjs), avec leurs +# captures dans build/browser-shots/accueil-tutoriel/. +# +# BROWSERS=chromium et SCRIPTS=accueil (ou play) restreignent le test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, # installé une fois par npm dans build/browser-tools/ (réseau nécessaire la @@ -14,6 +18,8 @@ cd "$(dirname "$0")/.." image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" browsers="${BROWSERS:-chromium firefox}" +# Les scripts à lancer (tests/browser/.mjs), tous par défaut. +scripts="${SCRIPTS:-play accueil}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -33,15 +39,39 @@ if [ ! -f "$tools/node_modules/playwright/package.json" ]; then "$image" npm install --no-audit --no-fund --save-exact "playwright@$version" >/dev/null fi +# Pour la mise à jour de l'application (tests/browser/accueil.mjs) : une +# copie du site, servie par la même image mais depuis un dossier que le test +# modifie, pour publier une deuxième version sur la même adresse. +updates=build/browser-site +rm -rf "$updates" +cp -r build/site "$updates" +update_container="$(docker run -d --rm -p 127.0.0.1::80 -v "$PWD/$updates:/srv:ro" "$site_image")" +update_cleanup() { + docker rm -f "$update_container" >/dev/null 2>&1 || true + site_cleanup +} +trap update_cleanup EXIT +update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://')" + rm -rf build/browser-shots mkdir -p build/browser-shots -cp tests/browser/play.mjs "$tools/" +cp tests/browser/play.mjs tests/browser/accueil.mjs "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker # demande un contexte sûr, ce qu'est localhost. + if [[ " $scripts " == *" play "* ]]; then + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node play.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi + [[ " $scripts " == *" accueil "* ]] || continue + # Accueil, tutoriel, aide, installation et mise à jour ; captures de la + # recette dans build/browser-shots/accueil-tutoriel/. docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ - -e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ - node play.mjs "http://localhost:$port/" "$browser" /shots || status=1 + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -v "$PWD/$updates:/site" \ + -w /tools "$image" \ + node accueil.mjs "http://localhost:$port/" "$browser" /shots \ + "http://localhost:$update_port/" /site || status=1 done exit "$status" diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs new file mode 100644 index 0000000..104fec8 --- /dev/null +++ b/tests/browser/accueil.mjs @@ -0,0 +1,430 @@ +// accueil.mjs -- l'accueil, le tutoriel des règles, l'aide, l'installation +// et la mise à jour de l'application, dans un vrai navigateur sans écran +// (Playwright), sur le site servi par l'image de production +// (docs/plans/2026-11-03-accueil-tutoriel-aide.md, point 7). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node accueil.mjs +// Lancé par tests/browser.sh. Le second site sert la même chose depuis un +// dossier que ce script modifie : il y publie une deuxième version pour +// essayer la mise à jour. Captures de la recette design (Chromium) dans +// /accueil-tutoriel/, en 412×915 et 915×412. + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; + +const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots || !updateBase || !updateDir) { + console.error("usage : node accueil.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/accueil-tutoriel`; +mkdirSync(dir, { recursive: true }); +const SIZES = [ + [412, 915], + [915, 412], +]; + +const browser = await browserType.launch(); +const problems = []; +const log = (text) => console.log(`[${browserName}] ${text}`); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let captures = 0; + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +/** Un contexte neuf (stockage vide), avec des réglages déjà enregistrés si + * settings est donné. Erreurs de la page et violations de la CSP relevées. */ +async function newPage(settings = null, url = base) { + const context = await browser.newContext({ + viewport: { width: 412, height: 915 }, + deviceScaleFactor: 1, + locale: "fr-FR", + }); + await context.addInitScript((saved) => { + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); + // Un élément est-il à l'écran ? (Utilisé par les attentes.) + window.__shown = (id) => { + const el = document.getElementById(id); + return el !== null && !el.closest("[hidden]") && el.getClientRects().length > 0; + }; + window.__vibrations = []; + Object.defineProperty(Navigator.prototype, "vibrate", { + configurable: true, + value: (pattern) => { + window.__vibrations.push(String(pattern)); + return true; + }, + }); + if (saved !== null && localStorage.getItem("ustagammon.settings") === null) { + localStorage.setItem("ustagammon.settings", saved); + } + }, settings === null ? null : JSON.stringify(settings)); + const page = await context.newPage(); + page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console : ${message.text()}`); + } + }); + page.on("dialog", (dialog) => { + problems.push(`boîte native : ${dialog.type()}`); + void dialog.dismiss(); + }); + await page.goto(url); + return { context, page }; +} + +/** Attend que test (évalué dans la page) soit vrai, au plus ms. (Pas de + * waitForFunction : Playwright y passe par eval, que la CSP interdit.) */ +async function until(page, what, test, arg, ms = 30_000) { + let last = null; + for (const end = Date.now() + ms; Date.now() < end; ) { + last = await page.evaluate(test, arg).catch((error) => `page illisible : ${error.message}`); + if (last === true) { + return; + } + await sleep(100); + } + await page.screenshot({ path: `${shots}/${browserName}-accueil-echec.png` }).catch(() => {}); + throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`); +} + +/** Range une capture de l'écran courant dans les deux tailles (Chromium). */ +async function capture(page, name, settle = async () => {}) { + if (!full) { + return; + } + for (const [width, height] of SIZES) { + await page.setViewportSize({ width, height }); + await sleep(250); + await settle(); + await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true }); + captures++; + } + await page.setViewportSize({ width: 412, height: 915 }); + await sleep(150); +} + +/** Vérifie la CSP et ferme le contexte. */ +async function close(context, page) { + const csp = await page.evaluate(() => window.__csp).catch(() => []); + check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`); + await context.close(); +} + +// ------------------------------------------------- Premier lancement + +/** L'écran du tutoriel prêt : plateau dessiné pour cette adresse, coup + * illustré joué (« Revoir » utilisable) s'il y en a un. */ +async function tutorialReady(page, hash) { + await until(page, `tutoriel ${hash}`, (hash) => { + const section = document.getElementById("tutorial"); + const replay = document.getElementById("tutorial-replay"); + return location.hash === hash && section.dataset.ready !== undefined && + document.querySelectorAll("#tutorial-board polygon[data-point]").length === 24 && + (replay.hidden || !replay.disabled); + }, hash); +} + +/** Touche une case du plateau du tutoriel. */ +async function touch(page, place) { + await page.click(`#tutorial-board [data-place="${place}"]`); + await sleep(200); +} + +async function verdict(page) { + await until(page, "verdict", () => { + const v = document.getElementById("tutorial-feedback").dataset.verdict; + return v === "right" || v === "wrong"; + }); + return page.evaluate(() => document.getElementById("tutorial-feedback").dataset.verdict); +} + +/** Les gestes des écrans « à vous » : un coup refusé, puis le bon. */ +const PRACTICE = { + // 24/21 : ne peut plus mener à 8/5 6/5, refusé tout de suite. + 3: { wrong: [["p24", "p21"]], right: [["p8", "p5"], ["p6"]] }, + // 24/23 puis 24/18 : un coup complet qui ne frappe pas. + 4: { wrong: [["p24", "p23"], ["p24"]], right: [["p13"], ["p8"]] }, + // 6/3 : ne sort pas deux pions. + 6: { wrong: [["p6", "p3"]], right: [["p6", "off-player"], ["p3"]] }, +}; + +async function playTouches(page, touches) { + for (const group of touches) { + for (const place of group) { + await touch(page, place); + } + await until(page, "demi-coup joué", () => !document.querySelector("#tutorial[data-busy]")); + await sleep(150); + } +} + +{ + const { context, page } = await newPage(); + await until(page, "premier lancement", () => + location.hash === "#/accueil" && !document.getElementById("home-welcome").hidden); + const first = await page.evaluate(() => ({ + topbar: getComputedStyle(document.querySelector(".topbar")).display, + links: [...document.querySelectorAll("#home-welcome a.choice")].map((a) => a.getAttribute("href")), + main: document.getElementById("home-main").hidden, + })); + check(first.topbar === "none" && first.main, `premier lancement : ${JSON.stringify(first)}`); + check(JSON.stringify(first.links) === '["#/tutoriel/1","#/"]', `choix : ${first.links}`); + await capture(page, "01-premier-lancement"); + log("premier lancement : « Je découvre » et « Je connais les règles », de vrais liens"); + + // Le tutoriel complet, par ses liens « Continuer ». + await page.click("#welcome-discover"); + let screens = 0; + for (let step = 1; step <= 8; step++) { + const hash = `#/tutoriel/${step}`; + await tutorialReady(page, hash); + // La barre ne mène qu'aux étapes déjà vues. + const links = await page.evaluate(() => + [...document.querySelectorAll("#tutorial-progress a")].map((a) => a.getAttribute("href"))); + const expected = Array.from({ length: step - 1 }, (_, i) => `#/tutoriel/${i + 1}`); + check(JSON.stringify(links) === JSON.stringify(expected), + `étape ${step} : barre de progression ${JSON.stringify(links)}`); + const illustrated = await page.evaluate(() => !document.getElementById("tutorial-replay").hidden); + if (illustrated) { + check(await page.locator("#tutorial-board .trace-arrow").count() > 0, + `étape ${step} : le coup illustré n'a pas laissé de traces`); + // « Revoir » rejoue le coup. + await page.click("#tutorial-replay"); + await tutorialReady(page, hash); + } + if (step === 2) { + check(await page.locator("#tutorial-board .tutorial-path").count() === 1, + "étape 2 : pas de chemin en pointillés"); + } + await capture(page, `tutoriel-${step}`); + screens++; + await page.click("#tutorial-next"); + const practice = await page.evaluate(() => location.hash.endsWith("/a-vous")); + if (!practice) { + continue; + } + const avous = `${hash}/a-vous`; + await tutorialReady(page, avous); + check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")), + `étape ${step}, à vous : « Continuer » principal avant le geste`); + await capture(page, `tutoriel-${step}-a-vous`); + if (step === 8) { + await page.click("#tutorial-drop"); + check(await verdict(page) === "wrong", "étape 8 : « Je passe » accepté"); + await capture(page, "tutoriel-8-a-vous-refuse"); + await page.click("#tutorial-take"); + await until(page, "étape 8 juste", () => + document.getElementById("tutorial-feedback").dataset.verdict === "right"); + } else { + const moves = PRACTICE[step]; + await playTouches(page, moves.wrong); + check(await verdict(page) === "wrong", `étape ${step} : coup faux accepté`); + // Le pion revient : de nouveau des pions jouables. + await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0); + const words = await page.textContent("#tutorial-feedback"); + check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`); + await capture(page, `tutoriel-${step}-a-vous-refuse`); + await playTouches(page, moves.right); + check(await verdict(page) === "right", `étape ${step} : bon coup refusé`); + check(await page.locator("#tutorial-board .hit.can-move").count() === 0, + `étape ${step} : plateau encore jouable après le bon coup`); + } + check(!(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary"))), + `étape ${step} : « Continuer » pas principal après le bon geste`); + const vibrated = await page.evaluate(() => window.__vibrations.at(-1)); + check(vibrated === "12", `étape ${step} : vibration ${vibrated}`); + await capture(page, `tutoriel-${step}-a-vous-reussi`); + screens++; + await page.click("#tutorial-next"); + } + await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" && + document.getElementById("tutorial").dataset.ready !== undefined); + await capture(page, "tutoriel-fin"); + // Le retour du téléphone : l'écran d'avant. + await page.goBack(); + await tutorialReady(page, "#/tutoriel/8/a-vous"); + await page.goForward(); + await until(page, "fin", () => location.hash === "#/tutoriel/fin"); + await page.click("#tutorial-next"); + await until(page, "première partie", () => location.hash === "#/" && window.__shown("game")); + log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin`); + + // Le choix ne revient plus : l'accueil, ensuite. + await page.goto(base); + await until(page, "accueil", () => location.hash === "#/accueil" && + document.getElementById("home-welcome").hidden && !document.getElementById("home-main").hidden); + await close(context, page); +} + +// --------------------------------------------------- Accueil, installation + +{ + // Une partie terminée : l'invitation à installer apparaît. + const settings = { welcomed: true, gamesStarted: 2, gamesFinished: 1, tutorialSeen: 99 }; + const { context, page } = await newPage(settings); + await until(page, "accueil prêt", () => location.hash === "#/accueil" && + !document.getElementById("home-new").disabled && !document.getElementById("install-card").hidden); + const home = await page.evaluate(() => ({ + resume: document.getElementById("home-resume").getAttribute("href"), + primaries: [...document.querySelectorAll("#home a.button:not(.secondary), #home button:not(.secondary):not(.icon-button)")] + .filter((el) => !el.closest("[hidden]")).length, + rules: document.getElementById("home-rules").getAttribute("href"), + })); + check(home.resume === "#/" && home.primaries === 1 && home.rules === "#/tutoriel/1", + `accueil : ${JSON.stringify(home)}`); + await capture(page, "02-accueil"); + await capture(page, "03-carte-installation", async () => { + await page.locator("#install-card").scrollIntoViewIfNeeded(); + }); + // Sans invite du navigateur : la marche à suivre, une feuille avec son + // adresse, que le retour referme. + await page.click("#install-card-button"); + await until(page, "feuille d'installation", () => location.hash === "#/installer" && window.__shown("install-sheet")); + check((await page.locator("#install-steps li").count()) === 3, "marche à suivre sans trois étapes"); + await capture(page, "04-feuille-installation"); + await page.goBack(); + await until(page, "feuille refermée", () => location.hash === "#/accueil" && !window.__shown("install-sheet")); + if (browserName === "chromium") { + // L'invite de Chrome, simulée : « Installer » l'ouvre. + await page.evaluate(() => { + window.__prompted = 0; + const event = new Event("beforeinstallprompt", { cancelable: true }); + event.prompt = () => { + window.__prompted++; + return Promise.resolve(); + }; + event.userChoice = Promise.resolve({ outcome: "dismissed" }); + window.dispatchEvent(event); + }); + await page.click("#install-card-button"); + await until(page, "invite du navigateur", () => window.__prompted === 1); + check(await page.evaluate(() => location.hash) === "#/accueil", "l'invite a ouvert la feuille"); + // Installée : plus aucune proposition. + await page.evaluate(() => window.dispatchEvent(new Event("appinstalled"))); + await until(page, "installée", () => document.getElementById("install-card").hidden && + document.getElementById("menu-install").hidden); + log("installation : invite de Chrome simulée, puis plus rien une fois installée"); + } else { + await page.click("#install-later"); + await until(page, "plus tard", () => document.getElementById("install-card").hidden); + log("installation : marche à suivre, « Plus tard »"); + } + await close(context, page); +} + +// ------------------------------------------------------------------- Aide + +{ + const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${base}#/aide`); + await until(page, "aide", () => window.__shown("help-play") && document.getElementById("help-content").dataset.loaded === "true"); + const help = await page.evaluate(() => ({ + steps: document.querySelectorAll("#help-play .steps, #help-play li").length, + questions: [...document.querySelectorAll("#help-play .question h3")].map((h) => h.textContent), + tutorial: document.getElementById("help-tutorial")?.getAttribute("href"), + })); + check(help.steps === 0, "aide : les étapes Gherkin sont affichées"); + check(help.questions.length >= 25 && help.tutorial === "#/tutoriel/1", `aide : ${JSON.stringify(help)}`); + check(help.questions.filter((q) => q.endsWith("?")).length >= 20, "aide : trop peu de questions"); + await capture(page, "05-aide-jouer"); + const question = await page.evaluate(() => + [...document.querySelectorAll("#help-play .question")].find((q) => q.querySelector(".see-board"))?.id); + await page.click(`#${question} h3 a`); + await until(page, "question ouverte", (id) => document.getElementById(id).classList.contains("open"), question); + await capture(page, "06-aide-question"); + // Le retour du téléphone referme la question. + await page.goBack(); + await until(page, "question refermée", () => location.hash === "#/aide" && !document.querySelector(".question.open")); + await page.click('#help-tabs a[data-screen="glossary"]'); + await until(page, "glossaire", () => window.__shown("help-glossary")); + const before = await page.locator("#help-glossary .term-card:not([hidden])").count(); + await page.fill("#glossary-search", "sortir"); + await sleep(200); + const after = await page.locator("#help-glossary .term-card:not([hidden])").count(); + check(after > 0 && after < before, `recherche : ${after} fiches sur ${before}`); + await capture(page, "07-aide-glossaire-recherche"); + await page.click('#help-tabs a[data-screen="about"]'); + await until(page, "à propos", () => window.__shown("help-about")); + const version = await page.textContent("#about-version"); + check(/^Version publiée : [0-9a-f]{12}\.$/.test(version), `version : « ${version} »`); + await capture(page, "08-aide-a-propos"); + log(`aide : ${help.questions.length} questions, recherche dans le glossaire (${after}/${before}), ${version}`); + await close(context, page); +} + +// ----------------------------------------------------------- Mise à jour + +if (browserName === "chromium") { + const { context, page } = await newPage({ welcomed: true, gamesStarted: 2 }, `${updateBase}#/`); + await until(page, "partie prête", () => !document.body.classList.contains("busy"), undefined, 60_000); + // Le service worker installé et aux commandes (première installation : + // sans attendre l'accord du joueur). + await until(page, "service worker", async () => { + const reg = await navigator.serviceWorker.getRegistration(); + return reg?.active?.state === "activated" && navigator.serviceWorker.controller !== null; + }, undefined, 120_000); + const journal = () => page.evaluate(() => localStorage.length >= 0 && + new Promise((resolve) => { + const open = indexedDB.open("ustagammon"); + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction(["settings"]); + const current = tx.objectStore("settings").get("current"); + current.onsuccess = () => { + db.close(); + resolve(current.result?.value ?? null); + }; + }; + open.onerror = () => resolve(null); + })); + const gameBefore = await journal(); + check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version"); + // Une deuxième version publiée : autre version.json, autre sw.js. + writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n'); + const sw = readFileSync(`${updateDir}/sw.js`, "utf8"); + writeFileSync(`${updateDir}/sw.js`, sw.replace(/^const VERSION = "[0-9a-f]+";$/m, 'const VERSION = "0000000000000002";')); + await page.click("#menu-button"); + await page.waitForSelector("#menu", { state: "visible" }); + await page.click("#menu-update"); + await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000); + check((await page.textContent("#update-text")) === "Nouvelle version disponible.", + `bandeau : « ${await page.textContent("#update-text")} »`); + await capture(page, "09-mise-a-jour"); + await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]); + await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000); + check(await journal() === gameBefore, "après la mise à jour, ce n'est plus la même partie"); + await page.goto(`${updateBase}#/a-propos`); + await until(page, "nouvelle version", () => + document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab."); + log("mise à jour : deuxième version publiée, bandeau, « Mettre à jour », partie retrouvée, nouvelle version"); + // Rien de neuf : « Vous avez la dernière version ». + await page.goto(`${updateBase}#/`); + await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000); + await page.click("#menu-button"); + await page.waitForSelector("#menu", { state: "visible" }); + await page.click("#menu-update"); + await until(page, "dernière version", () => + document.getElementById("update-text").textContent === "Vous avez la dernière version.", undefined, 60_000); + await close(context, page); +} + +await browser.close(); +if (problems.length > 0) { + console.error(problems.join("\n")); + process.exit(1); +} +log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans accueil-tutoriel/` : ""}`); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 3b9d3c7..f3fb289 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -638,6 +638,9 @@ async function screenOf(tab) { term: document.querySelector("#help-glossary dt.current")?.id ?? null, menu: shown("menu"), games: shown("games"), + home: shown("home"), + tutorial: shown("tutorial"), + install: shown("install-sheet"), listed: document.querySelectorAll("#games .games-item").length, expanded: document @@ -708,17 +711,19 @@ async function checkAddresses() { 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], + ["#/accueil", (s) => s.home && !s.game && !s.tutorial], ["#/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], + ["#/tutoriel", (s) => s.hash === "#/tutoriel/1" && s.tutorial && !s.game], + ["#/tutoriel/2", (s) => s.tutorial && !s.home], + ["#/tutoriel/3/a-vous", (s) => s.tutorial && s.hash === "#/tutoriel/3/a-vous"], + ["#/tutoriel/fin", (s) => s.tutorial && s.hash === "#/tutoriel/fin"], + ["#/installer", (s) => s.install && s.hash === "#/installer"], ["#/parties", (s) => s.games && s.listed >= 2 && !s.menu], [() => `#/parties/${gameId}`, (s) => s.games && s.expanded === gameId], ["#/menu", (s) => s.menu && !s.games], @@ -777,8 +782,20 @@ async function checkAddresses() { check((await tab.locator(selector).and(tab.getByRole("link")).count()) === 0, `${selector} : une action de jeu exposée comme un lien`); } + // Le bouton « Aide » ouvre la question du moment de la partie + // (help-context.ts) ; ici, l'onglet « Jouer » sans question. + const helpHref = await tab.getAttribute("#help-button", "href"); + check(/^#\/aide(\/[a-z0-9-]+)?$/.test(helpHref), `bouton Aide : ${helpHref}`); await tab.click("#help-button"); - await expectScreen(tab, "aide par son lien", (s) => s.hash === "#/aide" && s.help && s.play); + await expectScreen(tab, "aide par son lien", (s) => s.hash === helpHref && s.help && s.play); + if (helpHref !== "#/aide") { + await tab.goBack(); + await expectScreen(tab, "retour à la partie", playing); + await tab.evaluate(() => { + location.hash = "#/aide"; + }); + await expectScreen(tab, "aide", (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`); } @@ -920,7 +937,9 @@ await context.route("**/wasm/engine.data", async (route) => { await route.continue(); }); const start = Date.now(); -await page.goto(base); +// « #/ » : la partie ; sans adresse d'écran, l'application s'ouvrirait sur +// l'accueil (tests/browser/accueil.mjs). +await page.goto(`${base}#/`); await page.waitForSelector("#board polygon[data-point]"); { const loading = await page.evaluate(() => ({