Recette 1e : titre du bilan sur l'issue, notation du moteur partout, boutons de 48 px en paysage, filtre en accent-soft, longue partie ; tests navigateur adaptés au bilan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 14:37:44 +01:00
co-authored by Claude Opus 5.5
parent ced004edb0
commit 663ef35b51
11 changed files with 263 additions and 61 deletions
+102 -4
View File
@@ -22,7 +22,7 @@
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
import { hiddenUnderActionBar, tallButtons } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -116,6 +116,8 @@ async function capture(name) {
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` });
captures++;
}
@@ -172,8 +174,19 @@ async function play(dice, halfMoves) {
await startFrom(BEAROFF);
await play([6, 1], [[6, 5], [5, 0]]);
// L'ordinateur, sûr de gagner, double : le joueur passe.
// L'ordinateur, sûr de gagner, double. La ligne du maître commente le
// coup : sa feuille d'explication écrit le coup joué dans la notation du
// moteur, comme le bilan et l'analyse plus bas.
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await until("ligne du maître", () => !document.getElementById("advice-line").hidden);
await page.click("#advice-line");
await until("feuille du maître", () => document.getElementById("explain-mine") !== null);
await page.click("#explain-mine");
const sheetLegend = await page.textContent("#explain-legend");
check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`);
await page.click("#explain-done");
await until("feuille fermée", () => document.getElementById("explain-veil").hidden);
// Le joueur passe.
await page.click("#drop");
// Le panneau de fin de partie, puis le bilan à sa place, à son adresse.
await until(
@@ -203,8 +216,11 @@ const summary = await page.evaluate(() => ({
}));
const id = summary.hash.split("/")[2];
check(summary.panelHidden, "le panneau de fin de partie reste sous le bilan");
check(summary.title === "Vous passez", `titre : ${summary.title}`);
check(summary.reading.startsWith("1 point pour lui."), `lecture : ${summary.reading}`);
check(summary.title === "L'ordinateur gagne la partie simple", `titre : ${summary.title}`);
check(
summary.reading.startsWith("Vous avez passé son double : 1 point pour lui. "),
`lecture : ${summary.reading}`,
);
check(summary.reading.includes("D'après mon analyse"), `lecture : ${summary.reading}`);
check(!summary.reading.includes("!"), `lecture : ${summary.reading}`);
// Le refus du double, juste ; le coup 6/5/off, grosse erreur ; le jet
@@ -309,6 +325,19 @@ const best = await page.evaluate(() => ({
move: document.getElementById("analysis-board").dataset.move,
}));
check(best.move === "best" && best.legend.includes("6/off 1/off"), `maître : ${best.legend}`);
// Une seule notation, celle du moteur : le même coup s'écrit pareil dans
// la feuille du maître, le bilan, la liste et le visualiseur.
await page.click("#analysis-mine");
const notations = await page.evaluate(() => ({
legend: document.getElementById("analysis-legend").textContent,
row: document.querySelector('#analysis-list .analysis-row[data-turn="1"] .row-move')
?.textContent,
}));
check(
notations.legend.includes(" 6/off,") && notations.row === "6/off",
`notation : visualiseur « ${notations.legend} », liste « ${notations.row} »`,
);
await page.click("#analysis-best");
await capture("5-visualiseur");
log("analyse : courbe, point touché, clavier, liste filtrée, visualiseur");
@@ -370,6 +399,75 @@ check(branches.length === 2, `branches : ${branches.join(" | ")}`);
check(branches[0].startsWith("Partie d'origine : 1 coup, finie"), `origine : ${branches[0]}`);
log(`rejouer d'ici : ${branches.join(" | ")}`);
// ------------------------------------------- Une partie plus longue
// Pour juger la courbe en usage réel (Chromium seulement) : une longue
// course (tests/reference/positions.json, race-2) jouée jusqu'au bout,
// le joueur prenant à chaque fois le premier pion jouable et sa première
// arrivée, puis son analyse complète.
if (full) {
await startFrom("cLezAQBwN2cGAA");
for (let actions = 0; ; actions++) {
check(actions < 2000, "la longue partie ne finit pas");
const what = await page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return null;
}
if (!document.getElementById("summary-veil").hidden) {
return "over";
}
if (!document.getElementById("panel-veil").hidden) {
return "panel";
}
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["take", "skip", "confirm", "roll"]) {
if (usable(id)) {
return id;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "land";
}
return document.querySelector("#board .hit.can-move") ? "move" : null;
});
if (what === "over") {
break;
}
if (what === null || what === "panel") {
await sleep(100);
continue;
}
const target = {
take: "#take",
skip: "#skip",
confirm: "#confirm",
roll: "#roll",
land: "#board .hit.can-land",
move: "#board .hit.can-move",
}[what];
await page.locator(target).first().click();
await idle();
}
await until("bilan de la longue partie", () =>
document.getElementById("summary")?.dataset.ready === "true");
await page.click("#summary-analysis");
await until("analyse complète de la longue partie", () => {
const bands = document.querySelectorAll("#analysis-curve .curve-band").length;
return (
!document.getElementById("analysis").hidden &&
bands >= 20 &&
document.querySelectorAll("#analysis-curve .curve-dot").length === bands
);
}, null, 600_000);
const turns = await page.locator("#analysis-curve .curve-band").count();
await page.evaluate(() => window.scrollTo(0, 0));
await capture("7-analyse-partie-longue");
log(`longue partie : ${turns} coups, courbe complète`);
}
const csp = await page.evaluate(() => window.__csp);
for (const violation of csp) {
problems.push(`politique de sécurité : ${violation}`);
+19 -6
View File
@@ -117,6 +117,10 @@ async function todo() {
if (!panel.hidden) {
return panel.dataset.kind;
}
// Le bilan remplace le panneau de fin de partie (étape 1e).
if (!document.getElementById("summary-veil").hidden) {
return "over";
}
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
@@ -282,12 +286,16 @@ for (;;) {
check(now.cube === 0 && !now.double, `partie Crawford : videau visible ${JSON.stringify(now)}`);
}
if (what === "over") {
const title = await page.textContent("#panel-text");
const detail = await page.textContent("#panel-detail");
const primary = await page.textContent("#panel-primary");
// Le bilan, une fois l'analyse prête, à la place du panneau.
await until("bilan prêt", () =>
document.getElementById("summary")?.dataset.ready === "true", null, 120_000);
const title = await page.textContent("#summary-title");
const reading = await page.textContent("#summary-reading");
const detail = reading.split(" D'après mon analyse")[0];
const primary = await page.textContent("#summary-primary");
log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`);
const settingsLink = await page.evaluate(() => {
const a = document.getElementById("panel-settings");
const a = document.getElementById("summary-settings");
return a.hidden ? null : { text: a.textContent, href: a.getAttribute("href") };
});
check(settingsLink?.text === "Changer les réglages" && settingsLink.href === "#/nouvelle-partie",
@@ -309,10 +317,15 @@ for (;;) {
// Après le match, « Changer les réglages » mène à l'écran des
// choix ; le fermer ramène au résultat, où « Rejouer » relance
// tout de suite un match de même longueur.
await page.click("#panel-settings");
await page.click("#summary-settings");
await until("écran « Nouvelle partie » après le match", () =>
location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden);
await page.click("#new-game-close");
// Retour au bilan ; le fermer laisse le résultat sur le plateau.
await until("retour au bilan du match", () =>
/\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true");
await page.click("#summary-close");
await until("retour au résultat du match", () => location.hash === "#/" &&
!document.getElementById("again").hidden);
check((await page.textContent("#again")) === "Rejouer",
@@ -334,7 +347,7 @@ for (;;) {
}
check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail),
`fin de partie dans un match : ${primary} ; ${detail}`);
await page.click("#panel-primary");
await page.click("#summary-primary");
games++;
await until("partie suivante", () => !document.body.classList.contains("busy"));
const next = await shownMatch();
+46 -21
View File
@@ -184,13 +184,21 @@ async function savedGameCount() {
);
}
/** Les événements d'un journal et son événement courant : la partie,
* sans les analyses du moteur qui s'y rangent en arrière-plan (conseils,
* analyse de partie, étapes 1d et 1e). */
function played(journal) {
return journal === null ? null : { events: journal.events, head: journal.head };
}
/** Le journal enregistré, une fois les enregistrements en cours terminés
* (l'application enregistre sans attendre) : deux lectures identiques. */
* (l'application enregistre sans attendre) : deux lectures identiques de
* la partie. */
async function savedJournal() {
let last = await readJournal();
let last = played(await readJournal());
for (let i = 0; i < 50; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
const next = await readJournal();
const next = played(await readJournal());
if (JSON.stringify(next) === JSON.stringify(last)) {
return next;
}
@@ -222,6 +230,10 @@ async function waitForPlayer() {
if (!panel.hidden) {
return panel.dataset.kind;
}
// Le bilan remplace le panneau de fin de partie (étape 1e).
if (!document.getElementById("summary-veil").hidden) {
return "over";
}
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
@@ -1523,13 +1535,23 @@ for (let actions = 0; ; actions++) {
// --------------------------------------------------------- Fin de partie
// Le panneau de fin de partie, puis le bilan à sa place une fois
// l'analyse prête (étape 1e, testé en détail par bilan.mjs).
await page.waitForSelector('#summary[data-ready="true"]', { timeout: 120_000 });
const journal = await savedJournal();
const result = `${await page.textContent("#panel-text")}, ${await page.textContent("#panel-detail")}`;
check((await page.textContent("#panel-overline")) === "Fin de partie" &&
/^(Vous gagnez|L'ordinateur gagne|Vous passez|L'ordinateur passe)/.test(
await page.textContent("#panel-text")) &&
/^\d+ points? pour (vous|lui)(, videau à \d+)?\.$/.test(await page.textContent("#panel-detail")),
`panneau de fin de partie : ${result}`);
/** Le titre du bilan et la phrase du résultat. */
async function summaryResult() {
const title = await page.textContent("#summary-title");
const reading = await page.textContent("#summary-reading");
const detail = /^(Vous avez passé son double : |L'ordinateur a passé votre double : )?\d+ points? pour (vous|lui)(, videau à \d+)?\./.exec(reading)?.[0] ?? reading;
return `${title}, ${detail}`;
}
const result = await summaryResult();
check((await page.textContent("#summary .overline")) === "Fin de partie" &&
/^(Vous gagnez|L'ordinateur gagne)/.test(await page.textContent("#summary-title")) &&
/^(Vous avez passé son double : |L'ordinateur a passé votre double : )?\d+ points? pour (vous|lui)(, videau à \d+)?\. /.test(
await page.textContent("#summary-reading")),
`bilan de fin de partie : ${result}`);
check((await page.textContent("#prompt")) === "", "fin de partie : la consigne répète le panneau");
log(
`partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` +
@@ -1538,11 +1560,11 @@ log(
const won = journal.events.at(-1).winner === "player";
check((await vibrations()).at(-1) === (won ? "30,50,30,50,60" : "60"),
`fin de partie : vibration ${(await vibrations()).at(-1)}`);
check((await page.textContent("#panel-secondary")) === "Revoir la partie" &&
(await page.textContent("#panel-primary")) === "Rejouer" &&
(await page.textContent("#panel-settings")) === "Changer les réglages" &&
(await page.getAttribute("#panel-settings", "href")) === "#/nouvelle-partie",
"fin de partie : panneau sans « Revoir la partie », « Rejouer » et « Changer les réglages »");
check((await page.textContent("#summary-analysis")) === "Voir l'analyse" &&
(await page.textContent("#summary-primary")) === "Rejouer" &&
(await page.textContent("#summary-settings")) === "Changer les réglages" &&
(await page.getAttribute("#summary-settings", "href")) === "#/nouvelle-partie",
"fin de partie : bilan sans « Voir l'analyse », « Rejouer » et « Changer les réglages »");
await review("6-fin-de-partie");
await page.setViewportSize({ width: 844, height: 320 });
await sleep(150);
@@ -1573,12 +1595,13 @@ log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant l
log(`vibrations demandées : ${(await vibrations()).length}`);
await shot("4-fin-de-partie");
// « Revoir la partie » ferme le panneau ; « Rejouer » reste dans la barre
// d'actions.
await page.click("#panel-secondary");
// Fermer le bilan laisse voir la partie finie, sans panneau ; « Rejouer »
// reste dans la barre d'actions.
await page.click("#summary-close");
await page.waitForSelector("#summary-veil", { state: "hidden" });
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
(await page.isVisible("#again")),
"« Revoir la partie » : panneau toujours là, ou pas de « Rejouer »");
"bilan fermé : panneau toujours là, ou pas de « Rejouer »");
step("mes parties");
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
@@ -1628,14 +1651,16 @@ step("mes parties");
}));
throw new Error(`reprise bloquée : ${JSON.stringify(state)}`);
});
await page.waitForSelector("#panel");
// La partie finie reprise : son bilan, déjà analysé, à la place du
// panneau.
await page.waitForSelector('#summary[data-ready="true"]', { timeout: 60_000 });
const resumed = await savedJournal();
check(JSON.stringify(resumed) === JSON.stringify(finished), "la première partie n'a pas été reprise");
const shown = `${await page.textContent("#panel-text")}, ${await page.textContent("#panel-detail")}`;
const shown = await summaryResult();
check(shown === result, `écran différent après la reprise : ${shown}`);
check((await page.evaluate(() => performance.getEntriesByType("navigation").length)) === loads,
"la page a été rechargée");
await page.click("#panel-secondary");
await page.click("#summary-close");
log(
`mes parties : ${listed} parties listées (${branchCount} branche(s) pour la première), ` +
`SGF exporté (${sgf.length} octets), première partie reprise sans rechargement`,
+26
View File
@@ -5,6 +5,32 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* Les boutons visibles (et les liens qui en ont l'allure) plus hauts que
* 56 px : un bouton fait 48 px, il ne s'étire pas. Renvoie la liste des
* boutons trop hauts, décrits (vide si tout va bien).
*/
export async function tallButtons(target) {
return target.evaluate(() => {
const found = [];
for (const el of document.querySelectorAll("button, a.button")) {
if (el.closest("[hidden]")) {
continue;
}
const style = getComputedStyle(el);
const r = el.getBoundingClientRect();
if (style.display === "none" || style.visibility === "hidden" || r.width === 0) {
continue;
}
if (r.height > 56) {
const name = el.id ? `#${el.id}` : `« ${el.textContent.trim().slice(0, 30)} »`;
found.push(`${name} (${Math.round(r.height)} px)`);
}
}
return found;
});
}
/**
* Les éléments interactifs visibles cachés, même en partie, sous une barre
* d'action collée en bas de l'écran (position: sticky, ancrée par bottom) :