Conseil du maître : test dans le navigateur, ADR 0007 et 0013 amendés
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5d6cbec89b
commit
cd5f70e548
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+13
-3
@@ -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/<nom>.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"
|
||||
|
||||
@@ -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 <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// 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 <dossier des captures>/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 <adresse> <chromium|firefox> <dossier des captures>");
|
||||
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)`);
|
||||
Reference in New Issue
Block a user