diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index f9dab0e..c19b1de 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -25,7 +25,7 @@ import { type NewGameEvent, withAdvice, } 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"; @@ -168,6 +168,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); diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 0ce3f3f..4c385ae 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -265,6 +265,7 @@ export function startApp(options: AppOptions): void { panelVeil: byId("panel-veil"), panelText: byId("panel-text"), panelPrimary: byId("panel-primary"), + panelSettings: byId("panel-settings"), panelSecondary: byId("panel-secondary"), banner: byId("banner"), bannerText: byId("banner-text"), @@ -338,7 +339,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; @@ -588,7 +589,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, advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null, }); @@ -848,6 +853,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 = ""; @@ -855,6 +861,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; @@ -1146,14 +1153,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); } } @@ -1444,6 +1448,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(); @@ -2313,10 +2324,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 diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 443a067..35bf99a 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -523,6 +523,11 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): vo const width = 6 * g.pointWidth - 24 - before; const height = Math.max(44, g.bandFont + 20); const x = g.x0 + 12 + before; + // 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, @@ -538,8 +543,8 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): vo 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, ); diff --git a/app/src/ui/match-texts.ts b/app/src/ui/match-texts.ts index 0266498..4536050 100644 --- a/app/src/ui/match-texts.ts +++ b/app/src/ui/match-texts.ts @@ -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") => diff --git a/app/src/ui/new-game-view.ts b/app/src/ui/new-game-view.ts index b002839..9caa596 100644 --- a/app/src/ui/new-game-view.ts +++ b/app/src/ui/new-game-view.ts @@ -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" }); diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index 5a3dab9..a511981 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -506,6 +506,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 () => { @@ -538,6 +544,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. diff --git a/app/tests/steps/jouer-un-match.steps.ts b/app/tests/steps/jouer-un-match.steps.ts index 09a4bf0..18f0a7d 100644 --- a/app/tests/steps/jouer-un-match.steps.ts +++ b/app/tests/steps/jouer-un-match.steps.ts @@ -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; diff --git a/docs/identite/charte/recette-ux.md b/docs/identite/charte/recette-ux.md index 25718de..e9abc05 100644 --- a/docs/identite/charte/recette-ux.md +++ b/docs/identite/charte/recette-ux.md @@ -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 diff --git a/docs/scenarios/jouer-un-match.feature b/docs/scenarios/jouer-un-match.feature index 002c237..37e85ab 100644 --- a/docs/scenarios/jouer-un-match.feature +++ b/docs/scenarios/jouer-un-match.feature @@ -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 diff --git a/tests/browser.sh b/tests/browser.sh index cb48727..beccdd5 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -62,7 +62,8 @@ 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 tests/browser/maitre.mjs "$tools/" +cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.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 diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs index 039b5d6..9f51320 100644 --- a/tests/browser/accueil.mjs +++ b/tests/browser/accueil.mjs @@ -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++; } diff --git a/tests/browser/match.mjs b/tests/browser/match.mjs index 188b3e9..ce52877 100644 --- a/tests/browser/match.mjs +++ b/tests/browser/match.mjs @@ -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 /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) { diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index e54373d..ea064b3 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -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" }); diff --git a/tests/browser/recette.mjs b/tests/browser/recette.mjs new file mode 100644 index 0000000..66c66e4 --- /dev/null +++ b/tests/browser/recette.mjs @@ -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; + }); +} diff --git a/web/app.css b/web/app.css index b1cd786..bdd0964 100644 --- a/web/app.css +++ b/web/app.css @@ -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. */ diff --git a/web/index.html b/web/index.html index 0c196ac..1ca9a8e 100644 --- a/web/index.html +++ b/web/index.html @@ -195,6 +195,7 @@ +