Files
ark-stronghold-protocol/test/client-static.test.js
T
sganggs 7059528c47 feedback5: merge feedback4 (3af5b84) — PRs #186 / #134 / #185 / #24 / #183 ported to the split layout (terrainInfo / TERRAIN_TIPS into a new ui/gameLogic/terrain.js re-exported by the gameLogic.js barrel, closesOnFieldPress into ui/gameLogic/shortcuts.js, the rest in place; 42 strings through t(), #183's 设置 too); golden unchanged
Ported by hand:
- public/js/ui/gameLogic.js is a re-export shim on feedback5: #185's TERRAIN_TIPS / terrainInfo went into the new
  public/js/ui/gameLogic/terrain.js (the barrel gains one re-export line); closesOnFieldPress 'terrain' into
  ui/gameLogic/shortcuts.js.
- i18n (docs/I18N.md): every new user-visible string goes through t() with an English entry in public/i18n/en.json
  (42, after the 补位 strings): the terrain names / tags / fact table are N_() and translated where terrainInfo hands
  them out, the mechanism lines are msgids with their numbers as params (a line with an optional half is two whole
  msgids, the list joins use an array param), the card's 地形机制 / 这一格 titles in ui/detailPanel.js. Official EN
  terms where the EN client has them (stage_table tileInfo: Active Originium, Mire, Exhaust Grille, Deep Water Zone,
  Incursion Point, Protection Objective, Tunnel Entry / Exit, High Ground; Camouflage). The Chinese output is
  byte-identical to feedback4's terrainInfo on all 5187 tiles of the 13 stages and on synthetic stages.
