From 7c10c688c38507b44ccd0b8039e357f99444dda0 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 00:10:15 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Tutoriel=20:=20=C3=A9tape=207=20r=C3=A9?= =?UTF-8?q?=C3=A9crite,=20=C2=AB=20Prendre=20=C2=BB,=20=C2=AB=20Passer=20?= =?UTF-8?q?=C2=BB=20et=20=C2=AB=20Quitter=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Texte de l'étape 7 remplacé par la version de la recette design (gammon, backgammon et règle de Jacoby en un paragraphe), légende du plateau gardée. Le texte du tutoriel est validé par Pierre (9 novembre 2026). Scénario : les réponses au double sont « Prendre » et « Passer », le lien du haut est « Quitter ». Co-Authored-By: Claude Opus 5.5 --- app/tests/steps/apprendre-les-regles.steps.ts | 4 ++-- docs/scenarios/apprendre-les-regles.feature | 6 +++--- docs/tutoriel/regles.md | 6 ++---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/tests/steps/apprendre-les-regles.steps.ts b/app/tests/steps/apprendre-les-regles.steps.ts index 396ec7d..802a7ff 100644 --- a/app/tests/steps/apprendre-les-regles.steps.ts +++ b/app/tests/steps/apprendre-les-regles.steps.ts @@ -113,11 +113,11 @@ Then("le tutoriel salue votre coup", () => { assert.ok(screen.right.length > 0); }); -When("vous répondez « Je passe »", async () => { +When("vous répondez « Passer »", async () => { answeredRight = (await shouldTake(engine, screen.position)) === false; }); -When("vous répondez « Je prends »", async () => { +When("vous répondez « Prendre »", async () => { answeredRight = (await shouldTake(engine, screen.position)) === true; }); diff --git a/docs/scenarios/apprendre-les-regles.feature b/docs/scenarios/apprendre-les-regles.feature index 96b362c..423bb84 100644 --- a/docs/scenarios/apprendre-les-regles.feature +++ b/docs/scenarios/apprendre-les-regles.feature @@ -10,7 +10,7 @@ Fonctionnalité: Apprendre les règles avec le tutoriel Chaque étape tient en un écran court : un titre, le plateau, deux ou trois phrases. Le coup montré se joue tout seul après une seconde ; « Revoir » le rejoue. La barre du haut montre où vous en êtes et vous - ramène aux étapes déjà vues. « Passer » vous emmène jouer. + ramène aux étapes déjà vues. « Quitter » vous emmène jouer. Étant donné que vous commencez le tutoriel Alors il compte huit étapes, dont quatre où c'est à vous de jouer @@ -36,7 +36,7 @@ Fonctionnalité: Apprendre les règles avec le tutoriel passez : le maître vous dit ensuite ce qu'il aurait fait, et pourquoi. Étant donné que vous êtes à l'écran « à vous » de l'étape 8 - Quand vous répondez « Je passe » + Quand vous répondez « Passer » Alors le maître dit qu'il aurait pris - Quand vous répondez « Je prends » + Quand vous répondez « Prendre » Alors le tutoriel salue votre réponse diff --git a/docs/tutoriel/regles.md b/docs/tutoriel/regles.md index ea1371c..df1dcd8 100644 --- a/docs/tutoriel/regles.md +++ b/docs/tutoriel/regles.md @@ -1,7 +1,7 @@ # Les règles du backgammon Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0, -première version à valider par Pierre. Format : voir README.md. +validé par Pierre le 9 novembre 2026. Format : voir README.md. ## 1. Le but du jeu @@ -101,9 +101,7 @@ Vous avez 6 et 3 : sortez deux pions. - jet: 2-1 - coup: 2/off 1/off -Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion quand vous finissez, c'est un [gammon](gammon.md) : 2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. - -Dans une partie libre contre l'ordinateur, le gammon et le backgammon ne comptent double ou triple qu'une fois le videau doublé : c'est la [règle de Jacoby](regle-de-jacoby.md). +Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md). ## 8. Le videau From 86c0ebfc367b95abbb67cc0a71e75550f9f1e4ee Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 00:10:15 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Recette=20design=20:=20tutoriel=20en=20pays?= =?UTF-8?q?age,=20sens=20du=20jeu,=20bandeau=20de=20mise=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sens du jeu (étape 2) : le tracé passe sous les pions et sous les numéros des flèches ; liseré ivoire de 8 px, points de 4 px à l'écran. - Tutoriel en paysage : plateau à gauche sur toute la hauteur (géométrie du jeu en paysage, colonne gauche du cadre), colonne à droite avec la barre de progression, le titre, le texte, la consigne et les boutons ; seul le texte peut défiler. Tient en 915x412 et 844x320. - Recette automatique étendue au tutoriel : numéros d'au moins 12 px, page sans défilement et boutons en vue en paysage, captures en 844x320. - Boutons du videau « Prendre » et « Passer », lien « Quitter ». - Bandeau « Nouvelle version prête. » en bas, au-dessus de la barre d'actions, seulement entre deux tours (betweenTurns, testé). - Test des adresses : le lien Aide est lu au moment du toucher, la carte du terme ouvert est mesurée sous la barre de l'aide. Co-Authored-By: Claude Opus 5.5 --- app/src/ui/app.ts | 16 +- app/src/ui/board-view.ts | 1 + app/src/ui/home-texts.ts | 10 +- app/src/ui/tutorial-view.ts | 52 ++++-- app/src/ui/updates.ts | 29 ++-- app/tests/steps/regler-l-application.steps.ts | 25 ++- docs/scenarios/regler-l-application.feature | 11 +- tests/browser/accueil.mjs | 148 ++++++++++++++++-- tests/browser/play.mjs | 12 +- web/app.css | 135 ++++++++++++++-- web/index.html | 3 +- 11 files changed, 367 insertions(+), 75 deletions(-) diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 7b902d8..0733953 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -64,7 +64,7 @@ import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } f import { type PromptText, TEXTS } from "./texts.js"; import { seenAfter } from "./tutorial-nav.js"; import { createTutorialView, type TutorialAccess } from "./tutorial-view.js"; -import { type Updates, updateBannerShown } from "./updates.js"; +import { betweenTurns, type Updates, updateBannerShown } from "./updates.js"; /** Glissement d'un pion de l'ordinateur, et pause entre deux pions * (interaction.md : on suit le coup sans l'attendre). */ @@ -718,18 +718,17 @@ export function startApp(options: AppOptions): void { ui.helpButton.href = helpLink; ui.menuHelp.href = helpLink; ui.menuInstall.hidden = !installMenuShown(settings, installer.installed); - const moveInProgress = - busy || (game !== null && game.pendingHalfMoves.length > 0 && !ui.game.hidden); + // Le bandeau de mise à jour attend la fin du tour, même hors de + // l'écran de jeu : la partie enregistrée reprend alors sans perte. + const quiet = + !busy && + (game === null || betweenTurns(game.state.phase, game.pendingHalfMoves.length)); if (updateMessage !== null) { ui.updateBanner.hidden = false; ui.updateText.textContent = updateMessage; ui.updateApply.hidden = true; } else { - const shown = updateBannerShown( - updates?.available ?? false, - moveInProgress, - !ui.banner.hidden, - ); + const shown = updateBannerShown(updates?.available ?? false, quiet, !ui.banner.hidden); ui.updateBanner.hidden = !shown; ui.updateText.textContent = HOME_TEXTS.updateAvailable; ui.updateApply.hidden = false; @@ -1455,6 +1454,7 @@ export function startApp(options: AppOptions): void { }, placed: () => haptic("checkerPlaced"), reducedMotion, + landscape: () => layout() === "landscape", replace: (hash) => navigation.replace(hash), }); diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 15a08ec..e2efcc2 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -166,6 +166,7 @@ export function renderBoard( // Les pions, dessinés à part puis posés par-dessus les flèches du coup // de l'ordinateur : une flèche ne cache jamais un pion. const pieces = document.createElementNS(SVG_NS, "g"); + pieces.setAttribute("class", "pieces"); for (const point of board.points) { drawPoint(svg, pieces, g, hits, board, point.number, decor); } diff --git a/app/src/ui/home-texts.ts b/app/src/ui/home-texts.ts index 622635f..b662d27 100644 --- a/app/src/ui/home-texts.ts +++ b/app/src/ui/home-texts.ts @@ -68,7 +68,7 @@ export const HOME_TEXTS = { installed: "Ustagammon est installé : ouvrez-le depuis son icône.", // Mise à jour - updateAvailable: "Nouvelle version disponible.", + updateAvailable: "Nouvelle version prête.", updateApply: "Mettre à jour", updateCheck: "Rechercher une mise à jour", updateChecking: "Recherche d'une mise à jour…", @@ -80,8 +80,8 @@ export const HOME_TEXTS = { tutorialMenu: "Les règles (tutoriel)", tutorialProgress: "Étapes du tutoriel", tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`, - tutorialSkip: "Passer", - tutorialSkipLabel: "Passer le tutoriel", + tutorialSkip: "Quitter", + tutorialSkipLabel: "Quitter le tutoriel", tutorialOverline: (n: number, total: number, practice: boolean) => `Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`, tutorialEndOverline: "Les règles · fin", @@ -90,8 +90,8 @@ export const HOME_TEXTS = { next: "Continuer", replay: "Revoir", replayLabel: "Revoir le coup", - take: "Je prends", - drop: "Je passe", + take: "Prendre", + drop: "Passer", tutorialLoading: "Chargement du tutoriel…", tutorialFailed: "Le tutoriel n'a pas pu se charger.", pathLabel: "Le sens du jeu de vos pions, en pointillés", diff --git a/app/src/ui/tutorial-view.ts b/app/src/ui/tutorial-view.ts index 815734d..7c6b2c1 100644 --- a/app/src/ui/tutorial-view.ts +++ b/app/src/ui/tutorial-view.ts @@ -95,6 +95,8 @@ export interface TutorialViewOptions { /** Un geste juste : la vibration « pion posé ». */ readonly placed: () => void; readonly reducedMotion: MediaQueryList; + /** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */ + readonly landscape: () => boolean; /** L'adresse a désigné un écran qui n'existe pas : la remplacer. */ readonly replace: (hash: string) => void; } @@ -148,8 +150,11 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void { const SVG_NS = "http://www.w3.org/2000/svg"; /** Le sens du jeu des pions du joueur, en pointillés : du haut à droite - * (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie. */ -function drawPath(svg: SVGSVGElement, g: Geometry): void { + * (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie. + * Tracé sous les pions et sous les numéros des flèches, qu'il ne cache + * jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré + * ivoire fait 8 px, les points 4 px, quelle que soit la taille). */ +function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void { const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2; const top = g.height * 0.3; const bottom = g.height * 0.7; @@ -164,9 +169,10 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void { group.setAttribute("class", "tutorial-path"); group.setAttribute("role", "img"); group.setAttribute("aria-label", HOME_TEXTS.pathLabel); - const width = g.pointWidth * 0.22; + const px = (n: number) => n / pxPerUnit; + const width = px(4); for (const [className, stroke] of [ - ["path-edge", width * 2.4], + ["path-edge", px(8)], ["path-dots", width], ] as const) { const path = document.createElementNS(SVG_NS, "path"); @@ -174,21 +180,26 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void { path.setAttribute("d", d); path.setAttribute("stroke-width", String(stroke)); if (className === "path-dots") { - path.setAttribute("stroke-dasharray", `0.1 ${width * 2.6}`); + path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`); } group.append(path); } const head = document.createElementNS(SVG_NS, "path"); head.setAttribute("class", "path-head"); - const size = width * 2.2; + const size = px(7); head.setAttribute( "d", `M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`, ); - head.setAttribute("stroke-width", String(width * 1.2)); + head.setAttribute("stroke-width", String(px(3))); group.append(head); - // Sous les zones à toucher, toujours dessinées en dernier. - svg.insertBefore(group, svg.lastChild); + // Sous les pions, et sous les numéros des flèches, remontés au-dessus + // du tracé ; les zones à toucher restent devant tout. + const pieces = svg.querySelector(":scope > g.pieces"); + svg.insertBefore(group, pieces); + for (const number of svg.querySelectorAll(":scope > .point-number")) { + svg.insertBefore(number, pieces); + } } /** Crée l'écran du tutoriel dans section (vide dans index.html). */ @@ -215,9 +226,12 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { replay.append(icon("replay")); replay.setAttribute("aria-label", HOME_TEXTS.replayLabel); replay.title = HOME_TEXTS.replayLabel; - const text = element(section, "div", { id: "tutorial-text", className: "tutorial-text" }); - const task = element(section, "p", { id: "tutorial-task", className: "tutorial-task" }); - const feedback = element(section, "p", { + // Le texte, la consigne « à vous » et la réponse : en paysage, la seule + // partie qui défile si l'écran est trop bas (app.css). + const body = element(section, "div", { className: "tutorial-body" }); + const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" }); + const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" }); + const feedback = element(body, "p", { id: "tutorial-feedback", className: "tutorial-feedback", }); @@ -287,8 +301,16 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { return tutorial; }; + /** En paysage, le plateau occupe toute la hauteur, à gauche de la + * colonne du texte (app.css), avec la géométrie du jeu en paysage. */ function geometry(): Geometry { - const width = svg.clientWidth || Math.min(window.innerWidth, 640); + const box = svg.getBoundingClientRect(); + if (options.landscape()) { + const width = box.width || window.innerWidth; + const height = box.height || window.innerHeight; + return boardGeometry("landscape", width / height, height / 520); + } + const width = box.width || Math.min(window.innerWidth, 640); return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640); } @@ -337,7 +359,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { }; renderBoard(svg, g, boardModel(current), decor); if (screen?.path === true) { - drawPath(svg, g); + drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width); } } @@ -412,6 +434,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { function markSolved(): void { solved = true; + section.toggleAttribute("data-solved", true); next.classList.remove("secondary"); take.disabled = true; drop.disabled = true; @@ -553,6 +576,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { selected = null; targets = new Map(); solved = false; + section.toggleAttribute("data-solved", false); feedback.textContent = ""; feedback.dataset.verdict = ""; task.hidden = true; diff --git a/app/src/ui/updates.ts b/app/src/ui/updates.ts index 7e4201e..ff8b265 100644 --- a/app/src/ui/updates.ts +++ b/app/src/ui/updates.ts @@ -1,8 +1,9 @@ // La mise à jour de l'application : le service worker (web/sw.js) installe // chaque nouvelle version publiée dans son propre cache, puis attend -// l'accord du joueur. L'application affiche alors le bandeau « Nouvelle -// version disponible » ; « Mettre à jour » active la nouvelle version et -// recharge la page, qui reprend la partie enregistrée. +// l'accord du joueur. L'application affiche alors, entre deux tours, le +// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour » +// active la nouvelle version et recharge la page, qui reprend la partie +// enregistrée. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). @@ -25,17 +26,27 @@ export interface Updates { export const SKIP_WAITING = "skip-waiting"; /** - * Le bandeau « Nouvelle version disponible » est-il montré ? Quand une - * version attend, jamais pendant un coup en cours (des pions déjà déplacés, - * ou l'ordinateur qui joue), ni par-dessus le bandeau « Reprendre - * l'ancienne ». Fonction pure, testée sous Node. + * Est-on entre deux tours ? Oui quand la partie attend un lancer de dés + * (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à + * jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction + * pure, testée sous Node. + */ +export function betweenTurns(phase: string, pendingHalfMoves: number): boolean { + return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0); +} + +/** + * Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version + * attend, seulement entre deux tours (jamais pendant un coup en cours, ni + * quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre + * l'ancienne », qui occupe la même place. Fonction pure, testée sous Node. */ export function updateBannerShown( available: boolean, - moveInProgress: boolean, + betweenTwoTurns: boolean, otherBanner: boolean, ): boolean { - return available && !moveInProgress && !otherBanner; + return available && betweenTwoTurns && !otherBanner; } /** diff --git a/app/tests/steps/regler-l-application.steps.ts b/app/tests/steps/regler-l-application.steps.ts index 3c24d49..e1097ef 100644 --- a/app/tests/steps/regler-l-application.steps.ts +++ b/app/tests/steps/regler-l-application.steps.ts @@ -20,7 +20,7 @@ import { memoryStorage } from "../../src/storage/open-library.js"; import { BANNER_MS } from "../../src/ui/app.js"; import { createHaptics } from "../../src/ui/haptics.js"; import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js"; -import { updateBannerShown } from "../../src/ui/updates.js"; +import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js"; import { loadNodeEngine } from "../support/node-engine.js"; setDefaultTimeout(30_000); @@ -168,23 +168,34 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => { // ------------------------------------------------------------ Mise à jour // Le bandeau dépend de deux choses : une version attend (le service worker -// l'a installée), et aucun coup n'est en cours de saisie. La partie est -// celle du domaine, sur le vrai moteur. +// l'a installée), et la partie est entre deux tours. La partie est celle du +// domaine, sur le vrai moteur. let updateAvailable = false; Given("une nouvelle version d'Ustagammon est disponible", () => { updateAvailable = true; }); -Given("vous avez déjà déplacé un pion de votre coup", async () => { +const shownNow = (otherBanner = false) => + updateBannerShown( + updateAvailable, + betweenTurns(game.state.phase, game.pendingHalfMoves.length), + otherBanner, + ); + +Given("vous avez des dés à jouer", () => { // Jet d'ouverture 3-1 : vous commencez. game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), ply: 0 }); assert.equal(game.state.turn, "player"); + assert.equal(game.state.phase, "moving"); +}); + +When("vous déplacez un pion de votre coup", async () => { assert.ok(await game.playHalfMove({ from: 8, to: 5 })); }); Then("l'application ne vous propose pas encore de mettre à jour", () => { - assert.ok(!updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false)); + assert.ok(!shownNow()); }); When("vous avez joué votre coup", async () => { @@ -193,7 +204,7 @@ When("vous avez joué votre coup", async () => { }); Then("l'application vous propose de mettre à jour", () => { - assert.ok(updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false)); + assert.ok(shownNow()); // Jamais par-dessus le bandeau « Reprendre l'ancienne ». - assert.ok(!updateBannerShown(updateAvailable, false, true)); + assert.ok(!shownNow(true)); }); diff --git a/docs/scenarios/regler-l-application.feature b/docs/scenarios/regler-l-application.feature index 9a56668..c4083bf 100644 --- a/docs/scenarios/regler-l-application.feature +++ b/docs/scenarios/regler-l-application.feature @@ -52,13 +52,16 @@ Fonctionnalité: Régler l'application Scénario: Comment mettre l'application à jour ? Dès qu'une nouvelle version d'Ustagammon est publiée, l'application la - télécharge d'elle-même, puis affiche « Nouvelle version disponible », - jamais pendant que vous jouez un coup. « Mettre à jour » l'installe et - vous ramène où vous en étiez. Dans le menu, « Rechercher une mise à + télécharge d'elle-même, puis affiche en bas de l'écran « Nouvelle + version prête. », seulement entre deux tours, jamais pendant que vous + jouez un coup. « Mettre à jour » l'installe et vous ramène où vous en + étiez. Dans le menu, « Rechercher une mise à jour » vérifie tout de suite s'il y en a une. Étant donné qu'une nouvelle version d'Ustagammon est disponible - Et que vous avez déjà déplacé un pion de votre coup + Et que vous avez des dés à jouer + Alors l'application ne vous propose pas encore de mettre à jour + Quand vous déplacez un pion de votre coup Alors l'application ne vous propose pas encore de mettre à jour Quand vous avez joué votre coup Alors l'application vous propose de mettre à jour diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs index 104fec8..039b5d6 100644 --- a/tests/browser/accueil.mjs +++ b/tests/browser/accueil.mjs @@ -11,7 +11,8 @@ // Lancé par tests/browser.sh. Le second site sert la même chose depuis un // dossier que ce script modifie : il y publie une deuxième version pour // essayer la mise à jour. Captures de la recette design (Chromium) dans -// /accueil-tutoriel/, en 412×915 et 915×412. +// /accueil-tutoriel/, en 412×915 et 915×412, et aussi +// en 844×320 pour le tutoriel. import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { chromium, firefox } from "playwright"; @@ -29,6 +30,8 @@ const SIZES = [ [412, 915], [915, 412], ]; +// Le tutoriel est aussi vérifié dans le plus petit paysage de la recette. +const TUTORIAL_SIZES = [...SIZES, [844, 320]]; const browser = await browserType.launch(); const problems = []; @@ -102,12 +105,12 @@ async function until(page, what, test, arg, ms = 30_000) { throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`); } -/** Range une capture de l'écran courant dans les deux tailles (Chromium). */ -async function capture(page, name, settle = async () => {}) { +/** Range une capture de l'écran courant dans chaque taille (Chromium). */ +async function capture(page, name, settle = async () => {}, sizes = SIZES) { if (!full) { return; } - for (const [width, height] of SIZES) { + for (const [width, height] of sizes) { await page.setViewportSize({ width, height }); await sleep(250); await settle(); @@ -139,6 +142,60 @@ async function tutorialReady(page, hash) { }, hash); } +/** Recette du tutoriel dans la taille courante : numéros des flèches de + * 12 px au moins ; en paysage, rien ne défile et les boutons sont en vue. */ +async function checkTutorialView(page, name) { + const view = await page.evaluate(() => { + const svg = document.getElementById("tutorial-board"); + const box = svg.getBoundingClientRect(); + const vb = svg.viewBox.baseVal; + const scale = Math.min(box.width / vb.width, box.height / vb.height); + const numbers = [...svg.querySelectorAll(".point-number")] + .map((t) => Number(t.getAttribute("font-size")) * scale); + const buttons = ["tutorial-back", "tutorial-next", "tutorial-drop", "tutorial-take"] + .map((id) => document.getElementById(id)) + .filter((el) => el !== null && el.closest("[hidden]") === null) + .map((el) => { + const r = el.getBoundingClientRect(); + return { id: el.id, top: r.top, bottom: r.bottom }; + }); + return { + landscape: document.documentElement.classList.contains("landscape"), + minNumber: numbers.length === 0 ? 0 : Math.min(...numbers), + scroll: document.scrollingElement.scrollHeight - innerHeight, + body: (() => { + const b = document.querySelector("#tutorial .tutorial-body"); + return b.scrollHeight - b.clientHeight; + })(), + height: innerHeight, + buttons, + }; + }); + const size = `${name} en ${await page.evaluate(() => `${innerWidth}x${innerHeight}`)}`; + check(view.minNumber >= 12, `${size} : numéros des flèches de ${view.minNumber.toFixed(1)} px`); + if (view.landscape) { + check(view.scroll <= 1, `${size} : la page défile de ${view.scroll} px`); + check(view.body <= 1, `${size} : le texte défile de ${view.body} px`); + if (view.body > tutorialBodyScroll.px) { + tutorialBodyScroll = { px: view.body, where: size }; + } + for (const b of view.buttons) { + check(b.top >= 0 && b.bottom <= view.height + 0.5, + `${size} : « ${b.id} » hors de l'écran (${b.top}-${b.bottom} pour ${view.height})`); + } + } + tutorialViews++; + tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber); +} +let tutorialViews = 0; +let tutorialBodyScroll = { px: 0, where: "" }; +let tutorialMinNumber = Infinity; + +/** Capture d'un écran du tutoriel, recette comprise, dans les trois tailles. */ +async function tutorialCapture(page, name) { + await capture(page, name, () => checkTutorialView(page, name), TUTORIAL_SIZES); +} + /** Touche une case du plateau du tutoriel. */ async function touch(page, place) { await page.click(`#tutorial-board [data-place="${place}"]`); @@ -210,8 +267,17 @@ async function playTouches(page, touches) { if (step === 2) { check(await page.locator("#tutorial-board .tutorial-path").count() === 1, "étape 2 : pas de chemin en pointillés"); + // Sous les pions et sous les numéros des flèches. + const order = await page.evaluate(() => { + const children = [...document.getElementById("tutorial-board").children]; + const at = (selector) => children.findIndex((c) => c.matches(selector)); + const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c)); + return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) }; + }); + check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber, + `étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`); } - await capture(page, `tutoriel-${step}`); + await tutorialCapture(page, `tutoriel-${step}`); screens++; await page.click("#tutorial-next"); const practice = await page.evaluate(() => location.hash.endsWith("/a-vous")); @@ -222,11 +288,11 @@ async function playTouches(page, touches) { await tutorialReady(page, avous); check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")), `étape ${step}, à vous : « Continuer » principal avant le geste`); - await capture(page, `tutoriel-${step}-a-vous`); + await tutorialCapture(page, `tutoriel-${step}-a-vous`); if (step === 8) { await page.click("#tutorial-drop"); - check(await verdict(page) === "wrong", "étape 8 : « Je passe » accepté"); - await capture(page, "tutoriel-8-a-vous-refuse"); + check(await verdict(page) === "wrong", "étape 8 : « Passer » accepté"); + await tutorialCapture(page, "tutoriel-8-a-vous-refuse"); await page.click("#tutorial-take"); await until(page, "étape 8 juste", () => document.getElementById("tutorial-feedback").dataset.verdict === "right"); @@ -238,7 +304,7 @@ async function playTouches(page, touches) { await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0); const words = await page.textContent("#tutorial-feedback"); check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`); - await capture(page, `tutoriel-${step}-a-vous-refuse`); + await tutorialCapture(page, `tutoriel-${step}-a-vous-refuse`); await playTouches(page, moves.right); check(await verdict(page) === "right", `étape ${step} : bon coup refusé`); check(await page.locator("#tutorial-board .hit.can-move").count() === 0, @@ -248,13 +314,13 @@ async function playTouches(page, touches) { `étape ${step} : « Continuer » pas principal après le bon geste`); const vibrated = await page.evaluate(() => window.__vibrations.at(-1)); check(vibrated === "12", `étape ${step} : vibration ${vibrated}`); - await capture(page, `tutoriel-${step}-a-vous-reussi`); + await tutorialCapture(page, `tutoriel-${step}-a-vous-reussi`); screens++; await page.click("#tutorial-next"); } await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" && document.getElementById("tutorial").dataset.ready !== undefined); - await capture(page, "tutoriel-fin"); + await tutorialCapture(page, "tutoriel-fin"); // Le retour du téléphone : l'écran d'avant. await page.goBack(); await tutorialReady(page, "#/tutoriel/8/a-vous"); @@ -262,7 +328,9 @@ async function playTouches(page, touches) { await until(page, "fin", () => location.hash === "#/tutoriel/fin"); await page.click("#tutorial-next"); await until(page, "première partie", () => location.hash === "#/" && window.__shown("game")); - log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin`); + log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` + + (full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` + + `texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : "")); // Le choix ne revient plus : l'accueil, ensuite. await page.goto(base); @@ -391,8 +459,27 @@ if (browserName === "chromium") { }; open.onerror = () => resolve(null); })); - const gameBefore = await journal(); check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version"); + // Si vous avez la main (jet d'ouverture), le bandeau attend la fin du + // tour : on publie la nouvelle version d'abord, puis on joue le coup. + const toPlay = () => page.evaluate(() => { + if (document.body.classList.contains("busy")) { + return "busy"; + } + const usable = (id) => { + const b = document.getElementById(id); + return b !== null && !b.hidden && !b.disabled; + }; + for (const id of ["roll", "again", "confirm", "skip", "take"]) { + if (usable(id)) { + return id; + } + } + if (document.querySelector("#board .hit.can-land")) { + return "land"; + } + return document.querySelector("#board .hit.can-move") ? "move" : "busy"; + }); // Une deuxième version publiée : autre version.json, autre sw.js. writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n'); const sw = readFileSync(`${updateDir}/sw.js`, "utf8"); @@ -400,9 +487,40 @@ if (browserName === "chromium") { await page.click("#menu-button"); await page.waitForSelector("#menu", { state: "visible" }); await page.click("#menu-update"); + await until(page, "nouvelle version trouvée", () => + document.getElementById("update-text")?.textContent !== "Recherche d'une mise à jour…", undefined, 120_000); + // Pendant votre coup, jamais de bandeau : seulement entre deux tours. + let waited = false; + for (let i = 0; i < 40; i++) { + const todo = await toPlay(); + if (todo === "roll" || todo === "again") { + break; + } + if (todo === "busy") { + await sleep(100); + continue; + } + if (todo === "move" || todo === "land" || todo === "confirm") { + await sleep(400); + check(await page.isHidden("#update-banner"), `bandeau de mise à jour pendant votre coup (${todo})`); + waited = true; + } + await page.click(todo === "move" ? "#board .hit.can-move" : todo === "land" ? "#board .hit.can-land" : `#${todo}`); + await sleep(200); + } await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000); - check((await page.textContent("#update-text")) === "Nouvelle version disponible.", + check((await page.textContent("#update-text")) === "Nouvelle version prête.", `bandeau : « ${await page.textContent("#update-text")} »`); + // En bas, au-dessus de la barre d'actions, comme « Reprendre l'ancienne ». + const place = await page.evaluate(() => { + const banner = document.getElementById("update-banner").getBoundingClientRect(); + const actions = document.getElementById("actions").getBoundingClientRect(); + const top = document.querySelector(".topbar").getBoundingClientRect(); + return { bottom: banner.bottom, top: banner.top, actions: actions.top, bar: top.bottom }; + }); + check(place.bottom <= place.actions && place.top > place.bar, + `bandeau de mise à jour mal placé : ${JSON.stringify(place)}`); + const gameBefore = await journal(); await capture(page, "09-mise-a-jour"); await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]); await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000); @@ -410,7 +528,7 @@ if (browserName === "chromium") { await page.goto(`${updateBase}#/a-propos`); await until(page, "nouvelle version", () => document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab."); - log("mise à jour : deuxième version publiée, bandeau, « Mettre à jour », partie retrouvée, nouvelle version"); + log(`mise à jour : deuxième version publiée, bandeau ${waited ? "retenu pendant votre coup, puis " : ""}entre deux tours, « Mettre à jour », partie retrouvée, nouvelle version`); // Rien de neuf : « Vous avez la dernière version ». await page.goto(`${updateBase}#/`); await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 00fc3de..7a45b2e 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -815,7 +815,7 @@ async function checkAddresses() { const seen = await tab.evaluate(() => { const bar = document.getElementById("help-bar").getBoundingClientRect(); const target = document.querySelector( - "#help-play .question.open, #help-glossary dt.current", + "#help-play .question.open, #help-glossary .term-card.current", ).getBoundingClientRect(); return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top }; }); @@ -882,9 +882,15 @@ async function checkAddresses() { } // Le bouton « Aide » ouvre la question du moment de la partie // (help-context.ts) ; ici, l'onglet « Jouer » sans question. - const helpHref = await tab.getAttribute("#help-button", "href"); + // L'adresse est lue au moment du toucher : la partie qui vient de + // s'ouvrir peut encore changer de moment (jet d'ouverture). + const helpHref = await tab.evaluate(() => { + const button = document.getElementById("help-button"); + const href = button.getAttribute("href"); + button.click(); + return href; + }); check(/^#\/aide(\/[a-z0-9-]+)?$/.test(helpHref), `bouton Aide : ${helpHref}`); - await tab.click("#help-button"); await expectScreen(tab, "aide par son lien", (s) => s.hash === helpHref && s.help && s.play); if (helpHref !== "#/aide") { await tab.goBack(); diff --git a/web/app.css b/web/app.css index ae94cf1..a8ddcf9 100644 --- a/web/app.css +++ b/web/app.css @@ -1875,13 +1875,6 @@ html.bare .topbar { width: 100%; } -/* Bandeau de mise à jour : en haut, discret, jamais sur la barre - * d'actions ni sur « Reprendre l'ancienne ». */ -.update-banner { - top: var(--space-2); - bottom: auto; -} - /* Tutoriel. */ .tutorial-head { display: flex; @@ -2039,8 +2032,132 @@ html.bare .topbar { stroke: var(--brand-noyer); } -/* En paysage, le plateau du tutoriel tient dans la hauteur de l'écran. */ -html.landscape .tutorial-board, +/* En paysage, comme la position partagée : le plateau à gauche sur toute + * la hauteur (avec la colonne gauche du cadre), et à droite une colonne + * avec la barre de progression, le titre, le texte, la consigne « à vous » + * et les boutons, le tout sans défilement de la page. Si le texte est trop + * long pour l'écran, lui seul défile : les boutons restent en vue. */ +html.landscape .tutorial { + position: fixed; + inset: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(288px, 44vw, 400px); + grid-template-rows: auto auto minmax(0, 1fr) auto auto; + max-width: none; + margin: 0; + padding: 0; + overflow: hidden; + background: var(--background); +} + +html.landscape .tutorial > * { + grid-column: 2; + margin-inline: 0; + padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right)); +} + +html.landscape .tutorial .overline { + display: none; +} + +html.landscape .tutorial-head { + grid-row: 1; + min-height: 44px; + padding-top: 0; +} + +html.landscape .tutorial-title { + grid-row: 2; + margin: 0 0 var(--space-1); + padding-right: calc(44px + var(--space-4)); + font-size: 20px; + line-height: 26px; +} + +/* La boîte du plateau s'efface : le plateau et « Revoir » se placent + * eux-mêmes dans la grille. */ +html.landscape .tutorial-board-box { + display: contents; +} + +html.landscape .tutorial-board { + grid-row: 1 / -1; + grid-column: 1; + width: 100%; + height: 100%; + margin: 0; + aspect-ratio: auto; + background: var(--frame); +} + +html.landscape #tutorial-replay { + position: static; + grid-row: 2; + grid-column: 2; + justify-self: end; + align-self: start; + margin: -9px calc(var(--space-2) + env(safe-area-inset-right)) 0 0; + padding: 0; +} + +html.landscape .tutorial-body { + grid-row: 3; + min-height: 0; + overflow-y: auto; + font-size: 15px; + line-height: 20px; +} + +html.landscape .tutorial-text { + margin: 0; + padding-right: 0; +} + +html.landscape .tutorial-text p { + margin-bottom: var(--space-2); +} + +html.landscape .tutorial-task { + margin: 0; + padding: var(--space-2) 12px; +} + +html.landscape .tutorial-feedback { + min-height: 0; + margin: var(--space-2) 0 0; +} + +html.landscape .tutorial-cube { + grid-row: 4; + margin-top: var(--space-2); +} + +/* La réponse au double donnée et saluée, les deux boutons n'ont plus + * d'usage : en paysage, leur place revient au texte. */ +html.landscape .tutorial[data-solved] .tutorial-cube { + display: none; +} + +/* Écran très bas (844 × 320) : texte un cran plus petit. */ +@media (max-height: 360px) { + html.landscape .tutorial-body { + font-size: 14px; + line-height: 18px; + } + + html.landscape .tutorial-title { + font-size: 18px; + line-height: 24px; + } +} + +html.landscape .tutorial-nav { + grid-row: 5; + margin: 0; + padding-block: var(--space-2) calc(var(--space-2) + env(safe-area-inset-bottom)); +} + +/* Le premier lancement, en paysage : le plateau tient dans la hauteur. */ html.landscape .welcome-board { width: min(100%, calc(70vh * 640 / 520)); height: auto; diff --git a/web/index.html b/web/index.html index 27c4e72..d2dc71b 100644 --- a/web/index.html +++ b/web/index.html @@ -188,7 +188,8 @@ - +