272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
// 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-]+$/);
|
||
}
|
||
});
|