Files
Ustagammon/app/tests/layout.test.ts

272 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, string>();
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=|<style/);
for (const path of sources("app/src/")) {
assert.doesNotMatch(read(path), /setAttribute\(\s*"style"|\.style\./, path);
}
});
test("pictogrammes : le jeu de la charte, en tracés SVG", () => {
for (const name of [
"menu",
"help",
"fullscreen",
"exitFullscreen",
"undo",
"replay",
"close",
]) {
assert.ok(name in ICON_PATHS, `pictogramme ${name} absent`);
}
for (const d of Object.values(ICON_PATHS)) {
assert.match(d, /^M[\d.\s,a-zA-Z-]+$/);
}
});