Recette design du match : Crawford en paysage, « Nouvelle partie » en deux colonnes, « Rejouer »

- Paysage : pendant la partie Crawford, la consigne de la bande centrale
  commence par « Partie Crawford · » ; la police se resserre si besoin.
- Écran « Nouvelle partie » en paysage : deux colonnes au-dessus de la
  barre d'action (type de partie et longueur à gauche, niveau et conseil
  du maître à droite), tout tient en 915x412 et en 844x320.
- Fin de partie : « Rejouer » relance tout de suite une partie aux mêmes
  réglages (un match de même longueur après un match), « Partie suivante »
  dans un match qui continue, lien « Changer les réglages ».
- Recette automatique : aucun élément interactif visible sous une barre
  d'action collée, vérifié sur chaque écran capturé (tests/browser/recette.mjs).
- Scénario « Comment rejouer avec les mêmes réglages ? ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 05:03:59 +01:00
co-authored by Claude Opus 5.5
parent 67bd207337
commit 74693c4277
16 changed files with 484 additions and 51 deletions
+12 -1
View File
@@ -23,7 +23,7 @@ import {
migrateJournal,
type NewGameEvent,
} from "./events.js";
import { type Match, nextGameOf } from "./match.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
import { otherSide, type Side } from "./side.js";
@@ -166,6 +166,17 @@ export class Game {
return match === null ? null : { level: state.level, match };
}
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
* au même niveau ; pour un match, un nouveau match de même longueur,
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
* la position de départ. */
get rematch(): NewGame {
const state = this.state;
return state.match === null
? { level: state.level }
: { level: state.level, match: firstGameOf(state.match.length) };
}
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending.map((p) => p.halfMove);
+28 -11
View File
@@ -245,6 +245,7 @@ export function startApp(options: AppOptions): void {
panelVeil: byId("panel-veil"),
panelText: byId("panel-text"),
panelPrimary: byId<HTMLButtonElement>("panel-primary"),
panelSettings: byId<HTMLAnchorElement>("panel-settings"),
panelSecondary: byId<HTMLButtonElement>("panel-secondary"),
banner: byId("banner"),
bannerText: byId("banner-text"),
@@ -318,7 +319,7 @@ export function startApp(options: AppOptions): void {
ui.confirm.textContent = TEXTS.confirm;
label(ui.cancel, TEXTS.cancel, "undo");
ui.skip.textContent = TEXTS.skip;
ui.again.textContent = TEXTS.newGame;
ui.again.textContent = MATCH_TEXTS.replay;
ui.moveHint.textContent = TEXTS.moveHint;
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
@@ -536,7 +537,11 @@ export function startApp(options: AppOptions): void {
diceAction: action,
canDouble: !busy && canDouble,
band:
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
landscape && !panelShown
? text === null
? TEXTS.gameOver
: bandText(text.short, state.match?.crawford === true)
: null,
controls: landscape ? controls() : null,
});
}
@@ -730,6 +735,7 @@ export function startApp(options: AppOptions): void {
detail.textContent = end.detail;
ui.panelSecondary.textContent = TEXTS.reviewGame;
ui.panelPrimary.textContent = afterGameLabel(g);
ui.panelSettings.hidden = false;
ui.panelVeil.dataset.kind = "over";
} else if (ask) {
overline.textContent = "";
@@ -737,6 +743,7 @@ export function startApp(options: AppOptions): void {
detail.textContent = "";
ui.panelSecondary.textContent = TEXTS.drop;
ui.panelPrimary.textContent = TEXTS.take;
ui.panelSettings.hidden = true;
ui.panelVeil.dataset.kind = "doubled";
}
ui.panelPrimary.disabled = busy;
@@ -1019,14 +1026,11 @@ export function startApp(options: AppOptions): void {
}
/** Après une partie finie : la partie suivante du match s'il continue,
* sinon l'écran « Nouvelle partie ». */
* sinon « Rejouer », une nouvelle partie aux mêmes réglages (un match de
* même longueur après un match), tout de suite. */
function afterGame(): void {
const next = game?.nextGame ?? null;
if (next !== null) {
newGame(next);
} else {
closeOverlays();
navigation.go(routeHash({ screen: "newGame" }));
if (game !== null) {
newGame(game.nextGame ?? game.rematch);
}
}
@@ -1304,6 +1308,13 @@ export function startApp(options: AppOptions): void {
afterGame();
}
};
// « Changer les réglages » : un lien vers l'écran « Nouvelle partie » ;
// le panneau se ferme, le résultat reste sur l'écran de jeu.
ui.panelSettings.textContent = MATCH_TEXTS.changeSettings;
ui.panelSettings.addEventListener("click", () => {
panelDismissed = true;
render();
});
ui.panelSecondary.onclick = () => {
if (ui.panelVeil.dataset.kind === "doubled") {
actions.drop();
@@ -2153,10 +2164,16 @@ function matchScore(state: GameState): MatchScore | null {
return { length: state.match.length, computer: score.computer, player: score.player };
}
/** Paysage : la consigne de la bande centrale ; pendant la partie
* Crawford, elle le dit d'abord (la ligne du match n'y est pas). */
function bandText(prompt: string, crawford: boolean): string {
return crawford && prompt !== "" ? MATCH_TEXTS.crawfordPrompt(prompt) : prompt;
}
/** Le bouton qui suit une partie finie : « Partie suivante » dans un match
* qui continue, sinon « Nouvelle partie » (l'écran des choix). */
* qui continue, sinon « Rejouer » (mêmes réglages). */
function afterGameLabel(game: Game): string {
return game.nextGame === null ? TEXTS.newGame : MATCH_TEXTS.nextGame;
return game.nextGame === null ? MATCH_TEXTS.replay : MATCH_TEXTS.nextGame;
}
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
+7 -2
View File
@@ -502,6 +502,11 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
const width = 6 * g.pointWidth - 24;
const height = Math.max(44, g.bandFont + 20);
const x = g.x0 + 12;
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
// la police se resserre, sans descendre sous 13 px (charte). Largeur
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
// 0,45 em.
const fontSize = Math.max(13, Math.min(g.bandFont, (width - 24) / (0.45 * text.length)));
add(svg, "rect", {
class: "band-pill",
x,
@@ -517,8 +522,8 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
class: "band-text",
id: "band-text",
x: x + width / 2,
y: middle + g.bandFont * 0.35,
"font-size": g.bandFont,
y: middle + fontSize * 0.35,
"font-size": fontSize,
},
text,
);
+7 -1
View File
@@ -64,6 +64,8 @@ export const MATCH_TEXTS = {
/** « Match en 5 points », et « · partie Crawford ». */
matchLine: (length: number, crawford: boolean) =>
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
/** Ce que lit le lecteur d'écran. */
scoreSpoken: (length: number, computer: number, player: number) =>
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
@@ -72,7 +74,11 @@ export const MATCH_TEXTS = {
playerScore: (n: number) => `Votre score : ${n}`,
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
// Fin d'une partie de match
// Fin d'une partie
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
* après un match). */
replay: "Rejouer",
changeSettings: "Changer les réglages",
nextGame: "Partie suivante",
nextGameStarted: "Partie suivante du match.",
matchWonTitle: (winner: "player" | "computer") =>
+22 -9
View File
@@ -87,10 +87,14 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
closeLink.append(icon("close"));
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
const body = element(section, "div", { className: "new-game-body" });
const kindColumn = element(body, "div", { className: "new-game-column" });
const levelColumn = element(body, "div", { className: "new-game-column" });
// 1. Type de partie.
const kinds = element(body, "div", { className: "segmented" });
const kinds = element(kindColumn, "div", { className: "segmented" });
kinds.setAttribute("role", "radiogroup");
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
@@ -98,7 +102,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
free.addEventListener("click", () => options.change({ gameKind: "free" }));
match.addEventListener("click", () => options.change({ gameKind: "match" }));
const matchOptions = element(body, "div", {
const matchOptions = element(kindColumn, "div", {
id: "new-game-match-options",
className: "new-game-options",
});
@@ -119,7 +123,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
MATCH_TEXTS.crawfordLink,
"regle-de-crawford",
);
const freeOptions = element(body, "div", {
const freeOptions = element(kindColumn, "div", {
id: "new-game-free-options",
className: "new-game-options",
});
@@ -132,8 +136,8 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
);
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
element(body, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
const bars = element(body, "div", { className: "level-bars" });
element(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
const bars = element(levelColumn, "div", { className: "level-bars" });
bars.setAttribute("role", "radiogroup");
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
@@ -144,12 +148,21 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
button.addEventListener("click", () => options.change({ level }));
return button;
});
const levelName = element(body, "p", { id: "new-game-level-name", className: "level-name" });
const levelText = element(body, "p", { id: "new-game-level-text", className: "level-text" });
const levelName = element(levelColumn, "p", {
id: "new-game-level-name",
className: "level-name",
});
const levelText = element(levelColumn, "p", {
id: "new-game-level-text",
className: "level-text",
});
// 3. Pendant la partie.
element(body, "h3", { className: "overline", text: MATCH_TEXTS.duringGame });
const advice = element(body, "button", { id: "new-game-advice", className: "item" });
element(levelColumn, "h3", {
className: "overline during-game",
text: MATCH_TEXTS.duringGame,
});
const advice = element(levelColumn, "button", { id: "new-game-advice", className: "item" });
advice.type = "button";
advice.setAttribute("role", "switch");
const adviceLabel = element(advice, "span", { className: "item-label" });
+11
View File
@@ -505,6 +505,12 @@ test("une partie de match finie : le moteur dit le score, la fin du match et la
crawford: false,
});
assert.equal(crawford.nextGame, null);
// « Rejouer » après le match : un nouveau match de même longueur, à 0 à
// 0, au même niveau.
assert.deepEqual(crawford.rematch, {
level: "advanced",
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
});
});
test("passer un double dans un match : le moteur compte le match", async () => {
@@ -537,6 +543,11 @@ test("une partie libre n'a ni match ni partie suivante", () => {
assert.equal(game.nextGame, null);
});
test("« Rejouer » une partie libre : une partie libre au même niveau, depuis le départ", () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]), level: "0ply" });
assert.deepEqual(game.rematch, { level: "0ply" });
});
test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => {
// Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted
// sans level ni match, un coup joué, une branche.
+59
View File
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js";
import { COMPUTER_LEVELS, ENGINE_LEVELS } from "../../src/domain/game/level.js";
import { firstGameOf, type Match } from "../../src/domain/game/match.js";
import { START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
@@ -17,6 +18,7 @@ import { createGameLibrary } from "../../src/storage/game-library.js";
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
import { memoryStorage } from "../../src/storage/open-library.js";
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
import { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playToTheEnd, seededDice } from "../support/play.js";
@@ -167,6 +169,63 @@ Then("la partie Crawford s'est jouée sans videau", () => {
}
});
// ------------------------------------------------------------- Rejouer
/** Le niveau de gnubg d'un niveau nommé comme à l'écran (« Avancé »). */
function engineLevelNamed(name: string) {
const level = COMPUTER_LEVELS.find((l) => MATCH_TEXTS.levels[l].name === name);
assert.ok(level !== undefined, `niveau inconnu : ${name}`);
return ENGINE_LEVELS[level];
}
Given("une partie libre contre un ordinateur {word} vient de se terminer", async (name: string) => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
level: engineLevelNamed(name),
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
assert.equal(game.state.phase, "over");
assert.equal(game.nextGame, null);
});
Given(
"un match en {int} points contre un ordinateur {word} vient de se terminer",
async (n: number, name: string) => {
// Le joueur mène 1 à 0 et gagne un gammon : 3 points, le match est
// fini.
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
level: engineLevelNamed(name),
match: { length: n, score: { player: n - 2, computer: 0 }, crawford: false },
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
assert.equal(game.state.matchAfter?.over, true);
assert.equal(game.nextGame, null);
},
);
When("vous rejouez", () => {
game = Game.start(engine, { dice: fixedDice([3, 1]), ...game.rematch });
});
Then("une partie libre commence, contre un ordinateur {word}", (name: string) => {
assert.equal(game.state.phase, "moving");
assert.equal(game.state.position, START_POSITION);
assert.equal(game.state.match, null);
assert.equal(game.state.level, engineLevelNamed(name));
});
Then(
"un match en {int} points commence, à 0 à 0, contre un ordinateur {word}",
(n: number, name: string) => {
assert.equal(game.state.position, START_POSITION);
assert.deepEqual(game.state.match, firstGameOf(n));
assert.equal(game.state.level, engineLevelNamed(name));
},
);
// ------------------------------------------------------- La partie Crawford
let crawfordGame: Game;
+2 -1
View File
@@ -11,6 +11,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
- [ ] Une seule action principale (brique) visible à la fois.
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
- [ ] Aucun élément interactif visible caché, même en partie, sous une barre d'action collée en bas, sur aucun écran (vérifié sur chaque capture par `tests/browser/recette.mjs`).
- [ ] Textes à lire : 13 px au moins ; numéros des flèches : 12 px au moins.
- [ ] Thème sombre : aucune couleur codée en dur hors `tokens` ; le cadre se distingue du fond.
- [ ] Anneau de focus `focus` visible au clavier sur chaque élément interactif.
@@ -34,7 +35,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
- [ ] Comptes en pastilles aux deux bouts de la barre, 13 px, couleur du camp ; ils ne chevauchent pas les pions sur la barre.
- [ ] Consigne et dés dans la bande centrale, sans chevaucher un pion.
- [ ] Rotation portrait ↔ paysage en cours de partie : la position, la sélection et les dés sont conservés.
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Nouvelle partie ».
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Rejouer » (« Partie suivante » dans un match qui continue), lien « Changer les réglages » dessous.
## Comportement
+18 -3
View File
@@ -1,8 +1,8 @@
# language: fr
Fonctionnalité: Choisir sa partie et jouer un match
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et la fin
d'une partie. Vous y choisissez une partie libre ou un match en 3, 5, 7
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
coups. Vos choix sont gardés pour la prochaine fois.
@@ -25,7 +25,8 @@ Fonctionnalité: Choisir sa partie et jouer un match
gagnant (le double pour un gammon, le triple pour un backgammon, fois la
valeur du videau), et le premier qui atteint le nombre de points du
match le gagne. À la fin de chaque partie, « Partie suivante » lance la
suivante, avec le score du match.
suivante, avec le score du match. Le match fini, « Rejouer » en commence
un autre de même longueur.
Étant donné que vous commencez un match en 3 points contre un ordinateur Débutant
Quand le match se joue jusqu'au bout, partie après partie
@@ -33,6 +34,20 @@ Fonctionnalité: Choisir sa partie et jouer un match
Et chaque partie commence avec le score laissé par la précédente
Et la partie Crawford s'est jouée sans videau
Scénario: Comment rejouer avec les mêmes réglages ?
À la fin d'une partie, « Rejouer » en commence tout de suite une autre,
avec les mêmes réglages : le même niveau, et après un match, un nouveau
match de même longueur, à 0 à 0. Tant qu'un match n'est pas fini, c'est
« Partie suivante » qui le continue. Pour changer de niveau ou de type
de partie, touchez « Changer les réglages », sous les boutons.
Étant donné qu'une partie libre contre un ordinateur Expert vient de se terminer
Quand vous rejouez
Alors une partie libre commence, contre un ordinateur Expert
Étant donné qu'un match en 3 points contre un ordinateur Avancé vient de se terminer
Quand vous rejouez
Alors un match en 3 points commence, à 0 à 0, contre un ordinateur Avancé
Scénario: Qu'est-ce que la partie Crawford ?
Dans un match, quand un joueur arrive à un point de la victoire, la
partie suivante se joue sans videau : personne ne peut doubler. C'est la
+1 -1
View File
@@ -59,7 +59,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/recette.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
+3
View File
@@ -16,6 +16,7 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -114,6 +115,8 @@ async function capture(page, name, settle = async () => {}, sizes = SIZES) {
await page.setViewportSize({ width, height });
await sleep(250);
await settle();
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
captures++;
}
+92 -12
View File
@@ -13,10 +13,13 @@
// joueur joue toujours le premier pion jouable vers sa première case
// d'arrivée, prend les doubles et ne double jamais. Captures de la recette
// design (Chromium) dans <dossier des captures>/match/ : écran « Nouvelle
// partie », score du match et partie Crawford, en 412×915 et 915×412.
// partie » (aussi en 844×320), score du match et partie Crawford, en
// 412×915 et 915×412. Sur chaque capture, rien d'interactif ne doit être
// caché sous la barre d'action collée (recette.mjs).
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -29,6 +32,7 @@ const dir = `${shots}/match`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
const SMALL_LANDSCAPE = { width: 844, height: 320 };
// La graine des dés : un match qui passe par une partie Crawford.
const SEED = 7;
@@ -83,14 +87,18 @@ async function until(what, fn, arg, ms = 60_000) {
throw new Error(`attente trop longue : ${what}`);
}
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
async function capture(name) {
/** Une capture de la recette, dans chaque taille (Chromium seulement),
* après la règle commune : rien d'interactif sous la barre d'action. */
async function capture(name, sizes = [PORTRAIT, LANDSCAPE]) {
if (!full) {
return;
}
for (const size of [PORTRAIT, LANDSCAPE]) {
for (const size of sizes) {
await page.setViewportSize(size);
await sleep(200);
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0,
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
@@ -189,7 +197,45 @@ check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire
"lien vers la règle de Crawford");
check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"),
"description du niveau Débutant");
await capture("1-nouvelle-partie");
// Paysage : deux colonnes au-dessus de la barre d'action, tout tient sans
// défiler, jusqu'en 844×320 ; le type de partie à gauche, le niveau et le
// conseil du maître à droite.
for (const size of [LANDSCAPE, SMALL_LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(200);
const where = `${size.width}x${size.height}`;
const layout = await page.evaluate(() => {
const box = (selector) => {
const r = document.querySelector(selector).getBoundingClientRect();
return { top: r.top, bottom: r.bottom, left: r.left, right: r.right };
};
return {
scroll: document.scrollingElement.scrollHeight,
height: innerHeight,
bar: box(".new-game-actions"),
kinds: box(".segmented"),
lengths: box(".lengths"),
rule: box("#new-game-crawford"),
bars: box(".level-bars"),
name: box("#new-game-level-name"),
text: box("#new-game-level-text"),
advice: box("#new-game-advice"),
};
});
check(layout.scroll <= layout.height + 1,
`${where} : l'écran « Nouvelle partie » défile (${layout.scroll} px pour ${layout.height})`);
for (const part of ["kinds", "lengths", "rule", "bars", "name", "text", "advice"]) {
check(layout[part].top >= 0 && layout[part].bottom <= layout.bar.top + 1,
`${where} : ${part} n'est pas au-dessus de la barre d'action : ${JSON.stringify(layout)}`);
}
for (const part of ["bars", "name", "text", "advice"]) {
check(layout[part].left >= layout.kinds.right,
`${where} : ${part} n'est pas dans la colonne de droite : ${JSON.stringify(layout)}`);
}
}
await page.setViewportSize(PORTRAIT);
await sleep(200);
await capture("1-nouvelle-partie", [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE]);
// Fermer ne change rien : on revient à la partie, et les choix sont gardés.
const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings"));
await page.click("#new-game-close");
@@ -240,7 +286,13 @@ for (;;) {
const detail = await page.textContent("#panel-detail");
const primary = await page.textContent("#panel-primary");
log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`);
if (primary === "Nouvelle partie") {
const settingsLink = await page.evaluate(() => {
const a = document.getElementById("panel-settings");
return a.hidden ? null : { text: a.textContent, href: a.getAttribute("href") };
});
check(settingsLink?.text === "Changer les réglages" && settingsLink.href === "#/nouvelle-partie",
`lien « Changer les réglages » : ${JSON.stringify(settingsLink)}`);
if (primary === "Rejouer") {
check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"),
`fin du match : ${title} ; ${detail}`);
const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? [];
@@ -254,20 +306,30 @@ for (;;) {
`score en paysage : « ${box} »`);
await page.setViewportSize(PORTRAIT);
await sleep(200);
// Après le match, « Nouvelle partie » mène à l'écran des choix.
await page.click("#panel-primary");
// 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 until("écran « Nouvelle partie » après le match", () =>
location.hash === "#/nouvelle-partie");
location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden);
await page.click("#new-game-close");
await until("retour au résultat du match", () => location.hash === "#/" &&
!document.getElementById("again").hidden);
check((await page.textContent("#again")) === "Rejouer",
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
await page.click("#again");
await until("match rejoué", () => location.hash === "#/" &&
!document.body.classList.contains("busy"));
const again = await shownMatch();
check(again.line === "Match en 3 points" && again.computer === "0" && again.player === "0",
`« Rejouer » après le match : ${JSON.stringify(again)}`);
if (crawfordGames > 0 && scoreShot) {
break;
}
check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`);
matches++;
games = 1;
await page.click("#new-game-start");
await until("match suivant", () => location.hash === "#/" &&
!document.body.classList.contains("busy"));
continue;
}
check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail),
@@ -281,6 +343,24 @@ for (;;) {
check(next.cube === 0, "partie Crawford : le videau est dessiné");
if (!crawfordShot) {
crawfordShot = true;
check(next.line === "Match en 3 points · partie Crawford",
`partie Crawford, portrait : « ${next.line} »`);
await page.setViewportSize(LANDSCAPE);
await sleep(200);
const band = await page.evaluate(() => {
const text = document.getElementById("band-text");
const pill = document.querySelector("#board .band-pill");
return {
text: text?.textContent ?? "",
fits: text !== null && pill !== null &&
text.getBBox().width <= pill.getBBox().width - 16,
};
});
check(band.text.startsWith("Partie Crawford · "),
`partie Crawford, paysage : « ${band.text} »`);
check(band.fits, `partie Crawford, paysage : la consigne déborde (« ${band.text} »)`);
await page.setViewportSize(PORTRAIT);
await sleep(200);
await capture("3-partie-crawford");
}
} else if (!scoreShot) {
+25 -10
View File
@@ -21,6 +21,7 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -343,6 +344,8 @@ async function checkView(width, height, when) {
const where = `${width}x${height} (${when})`;
const wrapped = await wrappedButtons(page);
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
const seen = await page.evaluate(() => {
const visible = (el) => {
if (el.closest("[hidden]")) {
@@ -809,6 +812,8 @@ async function checkAddresses() {
const where = `${name} ${width}x${height}`;
const wrapped = await wrappedButtons(tab);
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
const hidden = await hiddenUnderActionBar(tab);
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
if (name === "question" || name === "terme") {
// La barre haute de l'aide reste en vue, et ce qu'on a
// ouvert s'arrête juste sous elle.
@@ -996,6 +1001,15 @@ async function startChosenGame() {
"« Commencer » ne ramène pas à la partie");
}
/** « Rejouer » après une partie finie : une nouvelle partie aux mêmes
* réglages commence tout de suite, sans passer par l'écran « Nouvelle
* partie ». */
async function rematch() {
await page.click("#again");
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
"« Rejouer » ouvre un autre écran que la partie");
}
/** Nouvelle partie depuis le menu, par l'écran « Nouvelle partie » : effet
* immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */
async function newGameFromMenu() {
@@ -1525,8 +1539,10 @@ 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")) === "Nouvelle partie",
"fin de partie : panneau sans « Revoir la partie » et « Nouvelle 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 »");
await review("6-fin-de-partie");
await page.setViewportSize({ width: 844, height: 320 });
await sleep(150);
@@ -1557,11 +1573,12 @@ 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 ; « Nouvelle partie » reste dans la
// barre d'actions.
// « Revoir la partie » ferme le panneau ; « Rejouer » reste dans la barre
// d'actions.
await page.click("#panel-secondary");
check(!(await page.isVisible("#panel")) && (await page.isVisible("#again")),
"« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »");
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
(await page.isVisible("#again")),
"« Revoir la partie » : panneau toujours là, ou pas de « Rejouer »");
step("mes parties");
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
@@ -1569,8 +1586,7 @@ step("mes parties");
// recharger la page.
{
const finished = await savedJournal();
await page.click("#again");
await startChosenGame();
await rematch();
await waitForPlayer();
const second = await savedJournal();
check(second.head !== finished.head || second.events.length !== finished.events.length,
@@ -1651,8 +1667,7 @@ log("compte de points : bulle avec la fiche du glossaire");
step("thème sombre");
await page.emulateMedia({ colorScheme: "dark" });
await page.click("#again");
await startChosenGame();
await rematch();
await waitForPlayer();
await shot("6-sombre");
await page.emulateMedia({ colorScheme: "light" });
+74
View File
@@ -0,0 +1,74 @@
// recette.mjs -- règles de la recette design communes aux tests dans un
// navigateur (play.mjs, accueil.mjs, match.mjs), vérifiées sur chaque écran
// capturé.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* 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) :
* un bouton, un lien ou un interrupteur que la barre recouvre ne se voit
* pas et ne se touche pas. Un élément entièrement sous le bas de l'écran
* n'est pas visible : il n'est pas compté. Ce qui passe devant la barre (le
* menu, un panneau) n'est pas caché : seul compte ce que la barre recouvre
* vraiment, au point du recouvrement.
*
* Renvoie la liste des éléments cachés, décrits (vide si tout va bien).
*/
export async function hiddenUnderActionBar(target) {
return target.evaluate(() => {
const shown = (el) => {
if (el.closest("[hidden]")) {
return false;
}
const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
const bars = [...document.querySelectorAll("body *")].filter((el) => {
const style = getComputedStyle(el);
return style.position === "sticky" && style.bottom !== "auto" && shown(el);
});
const describe = (el) =>
el.id
? `#${el.id}`
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")} « ${
el.getAttribute("aria-label") ?? el.textContent.trim().slice(0, 40)
} »`;
const hidden = [];
const interactive = document.querySelectorAll(
"button, a[href], input, select, textarea, [role=switch], [role=radio], " +
"[tabindex]:not([tabindex='-1'])",
);
for (const bar of bars) {
const b = bar.getBoundingClientRect();
for (const el of interactive) {
if (bar.contains(el) || el.contains(bar) || !shown(el)) {
continue;
}
const r = el.getBoundingClientRect();
if (r.top >= innerHeight) {
continue;
}
const top = Math.max(r.top, b.top);
const bottom = Math.min(r.bottom, b.bottom, innerHeight);
const left = Math.max(r.left, b.left);
const right = Math.min(r.right, b.right);
if (bottom - top <= 1 || right - left <= 1) {
continue;
}
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
if (hit !== null && bar.contains(hit)) {
hidden.push(
`${describe(el)} sous ${describe(bar)} (${Math.round(bottom - top)} px)`,
);
}
}
}
return hidden;
});
}
+122
View File
@@ -835,6 +835,17 @@ button:disabled {
padding: 0 12px;
}
/* Fin de partie : « Changer les réglages », lien secondaire sous les
* boutons. */
.panel-link {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: var(--space-2);
font-weight: 700;
}
/* « Nouvelle partie commencée. Reprendre l'ancienne », 10 s, avec sa
* barre de temps. */
.banner {
@@ -2339,6 +2350,14 @@ html.landscape .welcome {
padding-bottom: var(--space-6);
}
/* Portrait : les deux colonnes l'une sous l'autre, comme une seule. */
.new-game-column {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
}
.new-game-body .overline {
margin-top: var(--space-4);
}
@@ -2505,6 +2524,109 @@ html.landscape .welcome {
color: var(--muted);
}
/* En paysage (915 × 412, et jusqu'à 844 × 320), tout tient sans défiler :
* deux colonnes au-dessus de la barre d'action, le type de partie à
* gauche, le niveau et le conseil du maître à droite ; tout est resserré,
* et la barre d'action tient sur une ligne, le bouton à droite sous le
* pouce. */
html.landscape .new-game {
max-width: 60rem;
}
html.landscape .new-game-head {
min-height: 44px;
}
html.landscape .new-game-head h2 {
font-size: 20px;
line-height: 24px;
}
html.landscape .new-game-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-6);
align-content: start;
padding-bottom: var(--space-2);
}
html.landscape .new-game-column {
gap: var(--space-2);
}
html.landscape .new-game-body .overline {
margin-top: 0;
}
html.landscape .new-game-body .during-game {
display: none;
}
html.landscape .level-bars .level {
height: 48px;
padding: 3px;
}
html.landscape .level-1 .level-bar { height: 10px; }
html.landscape .level-2 .level-bar { height: 17px; }
html.landscape .level-3 .level-bar { height: 24px; }
html.landscape .level-4 .level-bar { height: 30px; }
html.landscape .level-5 .level-bar { height: 36px; }
html.landscape .level-6 .level-bar { height: 42px; }
html.landscape .level-name {
font-size: 18px;
line-height: 22px;
}
html.landscape .level-text {
font-size: 15px;
line-height: 20px;
}
html.landscape .new-game-body .item {
min-height: 44px;
}
html.landscape .new-game-body .item-label {
line-height: 22px;
}
html.landscape .new-game-body .item small {
line-height: 18px;
}
html.landscape .new-game-actions {
flex-direction: row;
gap: var(--space-4);
align-items: center;
justify-content: flex-end;
padding: var(--space-2) 0;
}
html.landscape .new-game-kept {
order: -1;
text-align: right;
}
/* Un peu plus d'air quand la hauteur le permet (915 × 412). */
@media (min-height: 380px) {
html.landscape .new-game-column {
gap: var(--space-3);
}
html.landscape .level-bars .level {
height: 64px;
}
html.landscape .level-1 .level-bar { height: 14px; }
html.landscape .level-2 .level-bar { height: 22px; }
html.landscape .level-3 .level-bar { height: 30px; }
html.landscape .level-4 .level-bar { height: 39px; }
html.landscape .level-5 .level-bar { height: 48px; }
html.landscape .level-6 .level-bar { height: 58px; }
}
/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
* hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
* dans une colonne à droite. */
+1
View File
@@ -186,6 +186,7 @@
<button id="panel-secondary" type="button" class="secondary"></button>
<button id="panel-primary" type="button"></button>
</div>
<a id="panel-settings" class="panel-link" href="#/nouvelle-partie" hidden></a>
</div>
</div>