Files
ark-stronghold-protocol/test/ui/playtest3.test.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

468 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Regression tests (Node) of user playtest #3, HUD / UI items:
// 2 the own LP drops live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
// rule min(lpCapPerRound, counted leaks), reset when the settled m.private lands, 联防中 during 联防), the team
// panel rows (rowLp: own live value, teammates' m.public players[].pendingLp), the LP tower's −N tick, and the
// server's pendingLp (server/match/Match.js _pendingLpView)
// 3 the temp overflow row (临时整备区): the ready button's visible reason, the row's frame geometry (tempRowFrame)
// 8 the operator's own effect (特质 / garrison) right under the detail card's header (CHESS_SECTIONS, rendered order)
// 9 no spinning busy indicators next to texts: no wait / progress cursors, the button's busy bar instead of a
// spinning ring, the 机变 card's 选择中 strip (never stuck), a still hourglass; the data store retries a transient
// failure and every data file the match UI reads is one the match screen waits for (GAME_FILES); the 特质 chip's
// icon is the garrison's official type icon (the spoked 特异化 glyph only on 特异化 特质, not on every 休整期 one)
// (browser counterparts: the scratch checks of the playtest-3 workstream; the E2E suites cover the flows)
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PHASE, GEO } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the browser data store reads the real data files from disk (ChessDetail / ChoiceOverlay renders below)
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { pendingLoss, liveLp, ownLeaks, pendingTip, tempInfo, tempReadyReason, ReadyToggle, TEMP_RULE } = await import('../../public/js/ui/hud.js');
const { rowLp } = await import('../../public/js/ui/teamPanel.js');
const { LpTower, GAME_FILES } = await import('../../public/js/ui/gameComponents.js');
const { tempRowFrame, tempRowRule } = await import('../../public/js/ui/underframe.js');
const { ChessDetail, CHESS_SECTIONS, garrisonTypeIconKey } = await import('../../public/js/ui/detailPanel.js');
const { ChoiceView, pickBusy } = await import('../../public/js/ui/choiceOverlay.js');
const { Button, Spinner } = await import('../../public/js/ui/components.js');
const { data, createDataStore, RETRY_DELAYS_MS } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
/** The text of a vnode tree in document order. */
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
// ---- 2: live LP ----------------------------------------------------------------------------------------------------
describe('2: the own LP drops live while the battle runs', () => {
test('pendingLoss is the settle rule: min(lpCapPerRound, counted leaks), never negative', () => {
assert.equal(pendingLoss(0), 0);
assert.equal(pendingLoss(3), 3);
assert.equal(pendingLoss(10), 10);
assert.equal(pendingLoss(14), 10, 'capped (config lpCapPerRound 10)');
assert.equal(pendingLoss(14, 12), 12, 'the configured cap');
for (const junk of [-3, NaN, null, undefined, 'x']) assert.equal(pendingLoss(junk), 0, String(junk));
const cfg = JSON.parse(read('data/config.json'));
assert.equal(cfg.lpCapPerRound, 10, 'the cap the client reads (gd.config.lpCapPerRound)');
});
test('the own count: the further of the local runner\'s and the server\'s (a display replica stands still while the player watches a teammate)', () => {
assert.equal(ownLeaks(3, 1), 3, 'the local battle is ahead of the ~1 Hz b.progress');
assert.equal(ownLeaks(2, 5), 5, 'a display replica paused while watching another field: the server moved on');
assert.equal(ownLeaks(undefined, 4), 4, 'no local entry (reconnect, still loading)');
assert.equal(ownLeaks(4, undefined), 4);
for (const [a, b] of [[undefined, undefined], [NaN, null], [-2, 'x']]) assert.equal(ownLeaks(a, b), 0);
// the game screen feeds liveLp with it
assert.match(read('public/js/screens/game.js'), /leaks: ownLeaks\(localLeaks, meP\?\.pendingLp\)/);
});
/** Feed liveLp a sequence of states, keeping its base like the game screen (lpBaseRef). */
const run = (steps) => {
let base = null;
return steps.map((s) => { const r = liveLp(base, s); base = r.base; return r; });
};
test('COMBAT: lp − pending as leaks come in; the settled m.private (sent before the SETTLE m.public) drops the pending part — never subtracted twice', () => {
const at = (o) => ({ phase: PHASE.COMBAT, round: 5, lp: 30, statsLeaks: 7, leaks: 0, cap: 10, ...o });
const out = run([
at({}),
at({ leaks: 1 }),
at({ leaks: 3 }),
// the settlement: m.private (lp 27, stats.leaks 10) lands while m.public still says COMBAT (Match.flush order)
at({ leaks: 3, lp: 27, statsLeaks: 10 }),
// then the SETTLE m.public
{ phase: PHASE.SETTLE, round: 5, lp: 27, statsLeaks: 10, leaks: 3, cap: 10 },
// the next round
{ phase: PHASE.PREP, round: 6, lp: 27, statsLeaks: 10, leaks: 0, cap: 10 },
{ phase: PHASE.COMBAT, round: 6, lp: 27, statsLeaks: 10, leaks: 0, cap: 10 },
{ phase: PHASE.COMBAT, round: 6, lp: 27, statsLeaks: 10, leaks: 2, cap: 10 },
]);
assert.deepEqual(out.map((r) => r.shown), [30, 29, 27, 27, 27, 27, 27, 25]);
assert.deepEqual(out.map((r) => r.pending), [0, 1, 3, 0, 0, 0, 0, 2]);
assert.ok(out.every((r) => r.unite === false));
});
test('a settlement that charged nothing (a 联防 saved everything) is seen through stats.leaks; the SETTLE phase alone drops it too', () => {
const at = (o) => ({ phase: PHASE.UNITE, round: 8, lp: 20, statsLeaks: 4, leaks: 5, cap: 10, ...o });
const out = run([
{ phase: PHASE.COMBAT, round: 8, lp: 20, statsLeaks: 4, leaks: 5, cap: 10 },
at({}),
at({ statsLeaks: 9 }), // settled: lp unchanged (0 lost), the counted leaks recorded
{ phase: PHASE.SETTLE, round: 8, lp: 20, statsLeaks: 9, leaks: 5, cap: 10 },
]);
assert.deepEqual(out.map((r) => r.shown), [15, 15, 20, 20]);
assert.deepEqual(out.map((r) => r.unite), [false, true, false, false], '联防中 while the 联防 may still save part of it');
// without stats (older view): the SETTLE phase ends it
const bare = run([{ phase: PHASE.UNITE, round: 8, lp: 20, leaks: 5 }, { phase: PHASE.SETTLE, round: 8, lp: 20, leaks: 5 }]);
assert.deepEqual(bare.map((r) => r.shown), [15, 20]);
});
test('clamped at 0; nothing pending for the eliminated, in prep, in boss rounds or without an LP', () => {
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 4, leaks: 9 }).shown, 0);
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 4, leaks: 9 }).pending, 4, 'never more than the LP left');
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 40, leaks: 9, alive: false }).pending, 0);
for (const phase of [PHASE.PREP, PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.SETTLE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE, PHASE.RESULT]) {
const r = liveLp(null, { phase, round: 3, lp: 40, leaks: 9 });
assert.deepEqual([r.pending, r.shown, r.base], [0, 40, null], phase);
}
assert.deepEqual(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: undefined, leaks: 2 }), { base: null, pending: 0, shown: null, unite: false });
assert.match(pendingTip(30, 3), /结算时扣除 3 点/);
assert.match(pendingTip(30, 10, { unite: true }), /联防中/);
assert.equal(pendingTip(30, 0), null);
});
test('team panel rows: the own row takes the top bar\'s live value, a teammate\'s m.public players[].pendingLp; only in COMBAT / 联防', () => {
const pub = { phase: PHASE.COMBAT };
assert.deepEqual(rowLp({ lp: 30, pendingLp: 3 }, pub), { lp: 30, pending: 3, unite: false });
assert.deepEqual(rowLp({ lp: 2, pendingLp: 3 }, pub), { lp: 2, pending: 2, unite: false }, 'clamped at the LP left');
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.UNITE }), { lp: 30, pending: 4, unite: true });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.SETTLE }), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4, alive: false }, pub), { lp: 30, pending: 0, unite: false });
// the own row: the top bar's numbers (m.private lp — m.public may lag behind it by a throttle interval)
assert.deepEqual(rowLp({ lp: 30, pendingLp: 1 }, pub, { lp: 30, pending: 5, unite: false }), { lp: 30, pending: 5, unite: false });
assert.deepEqual(rowLp({ lp: 30 }, pub, { lp: 25, pending: 0, unite: false }), { lp: 25, pending: 0, unite: false }, 'settled m.private first');
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false }, 'boss rounds: the row keeps its own share');
});
test('LP tower: lp − pending in red with a −N tick keyed by the pending value (it pops again on every leak), the 联防中 tag', () => {
const plain = LpTower({ value: 24, size: 'lg' });
assert.ok(!hasClass(plain, 'is-pending'));
assert.equal(textOf(plain), '24');
const v = LpTower({ value: 24, size: 'lg', pending: 3, tip: 'x' });
assert.ok(hasClass(v, 'is-pending'));
const pend = [...walk(v)].find((n) => hasClass(n, 'lp__pend'));
assert.equal(pend.key, 3, 'keyed: a new count re-mounts the tick (its pop animation plays again)');
assert.equal(textOf(pend), '−3');
const val = [...walk(v)].find((n) => hasClass(n, 'lp__val'));
assert.equal(textOf(val), '21');
assert.equal(v.props.title, 'x');
const unite = LpTower({ value: 5, pending: 9, note: '联防中' });
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '0', 'clamped');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__note'))), '联防中');
assert.equal(textOf(LpTower({ value: null, pending: 3 })), '--');
});
test('server: m.public players[].pendingLp = min(cap, the authority\'s reported leaks) during COMBAT, the own battle\'s count in 联防, gone at SETTLE', async () => {
const { makeMatch } = await import('../match/harness.js');
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 0, seed: 7331, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 1);
const m = h.m;
const view = (pid) => m.publicView().players.find((p) => p.playerId === pid);
const f = m.fields.find((x) => x.players.includes('p_0'));
assert.equal(f.mode, 'client');
assert.equal('pendingLp' in view('p_0'), false, 'omitted while nothing leaked');
m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 5, killed: 1, total: 9, leaks: 3 });
assert.equal(view('p_0').pendingLp, 3);
assert.equal('pendingLp' in view('p_1'), false);
m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 9, killed: 1, total: 20, leaks: 14 });
assert.equal(view('p_0').pendingLp, m.gd.lpCapPerRound, 'capped');
// 联防: the recorded own results
m.phase = PHASE.UNITE;
m.lastResults = new Map([['p_0', { leaked: [{ enemyKey: 'a' }, { enemyKey: 'b', counted: false }, { enemyKey: 'c', counted: true }] }]]);
assert.equal(view('p_0').pendingLp, 2, 'counted !== false, like settle()');
assert.equal('pendingLp' in view('p_1'), false);
for (const phase of [PHASE.SETTLE, PHASE.PREP, PHASE.FINAL_ASSAULT]) {
m.phase = phase;
assert.equal('pendingLp' in view('p_0'), false, phase);
}
m.dispose();
});
});
// ---- 3: the temp overflow row --------------------------------------------------------------------------------------
describe('3: the temp overflow row (临时整备区)', () => {
const priv = (temp) => ({ alive: true, ready: false, canReady: !temp.some(Boolean), temp });
test('the ready button shows why it is refused, under it, not only on hover', () => {
const p = priv([{ uid: 1, kind: 'item', id: 'x' }, { uid: 2, kind: 'chess', id: 'y' }, null, null, null]);
assert.deepEqual(tempInfo(p), { count: 2, items: 1 });
assert.match(tempReadyReason(p), /^临时整备区还有 2 个单位:/);
assert.ok(tempReadyReason(p).includes(TEMP_RULE));
assert.match(TEMP_RULE, /休整期结束时.*销毁/, 'says what happens at the end of the prep');
const v = ReadyToggle({ priv: p, onToggle() {}, readyCount: 1, total: 4 });
const why = [...walk(v)].find((n) => hasClass(n, 'readywrap__why'));
assert.ok(why, 'visible reason');
assert.equal(textOf(why), '临时整备区 2 个单位待处理');
const btn = [...walk(v)].find((n) => n.type === 'button');
assert.equal(btn.props.disabled, true);
// empty temp: no reason, enabled
const ok = ReadyToggle({ priv: priv([null, null, null, null, null]), onToggle() {} });
assert.equal([...walk(ok)].find((n) => hasClass(n, 'readywrap__why')), undefined);
assert.ok(![...walk(ok)].find((n) => n.type === 'button').props.disabled);
assert.deepEqual(tempInfo(null), { count: 0, items: 0 });
});
test('the row frame: the outer corners of both end tiles, the label on the side with room (mirrored Final Assault prep too)', () => {
// tileScreen polys: [back-left, back-right, front-right, front-left] (render/app.js tileScreen corner order)
const tile = (cx, y, w = 100, d = 60) => ({ x: cx, y, s: w, poly: [[cx - w / 2 + 6, y - d / 2], [cx + w / 2 - 6, y - d / 2], [cx + w / 2, y + d / 2], [cx - w / 2, y + d / 2]] });
const a = tile(800, 640), b = tile(1260, 640);
const f = tempRowFrame(a, b, { labelW: 290, gap: 10, vw: 1920 });
assert.deepEqual(f.quad, [[756, 610], [1304, 610], [1310, 670], [750, 670]]);
assert.deepEqual([f.left, f.right, f.top, f.bottom, f.y], [750, 1310, 610, 670, 640]);
assert.equal(f.side, 'left');
// the Final Assault prep of the right-hand player draws the board mirrored: the ends swap on screen
assert.deepEqual(tempRowFrame(b, a, { labelW: 290, gap: 10, vw: 1920 }).quad, f.quad);
// no room on the left (a narrow phone): the label goes right
assert.equal(tempRowFrame(tile(200, 300), tile(600, 300), { labelW: 290, gap: 10, vw: 1200 }).side, 'right');
// neither side has room: left (clipped rather than covering the row)
assert.equal(tempRowFrame(tile(200, 300), tile(600, 300), { labelW: 290, gap: 10, vw: 900 }).side, 'left');
for (const bad of [null, {}, { poly: [[0, 0]] }, { poly: [[0, 0], [1, 0], [1, NaN], [0, 1]] }]) assert.equal(tempRowFrame(bad, b), null);
});
test('the row\'s label matches the server\'s due rule (PlayerState tempDue): destroyed at this prep\'s end unless ready — then kept through the next prep', () => {
assert.match(tempRowRule(false), /才能准备;休整期结束时仍在此处的将被销毁$/);
// Ready is refused while the row holds pieces: what lies there while ready arrived after it (due at the next prep)
assert.match(tempRowRule(true), /保留到下个休整期/);
assert.match(tempRowRule(true), /取消准备则在本休整期结束时销毁/);
});
test('the game screen frames the row only on the own prep board while it holds pieces (source contract)', () => {
const game = read('public/js/screens/game.js');
assert.match(game, /const tempNotice = temp\.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;/);
assert.match(game, /<\$\{TempRowNotice\} view=\$\{view\} count=\$\{temp\.count\} items=\$\{temp\.items\} label=\$\{!drag && !facing\}\s+ready=\$\{phase === PHASE\.PREP && !!priv\?\.ready\} \/>/);
assert.match(game, /toast\(tempReadyReason\(L\.priv\) \|\| refused, 'warn'\)/, 'Space says why');
const css = read('public/css/screens/game-panels.css');
assert.match(css, /\.tempnote \{ position: fixed; inset: 0; z-index: 2; pointer-events: none; \}/, 'over the field, under the HUD, never takes the pointer');
});
test('while the ready button shows its reason the effects column moves down a line (on phones the ready count ran into it)', () => {
const game = read('public/js/screens/game.js');
// the same condition as ReadyToggle's reason: PREP, alive, not ready, pieces in the row
assert.match(game, /const readyWhy = phase === PHASE\.PREP && alive && !priv\?\.ready && temp\.count > 0;/);
assert.match(game, /readyWhy && 'has-readywhy'/);
const css = read('public/css/screens/game.css');
assert.match(css, /\.gm__effects \{ position: absolute; right: \.3rem; top: 2\.4rem; \}/);
assert.match(css, /\.gm\.has-readywhy \.gm__effects \{ top: calc\(2\.4rem \+ max\(\.3rem, 16px\)\); \}/);
});
});
// ---- 8: the operator's own effect first --------------------------------------------------------------------------------
describe('8: 特质 right under the detail card\'s header', () => {
test('the block order', () => {
assert.deepEqual(CHESS_SECTIONS, ['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents', 'actions']);
});
test('a rendered operator card: header, then 特质 (trigger + description), then the rest in CHESS_SECTIONS order', async () => {
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
const c = data.lookup('chess', 'chess_char_3_04_b'); // 琳琅诗怀雅 (elite): <休整期结束时> garrison, modules, talents
assert.ok(c && c.garrisonIds && c.garrisonIds[0]);
const g = data.lookup('garrisons', c.garrisonIds[0]);
assert.equal(g.eventType, 'SERVER_PREP_FIN');
const blocks = ChessDetail({ chess: c, piece: { uid: 7, kind: 'chess', id: c.chessId, golden: true, items: [] }, editable: false, onSell() {}, bonds: [], loadout: null, onBond: null });
const keys = blocks.map((b) => b.key);
assert.deepEqual(keys, ['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents']);
assert.equal(blocks[1].props.garrison, g, 'the operator\'s own garrison');
// the garrison block itself: the 特质 label, the trigger chip and the description
const gb = blocks[1].type(blocks[1].props);
assert.ok(hasClass(gb, 'dgarrison'));
assert.ok(textOf(gb).includes('特质'));
assert.ok(textOf(gb).includes(g.eventTypeDesc));
const rich = [...walk(gb)].find((n) => n.props?.text != null);
assert.equal(rich.props.text, g.descRaw || g.desc);
// an operator without a garrison (a summon's chess record may have none) simply starts with the trait
const bare = ChessDetail({ chess: { ...c, garrisonIds: [] }, piece: null, editable: false, bonds: [], loadout: null });
assert.deepEqual(bare.map((b) => b.key), ['head', 'trait', 'stats', 'skill', 'module', 'talents']);
});
test('the 特质 chip carries the garrison\'s official type icon (eventTypeIcon) — the spoked 特异化 glyph only on 特异化', async () => {
await data.loadAll('garrisons', 'assets', 'chess');
const all = data.list('garrisons');
assert.ok(all.length > 200);
const bad = all.filter((g) => garrisonTypeIconKey(g) !== `s_${g.eventTypeIcon}`);
assert.deepEqual(bad.map((g) => g.garrisonId), [], 'every garrison shows its own type icon');
// (the spoked icon_support reads like a loading spinner: it used to stand before every <休整期开始时 / 结束时> 特质)
const spoked = all.filter((g) => garrisonTypeIconKey(g) === 's_icon_support');
assert.ok(spoked.length > 0 && spoked.every((g) => g.eventTypeDesc === '特异化'), 'icon_support = 特异化 only');
const prep = all.filter((g) => g.eventType === 'SERVER_PREP_START' || g.eventType === 'SERVER_PREP_FIN');
assert.ok(prep.filter((g) => garrisonTypeIconKey(g) !== 's_icon_support').length > prep.length / 2, 'most 休整期 特质 are 持续叠加 / 整备能力');
// no official icon in the record: a guess by the trigger, never a path from the data
assert.equal(garrisonTypeIconKey({ eventType: 'SERVER_PREP_FIN' }), 's_icon_bond');
assert.equal(garrisonTypeIconKey({ eventType: 'IN_BATTLE', eventTypeIcon: '../../x' }), 's_icon_battle');
assert.equal(garrisonTypeIconKey(null), 's_icon_bond');
// the rendered card of 琳琅诗怀雅 (elite): <休整期结束时> · 整备能力 → the gold 整备能力 icon
const c = data.lookup('chess', 'chess_char_3_04_b');
const g = data.lookup('garrisons', c.garrisonIds[0]);
assert.equal(g.eventTypeDesc, '整备能力');
const blocks = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null });
const gb = blocks.find((b) => b.key === 'garrison');
const img = [...walk(gb.type(gb.props))].find((n) => hasClass(n, 'dgarrison__icon'));
assert.match(String(img.props.src), /garrisonTypeIcon\/s_icon_gold\.png$/);
});
test('css: a compact gold-accented block; stats in a 4 × 2 grid so the skill stays close', () => {
const css = read('public/css/screens/game-panels.css');
assert.match(css, /\.dgarrison \{[^}]*border-left: 3px solid var\(--gold-2\)/);
assert.match(css, /\.dstats \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/);
assert.match(read('public/css/devices.css'), /@media \(max-height: 460px\) \{\n {2}\.dhead__art \{ height: 1\.66rem; \}\n {2}\.dhead__en \{ display: none; \}/);
});
});
// ---- 9: no spinner next to texts; data loaded once, before the match screen --------------------------------------------
describe('9: busy indicators and data loading', () => {
const cssFiles = () => {
const out = [];
const walkDir = (d) => { for (const e of readdirSync(path.join(ROOT, d), { withFileTypes: true })) { const p = `${d}/${e.name}`; if (e.isDirectory()) walkDir(p); else if (p.endsWith('.css')) out.push(p); } };
walkDir('public/css');
return out;
};
test('no wait / progress cursor anywhere (the OS shows a spinning wheel next to the pointer — over the detail card beside an underframe button, the 交流 button …)', () => {
for (const f of cssFiles()) assert.doesNotMatch(read(f), /cursor:\s*(wait|progress)/, f);
});
test('a busy button keeps its icon and gets a delayed sweeping bar — no spinning ring', () => {
const v = Button({ loading: true, icon: 'play', children: '继续作战' });
assert.equal(v.props.disabled, true);
assert.equal(v.props['aria-busy'], 'true');
const nodes = [...walk(v)];
assert.ok(nodes.some((n) => hasClass(n, 'btn__icon')), 'the icon stays');
assert.ok(nodes.some((n) => hasClass(n, 'btn__busy')));
assert.ok(!nodes.some((n) => hasClass(n, 'btn__spin')));
const idle = [...walk(Button({ icon: 'play', children: 'x' }))];
assert.ok(!idle.some((n) => hasClass(n, 'btn__busy')));
const css = read('public/css/components.css');
assert.doesNotMatch(css, /\.btn__spin/);
assert.match(css, /\.btn__busy \{[^}]*animation: btn-busy-in 1ms linear 250ms forwards;/, 'only after 250 ms: quick replies show nothing');
assert.match(css, /@keyframes btn-busy \{ from \{ transform: translateX\(-100%\); \} to \{ transform: translateX\(250%\); \} \}/, 'a sweep, not a rotation');
});
test('机变: the card being picked shows 选择中 (no spinner) only until the pick shows in m.public', () => {
assert.equal(pickBusy(2, { idx: 2 }, null), true);
assert.equal(pickBusy(2, { idx: 1 }, null), false);
assert.equal(pickBusy(null, { idx: 2 }, null), false);
assert.equal(pickBusy(2, { idx: 2 }, 2), false, 'the pick landed: never stuck on the card');
assert.equal(pickBusy(0, { idx: 0 }, 0), false, 'index 0 too');
assert.equal(pickBusy(2, { idx: 2, takenBy: 'p_1' }, null), false);
const sp = { family: 'tactic', name: '战术决策', desc: '', cards: [{ idx: 0, name: 'A', desc: 'a' }, { idx: 1, name: 'B', desc: 'b' }], order: ['me'], turnPid: 'me', pickOf: new Map(), untimed: true };
// (ChoiceView: the overlay's pure view — ChoiceOverlay keeps the two-tap selection, user playtest #4 item 2)
const v = ChoiceView({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }] }, sp, myId: 'me', solo: true, busyIdx: 1 });
const nodes = [...walk(v)];
const busyCards = nodes.filter((n) => hasClass(n, 'spcard') && hasClass(n, 'is-busy'));
assert.equal(busyCards.length, 1);
assert.equal(textOf(nodes.find((n) => hasClass(n, 'spcard__busy'))), '选择中');
assert.ok(!nodes.some((n) => n.type === Spinner), 'no spinner over the card text');
// the pick landed (m.public) while the request still waits for its reply
const landed = ChoiceView({ pub: { players: [] }, sp: { ...sp, pickOf: new Map([['me', 1]]), cards: [sp.cards[0], { ...sp.cards[1], takenBy: 'me' }] }, myId: 'me', solo: true, busyIdx: 1 });
assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy')));
});
test('the "作战结束,等待队友完成作战" hourglass stands still', () => {
const css = read('public/css/screens/game.css');
assert.doesNotMatch(css, /hg-flip/);
assert.match(css, /\.chud__msg \.icon \{ width: \.22rem; height: \.22rem; color: var\(--gold\); \}/);
});
test('data store: a transient failure is retried (the file stays loading), a 404 / bad JSON is not; a superseded retry stays silent', async () => {
const waits = [];
const wait = async (ms) => { waits.push(ms); };
let calls = 0;
const flaky = createDataStore({
wait,
fetch: async () => {
calls++;
if (calls === 1) throw new TypeError('Failed to fetch');
if (calls === 2) return { ok: false, status: 502, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({ list: [{ id: 'x', v: 1 }] }) };
},
});
const p = flaky.load('chess');
assert.equal(flaky.status('chess'), 'loading');
await p;
assert.equal(flaky.status('chess'), 'ready');
assert.equal(flaky.lookup('chess', 'x').v, 1);
assert.deepEqual(waits, RETRY_DELAYS_MS.slice(0, 2));
assert.deepEqual(RETRY_DELAYS_MS, [600, 2000]);
const warn = console.warn;
console.warn = () => {};
try {
let n404 = 0;
const gone = createDataStore({ wait, fetch: async () => { n404++; return { ok: false, status: 404, json: async () => ({}) }; } });
await gone.load('bands');
assert.equal(gone.status('bands'), 'missing');
assert.equal(n404, 1, 'a 404 is final');
let nBad = 0;
const bad = createDataStore({ wait, fetch: async () => { nBad++; return { ok: true, status: 200, json: async () => JSON.parse('{"broken": ') }; } });
await bad.load('bands');
assert.equal(bad.status('bands'), 'missing');
assert.equal(nBad, 1, 'a delivered file that is not JSON is final');
let nDown = 0;
const down = createDataStore({ wait, fetch: async () => { nDown++; throw new TypeError('offline'); } });
await down.load('items');
assert.equal(down.status('items'), 'missing');
assert.equal(nDown, 1 + RETRY_DELAYS_MS.length, 'gives up after the retries');
// invalidated while waiting to retry: the old load stops, the new one decides
let release;
let nInv = 0;
const inv = createDataStore({
wait: () => new Promise((r) => { release = r; }),
fetch: async () => { nInv++; if (nInv === 1) throw new TypeError('offline'); return { ok: true, status: 200, json: async () => ({ a: { v: 2 } }) }; },
});
const seen = [];
inv.subscribe((name) => seen.push(`${name}:${inv.status(name)}`));
const first = inv.load('config');
await new Promise((r) => setImmediate(r));
const second = inv.invalidate('config');
await second;
release();
await first;
assert.equal(inv.status('config'), 'ready');
assert.equal(inv.get('config').a.v, 2);
assert.equal(nInv, 2, 'the superseded load did not fetch again');
assert.deepEqual(seen, ['config:loading', 'config:ready']);
} finally { console.warn = warn; }
});
test('every data file the in-match UI reads is in GAME_FILES (the match screen waits for them: no text appears late); main.js warms them in a room', () => {
const files = [
...readdirSync(path.join(ROOT, 'public/js/ui')).filter((f) => f.endsWith('.js')).map((f) => `public/js/ui/${f}`),
...['game', 'briefing', 'bandDraft', 'result'].map((s) => `public/js/screens/${s}.js`),
];
const used = new Set();
for (const f of files) for (const m of read(f).matchAll(/data\.(?:get|lookup|list|load|status)\('([A-Za-z-]+)'/g)) used.add(m[1]);
for (const m of read('public/js/ui/gameComponents.js').matchAll(/useData\('([A-Za-z-]+)'/g)) used.add(m[1]);
assert.ok(used.size >= 10, [...used].join(','));
const missing = [...used].filter((n) => !GAME_FILES.includes(n));
assert.deepEqual(missing, [], 'read by the match UI but not awaited by the match screen');
const main = read('public/js/main.js');
assert.match(main, /if \(s\.room && !prev\.room\) warmGameData\(\);/);
assert.match(main, /data\.loadAll\(GAME_FILES\)/);
});
});
test('GEO: the temp row the notice frames is the server\'s (row 8, cols 4–8)', () => {
assert.deepEqual([GEO.TEMP_ROW, GEO.TEMP_C0, GEO.TEMP_SIZE], [8, 4, 5]);
});