// Tests de la révision UX qui se vérifient sans navigateur : géométrie du // plateau selon l'écran, vibrations, réglages, compte de points, plateau // gardé pour le chargement, et règles de la charte vérifiables dans le code. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). import assert from "node:assert/strict"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { test } from "node:test"; import { createSettingsStore } from "../src/storage/settings-store.js"; import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js"; import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js"; import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js"; import { ICON_PATHS } from "../src/ui/icons.js"; import { DEFAULT_SETTINGS, LONG_PROMPT_GAMES, longPrompts, nextTheme, parseSettings, } from "../src/ui/settings.js"; const root = new URL("../../../", import.meta.url); const read = (path: string) => readFileSync(new URL(path, root), "utf8"); // --------------------------------------------------------------- Géométrie /** Les écrans en paysage de la recette : largeur et hauteur en pixels, * dont 844 × 320 (Chrome garde sa barre d'adresse) et un rapport plus large * encore. */ const LANDSCAPES: [number, number][] = [ [915, 412], [844, 390], [844, 320], [740, 360], [800, 308], ]; test("géométrie en portrait : les mesures du plateau d'origine", () => { const g = boardGeometry("portrait", 640 / 520, 396 / 640); assert.equal(g.width, 640); assert.equal(g.pointWidth, 44); assert.equal(g.radius, 20); assert.equal(g.step, 42); assert.equal(g.trayLeft + g.tray + g.frame, 640); // Numéros d'au moins 12 px à l'écran, à 412 px de large. assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`); }); test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => { for (const [width, height] of LANDSCAPES) { const px = height / BOARD_HEIGHT; const g = boardGeometry("landscape", width / height, px); const where = `${width}x${height}`; assert.ok(Math.abs(g.width / g.height - width / height) < 0.01, `${where} : proportions`); // Les colonnes, la barre et les flèches occupent toute la largeur. assert.ok(Math.abs(g.trayLeft + g.tray + g.frame - g.width) < 1e-9, `${where} : largeur`); assert.ok(g.pointWidth * px >= 44, `${where} : flèches de ${g.pointWidth * px} px`); assert.ok(2 * g.radius * px >= 22, `${where} : pions de ${2 * g.radius * px} px`); assert.ok(g.numberSize * px >= 12, `${where} : numéros de ${g.numberSize * px} px`); assert.ok(g.bandFont * px >= 13, `${where} : consigne de ${g.bandFont * px} px`); assert.ok(g.chipFont * px >= 13, `${where} : comptes de ${g.chipFont * px} px`); assert.ok(g.target * px >= 44, `${where} : cibles de ${g.target * px} px`); assert.ok(g.cubeFont * px >= 13, `${where} : videau en ${g.cubeFont * px} px`); assert.ok(g.countFont * px >= 13, `${where} : nombres des piles en ${g.countFont * px} px`); assert.ok(g.cube <= g.left - 8, `${where} : videau plus large que sa colonne`); assert.ok(g.left - 4 >= g.target - 4, `${where} : colonne de commandes trop étroite`); // Les numéros tiennent dans le cadre. assert.ok(g.numberSize < g.edge, `${where} : numéros hors du cadre`); } }); test("une pile de cinq pions ne déborde pas dans la bande centrale (dés, consigne)", () => { const cases = [ boardGeometry("portrait", 640 / 520, 0.62), ...[1.9, 2.16, 2.6].map((aspect) => boardGeometry("landscape", aspect, 0.615)), ]; for (const g of cases) { const band = g.height / 2 - g.die / 2; const last = stackY(g, true, MAX_STACK - 1) + g.radius; assert.ok(last <= band, `${g.layout} ${g.width} : pion jusqu'à ${last}, dés dès ${band}`); } }); test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comptes", () => { for (const [width, height] of LANDSCAPES) { const g = boardGeometry("landscape", width / height, height / BOARD_HEIGHT); const lastOffset = g.radius + 4 + (g.barStack - 1) * g.step + g.radius; const chipEdge = g.height / 2 - (g.edge + 4 + g.chipHeight); assert.ok(g.barStack >= 1); assert.ok(lastOffset <= chipEdge, `${width}x${height} : ${lastOffset} > ${chipEdge}`); } }); // -------------------------------------------------------------- Vibrations test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => { const calls: number[][] = []; let on = true; const haptic = createHaptics( (pattern) => { calls.push(pattern); return true; }, () => on, ); haptic("checkerPlaced"); haptic("checkerHit"); haptic("computerDoubles"); haptic("gameWon"); haptic("gameLost"); assert.deepEqual(calls, [[12], [25, 40, 25], [40, 60, 40], [30, 50, 30, 50, 60], [60]]); on = false; haptic("checkerHit"); assert.equal(calls.length, 5); // Sans navigator.vibrate, ou si le navigateur refuse : aucune erreur. assert.doesNotThrow(() => createHaptics(undefined, () => true)("gameWon")); const refusing = createHaptics( () => { throw new Error("refusé"); }, () => true, ); assert.doesNotThrow(() => refusing("gameWon")); // Aucun motif ne dépasse 60 ms par impulsion (interaction.md). for (const pattern of Object.values(HAPTIC_PATTERNS)) { assert.ok(pattern.every((ms) => ms > 0 && ms <= 60)); } }); // ---------------------------------------------------------------- Réglages test("réglages : valeurs par défaut, lecture tolérante, thème en boucle", () => { assert.deepEqual(parseSettings(null), DEFAULT_SETTINGS); assert.equal(DEFAULT_SETTINGS.vibrations, true); assert.equal(DEFAULT_SETTINGS.keepScreenOn, true); assert.deepEqual(parseSettings("pas du JSON"), DEFAULT_SETTINGS); assert.deepEqual(parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}'), { ...DEFAULT_SETTINGS, vibrations: false, theme: "dark", gamesStarted: 4, }); assert.deepEqual(parseSettings('{"vibrations": "non", "theme": "rose", "gamesStarted": -1}'), { ...DEFAULT_SETTINGS, }); assert.equal(nextTheme("auto"), "light"); assert.equal(nextTheme("light"), "dark"); assert.equal(nextTheme("dark"), "auto"); }); test("consigne longue pendant les trois premières parties, courte ensuite", () => { assert.equal(LONG_PROMPT_GAMES, 3); for (const games of [0, 1, 2, 3]) { assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: games }), true); } assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: 4 }), false); }); test("stockage des réglages et du dernier plateau affiché", () => { const data = new Map(); const store = createSettingsStore({ getItem: (key) => data.get(key) ?? null, setItem: (key, value) => void data.set(key, value), }); assert.equal(store.loadSettings(), null); store.saveSettings('{"vibrations":false}'); assert.equal(store.loadSettings(), '{"vibrations":false}'); assert.equal(store.loadBoard(), null); const board = { sides: OPENING_BOARDS, cube: { value: 2, owner: "player" as const } }; store.saveBoard(board); assert.deepEqual(store.loadBoard(), board); // Un plateau abîmé est ignoré : la position de départ sera dessinée. data.set("ustagammon.last-board", '{"sides": {"player": {"points": [1]}}}'); assert.equal(store.loadBoard(), null); const full = createSettingsStore({ getItem: () => { throw new Error("interdit"); }, setItem: () => { throw new Error("plein"); }, }); assert.doesNotThrow(() => full.saveBoard(board)); assert.equal(full.loadBoard(), null); assert.equal(full.loadSettings(), null); }); // ------------------------------------------------------- Compte de points test("compte de points : le plus petit mène, de l'écart", () => { assert.deepEqual(pipLead({ player: 136, computer: 128 }), { leader: "computer", by: 8 }); assert.deepEqual(pipLead({ player: 100, computer: 128 }), { leader: "player", by: 28 }); assert.deepEqual(pipLead({ player: 167, computer: 167 }), { leader: null, by: 0 }); // La position de départ : 15 pions, 167 points chacun. for (const side of [OPENING_BOARDS.player, OPENING_BOARDS.computer]) { assert.equal( side.points.reduce((a, b) => a + b, 0), 15, ); assert.equal( side.points.reduce((sum, n, i) => sum + n * (i + 1), 0), side.pipCount, ); } }); // ------------------------------------------------------------------ Charte /** Les fichiers .ts d'un dossier, récursivement. */ function sources(dir: string): string[] { const url = new URL(dir, root); return readdirSync(url).flatMap((name) => { const path = `${dir}${name}`; return statSync(new URL(path, root)).isDirectory() ? sources(`${path}/`) : [path]; }); } test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => { for (const path of sources("app/src/")) { const text = read(path); assert.doesNotMatch( text, /\b(window\.)?(confirm|alert|prompt)\s*\(/, `${path} appelle une boîte native`, ); } }); test("les deux blocs du thème sombre de app.css sont identiques", () => { const css = read("web/app.css"); const block = (start: string) => { const from = css.indexOf(start); assert.ok(from >= 0, `bloc ${start} absent`); const body = css.slice(css.indexOf("{", from + start.length - 1) + 1); return body .slice(0, body.indexOf("}")) .split("\n") .map((line) => line.trim()) .filter(Boolean); }; assert.deepEqual( block(':root:not([data-theme="light"]) {'), block(':root[data-theme="dark"] {'), ); }); test("aucun style en ligne ni attribut style dans la page et le code (CSP)", () => { assert.doesNotMatch(read("web/index.html"), /\sstyle=|