Files

1434 lines
68 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);
});
for (const [firstId, secondId, expected] of [
['a-tab', 'b-tab', ['tokShared', null]],
['b-tab', 'a-tab', [null, 'tokShared']],
]) {
test(`staggered channels: ${firstId} starts before ${secondId}`, async () => {
const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js');
const hub = channelHub(), timers = fakeTimers(), local = memStorage();
local.setItem('sp.tokens', JSON.stringify(['tokShared']));
const firstSession = memStorage(), secondSession = memStorage();
const first = createIdentity({ local, session: firstSession, tabId: firstId,
channel: hub.create(), setTimeout: timers.setTimeout });
const firstPick = first.init();
timers.advance(30);
// This channel did not exist when the first tab broadcast its query.
const second = createIdentity({ local, session: secondSession, tabId: secondId,
channel: hub.create(), setTimeout: timers.setTimeout });
const secondPick = second.init();
await new Promise((r) => setImmediate(r));
timers.advance(CLAIM_QUERY_MS - 30);
assert.equal(await firstPick, expected[0]);
timers.advance(30);
assert.equal(await secondPick, expected[1]);
assert.deepEqual([first.getToken(), second.getToken()], expected);
assert.deepEqual([firstSession.getItem('sp.token'), secondSession.getItem('sp.token')], expected);
});
}
test('staggered elections reserve fallback candidates while a live owner keeps its token', async () => {
const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js');
const hub = channelHub(), timers = fakeTimers(), local = memStorage();
const owner = createIdentity({ local, session: memStorage(), tabId: 'z-owner', channel: hub.create() });
await owner.init();
owner.saveToken('tokOwned');
local.setItem('sp.tokens', JSON.stringify(['tokOwned', 'tokFree']));
const first = createIdentity({ local, session: memStorage(), tabId: 'a-tab',
channel: hub.create(), setTimeout: timers.setTimeout });
const firstPick = first.init();
timers.advance(30);
const duplicateSession = memStorage();
duplicateSession.setItem('sp.token', 'tokOwned');
const second = createIdentity({ local, session: duplicateSession, tabId: 'b-tab',
channel: hub.create(), setTimeout: timers.setTimeout });
const secondPick = second.init();
await new Promise((r) => setImmediate(r));
timers.advance(CLAIM_QUERY_MS - 30);
assert.equal(await firstPick, 'tokFree');
timers.advance(30);
assert.equal(await secondPick, null);
assert.equal(duplicateSession.getItem('sp.token'), null, 'the copied live token is discarded');
assert.equal(owner.getToken(), 'tokOwned', 'a live owner outranks even a smaller tab id');
});
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/);
});
});
describe('explicit local match recovery (#431)', () => {
async function recoveryPair() {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub(), ownerSession = memStorage(), guestSession = memStorage();
const ownerChannel = hub.create();
const owner = createIdentity({ local, session: ownerSession, tabId: 'z-owner', channel: ownerChannel, queryMs: 5, now: () => 100 });
const guest = createIdentity({ local, session: guestSession, tabId: 'a-guest', channel: hub.create(), queryMs: 5, now: () => 200 });
await owner.init(); owner.saveToken('seat-owner'); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' });
await guest.init(); guest.saveToken('seat-guest');
return { createIdentity, local, hub, ownerSession, guestSession, ownerChannel, owner, guest, id: guest.recoverable()[0].id };
}
test('live: a smaller tab id cannot reclaim an active seat', async () => {
const { guest, owner, id } = await recoveryPair();
assert.equal(await guest.resume(id), false);
assert.equal(guest.getToken(), 'seat-guest');
assert.equal(owner.getToken(), 'seat-owner');
});
test('reload: the original session wins over a recovery attempt during navigation', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
assert.equal(await p.guest.resume(p.id), true, 'old document is gone');
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5 });
assert.equal(await reloaded.init(), 'seat-owner');
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
assert.equal(p.guest.getToken(), 'seat-guest', 'unconfirmed recovery yields before hello');
});
test('midInit: the session token answers claims before init finishes', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
let finish;
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), setTimeout: (fn) => { finish = fn; } });
const pending = reloaded.init();
assert.equal(await p.guest.resume(p.id), false);
finish();
assert.equal(await pending, 'seat-owner');
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
assert.equal(p.guest.getToken(), 'seat-guest');
});
test('an occupied session token never falls back to a different recent identity', async () => {
const p = await recoveryPair();
p.local.setItem('sp.tokens', JSON.stringify(['seat-free', 'seat-owner']));
const duplicate = p.createIdentity({ local: p.local, session: p.ownerSession.clone(), tabId: '0-copy', channel: p.hub.create(), queryMs: 5 });
assert.equal(await duplicate.init(), null);
assert.equal(duplicate.getToken(), null);
});
test('resume persists neither its token nor a welcome stamp until the server welcomes it', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
const stamp = p.guestSession.getItem('sp.tokenWelcome');
assert.equal(await p.guest.resume(p.id), true);
assert.equal(p.guestSession.getItem('sp.token'), 'seat-guest');
assert.equal(p.guestSession.getItem('sp.tokenWelcome'), stamp);
p.guest.saveToken('seat-owner');
assert.equal(p.guestSession.getItem('sp.token'), 'seat-owner');
assert.ok(p.guestSession.getItem('sp.tokenWelcome'));
});
test('an earlier welcome stamp survives reload and beats a later live welcome', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
assert.equal(await p.guest.resume(p.id), true);
p.guest.saveToken('seat-owner'); // recovery's welcome is later than the original owner's
const originalStamp = p.ownerSession.getItem('sp.tokenWelcome');
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5, now: () => 300 });
assert.equal(await reloaded.init(), 'seat-owner');
reloaded.saveToken('seat-owner');
assert.equal(p.ownerSession.getItem('sp.tokenWelcome'), originalStamp, 'reconnect does not reset seniority');
});
test('a welcome received during init cannot be overwritten or erased by its pending result', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), session = memStorage(), hub = channelHub();
session.setItem('sp.token', 'old');
let finish;
const id = createIdentity({ local, session, channel: hub.create(), setTimeout: (fn) => { finish = fn; } });
const pending = id.init();
id.saveToken('new'); finish();
assert.equal(await pending, 'new');
assert.equal(session.getItem('sp.token'), 'new');
});
test('an idle tab can reclaim a closed match tab without taking a live one', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub(), ca = hub.create(), cb = hub.create();
const a = createIdentity({ local, session: memStorage(), tabId: 'a', channel: ca, queryMs: 5 });
const b = createIdentity({ local, session: memStorage(), tabId: 'b', channel: cb, queryMs: 5 });
await a.init(); a.saveToken('idle-token');
await b.init(); b.saveToken('match-token'); b.rememberMatch({ name: 'Doctor B', code: 'ABC123' });
const [item] = a.recoverable();
assert.equal(item.name, 'Doctor B');
assert.equal(JSON.stringify(item).includes('match-token'), false);
assert.equal(await a.resume(item.id), false, 'live original keeps its seat');
assert.equal(a.getToken(), 'idle-token');
cb.close();
assert.equal(await a.resume(item.id), true);
assert.equal(a.getToken(), 'match-token');
a.rememberMatch(null);
assert.deepEqual(JSON.parse(local.getItem('sp.matches')), {});
ca.close();
});
test('simultaneous recoveries pick one winner; absent channels and unknown ids cannot recover', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub();
local.setItem('sp.tokens', JSON.stringify(['idle-b', 'idle-a']));
const sa = memStorage(), sb = memStorage(); sa.setItem('sp.token', 'idle-a'); sb.setItem('sp.token', 'idle-b');
const a = createIdentity({ local, session: sa, tabId: 'a', channel: hub.create(), queryMs: 5 });
const b = createIdentity({ local, session: sb, tabId: 'b', channel: hub.create(), queryMs: 5 });
await Promise.all([a.init(), b.init()]);
const closed = createIdentity({ local, session: memStorage(), channel: null });
closed.saveToken('closed-token'); closed.rememberMatch({ name: 'C', code: 'XYZ' });
const id = a.recoverable()[0].id;
assert.deepEqual(await Promise.all([a.resume(id), b.resume(id)]), [true, false]);
assert.equal(await b.resume('unknown'), false);
assert.equal(await closed.resume(id), false);
});
test('server replacement clears expired metadata and malformed storage remains safe', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(); const a = createIdentity({ local, session: memStorage(), channel: null });
a.saveToken('old'); a.rememberMatch({ name: 'D', code: 'AAA' }); a.saveToken('new');
assert.deepEqual(a.recoverable(), []);
for (const bad of ['null', '[]', '{', '"hello"']) {
local.setItem('sp.matches', bad); assert.deepEqual(a.recoverable(), []);
}
});
});