Accueil, tutoriel des règles, aide en questions, installation et mise à jour (0e.5) #44

Manually merged
pierre merged 9 commits from accueil-tutoriel-aide into main 2026-11-09 23:14:15 +00:00
7 changed files with 644 additions and 30 deletions
Showing only changes of commit 5fdca812d9 - Show all commits
+24 -11
View File
@@ -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;
+16 -8
View File
@@ -252,7 +252,13 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
// à l'écran (le joueur est déjà ailleurs).
let shown = 0;
let autoplay: ReturnType<typeof setTimeout> | 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);
},
};
}
+37
View File
@@ -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 " +
+78 -1
View File
@@ -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/<n>`, `#/tutoriel/<n>/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/<n>/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.
+34 -4
View File
@@ -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/<nom>.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"
+430
View File
@@ -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 <adresse du site> <chromium|firefox> <dossier des
// captures> <adresse du site modifiable> <dossier de ce site>
// 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
// <dossier des captures>/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 <adresse> <chromium|firefox> <captures> <adresse 2> <dossier 2>");
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/` : ""}`);
+25 -6
View File
@@ -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(() => ({