Files
maze32/test/web/test_player_app.js
T

231 lines
12 KiB
JavaScript
Raw 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.
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const project = path.resolve(__dirname, "../..");
const player = require(path.join(project, "web/player.js"));
test("keyboard and swipe controls produce equivalent directions", () => {
const cases = [
["ArrowUp", [50, 80, 50, 20], "north"],
["KeyD", [20, 50, 80, 50], "east"],
["ArrowDown", [50, 20, 50, 80], "south"],
["KeyA", [80, 50, 20, 50], "west"]
];
for (const [key, swipe, expected] of cases) {
assert.equal(player.directionFromKey(key), expected);
assert.equal(player.directionFromSwipe(...swipe), expected);
}
assert.equal(player.directionFromSwipe(0, 0, 12, 4), null);
});
test("joystick produces only cardinal, bounded movement intentions", () => {
assert.equal(player.joystickDirection(10, 10, null), null);
assert.equal(player.joystickDirection(44, 8, null), "east");
assert.equal(player.joystickDirection(-8, -44, null), "north");
assert.equal(player.joystickDirection(42, 38, "east"), "east");
assert.equal(player.joystickDirection(38, 42, "north"), "south");
const vector = player.joystickVector(300, 0, 57, null);
assert.equal(vector.direction, "east");
assert.equal(vector.x, 57);
assert.equal(vector.y, 0);
});
test("joystick repeat interval is monotonic and remains slower than cooldown", () => {
assert.equal(player.joystickRepeatMs(player.JOYSTICK_DEAD_ZONE, 60), null);
const near = player.joystickRepeatMs(25, 60);
const far = player.joystickRepeatMs(60, 60);
assert.ok(near > far);
assert.ok(far >= player.JOYSTICK_FAST_REPEAT_MS);
assert.ok(player.JOYSTICK_FAST_REPEAT_MS > 150);
});
test("latency correlation is bounded and only completes matching authoritative views", () => {
let clock = 0;
const tracker = new player.LatencyTracker(() => clock);
tracker.sent("move-1");
clock = 12;
tracker.acknowledged("move-1", 41);
clock = 16;
tracker.painted(40);
assert.equal(tracker.snapshot().length, 0);
clock = 24;
tracker.painted(41);
assert.deepEqual(tracker.snapshot(), [{requestId: "move-1", commandToAckMs: 12,
commandToPaintMs: 24, ackToPaintMs: 12}]);
for (let index = 0; index < player.LATENCY_SAMPLE_LIMIT + 4; index++) {
tracker.sent(`move-${index + 2}`);
}
assert.ok(tracker.pending.size <= player.LATENCY_SAMPLE_LIMIT);
});
test("player map cells enlarge small projections without exceeding a readable bound", () => {
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
const fourByFour = player.playerCellSize(396, 730, 4, 4);
assert.ok(sevenBySeven >= 46);
assert.ok(fourByFour > sevenBySeven);
assert.ok(fourByFour <= 72);
});
test("onboarding has bounded, replayable steps", () => {
assert.equal(player.ONBOARDING_STEPS.length, 8);
assert.equal(player.nextOnboardingStep(0), 1);
assert.equal(player.nextOnboardingStep(6), 7);
assert.equal(player.nextOnboardingStep(7), null);
});
test("child controls reset safely and retain accessible alternatives", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /addEventListener\("pointerup", stopJoystick\)/);
assert.match(script, /addEventListener\("pointercancel", stopJoystick\)/);
assert.match(script, /addEventListener\("blur", resetJoystick\)/);
assert.match(script, /connectionState !== "online"\) resetJoystick\(\)/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetJoystick\(\)/);
assert.match(script, /setMuted\(!state\.muted\)/);
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
assert.match(script, /onboarding-replay/);
assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/);
});
test("frontend name suggestions randomize both words and remain bounded", () => {
const picks = [3, 4];
const suggestion = player.randomSuggestedName([], () => picks.shift());
assert.equal(suggestion, "Смелый-Кактус");
assert.match(suggestion, /^[^-]+-[^-]+$/u);
assert.ok(Buffer.byteLength(suggestion, "utf8") <= 32);
assert.equal(player.NAME_COMBINATION_COUNT, 144);
const occupied = "Бодрый-Барсук";
const skipped = player.randomSuggestedName([occupied], () => 0);
assert.notEqual(skipped, occupied);
assert.equal(skipped, "Бодрый-Крот");
const allNames = [];
for (let adjective = 0; adjective < 12; adjective++) {
for (let noun = 0; noun < 12; noun++) {
const pair = [adjective, noun];
allNames.push(player.randomSuggestedName([], () => pair.shift()));
}
}
assert.equal(player.randomSuggestedName(allNames, () => 0), null);
assert.equal(player.randomIndex(12, () => 99), 0);
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.doesNotMatch(script, /suggest_name|name_suggestion/);
});
test("login headline selection is randomizable, Russian, and has a fallback", () => {
assert.ok(player.LOGIN_HEADLINES.length >= 8);
const selected = player.randomLoginHeadline(() => 5);
assert.deepEqual(selected, {first: "Крот сказал", highlight: "что это срез"});
assert.equal(player.randomLoginHeadline(() => -1), player.LOGIN_HEADLINES[0]);
for (const headline of player.LOGIN_HEADLINES) {
assert.match(headline.first, /[А-Яа-яЁё]/u);
assert.match(headline.highlight, /[А-Яа-яЁё]/u);
}
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /renderWelcomeHeadline\(randomLoginHeadline\(\)\)/);
assert.match(script, /title\.replaceChildren/);
});
test("command envelopes contain intentions rather than client-owned state", () => {
const move = player.createEnvelope("move", 7, "move-7", {direction: "west"});
assert.deepEqual(move, {v: 1, type: "move", seq: 7, requestId: "move-7", payload: {direction: "west"}});
assert.equal("position" in move.payload, false);
assert.deepEqual(player.createEnvelope("drop_key", 8, "drop_key-8", {}), {v: 1, type: "drop_key", seq: 8, requestId: "drop_key-8", payload: {}});
});
test("chunk assembly replaces projection cells without explored-map memory", () => {
const assembler = new player.SnapshotAssembler();
const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15]], objects: []}};
const first = {v: 1, type: "view", seq: 20, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 9, inventory: null, visionMoves: 0, hint: "none", chunk: 0, chunks: 2, cells: [[9, 15]], objects: [["key", 0, 0, 10]]}};
assert.equal(assembler.accept(second), null);
const complete = assembler.accept(first);
assert.deepEqual(player.projectedCellIndices(complete), [9, 10]);
const replacement = assembler.accept({v: 1, type: "view", seq: 21, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 18, inventory: 0, visionMoves: 9, hint: "ne", chunk: 0, chunks: 1, cells: [[18, 15]], objects: []}});
assert.deepEqual(player.projectedCellIndices(replacement), [18]);
assert.equal(replacement.cells.some(cell => cell[0] === 9 || cell[0] === 10), false);
assert.equal(assembler.accept({v: 1, type: "view", seq: 22, payload: {chunk: 0, chunks: player.MAX_VIEW_CHUNKS + 1, cells: [], objects: []}}), null);
assert.equal(player.snapshotIsValid({width: 32, height: 32, cells: [[1023, 15]], objects: []}), true);
assert.equal(player.snapshotIsValid({width: 33, height: 32, cells: [], objects: []}), false);
assert.equal(player.snapshotIsValid({width: 32, height: 33, cells: [], objects: []}), false);
});
test("refresh contract restores only the token and waits for server projection", () => {
const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"});
assert.deepEqual(Object.keys(restore.payload), ["token"]);
assert.equal("position" in restore.payload, false);
assert.equal("inventory" in restore.payload, false);
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.equal((script.match(/localStorage\.setItem/g) || []).length, 1);
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
});
test("player assets are local, Russian, accessible, and responsive", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const all = `${html}\n${css}\n${script}\n${icons}`;
assert.doesNotMatch(all, /(?:src|href)=["']https?:|url\(["']?https?:/i);
assert.doesNotMatch(all, /@import|googleapis|analytics/i);
assert.match(html, /lang="ru"/);
assert.match(html, /aria-label="Сбросить ключ"/);
assert.match(html, /Кусты уже|Сбор заблудившихся/);
assert.match(css, /@media \(max-width: 720px\)/);
assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/);
assert.match(css, /height: calc\(100dvh - 78px\)/);
assert.match(css, /\.dpad \{ display: none; \}/);
assert.match(html, /id="joystick"/);
assert.match(html, /id="onboarding"/);
assert.match(html, /id="mute-toggle"/);
assert.match(css, /\.joystick \{[^}]*width: 180px; height: 180px/);
assert.match(script, /resetJoystick/);
assert.match(script, /playerCellSize/);
assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/);
assert.match(icons, /id="key"/);
assert.match(icons, /id="maze"/);
});
test("observer view fits large screens and bounds zoom", () => {
assert.equal(player.observerFitScale(1024, 600, 32, 32), 2.25);
assert.equal(player.clampObserverZoom(.01), .25);
assert.equal(player.clampObserverZoom(20), 6);
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(html, /Режим наблюдателя · только чтение/);
assert.match(html, /Нужен экран побольше/);
assert.match(html, /Зрительный зал открывается от 1024 × 768/);
assert.match(css, /@media \(max-width: 1023px\), \(max-height: 767px\)/);
assert.match(css, /\.observer-stage \{[^}]*overflow: hidden/);
assert.match(script, /snapshot\.role === "observer"/);
assert.match(script, /state\.session\.role === "observer" \? "observer" : "match"/);
assert.match(script, /!player\.connected \? "нет связи"/);
assert.match(script, /addEventListener\("wheel"/);
assert.match(script, /addEventListener\("pointermove"/);
});
test("observer protocol exposes no mutation control path", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const observerSection = html.match(/<section class="screen observer-screen"[\s\S]*?<\/section>/)[0];
assert.doesNotMatch(observerSection, /data-direction|drop-key|start-match/);
const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6], [1, 12], [2, 3], [3, 9]], objects: []}};
const snapshot = new player.SnapshotAssembler().accept(observer);
assert.equal(snapshot.role, "observer");
assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]);
assert.equal("self" in snapshot, false);
});
test("finished matches expose only an owner restart intention", () => {
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /send\("restart", \{\}, byId\("modal-action"\)\)/);
assert.match(script, /message\.type === "reset"/);
assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/);
});