Révision UX : portrait stable, bandeaux, menu, paysage plateau = écran, vibrations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2be5bdcb4f
commit
c6649f94c0
@@ -0,0 +1,268 @@
|
||||
// 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.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-]+$/);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user