diff --git a/app/src/ui/progression-view.ts b/app/src/ui/progression-view.ts index 87a9ea2..1495b45 100644 --- a/app/src/ui/progression-view.ts +++ b/app/src/ui/progression-view.ts @@ -203,7 +203,7 @@ export function createProgressionView(options: ProgressionViewOptions): Progress }).setAttribute("role", "status"); } - // « Créer des exercices » : la feuille du prompt (1f), secondaire. + // « Créer des exercices » : la feuille du prompt, étape 1f ; bouton secondaire. if (points.length > 0) { const actions = element(body, "div", { className: "progression-actions" }); const exercises = element(actions, "a", { diff --git a/docs/etape-1.md b/docs/etape-1.md index 211f7fd..7b738d2 100644 --- a/docs/etape-1.md +++ b/docs/etape-1.md @@ -24,7 +24,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001 | 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d | | 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e | | 1g. Accueil, tutoriel et leçons (fait) | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 livrés sans relecture préalable, sous les garde-fous du moteur (décision du 2026-11-09) | 0e.5, 1a | -| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e | +| 1h. Progression (fait) | Historique des parties et courbe du taux d'erreur | 1b, 1e | | 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 | | 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes | diff --git a/docs/plans/2026-11-03-progression.md b/docs/plans/2026-11-03-progression.md deleted file mode 100644 index 66402bf..0000000 --- a/docs/plans/2026-11-03-progression.md +++ /dev/null @@ -1,30 +0,0 @@ -# Progression (1h) - -- **Date** : 2026-11-03 -- **Étape** : 1h (`docs/etape-1.md`) -- **Statut** : prévu (après 1e) -- **Exécutant** : sous-agent general-purpose, dans un worktree - -## Objectif - -Mettre en place la progression du cadrage pour la V1 : l'historique des parties et la courbe du taux d'erreur. - -## Consignes - -1. **Écran `#/progression`.** Il est conforme à la charte et s'ajoute au tableau de l'ADR 0013. Il montre : - - la courbe du taux d'erreur par partie terminée, en SVG à la main, sans dépendance ; - - la liste des parties, reliée à leur bilan et à leur analyse (1e) ; - - un résumé en une phrase. -2. **Accueil.** Il reçoit la « ligne de progression » de la charte. -3. **Données.** Elles proviennent des analyses enregistrées : rien n'est recalculé si l'analyse existe. Une partie non analysée est analysée en arrière-plan. -4. **Tests.** - - Scénarios Gherkin. - - `make lint`, `make test`, `make test-browser` avec trois parties jouées, et captures. - -## Critères de réussite - -Après trois parties, la courbe a trois points égaux aux taux d'erreur des bilans. Les trois commandes passent. - -## Résultat - -À remplir à la clôture. diff --git a/docs/scenarios/suivre-ma-progression.feature b/docs/scenarios/suivre-ma-progression.feature index 76c5996..6d2eaa7 100644 --- a/docs/scenarios/suivre-ma-progression.feature +++ b/docs/scenarios/suivre-ma-progression.feature @@ -45,8 +45,8 @@ Fonctionnalité: Suivre ma progression Scénario: Comment créer des exercices sur mes erreurs ? « Créer des exercices » prépare une question pour une IA : vos erreurs - les plus coûteuses sur vos dernières parties, avec leur coût d'après le - moteur, ce qui y revient (pions frappés, points faits par le coup du + les plus coûteuses sur vos dernières parties, avec leur coût selon le + maître, ce qui y revient (pions frappés, points faits par le coup du maître et pas par le vôtre), et la position de la plus coûteuse. Votre niveau n'y figure qu'à partir de 20 décisions analysées en tout. diff --git a/tests/browser.sh b/tests/browser.sh index b1e7d51..802aee3 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -20,10 +20,13 @@ # cadre, flèches du tutoriel, en thème clair et sombre), captures dans # build/browser-shots/retours-joueurs/. Les leçons (tests/browser/lecons.mjs : # carte de l'accueil, liste, explication, exercice refusé puis réussi, -# progression gardée), captures dans build/browser-shots/lecons/. +# progression gardée), captures dans build/browser-shots/lecons/. La +# progression (tests/browser/progression.mjs : cas vide, trois parties, courbe +# égale aux taux des bilans, ligne de l'accueil, « Créer des exercices »), +# captures dans build/browser-shots/progression/. # # BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan, -# prompts, nouvelle-partie, retours-joueurs, lecons) restreignent le test. +# prompts, nouvelle-partie, retours-joueurs, lecons, progression) 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 @@ -36,7 +39,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 maitre bilan prompts nouvelle-partie retours-joueurs lecons}" +scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs lecons progression}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -75,7 +78,7 @@ rm -rf build/browser-shots mkdir -p build/browser-shots cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \ tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \ - tests/browser/retours-joueurs.mjs tests/browser/lecons.mjs "$tools/" + tests/browser/retours-joueurs.mjs tests/browser/lecons.mjs tests/browser/progression.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 @@ -145,5 +148,12 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node lecons.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" progression "* ]]; then + # La progression : courbe du taux d'erreur, parties, exercices ; + # captures dans build/browser-shots/progression/. + 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 progression.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/progression.mjs b/tests/browser/progression.mjs new file mode 100644 index 0000000..44eeacd --- /dev/null +++ b/tests/browser/progression.mjs @@ -0,0 +1,344 @@ +// progression.mjs -- l'écran de progression (étape 1h) dans un vrai +// navigateur sans écran (Playwright), sur le site servi par l'image de +// production (charte, ecrans-etape-1.md, « 1h. Progression »). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node progression.mjs +// Lancé par tests/browser.sh. L'écran sans partie terminée, puis trois +// fins de course courtes jouées par l'interface, depuis un lien de +// position, avec des dés imposés (comme bilan.mjs) : le joueur a 6-1 et +// deux pions, sur 6 et 1 ; il joue 6/5/off (grosse erreur, l'ordinateur +// double et le joueur passe), puis 6/off 1/off (gagne), puis de nouveau +// 6/5/off. Vérifie que la ligne de l'accueil mène à #/progression, que la +// courbe a trois points égaux, dans l'ordre, aux taux d'erreur des trois +// bilans, la phrase du maître, la liste reliée aux bilans et aux analyses, +// et « Créer des exercices » (feuille du prompt). Rien d'interactif sous +// une barre d'action, aucune erreur ni violation de la politique de +// sécurité. Captures (Chromium) dans /progression/, +// en 412×915 et 915×412. + +import { mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; +import { hiddenUnderActionBar, tallButtons } from "./recette.mjs"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node progression.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/progression`; +mkdirSync(dir, { recursive: true }); +const PORTRAIT = { width: 412, height: 915 }; +const LANDSCAPE = { width: 915, height: 412 }; +const SIZES = [PORTRAIT, LANDSCAPE]; +// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1, +// un sur 2 (positions de gnubg, chacun vu de lui-même). +const BEAROFF = "BQAACAIAAAAAAA"; + +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", + reducedMotion: "reduce", +}); +await context.addInitScript(() => { + // Les dés imposés d'abord (un dé par valeur), puis une graine fixe. + window.__dice = []; + let state = 5; + 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 = 120_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), + * après la règle commune ; settle replace l'écran après le changement de + * taille. */ +async function capture(name, settle = async () => {}) { + if (!full) { + return; + } + for (const size of SIZES) { + await page.setViewportSize(size); + await sleep(300); + await settle(); + const hidden = await hiddenUnderActionBar(page); + check( + hidden.length === 0, + `${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`, + ); + const tall = await tallButtons(page); + check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.join(", ")}`); + await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png`, fullPage: true }); + 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, + ); +} + +const place = (point) => (point === 0 ? "off-player" : `p${point}`); + +/** Lance les dés imposés et joue un coup, demi-coup par demi-coup. */ +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="${place(from)}"]`); + await idle(); + const land = page.locator(`#board .hit.can-land[data-place="${place(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"); +} + +/** Le bilan de la partie qui vient de finir : son identifiant et son taux + * d'erreur (celui de la jauge « Votre jeu »). */ +async function summaryRate() { + await until( + "bilan prêt", + () => + /^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) && + document.getElementById("summary")?.dataset.ready === "true", + ); + return page.evaluate(() => ({ + id: location.hash.split("/")[2], + rate: document.getElementById("gauge-play").dataset.errorRate, + })); +} + +/** Une partie : le joueur joue 6/5/off (puis passe le double) ou 6/off + * 1/off. */ +async function oneGame(mistake) { + await startFrom(BEAROFF); + if (mistake) { + await play([6, 1], [[6, 5], [5, 0]]); + await until("double de l'ordinateur", () => !document.getElementById("drop").hidden); + await page.click("#drop"); + } else { + await play([6, 1], [[6, 0], [1, 0]]); + } + return summaryRate(); +} + +/** L'écran de progression lu, prêt (toutes ses parties analysées). */ +async function progressionReady() { + await until( + "progression prête", + () => + location.hash === "#/progression" && + !document.getElementById("progression").hidden && + document.getElementById("progression").dataset.ready === "true", + null, + 300_000, + ); +} + +// --------------------------------------------------- Aucune partie terminée + +await page.goto(`${base}#/progression`); +await progressionReady(); +const empty = await page.evaluate(() => ({ + summary: document.getElementById("progression-summary").textContent, + start: document.getElementById("progression-new-game")?.getAttribute("href") ?? null, + curve: document.getElementById("progression-curve") !== null, + list: document.getElementById("progression-list") !== null, +})); +check( + empty.summary.startsWith("Aucune partie terminée pour l'instant") && + empty.start === "#/nouvelle-partie" && + !empty.curve && + !empty.list, + `cas vide : ${JSON.stringify(empty)}`, +); +await capture("1-vide"); +log("progression sans partie terminée : phrase et « Nouvelle partie »"); + +// ----------------------------------------------------- Trois parties jouées + +const games = []; +for (const mistake of [true, false, true]) { + games.push(await oneGame(mistake)); +} +check( + games.every((g) => g.id && g.rate !== undefined && !Number.isNaN(Number(g.rate))), + `bilans : ${JSON.stringify(games)}`, +); +check(Number(games[0].rate) > 0 && Number(games[1].rate) === 0, `taux des bilans : ${JSON.stringify(games)}`); +log(`trois parties jouées, taux des bilans : ${games.map((g) => g.rate).join(", ")}`); + +// La ligne de l'accueil mène à la progression. +await page.goto(`${base}#/accueil`); +await until("accueil", () => { + const line = document.getElementById("home-progress"); + return ( + !document.getElementById("home").hidden && + line !== null && + !line.hidden && + line.textContent.includes("3 parties jouées") + ); +}); +const line = await page.evaluate(() => { + const a = document.getElementById("home-progress"); + return { tag: a.tagName, href: a.getAttribute("href"), text: a.textContent }; +}); +check(line.tag === "A" && line.href === "#/progression", `ligne de l'accueil : ${JSON.stringify(line)}`); +await capture("3-accueil-ligne", async () => { + await page.locator("#home-progress").scrollIntoViewIfNeeded(); +}); +log(`accueil : « ${line.text} » mène à #/progression`); +await page.click("#home-progress"); +await progressionReady(); + +// La courbe : trois points, dans l'ordre des parties, égaux aux taux des +// bilans. +const shown = await page.evaluate(() => ({ + summary: document.getElementById("progression-summary").textContent, + dots: [...document.querySelectorAll("#progression-curve .progression-dot")].map((c) => ({ + id: c.getAttribute("data-id"), + rate: c.getAttribute("data-rate"), + last: c.classList.contains("last"), + })), + items: [...document.querySelectorAll("#progression-list .progression-item")].map((li) => ({ + id: li.dataset.id, + summary: li.querySelector(".progression-game").getAttribute("href"), + analysis: li.querySelector(".progression-analysis").getAttribute("href"), + rate: li.querySelector(".progression-rate")?.dataset.rate, + })), + glossary: document.getElementById("progression-glossary")?.getAttribute("href"), + exercises: document.getElementById("progression-exercises")?.getAttribute("href"), +})); +check(shown.dots.length === 3, `courbe : ${shown.dots.length} points`); +for (const [i, game] of games.entries()) { + const dot = shown.dots[i]; + check(dot.id === game.id && dot.rate === game.rate, `point ${i + 1} : ${JSON.stringify(dot)} contre ${JSON.stringify(game)}`); +} +check(shown.dots[2].last && !shown.dots[0].last, "le dernier point n'est pas mis en avant"); +check(shown.summary.startsWith("Sur vos 3 dernières parties"), `phrase : ${shown.summary}`); +check(!shown.summary.includes("!"), `phrase : ${shown.summary}`); +check(shown.items.length === 3 && shown.items[0].id === games[2].id, `liste : ${JSON.stringify(shown.items)}`); +for (const item of shown.items) { + const game = games.find((g) => g.id === item.id); + check( + item.summary === `#/parties/${item.id}/bilan` && + item.analysis === `#/parties/${item.id}/analyse` && + item.rate === game.rate, + `ligne : ${JSON.stringify(item)}`, + ); +} +check(shown.glossary === "#/glossaire/taux-d-erreur", `lien du glossaire : ${shown.glossary}`); +check(shown.exercises === "#/progression/ia", `exercices : ${shown.exercises}`); +await capture("2-trois-parties"); +log(`courbe : ${shown.dots.map((d) => d.rate).join(", ")} ; « ${shown.summary} »`); + +// Une ligne ouvre le bilan ; le fermer ramène à la progression. +await page.click(`#progression-list .progression-item[data-id="${games[0].id}"] .progression-game`); +await until( + "bilan depuis la progression", + ([id]) => + location.hash === `#/parties/${id}/bilan` && + document.getElementById("summary")?.dataset.ready === "true", + [games[0].id], +); +await page.click("#summary-close"); +await progressionReady(); +log("une ligne ouvre le bilan, la croix ramène à la progression"); + +// « Créer des exercices » : la feuille du prompt. +await page.click("#progression-exercises"); +await until( + "prompt des exercices", + () => + location.hash === "#/progression/ia" && + document.getElementById("prompt-page")?.dataset.ready === "true", +); +const prompt = await page.textContent("#prompt-text"); +check(prompt.includes("Voici mes 3 dernières parties"), `prompt : ${prompt}`); +check(prompt.includes("Ce qui revient dans ces erreurs"), `prompt : ${prompt}`); +check(prompt.includes("Je débute au backgammon."), `prompt : ${prompt}`); +check(!/[{}]/.test(prompt), `repère non rempli : ${prompt}`); +await page.keyboard.press("Escape"); +await until("retour à la progression", () => location.hash === "#/progression"); +log("« Créer des exercices » : prompt avec les erreurs et leurs thèmes"); + +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(`progression : tout est conforme (${captures} captures)`);