From cd5f70e54837d8fff301fce94a4cb92700694448 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 04:49:03 +0100 Subject: [PATCH] =?UTF-8?q?Conseil=20du=20ma=C3=AEtre=20:=20test=20dans=20?= =?UTF-8?q?le=20navigateur,=20ADR=200007=20et=200013=20amend=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ...007-architecture-et-conventions-de-code.md | 22 ++ docs/adr/0013-position-dans-l-adresse.md | 18 ++ tests/browser.sh | 16 +- tests/browser/maitre.mjs | 297 ++++++++++++++++++ 4 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 tests/browser/maitre.mjs diff --git a/docs/adr/0007-architecture-et-conventions-de-code.md b/docs/adr/0007-architecture-et-conventions-de-code.md index 337266c..22a7ec9 100644 --- a/docs/adr/0007-architecture-et-conventions-de-code.md +++ b/docs/adr/0007-architecture-et-conventions-de-code.md @@ -128,3 +128,25 @@ ne change pas (version 1) : le résumé d'une partie gagne un champ facultatif `match`, recalculé à chaque enregistrement, et les réglages (`ustagammon.settings`) quatre champs (`gameKind`, `matchLength`, `level`, `masterAdvice`) qui ont une valeur par défaut quand ils manquent. + +### 2026-11-10 — Le conseil du maître rattaché au journal + +Avec le conseil du maître (étape 1d), le journal d'une partie +(`GameJournal`, `app/src/domain/game/events.ts`) gagne un champ facultatif +`advice` : pour chaque décision du joueur analysée (coup, jet sans doubler, +double, prise ou refus d'un double), l'analyse du moteur à 2 plis, rangée +sous l'identifiant de l'événement de la décision +(`app/src/domain/game/advice.ts`). Ce n'est pas un événement : l'analyse +arrive après coup, d'un second moteur, et ne change pas la partie ; les +événements gardent leur version (2). Le bilan de fin de partie (1e) la +relira sans recalcul. + +- Chaque conseil porte sa version (`ADVICE_VERSION`, 1). À la reprise + (`migrateJournal`), un conseil d'une autre version, ou rattaché à un + événement absent, est oublié : ce n'est qu'une analyse, qui peut être + refaite. Un journal enregistré avant l'étape 1d n'a pas de champ `advice` + et se reprend tel quel. Testé dans `app/tests/advice.test.ts`. +- Ajouter un événement, revenir en arrière ou changer de branche garde les + conseils ; un conseil reste attaché à son événement, même hors de la + branche courante. +- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel. diff --git a/docs/adr/0013-position-dans-l-adresse.md b/docs/adr/0013-position-dans-l-adresse.md index 6f80722..1b26b81 100644 --- a/docs/adr/0013-position-dans-l-adresse.md +++ b/docs/adr/0013-position-dans-l-adresse.md @@ -112,3 +112,21 @@ Le menu, l'accueil et « Nouvelle partie » en fin de partie y mènent. « Comme remplace cette adresse par celle de la partie (`#/`) : le retour du téléphone ne rouvre pas l'écran. Le lien d'une position d'une partie de match porte le Match ID du match (longueur, score, partie Crawford), écrit par le moteur. + +### 2026-11-10 — Adresse de la feuille d'explication du maître + +Ajoutée avec le conseil du maître (étape 1d), dans le même schéma : + +| Écran | Adresse | +| --- | --- | +| Feuille d'explication du maître sur une décision de la partie en cours | `#/explication/<événement>` | + +`<événement>` est l'identifiant, dans le journal de la partie en cours, de +l'événement de la décision commentée (le coup, le double, la prise…). La +ligne du maître est un lien vers cette adresse ; la feuille monte au-dessus +de la partie, comme le menu, et « Fermer » (ou le retour du téléphone) +revient à la partie. Rechargée, l'adresse rouvre la même explication : le +conseil est enregistré dans le journal (ADR 0007, amendement du +2026-11-10). Un événement sans conseil donne un message, jamais une page +cassée. L'adresse n'a de sens que sur l'appareil qui a joué la partie : ce +n'est pas un lien à partager (le lien de la position, lui, se partage). diff --git a/tests/browser.sh b/tests/browser.sh index 9d61203..cb48727 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -8,7 +8,10 @@ # partie » et un match en 3 points joué jusqu'au bout (tests/browser/match.mjs), # captures dans build/browser-shots/match/. # -# BROWSERS=chromium et SCRIPTS=accueil (ou play, ou match) restreignent le +# Le conseil du maître (tests/browser/maitre.mjs), captures dans +# build/browser-shots/maitre/. +# +# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre) restreignent le # test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, @@ -22,7 +25,7 @@ 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 match}" +scripts="${SCRIPTS:-play accueil match maitre}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -59,7 +62,7 @@ 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 tests/browser/accueil.mjs tests/browser/match.mjs "$tools/" +cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/maitre.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 @@ -85,5 +88,12 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node match.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" maitre "* ]]; then + # Conseil du maître : ligne, feuille d'explication, videau, réglage ; + # captures de la recette dans build/browser-shots/maitre/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node maitre.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/maitre.mjs b/tests/browser/maitre.mjs new file mode 100644 index 0000000..74aa0ab --- /dev/null +++ b/tests/browser/maitre.mjs @@ -0,0 +1,297 @@ +// maitre.mjs -- le conseil du maître (étape 1d) joué par l'interface, dans +// un vrai navigateur sans écran (Playwright), sur le site servi par l'image +// de production (docs/plans/2026-11-03-conseil-du-maitre.md). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node maitre.mjs +// Lancé par tests/browser.sh. Chaque cas part d'une position connue +// (#/position/..., « Jouer à partir d'ici ») avec des dés imposés : les dés +// de l'application viennent de Math.random, remplacé ici par une file de +// valeurs choisies (window.__dice), puis par un générateur à graine. +// Vérifie la ligne du maître (bon coup, imprécision, erreur, grosse +// erreur), la feuille d'explication et son adresse, une décision de videau +// commentée, le conseil coupé dans le menu, l'absence d'erreur et de +// violation de la politique de sécurité. Captures de la recette design +// (Chromium) dans /maitre/, en 412×915 et 915×412. + +import { mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node maitre.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/maitre`; +mkdirSync(dir, { recursive: true }); +const PORTRAIT = { width: 412, height: 915 }; +const LANDSCAPE = { width: 915, height: 412 }; +const START = "4HPwATDgc/ABMA"; +// Une position de double et prise (videau au centre) : doubler y est juste. +const DOUBLE_TAKE = "6O5uAAC4uzsAAA"; + +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); + } +} + +const browser = await browserType.launch(); +const context = await browser.newContext({ + viewport: PORTRAIT, + deviceScaleFactor: 1, + locale: "fr-FR", + // Sans animation : les captures montrent les coups posés, avec leurs + // traces. + reducedMotion: "reduce", +}); +await context.addInitScript(() => { + // Les dés imposés d'abord (un dé par valeur), puis une graine fixe. + window.__dice = []; + let state = 11; + Math.random = () => { + const die = window.__dice.shift(); + if (die !== undefined) { + return (die - 0.5) / 6; + } + state = (state * 1103515245 + 12345) % 2147483648; + return state / 2147483648; + }; + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); +}); +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()}`); + } +}); + +/** Attend une condition dans la page (sans page.waitForFunction, qui passe + * par eval, interdit par la politique de sécurité du site). */ +async function until(what, fn, arg, ms = 90_000) { + for (let waited = 0; waited < ms; waited += 100) { + if (await page.evaluate(fn, arg)) { + return; + } + await sleep(100); + } + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw new Error(`attente trop longue : ${what}`); +} + +const idle = () => until("écran libre", () => !document.body.classList.contains("busy")); + +/** Une capture de la recette, dans les deux tailles (Chromium seulement). */ +async function capture(name) { + if (!full) { + return; + } + // Le bandeau « Reprendre l'ancienne » (10 s) ne gêne pas la lecture. + await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000); + for (const size of [PORTRAIT, LANDSCAPE]) { + await page.setViewportSize(size); + await sleep(300); + await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); + captures++; + } + await page.setViewportSize(PORTRAIT); + await sleep(300); +} + +/** Une nouvelle partie à partir d'une position, joueur au trait. */ +async function startFrom(position) { + await page.goto(`${base}#/position/${encodeURIComponent(position)}`); + await until("position lue", () => { + const play = document.getElementById("position-play"); + return !play.hidden && !play.disabled; + }); + await page.click("#position-play"); + await until("partie commencée", () => + location.hash === "#/" && !document.body.classList.contains("busy") && + !document.getElementById("roll").hidden); +} + +/** Lance les dés imposés et joue un coup, demi-coup par demi-coup : le pion + * de départ, puis sa case d'arrivée si le pion ne s'y est pas déjà posé. */ +async function play(dice, halfMoves) { + await page.evaluate((d) => { + window.__dice.push(...d); + }, dice); + await page.click("#roll"); + await idle(); + for (const [from, to] of halfMoves) { + await page.click(`#board .hit[data-place="p${from}"]`); + await idle(); + const land = page.locator(`#board .hit.can-land[data-place="p${to}"]`); + if ((await land.count()) > 0) { + await land.click(); + await idle(); + } + } + check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`); + await page.click("#confirm"); +} + +/** La ligne du maître telle qu'elle s'affiche. */ +async function adviceLine() { + return page.evaluate(() => { + const line = document.getElementById("advice-line"); + return { + shown: !line.hidden, + word: line.querySelector(".advice-word")?.textContent ?? "", + phrase: line.querySelector(".advice-phrase")?.textContent ?? "", + href: line.getAttribute("href"), + infoShown: !document.getElementById("info").hidden, + height: line.getBoundingClientRect().height, + chip: document.getElementById("advice-chip") !== null, + }; + }); +} + +async function waitAdvice() { + await until("ligne du maître", () => + !document.getElementById("advice-line").hidden && + !document.body.classList.contains("busy")); + return adviceLine(); +} + +// ------------------------------------------------- Bon coup, et ses voisins + +const cases = [ + { name: "1-bon-coup", dice: [3, 1], moves: [[8, 5], [6, 5]], word: "Bon coup." }, + { name: "2-imprecision", dice: [2, 1], moves: [[13, 11], [11, 10]], word: "Imprécision." }, + { name: "3-erreur", dice: [2, 1], moves: [[24, 22], [6, 5]], word: "Erreur." }, + { name: "4-grosse-erreur", dice: [3, 1], moves: [[24, 23], [13, 10]], word: "Grosse erreur." }, +]; +for (const c of cases) { + await startFrom(START); + // Rien pendant que le joueur réfléchit. + check(!(await adviceLine()).shown, `${c.name} : ligne avant le coup`); + await play(c.dice, c.moves); + const line = await waitAdvice(); + check(line.word === c.word, `${c.name} : mot « ${line.word} », attendu « ${c.word} »`); + check(line.phrase.length > 0 && !line.phrase.includes("!"), `${c.name} : phrase « ${line.phrase} »`); + check(!line.infoShown, `${c.name} : la ligne du dernier coup reste visible`); + check(line.height <= 44, `${c.name} : ligne de ${line.height} px`); + check(/^#\/explication\/\d+$/.test(line.href), `${c.name} : lien ${line.href}`); + if (c.word !== "Bon coup.") { + check(line.phrase.startsWith("J'aurais joué") || line.phrase.startsWith("Tout près"), + `${c.name} : le coup du maître n'est pas nommé : « ${line.phrase} »`); + } + await capture(c.name); + if (full) { + // En paysage, la pastille du mot mène à la même feuille. + await page.setViewportSize(LANDSCAPE); + await sleep(200); + check((await adviceLine()).chip, `${c.name} : pas de pastille en paysage`); + await page.setViewportSize(PORTRAIT); + await sleep(200); + } + log(`${c.name} : « ${line.word} ${line.phrase} »`); +} + +// La ligne s'efface à l'action suivante du joueur. +await page.click("#roll"); +await idle(); +check(!(await adviceLine()).shown, "la ligne reste après le lancer suivant"); + +// ------------------------------------------------- Feuille d'explication + +await startFrom(START); +await play([3, 1], [[24, 23], [13, 10]]); +await waitAdvice(); +await page.click("#advice-line"); +await until("feuille ouverte", () => + /^#\/explication\/\d+$/.test(location.hash) && + !document.getElementById("explain-veil").hidden && + document.getElementById("explain-board") !== null && + document.querySelector("#explain-board .board-frame") !== null); +const sheet = await page.evaluate(() => ({ + verdict: document.getElementById("explain-verdict").textContent, + title: document.getElementById("explain-title").textContent, + mine: document.getElementById("explain-mine")?.getAttribute("aria-checked"), + best: document.getElementById("explain-best")?.getAttribute("aria-checked"), + numbersOpen: document.getElementById("explain-numbers").open, +})); +check(sheet.verdict === "Grosse erreur · votre 3-1 d'ouverture", `feuille : ${JSON.stringify(sheet)}`); +check(sheet.title === "J'aurais joué 8/5 6/5", `titre : ${sheet.title}`); +check(sheet.best === "true" && sheet.mine === "false", "le coup du maître n'est pas montré d'abord"); +check(!sheet.numbersOpen, "les chiffres du moteur ne sont pas repliés"); +await capture("5-feuille-explication"); +await page.click("#explain-mine"); +await sleep(300); +await page.click("#explain-numbers summary"); +const rows = await page.evaluate(() => document.querySelector(".explain-table .strong")?.textContent); +check(rows !== undefined, "votre coup n'est pas en gras dans les chiffres"); +await capture("5b-feuille-votre-coup-chiffres"); +// L'adresse se recharge : la feuille revient, le conseil vient du journal. +await page.reload(); +await until("feuille rouverte après rechargement", () => + !document.getElementById("explain-veil").hidden && + document.getElementById("explain-title")?.textContent === "J'aurais joué 8/5 6/5"); +await page.click("#explain-done"); +await until("feuille fermée", () => + location.hash === "#/" && document.getElementById("explain-veil").hidden); +log("feuille d'explication : mot, coup du maître, votre coup, chiffres repliés, adresse rechargée"); + +// ------------------------------------------------- Décision de videau + +await startFrom(DOUBLE_TAKE); +check(!(await page.locator("#double").isHidden()), "doubler n'est pas proposé"); +await page.click("#double"); +await page.click("#double-confirm"); +const cube = await waitAdvice(); +check(cube.word === "Bonne décision." && cube.phrase === "Doubler était le bon choix.", + `videau : « ${cube.word} ${cube.phrase} »`); +await capture("6-decision-de-videau"); +log(`décision de videau : « ${cube.word} ${cube.phrase} »`); + +// ------------------------------------------------- Conseil coupé + +await page.click("#menu-button"); +await page.waitForSelector("#menu", { state: "visible" }); +check((await page.getAttribute("#menu-advice", "aria-checked")) === "true", "conseil coupé par défaut"); +await page.click("#menu-advice"); +check((await page.getAttribute("#menu-advice", "aria-checked")) === "false", "interrupteur sans effet"); +await page.click("#menu-close"); +await startFrom(START); +await play([3, 1], [[24, 23], [13, 10]]); +await idle(); +// Laisse le temps à l'analyse : la ligne ne doit pas venir. +await sleep(3000); +const off = await adviceLine(); +check(!off.shown && off.infoShown, `conseil coupé : ${JSON.stringify(off)}`); +await capture("7-conseil-desactive"); +// On le rallume : le conseil déjà prêt réapparaît. +await page.click("#menu-button"); +await page.waitForSelector("#menu", { state: "visible" }); +await page.click("#menu-advice"); +await page.click("#menu-close"); +check((await waitAdvice()).word === "Grosse erreur.", "conseil rallumé : la ligne ne revient pas"); +log("conseil coupé dans le menu : la ligne reste vide ; rallumé, elle revient"); + +const csp = await page.evaluate(() => window.__csp); +for (const violation of csp) { + problems.push(`politique de sécurité : ${violation}`); +} +await browser.close(); +if (problems.length > 0) { + for (const problem of problems) { + console.error(`[${browserName}] ${problem}`); + } + process.exit(1); +} +log(`conseil du maître : tout est conforme (${captures} captures)`);