- public/js/screens/game.js (MatchScreen still lives there): the import keeps feedback5's names plus terrainInfo /
  fieldTile; the terrain stage is the one view.setStage gets, so it includes feedback5's fieldStage — a 联防 field is
  played on its own map (GitHub #41), and a tap there explains that map's tile, not the round stage's. The tileClick
  handler and render/app.js emitTileClick / ui/fallbackField.js auto-merged in place. terrainInfo reads the stage only,
  so a 补位 / 自选 board explains its tiles the same way (their pieces only change a piece's art and name).
- public/js/ui/detailPanel.js: the terrain branch of resolveDetail before feedback5's stand-in / 自选 helpers.
- public/js/screens/title.js (#183): feedback5's LangToggle / t() imports plus GIcon / SettingsModal; the gear
  button's aria-label / title are t('设置') (en.json already has "Settings").
- tools/fetch-assets.mjs / tools/assets/downloader.mjs: --add-only (keepExisting, the font skip) beside #24's
  --asset-source / SP_ASSET_SOURCE and the shared MirrorPolicy; feedback5's extraOperators / moduleTypes and the
  voice plan unchanged.
- data/assets.json: feedback5's manifest plus #134's audio.sfx.battle.leak (after heal, the plan order); stats files
  6993 → 6994 and bytes +23 240 as measured on feedback4; the hash recomputed with the generator's contentHash
  (c01aa739a2d3 → 6a547fc5c937). A field-wise JSON diff against feedback5 shows only those four fields.
- docs: DESIGN §24 intro and §24.5 / §24.6 as on feedback4 (the rest of DESIGN.md is feedback5's); ASSETS.md,
  DEPLOY.md, PLAYING.md auto-merged.
- Auto-merged and checked: audio.js (the sim still emits ['leak', id], server/sim/battle/deploy.js), render/style.js,
  textures.js, tiles.js, board3d/materials.js, boardArt.js, tools/assets/*, tools/setup.mjs, extract.py, the tests.

Checks: the PRs' unit tests (audio 22, gameLogic 44, terrain-detail 1, originium-art 4, boardart 15,
board3d-extract 9, asset-network 21, fetch-assets-shrink 4), i18n 9 / i18n-data 8 / i18n-server 4, client-static 313,
docs-consistency 24, kits_layout 6 pass; lint 0 errors (no new warnings), check:imports, typecheck; golden: all 283
scenarios match, nothing regenerated.
2026-10-06 14:41:40 +08:00

1259 lines
58 KiB
JavaScript

// test/client-static.test.js — static and unit checks for the browser shell (public/).
//
// Static:
// * shared/constants.js + shared/protocol.js import cleanly in Node (pure ESM, no Node APIs needed).
// * every public/js/**/*.js file (and every inline <script type="module"> in public HTML) parses as an
// ES module (`node --input-type=module --check`, stdin).
// * public/index.html and public/dev/uikit.html only reference files that exist (src/href/import map);
// /fonts/ and /assets/ are produced by the assets pipeline and are reported, not failed, when absent.
// * the static import graph resolves: relative/absolute specifiers point at existing files (with browser
// URL semantics: '../../shared/x.js' from /js/ is /shared/x.js), bare specifiers are in the import map,
// and every named import is actually exported by its target.
// Unit (pure logic of the client modules, imported directly in Node — 'preact'/'htm' resolve from
// node_modules): store, net (fake WebSocket + fake timers), identity (fake BroadcastChannel hub), data,
// screen helpers, toasts.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PUBLIC = path.join(ROOT, 'public');
const HTML_PAGES = ['index.html', 'dev/uikit.html'];
const OPTIONAL_PREFIXES = ['/fonts/', '/assets/']; // generated by tools/fetch-assets.mjs
// ---- helpers -------------------------------------------------------------------------------------------
function walk(dir, ext, out = []) {
for (const name of readdirSync(dir)) {
const p = path.join(dir, name);
if (statSync(p).isDirectory()) walk(p, ext, out);
else if (p.endsWith(ext)) out.push(p);
}
return out;
}
/** URL path served by the server → file on disk (mirrors the server/http/static.js mounts). */
function urlPathToFile(urlPath) {
const clean = decodeURIComponent(urlPath.split(/[?#]/)[0]);
if (clean.startsWith('/shared/')) return path.join(ROOT, clean);
if (clean.startsWith('/data/')) return path.join(ROOT, clean);
return path.join(PUBLIC, clean);
}
/** File under public/ → its URL path. */
const fileToUrlPath = (file) => '/' + path.relative(PUBLIC, file).split(path.sep).join('/');
function checkModuleSyntax(source) {
const r = spawnSync(process.execPath, ['--input-type=module', '--check'], { input: source, encoding: 'utf8' });
return r.status === 0 ? null : (r.stderr || r.stdout || 'syntax error').split('\n').slice(0, 6).join('\n');
}
function parseImportMap(htmlSrc) {
const m = htmlSrc.match(/<script\s+type="importmap"\s*>([\s\S]*?)<\/script>/);
return m ? JSON.parse(m[1]).imports || {} : {};
}
function inlineModules(htmlSrc) {
const out = [];
const re = /<script\s+type="module"\s*>([\s\S]*?)<\/script>/g;
let m;
while ((m = re.exec(htmlSrc))) out.push(m[1]);
return out;
}
function htmlRefs(htmlSrc) {
const refs = [];
const re = /\s(?:src|href)="([^"]+)"/g;
let m;
while ((m = re.exec(htmlSrc))) {
const v = m[1];
if (/^(https?:|data:|mailto:|#|javascript:)/.test(v)) continue;
refs.push(v);
}
return refs;
}
/** Static import/export-from statements: [{ spec, clause }]. Statements start at the beginning of a line. */
function staticImports(src) {
const out = [];
const re = /^[ \t]*(?:import|export)\b([^;'"]*?)\bfrom\s*['"]([^'"]+)['"]|^[ \t]*import\s*['"]([^'"]+)['"]/gm;
let m;
while ((m = re.exec(src))) {
if (m[3]) out.push({ spec: m[3], clause: '' });
else out.push({ spec: m[2], clause: m[1].trim() });
}
return out;
}
/** Names a module exports ('*' when it re-exports everything and names can't be listed). */
function exportedNames(src) {
const names = new Set();
let m;
const decl = /export\s+(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([\w$]+)/g;
while ((m = decl.exec(src))) names.add(m[1]);
const list = /export\s*\{([^}]*)\}/g;
while ((m = list.exec(src))) {
for (const part of m[1].split(',')) {
const p = part.trim();
if (!p) continue;
const as = p.split(/\s+as\s+/);
names.add((as[1] || as[0]).trim());
}
}
if (/export\s+default\b/.test(src)) names.add('default');
if (/export\s*\*\s*from/.test(src)) names.add('*');
return names;
}
/** Imported binding names from an import clause ('default' for a default import). */
function importedNames(clause) {
if (!clause || /^\*\s*as\s/.test(clause)) return [];
const names = [];
const brace = clause.match(/\{([^}]*)\}/);
if (brace) {
for (const part of brace[1].split(',')) {
const p = part.trim();
if (p) names.push(p.split(/\s+as\s+/)[0].trim());
}
}
const head = clause.replace(/\{[^}]*\}/, '').replace(/,/g, ' ').trim();
if (head && !head.startsWith('*') && /^[\w$]+$/.test(head)) names.push('default');
return names;
}
// ---- static checks --------------------------------------------------------------------------------------
describe('shared modules', () => {
test('constants.js and protocol.js import in Node', async () => {
const c = await import(pathToFileURL(path.join(ROOT, 'shared/constants.js')).href);
const p = await import(pathToFileURL(path.join(ROOT, 'shared/protocol.js')).href);
assert.equal(typeof c.PROTOCOL_VERSION, 'number');
for (const d of c.DIFFICULTIES) {
assert.ok(c.DIFFICULTY_NAMES[d], `name for ${d}`);
assert.match(c.DIFFICULTY_COLORS[d], /^#[0-9a-f]{6}$/i);
}
for (const code of Object.keys(c.ERR)) assert.ok(c.ERR_TEXT[code], `ERR_TEXT.${code}`);
for (const ph of Object.keys(c.PHASE)) assert.ok(c.PHASE_NAMES[ph], `PHASE_NAMES.${ph}`);
assert.equal(typeof p.validateC2S, 'function');
assert.equal(p.validateC2S({ t: 'room.create', mode: 'coop', difficulty: 'HARD' }), null);
assert.ok(Array.isArray(p.S2C) && p.S2C.includes('room.state'));
});
});
const JS_FILES = walk(path.join(PUBLIC, 'js'), '.js');
describe('client modules parse as ES modules', () => {
test('found client modules', () => {
for (const f of ['main.js', 'net.js', 'store.js', 'data.js', 'ui/components.js', 'ui/toasts.js',
'screens/title.js', 'screens/lobby.js', 'screens/room.js', 'screens/game.js']) {
assert.ok(existsSync(path.join(PUBLIC, 'js', f)), `public/js/${f} exists`);
}
});
for (const file of JS_FILES) {
test(path.relative(ROOT, file), () => {
const err = checkModuleSyntax(readFileSync(file, 'utf8'));
assert.equal(err, null, err || '');
});
}
for (const page of HTML_PAGES) {
test(`inline modules of public/${page}`, () => {
const src = readFileSync(path.join(PUBLIC, page), 'utf8');
for (const mod of inlineModules(src)) {
const err = checkModuleSyntax(mod);
assert.equal(err, null, err || '');
}
});
}
});
describe('HTML pages reference existing files', () => {
for (const page of HTML_PAGES) {
test(`public/${page}`, (t) => {
const src = readFileSync(path.join(PUBLIC, page), 'utf8');
const pageUrl = new URL(`http://host/${page}`);
const imports = parseImportMap(src);
assert.ok(imports.preact && imports['preact/hooks'] && imports.htm, 'import map has preact, preact/hooks, htm');
const refs = [...htmlRefs(src), ...Object.values(imports)];
for (const ref of refs) {
const urlPath = new URL(ref, pageUrl).pathname;
const file = urlPathToFile(urlPath);
if (!existsSync(file)) {
if (OPTIONAL_PREFIXES.some((p) => urlPath.startsWith(p))) t.diagnostic(`optional asset missing: ${urlPath}`);
else assert.fail(`${page} references missing ${urlPath}`);
}
}
});
}
test('index.html boots main.js as a module and has the rotate hint', () => {
const src = readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');
assert.match(src, /<script type="module" src="\/js\/main\.js"[^>]*><\/script>/);
assert.match(src, /class="rotate-hint"/);
assert.match(src, /fonts\.googleapis\.com\/css2\?family=Noto\+Sans\+SC/);
assert.match(src, /href="\/fonts\/fonts\.css"/);
});
});
describe('static import graph resolves', () => {
const importMap = parseImportMap(readFileSync(path.join(PUBLIC, 'index.html'), 'utf8'));
const exportsCache = new Map();
const exportsOf = (file) => {
if (!exportsCache.has(file)) exportsCache.set(file, exportedNames(readFileSync(file, 'utf8')));
return exportsCache.get(file);
};
function checkSource(label, src, baseUrl) {
for (const { spec, clause } of staticImports(src)) {
let urlPath;
if (/^(\.{1,2}\/|\/)/.test(spec)) urlPath = new URL(spec, baseUrl).pathname;
else {
assert.ok(importMap[spec], `${label}: bare specifier '${spec}' is not in the import map`);
urlPath = importMap[spec];
}
const file = urlPathToFile(urlPath);
assert.ok(existsSync(file), `${label}: '${spec}' → ${urlPath} does not exist`);
const names = exportsOf(file);
if (names.has('*')) continue;
for (const n of importedNames(clause)) {
assert.ok(names.has(n), `${label}: '${n}' is not exported by ${urlPath}`);
}
}
}
for (const file of JS_FILES) {
test(path.relative(ROOT, file), () => {
checkSource(path.relative(ROOT, file), readFileSync(file, 'utf8'), new URL(`http://host${fileToUrlPath(file)}`));
});
}
test('dev/uikit.html inline module', () => {
const src = readFileSync(path.join(PUBLIC, 'dev/uikit.html'), 'utf8');
for (const mod of inlineModules(src)) checkSource('uikit.html', mod, new URL('http://host/dev/uikit.html'));
});
test('client modules never import Node built-ins', () => {
for (const file of JS_FILES) {
const src = readFileSync(file, 'utf8');
assert.doesNotMatch(src, /from\s+['"]node:/, `${path.relative(ROOT, file)} imports a node: module`);
}
});
});
// ---- unit: store ------------------------------------------------------------------------------------------
const mod = (rel) => import(pathToFileURL(path.join(PUBLIC, 'js', rel)).href);
describe('store.js', () => {
test('createStore get/set/patch/subscribe', async () => {
const { createStore } = await mod('store.js');
const s = createStore({ a: 1, nested: { x: 1, y: 2 } });
const seen = [];
const off = s.subscribe((st, prev) => seen.push([st.a, prev.a]));
s.set({ a: 2 });
s.set((st) => ({ a: st.a + 1 }));
s.patch('nested', { y: 5 });
assert.deepEqual(s.get().nested, { x: 1, y: 5 });
s.set(null); // ignored
off();
s.set({ a: 99 });
assert.deepEqual(seen, [[2, 1], [3, 2], [3, 3]]);
s.reset();
assert.equal(s.get().a, 1);
});
test('a throwing listener does not break others', async () => {
const { createStore } = await mod('store.js');
const s = createStore({ v: 0 });
let ok = 0;
const orig = console.error;
console.error = () => {};
try {
s.subscribe(() => { throw new Error('boom'); });
s.subscribe(() => { ok++; });
s.set({ v: 1 });
} finally { console.error = orig; }
assert.equal(ok, 1);
});
test('shallowEqual', async () => {
const { shallowEqual } = await mod('store.js');
assert.ok(shallowEqual({ a: 1, b: 'x' }, { a: 1, b: 'x' }));
assert.ok(!shallowEqual({ a: 1 }, { a: 1, b: 2 }));
assert.ok(!shallowEqual({ a: {} }, { a: {} }));
assert.ok(shallowEqual([1, 2], [1, 2]));
assert.ok(!shallowEqual([1], { 0: 1 }));
assert.ok(shallowEqual(null, null));
assert.ok(!shallowEqual(null, {}));
});
test('selectRoute', async () => {
const { selectRoute } = await mod('store.js');
const base = { session: { entered: true }, room: null, match: { public: null } };
assert.equal(selectRoute({ ...base, session: { entered: false } }), 'title');
assert.equal(selectRoute(base), 'lobby');
assert.equal(selectRoute({ ...base, room: { code: 'ABCD' } }), 'room');
assert.equal(selectRoute({ ...base, room: { code: 'ABCD', inMatch: true } }), 'game');
assert.equal(selectRoute({ ...base, room: { code: 'ABCD' }, match: { public: { phase: 'PREP' } } }), 'game');
assert.equal(selectRoute({ ...base, room: { code: 'ABCD' }, match: { public: { phase: 'LOBBY' } } }), 'room');
assert.equal(selectRoute(undefined), 'title');
});
test('serverNow applies the clock offset', async () => {
const { store, serverNow } = await mod('store.js');
store.set({ clock: { offset: 5000, rtt: 10, synced: true } });
const d = serverNow() - Date.now();
assert.ok(d >= 4990 && d <= 5010, `offset applied (${d})`);
store.set({ clock: { offset: NaN } });
assert.ok(Math.abs(serverNow() - Date.now()) < 50, 'NaN offset ignored');
store.set({ clock: { offset: 0, rtt: null, synced: false } });
});
});
// ---- unit: net --------------------------------------------------------------------------------------------
function fakeTimers(start = 1_000_000) {
let now = start;
let seq = 0;
const q = new Map();
const add = (fn, ms, every) => { const id = ++seq; q.set(id, { at: now + Math.max(0, ms | 0), fn, every }); return id; };
return {
now: () => now,
setTimeout: (fn, ms) => add(fn, ms, 0),
clearTimeout: (id) => q.delete(id),
setInterval: (fn, ms) => add(fn, ms, Math.max(1, ms | 0)),
clearInterval: (id) => q.delete(id),
advance(ms) {
const end = now + ms;
for (;;) {
let next = null;
for (const [id, t] of q) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t];
if (!next) break;
const [id, t] = next;
now = t.at;
if (t.every) t.at += t.every; else q.delete(id);
t.fn();
}
now = end;
},
};
}
function makeFakeWS() {
const sockets = [];
class FakeWS {
constructor(url) {
this.url = url;
this.readyState = 0;
this.sent = [];
this.closedWith = null;
sockets.push(this);
}
send(data) {
if (this.readyState !== 1) throw new Error('not open');
this.sent.push(JSON.parse(data));
}
close(code) { this.readyState = 3; this.closedWith = code ?? 1000; }
// test helpers
open() { this.readyState = 1; this.onopen?.(); }
recv(obj) { this.onmessage?.({ data: typeof obj === 'string' ? obj : JSON.stringify(obj) }); }
drop(code = 1006) { this.readyState = 3; this.onclose?.({ code }); }
last(t) { return [...this.sent].reverse().find((m) => m.t === t); }
}
return { FakeWS, sockets };
}
async function makeNet(extra = {}) {
const { Net } = await mod('net.js');
const timers = fakeTimers();
const { FakeWS, sockets } = makeFakeWS();
const net = new Net({ url: 'ws://test/ws', WebSocket: FakeWS, timers, now: timers.now, random: () => 0.5, getToken: () => 'tok-1', ...extra });
const statuses = [];
net.on('status', (s) => statuses.push(s.status));
return { net, timers, sockets, statuses, ws: () => sockets[sockets.length - 1] };
}
/** Connect, open, set a name and complete the handshake. */
async function onlineNet(extra) {
const ctx = await makeNet(extra);
ctx.net.setName('凯尔希');
ctx.ws().open();
const hello = ctx.ws().last('hello');
ctx.ws().recv({ t: 'welcome', rid: hello.rid, playerId: 'p_1', token: 'tok-2', name: '凯尔希', serverNow: ctx.timers.now() });
return ctx;
}
const quiet = async (fn) => {
const w = console.warn;
console.warn = () => {};
try { return await fn(); } finally { console.warn = w; }
};
/** Answer the latest ping on a fake socket. */
function ws0Pong(sock) {
const p = sock.last('ping');
if (p) sock.recv({ t: 'pong', c: p.c, s: p.c });
}
describe('net.js', () => {
test('backoffDelay grows, is capped and jittered', async () => {
const { backoffDelay, BACKOFF } = await mod('net.js');
const mid = () => 0.5;
assert.equal(backoffDelay(0, mid), BACKOFF.base);
assert.equal(backoffDelay(1, mid), BACKOFF.base * 2);
assert.equal(backoffDelay(50, mid), BACKOFF.max);
assert.ok(backoffDelay(3, () => 0) < backoffDelay(3, () => 0.999));
assert.ok(backoffDelay(-5, mid) >= 100);
assert.ok(backoffDelay(NaN, mid) >= 100);
});
test('errorText / NetError', async () => {
const { errorText, NetError } = await mod('net.js');
assert.equal(errorText('ROOM_FULL'), '房间已满');
assert.equal(errorText('TIMEOUT'), '请求超时,请重试');
assert.equal(errorText('WHATEVER', 'server says'), 'server says');
const e = new NetError('NO_FUNDS');
assert.ok(e instanceof Error);
assert.equal(e.code, 'NO_FUNDS');
assert.equal(e.message, '资金不足');
assert.match(new NetError('BAD_MSG', 'x', 'version mismatch: server 2').message, /版本/);
});
test('defaultWsUrl', async () => {
const { defaultWsUrl } = await mod('net.js');
assert.equal(defaultWsUrl({ protocol: 'http:', host: 'a:3000' }), 'ws://a:3000/ws');
assert.equal(defaultWsUrl({ protocol: 'https:', host: 'x.io' }), 'wss://x.io/ws');
});
test('connect without a name → connected + ping; setName → hello with token & version', async () => {
const { PROTOCOL_VERSION } = await import(pathToFileURL(path.join(ROOT, 'shared/constants.js')).href);
const { net, ws, statuses } = await makeNet();
net.connect();
assert.equal(net.status, 'connecting');
ws().open();
assert.equal(net.status, 'connected');
assert.ok(ws().last('ping'), 'latency probe before hello');
net.setName(' 凯尔希 ');
const hello = ws().last('hello');
assert.deepEqual({ ...hello, rid: 0 }, { t: 'hello', rid: 0, name: '凯尔希', version: PROTOCOL_VERSION, token: 'tok-1' });
assert.equal(net.status, 'handshaking');
ws().recv({ t: 'welcome', rid: hello.rid, playerId: 'p_1', token: 't', name: '凯尔希', serverNow: Date.now() });
assert.equal(net.status, 'online');
assert.equal(net.playerId, 'p_1');
assert.deepEqual(statuses.slice(0, 2), ['connecting', 'connected']);
net.setName('凯尔希');
assert.equal(ws().sent.filter((m) => m.t === 'hello').length, 1, 'same name: no second hello');
net.setName('阿米娅');
assert.equal(ws().last('hello').name, '阿米娅', 'rename re-sends hello on the live socket');
});
test('token longer than the protocol limit is not sent', async () => {
const { net, ws } = await makeNet({ getToken: () => 'x'.repeat(65) });
net.setName('A');
ws().open();
assert.equal(ws().last('hello').token, undefined);
});
test('request resolves on ok, rejects on error with ERR text', async () => {
const { net, ws } = await onlineNet();
const p1 = net.request('room.create', { mode: 'coop', difficulty: 'HARD' });
const m1 = ws().last('room.create');
assert.equal(m1.mode, 'coop');
ws().recv({ t: 'ok', rid: m1.rid });
assert.equal((await p1).t, 'ok');
const p2 = net.request('room.join', { code: 'ABCD' });
const m2 = ws().last('room.join');
ws().recv({ t: 'error', rid: m2.rid, code: 'ROOM_NOT_FOUND', msg: '…' });
await assert.rejects(p2, (e) => e.code === 'ROOM_NOT_FOUND' && e.message === '未找到该同盟密钥对应的房间');
assert.equal(net.pendingCount, 0);
});
test('invalid request fails locally with BAD_MSG; hello via request is refused', async () => {
const { net, ws } = await onlineNet();
const before = ws().sent.length;
await quiet(() => assert.rejects(net.request('room.create', { mode: 'pvp', difficulty: 'HARD' }), (e) => e.code === 'BAD_MSG'));
await assert.rejects(net.request('hello', { name: 'x' }), (e) => e.code === 'BAD_MSG');
await quiet(() => assert.rejects(net.request('nope', {}), (e) => e.code === 'BAD_MSG'));
assert.equal(ws().sent.length, before, 'nothing sent');
});
test('request times out after 8 s', async () => {
const { net, timers, ws } = await onlineNet();
const p = net.request('room.leave', {});
timers.advance(7999);
assert.equal(net.pendingCount, 1);
timers.advance(2);
await assert.rejects(p, (e) => e.code === 'TIMEOUT');
assert.equal(net.pendingCount, 0);
const unhandled = [];
net.on('unhandledError', (e) => unhandled.push(e.code));
ws().recv({ t: 'error', rid: ws().last('room.leave').rid, code: 'NOT_IN_ROOM', msg: 'x' });
assert.deepEqual(unhandled, [], 'late reply to a timed-out request is not reported twice');
});
test('requests before a session: OFFLINE when idle, queued while handshaking then flushed', async () => {
const { net, ws } = await makeNet();
await assert.rejects(net.request('room.leave', {}), (e) => e.code === 'OFFLINE');
net.setName('A');
const p = net.request('room.ready', { ready: true });
ws().open();
assert.equal(ws().last('room.ready'), undefined, 'not sent before welcome');
ws().recv({ t: 'welcome', rid: ws().last('hello').rid, playerId: 'p', token: 't', name: 'A', serverNow: 1 });
const sent = ws().last('room.ready');
assert.ok(sent, 'flushed after welcome');
ws().recv({ t: 'ok', rid: sent.rid });
await p;
});
test('drop → pending rejects, reconnect with backoff, hello again', async () => {
const { net, ws, sockets, timers } = await onlineNet();
const p = net.request('room.leave', {});
ws().drop();
await assert.rejects(p, (e) => e.code === 'DISCONNECTED');
assert.equal(net.status, 'reconnecting');
assert.equal(net.attempt, 1);
assert.equal(sockets.length, 1);
timers.advance(499);
assert.equal(sockets.length, 1);
timers.advance(2);
assert.equal(sockets.length, 2, 'reconnected after backoff');
ws().open();
assert.equal(ws().last('hello').name, '凯尔希');
ws().drop();
assert.equal(net.attempt, 2, 'attempt keeps growing until welcome');
timers.advance(1001);
ws().open();
ws().recv({ t: 'welcome', rid: ws().last('hello').rid, playerId: 'p_1', token: 't', name: '凯尔希', serverNow: 1 });
assert.equal(net.attempt, 0);
assert.equal(net.status, 'online');
});
test('pong gives latency and clock offset', async () => {
const { net, ws, timers } = await onlineNet();
const clocks = [];
net.on('clock', (c) => clocks.push(c));
net.probe();
const ping = ws().last('ping');
timers.advance(40);
ws().recv({ t: 'pong', c: ping.c, s: ping.c + 20 + 5000 });
assert.equal(net.ping, 40);
assert.equal(net.clockOffset, 5000);
assert.ok(clocks.at(-1).synced);
assert.ok(Math.abs(net.serverNow() - (timers.now() + 5000)) <= 1);
ws().recv({ t: 'pong', c: 'garbage', s: 1 });
ws().recv({ t: 'pong', c: timers.now() + 99999, s: 1 }); // negative rtt ignored
assert.equal(net.ping, 40);
});
test('silent socket is detected and replaced', async () => {
const { net, sockets, timers } = await quiet(() => onlineNet());
await quiet(async () => { timers.advance(20000); });
assert.equal(sockets[0].closedWith, 4000);
assert.equal(net.status, 'reconnecting');
});
test('throttled background timers (heartbeat ~1/min) do not look like a dead socket', async () => {
const { timers, sockets, net, ws } = await (async () => {
const t = fakeTimers();
const throttled = { ...t, setInterval: (fn) => t.setInterval(fn, 60_000) }; // intensive throttling
return onlineNet({ timers: throttled, now: t.now }).then((ctx) => ({ ...ctx, timers: t }));
})();
for (let i = 0; i < 5; i++) {
const ping = ws().last('ping');
timers.advance(30);
ws().recv({ t: 'pong', c: ping.c, s: ping.c }); // server answers promptly
timers.advance(60_000 - 30); // next (late) heartbeat
}
assert.equal(sockets.length, 1, 'no reconnect churn');
assert.equal(net.status, 'online');
// …but a socket that really stopped answering is still replaced (at the next late heartbeat).
await quiet(async () => { timers.advance(130_000); });
assert.equal(net.status, 'reconnecting');
});
test('server close 4001 (session replaced) stops auto-reconnect', async () => {
const { net, ws, sockets, timers } = await onlineNet();
const events = [];
net.on('replaced', (e) => events.push(e.code));
ws().drop(4001);
assert.equal(net.status, 'closed');
assert.deepEqual(events, ['REPLACED']);
timers.advance(60000);
assert.equal(sockets.length, 1, 'no reconnect');
await assert.rejects(net.request('room.leave', {}), (e) => e.code === 'CLOSED');
net.connect();
assert.equal(sockets.length, 2, 'manual connect resumes');
});
test('server drops an idle pre-hello socket (4002): swapped quietly, no status flicker', async () => {
const { net, sockets, timers, statuses } = await makeNet();
net.connect();
sockets[0].open();
assert.equal(net.status, 'connected');
for (let i = 0; i < 10; i++) { ws0Pong(sockets[0]); timers.advance(4000); } // idle on the title, server answers pings
ws0Pong(sockets[0]);
const before = statuses.length;
sockets[0].drop(4002);
assert.equal(sockets.length, 2, 'fresh socket opened at once');
assert.equal(net.status, 'connected');
sockets[1].open();
assert.equal(net.status, 'connected');
assert.deepEqual(statuses.slice(before), ['connected'], 'only the (unchanged) connected status is re-emitted');
net.setName('A');
assert.ok(sockets[1].last('hello'), 'hello goes out on the new socket');
// a socket closed with 4002 right after opening is not swapped quietly (no tight loop)
sockets[1].drop(4002);
assert.equal(net.status, 'reconnecting');
});
test('hello error → connected, helloError, queued requests fail', async () => {
const { net, ws } = await makeNet();
const errs = [];
net.on('helloError', (e) => errs.push(e));
net.on('unhandledError', () => assert.fail('hello error must not be reported as unhandled'));
net.setName('A');
const p = net.request('room.leave', {});
ws().open();
ws().recv({ t: 'error', rid: ws().last('hello').rid, code: 'BAD_MSG', msg: 'x', detail: 'version mismatch: server 2' });
assert.equal(net.status, 'connected');
assert.equal(errs.length, 1);
assert.match(errs[0].message, /版本/);
await assert.rejects(p, (e) => e.code === 'OFFLINE');
});
test('pushes are emitted by type and "*"; unsolicited errors as unhandledError; junk ignored', async () => {
const { net, ws } = await onlineNet();
const got = [];
net.on('room.state', (m) => got.push(m.code));
const all = [];
const off = net.on('*', (m) => all.push(m.t));
const unhandled = [];
net.on('unhandledError', (e) => unhandled.push(e.code));
ws().recv({ t: 'room.state', code: 'WXYZ', seats: [] });
ws().recv({ t: 'error', code: 'RATE', msg: 'r' });
await quiet(async () => { ws().recv('not json'); });
ws().recv('[1,2]');
ws().recv({ nope: 1 });
off();
ws().recv({ t: 'm.toast', text: 'x' });
assert.deepEqual(got, ['WXYZ']);
assert.deepEqual(all, ['room.state', 'error']);
assert.deepEqual(unhandled, ['RATE']);
});
test('a throwing listener does not break dispatch', async () => {
const { net, ws } = await onlineNet();
let ok = 0;
const orig = console.error;
console.error = () => {};
try {
net.on('m.public', () => { throw new Error('boom'); });
net.on('m.public', () => { ok++; });
ws().recv({ t: 'm.public', phase: 'PREP', serverNow: 1 });
} finally { console.error = orig; }
assert.equal(ok, 1);
});
test('send() is fire-and-forget and validated', async () => {
const { net, ws } = await onlineNet();
assert.equal(net.send('g.emote', { id: 'autochess_battle_happy' }), true);
assert.deepEqual(ws().last('g.emote'), { t: 'g.emote', id: 'autochess_battle_happy' });
await quiet(async () => assert.equal(net.send('g.emote', { id: 'nope' }), false));
net.close();
assert.equal(net.send('g.emote', { id: 'autochess_battle_happy' }), false);
assert.equal(net.status, 'closed');
});
test('close() rejects queued requests and stops reconnecting', async () => {
const { net, sockets, timers } = await makeNet();
net.setName('A');
const p = net.request('room.leave', {});
net.close();
await assert.rejects(p, (e) => e.code === 'CLOSED');
timers.advance(60000);
assert.equal(sockets.length, 1);
});
test('constructor failure schedules a retry instead of throwing', async () => {
const { Net } = await mod('net.js');
const timers = fakeTimers();
let n = 0;
class Boom { constructor() { n++; throw new Error('SecurityError'); } }
const net = new Net({ url: 'ws://x/ws', WebSocket: Boom, timers, now: timers.now, random: () => 0.5 });
await quiet(async () => { net.connect(); timers.advance(600); });
assert.equal(n, 2);
assert.equal(net.status, 'reconnecting');
net.close();
});
});
// ---- unit: identity ----------------------------------------------------------------------------------------
function memStorage() {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => { m.set(k, String(v)); },
removeItem: (k) => { m.delete(k); },
/** sessionStorage is copied when a tab is duplicated */
clone() { const c = memStorage(); for (const [k, v] of m) c.setItem(k, v); return c; },
};
}
/** In-memory BroadcastChannel hub: messages reach every other open member asynchronously. */
function channelHub() {
const members = new Set();
return {
create() {
const ch = {
listeners: new Set(),
postMessage(data) {
if (!members.has(ch)) throw new Error('InvalidStateError: channel closed');
const copy = structuredClone(data);
for (const m of members) {
if (m === ch) continue;
queueMicrotask(() => { if (members.has(m)) for (const fn of m.listeners) fn({ data: copy }); });
}
},
addEventListener(type, fn) { if (type === 'message') ch.listeners.add(fn); },
close() { members.delete(ch); },
};
members.add(ch);
return ch;
},
};
}
describe('identity (reconnect-token selection across tabs)', () => {
const recent = (local) => JSON.parse(local.getItem('sp.tokens') || '[]');
test('new tab, second tab, duplicated tab, reload, reopen', async () => {
const { createIdentity } = await mod('net.js');
const hub = channelHub();
const local = memStorage();
const tab = (id, session = memStorage()) => {
const channel = hub.create();
return { id: createIdentity({ local, session, tabId: id, channel, queryMs: 5 }), session, channel };
};
const A = tab('A');
assert.equal(await A.id.init(), null, 'fresh browser: no token → new session');
assert.equal(A.id.getToken(), null);
A.id.saveToken('tokA');
assert.equal(A.id.getToken(), 'tokA');
assert.deepEqual(recent(local), ['tokA']);
const B = tab('B');
assert.equal(await B.id.init(), null, 'A is live: a second tab must not take tokA');
B.id.saveToken('tokB');
assert.deepEqual(recent(local), ['tokB', 'tokA']);
const dup = tab('Adup', A.session.clone());
assert.equal(dup.session.getItem('sp.token'), 'tokA', 'duplicated tab starts with a copy of A\'s token');
assert.equal(await dup.id.init(), null, 'duplicated tab becomes a new player');
assert.equal(dup.session.getItem('sp.token'), null, 'copied token dropped from the duplicate');
assert.equal(A.id.getToken(), 'tokA', 'original tab keeps its session');
dup.channel.close();
A.channel.close(); // A reloads: the old document is gone before the new one asks
const A2 = tab('A2', A.session);
assert.equal(await A2.id.init(), 'tokA', 'reload resumes the own token');
A2.channel.close(); // tab closed
const C = tab('C');
assert.equal(await C.id.init(), 'tokA', 'reopened tab resumes the most recent token no live tab holds (tokB is B\'s)');
assert.equal(C.session.getItem('sp.token'), 'tokA');
});
test('a busy background tab still answers (no timer-based liveness)', async () => {
const { createIdentity } = await mod('net.js');
const hub = channelHub();
const local = memStorage();
const bg = createIdentity({ local, session: memStorage(), tabId: 'bg', channel: hub.create(), queryMs: 5 });
await bg.init();
bg.saveToken('tokBg');
await new Promise((r) => setTimeout(r, 30)); // arbitrary idle time: nothing to refresh
const fresh = createIdentity({ local, session: memStorage(), tabId: 'fresh', channel: hub.create(), queryMs: 5 });
assert.equal(await fresh.init(), null);
});
test('simultaneous resolution: the smaller tab id wins a shared token', async () => {
const { createIdentity } = await mod('net.js');
const hub = channelHub();
const local = memStorage();
local.setItem('sp.tokens', JSON.stringify(['tokShared']));
const x = createIdentity({ local, session: memStorage(), tabId: 'a-tab', channel: hub.create(), queryMs: 5 });
const y = createIdentity({ local, session: memStorage(), tabId: 'b-tab', channel: hub.create(), queryMs: 5 });
const [tx, ty] = await Promise.all([x.init(), y.init()]);
assert.equal(tx, 'tokShared');
assert.equal(ty, null);
});
test('without BroadcastChannel only the own token is used; init is idempotent; welcome during init wins', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage();
local.setItem('sp.tokens', JSON.stringify(['someoneElse']));
const lone = createIdentity({ local, session: memStorage(), tabId: 'L', channel: null });
assert.equal(await lone.init(), null, 'never adopts a shared token it cannot verify');
const session = memStorage();
session.setItem('sp.token', 'mine');
const own = createIdentity({ local, session, tabId: 'O', channel: null });
assert.equal(own.getToken(), 'mine', 'before init: own token');
const p1 = own.init();
assert.equal(own.init(), p1, 'same promise');
assert.equal(await p1, 'mine');
const hub = channelHub();
const racing = createIdentity({ local: memStorage(), session: memStorage(), tabId: 'R', channel: hub.create(), queryMs: 5 });
racing.saveToken('fromWelcome'); // a welcome arrived before init finished
assert.equal(await racing.init(), 'fromWelcome');
});
test('entered flag, name, clearToken, bad tokens and missing storage', async () => {
const { createIdentity, tokenHash } = await mod('net.js');
const local = memStorage();
const id = createIdentity({ local, session: memStorage(), tabId: 'X', channel: null });
await id.init();
assert.equal(id.wasEntered(), false);
id.setEntered(true);
assert.equal(id.wasEntered(), true);
id.setEntered(false);
assert.equal(id.wasEntered(), false);
id.saveName('凯尔希');
assert.equal(id.loadName(), '凯尔希');
id.saveToken('t');
assert.deepEqual(recent(local), ['t']);
id.clearToken();
assert.equal(id.getToken(), null);
assert.deepEqual(recent(local), [], 'forgotten token leaves the recent list');
id.saveToken('');
id.saveToken('x'.repeat(65));
assert.equal(id.getToken(), null, 'invalid tokens ignored');
for (let i = 0; i < 9; i++) id.saveToken(`t${i}`);
assert.equal(recent(local).length, 4, 'recent list capped');
local.setItem('sp.tokens', '{broken');
id.saveToken('ok');
assert.deepEqual(recent(local), ['ok'], 'corrupt list replaced');
assert.match(tokenHash('abc'), /^[0-9a-f]{8}$/);
assert.notEqual(tokenHash('abc'), tokenHash('abd'));
const none = createIdentity({ local: null, session: null, channel: null });
assert.equal(await none.init(), null);
none.saveToken('x');
assert.equal(none.getToken(), 'x', 'kept in memory for this page');
assert.equal(none.loadName(), '');
assert.equal(none.wasEntered(), false);
});
});
// ---- unit: data --------------------------------------------------------------------------------------------
function fakeFetch(files) {
const calls = [];
const fn = async (url) => {
calls.push(url);
const name = url.split('/').pop();
if (!(name in files)) return { ok: false, status: 404, json: async () => ({}) };
const v = files[name];
return { ok: true, status: 200, json: async () => (typeof v === 'string' ? JSON.parse(v) : v) };
};
fn.calls = calls;
return fn;
}
describe('data.js', () => {
test('indexes map, array and wrapped shapes; tolerates missing/bad files', async () => {
const { createDataStore } = await mod('data.js');
const fetch = fakeFetch({
'chess.json': { chess_a: { name: 'A' }, chess_b: { name: 'B' } },
'bonds.json': [{ bondId: 'yanShip', name: '炎' }, { bondId: 'sargonShip', name: '萨尔贡' }, { junk: 1 }],
'items.json': { version: 2, items: [{ id: 'it1', name: 'I' }] },
'bands.json': '{"broken": ',
});
const d = createDataStore({ fetch, base: '/data/' });
const w = console.warn;
const warns = [];
console.warn = (m) => warns.push(m);
try {
const [chess] = await Promise.all([d.load('chess'), d.load('chess')]);
assert.equal(fetch.calls.filter((u) => u === '/data/chess.json').length, 1, 'single fetch for concurrent loads');
assert.equal(chess.chess_a.name, 'A');
assert.equal(d.lookup('chess', 'chess_b').name, 'B');
await d.loadAll('bonds', 'items', 'bands', 'enemies');
assert.equal(d.lookup('bonds', 'yanShip').name, '炎');
assert.equal(d.list('bonds').length, 2);
assert.equal(d.lookup('items', 'it1').name, 'I');
assert.equal(d.get('bands'), null);
assert.equal(d.status('bands'), 'missing');
assert.equal(d.status('enemies'), 'missing');
assert.equal(d.lookup('enemies', 'x'), null);
assert.equal(d.status('never'), 'idle');
assert.equal(await d.load('../etc/passwd'), null, 'unsafe names rejected');
await d.load('enemies');
assert.equal(warns.filter((m) => String(m).includes('enemies')).length, 1, 'missing file warned once');
} finally { console.warn = w; }
});
test('subscribe + invalidate refetches', async () => {
const { createDataStore } = await mod('data.js');
const fetch = fakeFetch({ 'config.json': { modes: { mode_multi_hard: { name: '绝境模拟' } } } });
const d = createDataStore({ fetch });
const seen = [];
d.subscribe((n) => seen.push(n));
await d.load('config');
d.invalidate('config');
await d.load('config');
assert.equal(fetch.calls.length, 2);
assert.deepEqual(seen, ['config', 'config', 'config']);
});
test('invalidate during an in-flight load: the stale load stays silent, the refetch wins', async () => {
const { createDataStore } = await mod('data.js');
let n = 0;
const releases = [];
const fetch = (url) => new Promise((resolve) => {
const v = ++n;
releases.push(() => resolve({ ok: true, status: 200, json: async () => ({ rec: { v } }) }));
});
const d = createDataStore({ fetch });
const seen = [];
d.subscribe((name) => seen.push(`${name}:${d.status(name)}`));
const first = d.load('bonds');
const second = d.invalidate('bonds');
releases[1]();
await second;
releases[0]();
await first;
assert.equal(d.lookup('bonds', 'rec').v, 2, 'refetched value kept');
assert.equal(d.status('bonds'), 'ready');
assert.deepEqual(seen, ['bonds:loading', 'bonds:ready'], 'no notification from the superseded load');
assert.equal(await d.invalidate('never-loaded'), null);
});
test('buildIndex edge cases', async () => {
const { buildIndex } = await mod('data.js');
assert.equal(buildIndex('x', null).size, 0);
assert.equal(buildIndex('x', 42).size, 0);
assert.equal(buildIndex('x', [{ id: 1 }, { id: 1 }, null]).size, 1);
assert.equal(buildIndex('x', { a: 1, b: { v: 1 } }).size, 1, 'non-object values skipped');
});
});
// ---- unit: screen helpers & components ----------------------------------------------------------------------
describe('screen helpers', () => {
test('title: sanitizeName / isValidName / findUiAsset', async () => {
const { sanitizeName, isValidName, findUiAsset } = await mod('screens/title.js');
assert.equal(sanitizeName(' 凯尔希 '), '凯尔希');
assert.equal(sanitizeName('a\u0000b\u200bc\u202ed'), 'abcd');
assert.equal(sanitizeName('a b'), 'a b');
assert.equal(sanitizeName('x'.repeat(30)).length, 12);
const emoji = '😀'.repeat(7); // 14 UTF-16 units
const cut = sanitizeName(emoji);
assert.ok(cut.length <= 12 && !/[\ud800-\udbff]$/.test(cut), 'no dangling surrogate');
const server = await import(pathToFileURL(path.join(ROOT, 'server/net.js')).href);
for (const raw of ['e\u0301', '\ud800x', '\udc00', 'a \u200b b', '\u00a0\u3000A\u3000B', 'a\u2028b', '😀'.repeat(7), ' x '.repeat(9)]) {
const c = sanitizeName(raw);
assert.ok(c.length <= 12, 'protocol limit (UTF-16 units)');
assert.equal(server.sanitizeName(c) ?? '', c, `server keeps the client-sanitized ${JSON.stringify(raw)} unchanged`);
}
assert.equal(sanitizeName(null), '');
assert.equal(isValidName(' '), false);
assert.equal(isValidName('Doctor'), true);
assert.equal(findUiAsset({ ui: { 'entry/bkg_01': '/assets/ui/entry/bkg_01.png' } }, ['titleBackdrop', 'entry/bkg_01']), '/assets/ui/entry/bkg_01.png');
assert.equal(findUiAsset({ ui: { titleBackdrop: { url: '/a.png' } } }, ['titleBackdrop']), '/a.png');
assert.equal(findUiAsset({ files: ['/assets/ui/entry_bkg_01.webp'] }, ['entry_bkg_01']), '/assets/ui/entry_bkg_01.webp');
assert.equal(findUiAsset(null, ['x']), null);
assert.equal(findUiAsset({ ui: {} }, ['x']), null);
});
test('title exposes the shared settings modal', () => {
const source = readFileSync(path.join(PUBLIC, 'js/screens/title.js'), 'utf8');
assert.match(source, /import \{ SettingsModal \} from '\.\.\/ui\/settings\.js'/);
assert.match(source, /class="title-settings fsbtn tapx"/, 'title screen includes the settings control');
assert.match(source, /<\$\{SettingsModal\} open=\$\{settingsOpen\}/, 'settings control opens the shared modal');
});
test('lobby: normalizeCode / parseRoomParam / difficultyInfo', async () => {
const { normalizeCode, parseRoomParam, difficultyInfo, MODE_TEXT } = await mod('screens/lobby.js');
assert.equal(normalizeCode('ab-c d9'), 'ABCD');
assert.equal(normalizeCode('http://192.168.1.2:3000/?room=wxyz'), 'WXYZ');
assert.equal(normalizeCode(undefined), '');
assert.equal(parseRoomParam('?room=abcd'), 'ABCD');
assert.equal(parseRoomParam('?x=1&room=ABCDEF'), 'ABCDEF');
assert.equal(parseRoomParam('?room=ABCDEFG'), null);
assert.equal(parseRoomParam('?room=AB'), null);
assert.equal(parseRoomParam('?room=AB%3Ccd'), null);
assert.equal(parseRoomParam(''), null);
const solo = difficultyInfo('solo', 'FUNNY');
assert.equal(solo.rounds, 9);
assert.equal(solo.hidden, false);
assert.equal(solo.desc, MODE_TEXT.single.FUNNY.desc);
const hard = difficultyInfo('coop', 'HARD');
assert.equal(hard.rounds, 14);
assert.equal(hard.hidden, true);
assert.equal(hard.code, 'AC-3');
assert.ok(hard.effects.length >= 2);
assert.doesNotThrow(() => difficultyInfo('coop', undefined), 'unknown difficulty degrades gracefully');
assert.equal(difficultyInfo('coop', 'BOGUS').rounds, 14);
});
// Regression: `onClick=${handler}` hands Preact's click EVENT as the first argument, and a default parameter only
// applies to `undefined` — so `normalizeCode(eventTarget)` produced "[object HTMLElement]" → "OBJE", and every
// 观战 click sent the same nonsense key no matter what was typed in the field. codeArg() is the guard for that.
test('lobby: a click event is never mistaken for an alliance key (codeArg)', async () => {
const { codeArg, normalizeCode } = await mod('screens/lobby.js');
// a DOM element stringifies to "[object HTMLElement]" — the shape that caused the bug
const element = { toString: () => '[object HTMLElement]' };
assert.equal(normalizeCode(element), 'OBJE', 'the old bug: the element normalises into a fake 4-letter code');
assert.equal(codeArg(element, 'NJBU'), 'NJBU', 'a non-string argument falls back to the input field');
// plain event-like objects and other non-strings behave the same
for (const weird of [{}, [], 42, true, null, undefined, Symbol('x'), () => {}]) {
assert.equal(codeArg(weird, 'NJBU'), 'NJBU', `falls back for ${String(weird)}`);
}
// an explicit string always wins (the full-room prompt passes the code it asked for)
assert.equal(codeArg('ab cd', 'NJBU'), 'ABCD');
// no usable code at all
assert.equal(codeArg(element, ''), null);
assert.equal(codeArg(element, 'AB'), null);
assert.equal(codeArg(undefined, undefined), null);
assert.equal(codeArg('ZZZ QQQ', ''), 'ZZZQ', 'a real string is still truncated to ROOM_CODE_LEN');
});
test('lobby: battlefield note per difficulty (标准 fixed 战场#01, 险境 8 / 绝境·终极 7 random) matches config.json modes[].stages', async () => {
const { difficultyInfo, stageNote, stageLabel, STAGE_POOL } = await mod('screens/lobby.js');
const cfg = JSON.parse(readFileSync(path.join(ROOT, 'data/config.json'), 'utf8'));
for (const [modeId, m] of Object.entries(cfg.modes)) {
if (!m.inScope) continue;
const pool = STAGE_POOL[m.difficulty];
assert.equal(Array.isArray(pool) ? pool.length : pool, m.stages.length, `${modeId} fallback pool size`);
if (Array.isArray(pool)) assert.deepEqual(pool, m.stages, `${modeId} fixed stage`);
}
// config.json not loaded here: the embedded fallback
for (const room of ['solo', 'coop']) {
assert.equal(difficultyInfo(room, 'FUNNY').stageNote, '战场固定为 战场#01');
assert.equal(difficultyInfo(room, 'NORMAL').stageNote, '战场随机(共8张)');
assert.equal(difficultyInfo(room, 'HARD').stageNote, '战场随机(共7张)');
assert.equal(difficultyInfo(room, 'ABYSS').stageNote, '战场随机(共7张)');
}
assert.equal(difficultyInfo('coop', 'BOGUS').stageNote, '');
assert.equal(stageNote(cfg.modes.mode_multi_normal.stages), '战场随机(共8张)');
assert.equal(stageNote(['act1autochess_m01']), '战场固定为 战场#01');
assert.equal(stageNote([]), '');
assert.equal(stageNote(null), '');
assert.equal(stageLabel('act2autochess_m02'), '战场#06', 'act2 continues the numbering');
assert.equal(stageLabel('nope'), '');
});
test('room: normalizeSeats / roomFacts / inviteLink', async () => {
const { normalizeSeats, roomFacts, inviteLink } = await mod('screens/room.js');
const room = {
code: 'ABCD', hostId: 'h', mode: 'coop', difficulty: 'HARD',
seats: [
{ seat: 0, playerId: 'h', name: 'Host', isBot: false, ready: false, connected: true },
{ seat: 1, playerId: 'g', name: 'Guest', isBot: false, ready: false, connected: true },
null,
{ seat: 3, playerId: 'ai_1', name: 'AI·华法琳', isBot: true, ready: true, connected: true },
],
};
assert.equal(normalizeSeats(room).length, 4);
assert.equal(normalizeSeats({ mode: 'solo', seats: [room.seats[0], null, null, null] }).length, 1);
assert.deepEqual(normalizeSeats({ mode: 'coop', seats: 'bad' }), [null, null, null, null]);
let f = roomFacts(room, 'h');
assert.equal(f.isHost, true);
assert.equal(f.canStart, false, 'guest not ready');
assert.equal(f.humans.length, 2);
assert.equal(f.emptySeats, 1);
assert.equal(f.readyHumans, 1, 'host counts as ready (start = host ready)');
room.seats[1].ready = true;
f = roomFacts(room, 'h');
assert.equal(f.canStart, true, 'host start does not need the host to be ready');
room.seats[1].connected = false;
assert.equal(roomFacts(room, 'h').canStart, false, 'disconnected guest blocks start');
assert.equal(roomFacts(room, 'g').canStart, false, 'guests cannot start');
assert.equal(roomFacts(null, 'x').mine, null);
assert.match(inviteLink('ABCD'), /\?room=ABCD$/);
});
test('room: spectator seats (community report #26) — isSpectating; roomFacts never counts a spectator as a player', async () => {
const { roomFacts } = await mod('screens/room.js');
const { isSpectating } = await mod('store.js');
const room = {
code: 'ABCD', hostId: 'h', mode: 'coop', difficulty: 'HARD',
seats: [{ seat: 0, playerId: 'h', name: 'Host', isBot: false, ready: false, connected: true }, null, null, null],
spectators: [{ playerId: 's', name: 'Spec', connected: true }],
};
assert.equal(isSpectating(room, 's'), true);
assert.equal(isSpectating(room, 'h'), false);
assert.equal(isSpectating({ ...room, spectators: undefined }, 's'), false, 'a room.state without the list');
assert.equal(isSpectating(room, null), false);
assert.equal(isSpectating(null, 's'), false);
const f = roomFacts(room, 's');
assert.equal(f.spectating, true);
assert.equal(f.mine, null);
assert.equal(f.humans.length, 1, 'never a player');
assert.equal(f.emptySeats, 3, 'a free player seat stays free (入座)');
assert.deepEqual(f.spectators.map((x) => x.playerId), ['s']);
const hf = roomFacts(room, 'h');
assert.equal(hf.canStart, true, 'a spectator never blocks the start');
assert.equal(hf.spectating, false);
assert.deepEqual(roomFacts({ ...room, spectators: 'bad' }, 'h').spectators, []);
});
test('components: roman / doctorNo / secondsLeft', async () => {
const { roman, doctorNo, secondsLeft } = await mod('ui/components.js');
assert.deepEqual([1, 2, 3, 4, 5, 6].map(roman), ['I', 'II', 'III', 'IV', 'V', 'VI']);
assert.equal(roman(0), '');
assert.equal(roman(99), '');
assert.match(doctorNo('p_abc123'), /^\d{4}$/);
assert.equal(doctorNo('p_abc123'), doctorNo('p_abc123'));
assert.notEqual(doctorNo('p_abc123'), doctorNo('p_abc124'));
assert.match(doctorNo(undefined), /^\d{4}$/);
assert.equal(secondsLeft(0), null, 'deadline 0 = untimed phase');
assert.equal(secondsLeft(null), null);
assert.equal(secondsLeft(-5), null);
assert.equal(secondsLeft(undefined), null);
assert.equal(secondsLeft(Date.now() - 5000), 0);
const s = secondsLeft(Date.now() + 9500);
assert.ok(s === 10 || s === 9, `ceil seconds (${s})`);
});
test('toasts: merge identical, describeError', async () => {
const { toast, dismissToast, describeError } = await mod('ui/toasts.js');
const a = toast('资金不足', 'error');
const b = toast('资金不足', 'error');
assert.equal(a, b, 'identical toasts merge');
const c = toast('资金不足', 'warn');
assert.notEqual(a, c);
assert.equal(toast(' '), -1);
dismissToast(a);
dismissToast(c);
dismissToast(12345); // unknown id: no-op
assert.equal(describeError({ code: 'NO_FUNDS' }), '资金不足');
assert.equal(describeError('ROOM_FULL'), '房间已满');
assert.equal(describeError(new Error('boom')), 'boom');
assert.equal(describeError(null), '发生未知错误');
assert.equal(describeError({}), '发生未知错误');
});
});
// ---- multi-device / browser compatibility (static) -----------------------------------------------------------------
// Supported: desktop Chrome / Edge / Firefox (incl. ESR 115) / Safari 15+, iPadOS / iOS 15+ Safari, Android Chrome.
// Guards against syntax and CSS features that silently break those (a lookbehind regex is a SyntaxError in Safari
// < 16.4 and stops the whole client; :has() / container queries / CSS nesting are ignored by Firefox ESR 115 or Safari
// 15; color-mix() by Safari < 16.2; backdrop-filter needs its -webkit- twin before Safari 18).
const CSS_FILES = walk(path.join(PUBLIC, 'css'), '.css');
const BROWSER_JS = [...JS_FILES, ...walk(path.join(ROOT, 'shared'), '.js'), ...walk(path.join(ROOT, 'server/sim'), '.js').filter((f) => !/nodeData\.js$/i.test(f))];
/** Rules of a stylesheet as { selector, body } (flat; @media blocks are walked into). */
function cssRules(src) {
const out = [];
const clean = src.replace(/\/\*[\s\S]*?\*\//g, '');
const re = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = re.exec(clean))) out.push({ selector: m[1].trim(), body: m[2] });
return out;
}
describe('multi-device & browser compatibility (static)', () => {
const index = readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');
test('index.html: landscape viewport without page zoom, notch-aware, devices.css last, boot diagnostics', () => {
const vp = index.match(/<meta name="viewport" content="([^"]+)"/)?.[1] || '';
for (const k of ['width=device-width', 'initial-scale=1', 'maximum-scale=1', 'user-scalable=no', 'viewport-fit=cover']) assert.ok(vp.includes(k), `viewport has ${k}`);
const sheets = [...index.matchAll(/<link rel="stylesheet" href="(\/css\/[^"]+)"/g)].map((x) => x[1]);
assert.equal(sheets[sheets.length - 1], '/css/devices.css', 'devices.css is the last stylesheet');
assert.match(index, /<script nomodule>/, 'a browser without ES modules gets a message');
assert.match(index, /onerror="window\.__spBootFail/, 'a module graph that cannot load gets a message');
assert.match(index, /apple-mobile-web-app-capable/);
assert.match(index, /class="rotate-hint"/);
});
test('main.js imports the polyfills before anything else and installs the device layer', () => {
const src = readFileSync(path.join(PUBLIC, 'js/main.js'), 'utf8');
const first = staticImports(src)[0];
assert.equal(first?.spec, './ui/compat.js');
assert.match(src, /installDeviceSupport\(\)/);
});
test('client modules use no bare specifiers (import maps need Safari 16.4+) — ui/fieldHost.js included', () => {
// (the vendored hooks.module.js is rewritten by tools/vendor.mjs to import "./preact.module.js" — see the vendor
// test below; the import map stays for pages that still name "preact" / "htm")
for (const file of JS_FILES) {
const rel = path.relative(path.join(PUBLIC, 'js'), file).split(path.sep).join('/');
for (const { spec } of staticImports(readFileSync(file, 'utf8'))) {
assert.match(spec, /^(\.{1,2}\/|\/)/, `${rel}: bare specifier '${spec}'`);
}
}
});
test('vendored ES modules import no bare specifiers (hooks → ./preact.module.js, the URL the import map names)', async () => {
const { rewriteBare, VENDOR_REWRITES } = await import('../tools/vendor.mjs');
assert.equal(rewriteBare('import{options as n}from"preact";', { preact: './preact.module.js' }), 'import{options as n}from"./preact.module.js";');
assert.equal(rewriteBare("import { h } from 'preact'; import('preact')", { preact: './p.js' }), "import { h } from './p.js'; import('./p.js')");
assert.equal(rewriteBare('from"preact/hooks";from"preactx"', { preact: './p.js' }), 'from"preact/hooks";from"preactx"', 'only the exact specifier');
assert.deepEqual(VENDOR_REWRITES['hooks.module.js'], { preact: './preact.module.js' });
for (const f of ['hooks.module.js', 'preact.module.js', 'htm.module.js']) {
const file = path.join(PUBLIC, 'vendor', f);
if (!existsSync(file)) continue;
for (const { spec } of staticImports(readFileSync(file, 'utf8'))) assert.match(spec, /^(\.{1,2}\/|\/)/, `vendor/${f}: bare specifier '${spec}' (run npm run vendor)`);
}
});
test('no regex lookbehind in code that runs in the browser (SyntaxError in Safari < 16.4)', () => {
for (const file of BROWSER_JS) {
const src = readFileSync(file, 'utf8').replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, '');
assert.doesNotMatch(src, /\(\?<[=!]/, `${path.relative(ROOT, file)} uses a lookbehind`);
}
});
test('APIs newer than Safari 15.0 / Firefox ESR are polyfilled by ui/compat.js', () => {
const compat = readFileSync(path.join(PUBLIC, 'js/ui/compat.js'), 'utf8');
const needs = { 'Object.hasOwn': 'hasOwn', '.at(': "'at'", structuredClone: 'structuredClone', '.findLast(': 'findLast' };
for (const file of BROWSER_JS) {
const src = readFileSync(file, 'utf8');
for (const [api, mark] of Object.entries(needs)) {
if (src.includes(api)) assert.ok(compat.includes(mark), `${path.relative(ROOT, file)} uses ${api}: compat.js must polyfill it`);
}
// never polyfillable: these would break Safari 15 / Firefox ESR outright
for (const bad of ['Object.groupBy', 'Promise.withResolvers', 'Array.fromAsync', 'Iterator.from', '.toSorted(', '.toReversed(', '.toSpliced(', 'Set.prototype.union', '.union(']) {
assert.ok(!src.includes(bad), `${path.relative(ROOT, file)} uses ${bad}`);
}
}
});
test('CSS: no :has(), container queries, @layer or nesting; prefixed backdrop-filter; color-mix() has a fallback', () => {
for (const file of CSS_FILES) {
const rel = path.relative(ROOT, file);
const src = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
assert.doesNotMatch(src, /:has\(/, `${rel}: :has() (Firefox ESR 115, Safari < 15.4)`);
assert.doesNotMatch(src, /@container|container-type|@layer/, `${rel}: container queries / cascade layers`);
for (const r of cssRules(src)) {
assert.ok(!/(^|[\s,])&/.test(r.selector), `${rel}: CSS nesting in '${r.selector}'`);
const decls = r.body.split(';').map((d) => d.trim()).filter(Boolean);
decls.forEach((d, i) => {
const prop = d.split(':')[0].trim();
if (prop === 'backdrop-filter') assert.ok(decls.some((x) => x.startsWith('-webkit-backdrop-filter')), `${rel} '${r.selector}': backdrop-filter without -webkit- twin`);
if (/color-mix\(/.test(d)) {
const fallback = decls.slice(0, i).some((x) => x.split(':')[0].trim() === prop && !/color-mix\(/.test(x));
assert.ok(fallback, `${rel} '${r.selector}': ${prop} uses color-mix() without a fallback before it`);
}
});
}
}
});
test('devices.css: safe-area insets for the HUD and screens, touch-action, hit areas, overscroll', () => {
const src = readFileSync(path.join(PUBLIC, 'css/devices.css'), 'utf8');
for (const k of ['safe-area-inset-left', 'safe-area-inset-right', 'safe-area-inset-top', 'safe-area-inset-bottom']) assert.ok(src.includes(k), k);
assert.match(src, /\.gm__hud \{[^}]*var\(--sa-l\)/);
assert.match(src, /\.screen:not\(\.gm\) \{[^}]*var\(--sa-t\)/);
assert.match(src, /overscroll-behavior: none/);
assert.match(src, /touch-action: none/);
assert.match(src, /width: max\(100%, var\(--tap-min\)\)/);
assert.match(src, /--tap-min: 44px/);
});
});