- ui/loadoutModel.js: `ops` beside the entries (sp.pref.loadout { v, entries, ops }; an older stored loadout has none =
潜能 6, 精英2 Lv.60): parseStoredOps, opsOf / setOps / resetOps (defaults never stored), sanitizeOps (only what the
server keeps: the roster and the 自选 owned pool); 导出 carries `ops`, 导入 replaces the settings when the payload has
them (an older export leaves them alone; settings alone import); the 已调整 count and 仅看已调整 count them.
- ui/loadoutSync.js: the store's `ops` (setOpsMap persists both); the loadout sync watches entries and ops and sends
room.loadout { entries, ops } (the ops sanitised against chess.json + backups.json; nothing set: no download);
openLoadout loads effects.json (the 练度 multipliers).
- Cards: gameLogic chessLoadout(chess, loadout, getChess, { ops | cultivation, effects }) composes the record at the
operator's potential and multiplies its stats by the 练度 tier (the player's m.private.ops — shop / hand / board cards
and the detail panel; a teammate's unit its UnitInfo potential / cultivate: unitCultivation; a teammate's bond-popup
card the defaults, never the viewer's settings; a stand-in neither); a 自选 card is composed at its pick's potential
(ownDiyPick from m.private.ops, unitPick from UnitInfo).
- Tests: test/ui/potential-ui.test.js (new, 7): storage, helpers, 导出 / 导入, the 已调整 count, the sync (sanitised ops,
settings-only edits, no download when none), the cards (刺玫 413 / 17 at 潜能1, ×1.05 at 精英1, a teammate's unit, a
stand-in), 自选 cards at 潜能1 (凯尔希 392 / 20) and the 练度 on top; test/ui/loadout.test.js expects the envelope's
`ops` and room.loadout's `ops: {}`.
504 lines
27 KiB
JavaScript
504 lines
27 KiB
JavaScript
// test/ui/loadout.test.js — 干员调配 client logic (DESIGN §16): the loadout model (storage parsing, options, choices,
|
||
// sanitising a stale browser loadout so the server never refuses it, roster filters, display helpers), the server
|
||
// sync (room.loadout after welcome / edits, debounce, lock / rate handling, never on missing data) and the overlay's
|
||
// auto-close rule.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import { checkLoadout, MODULE_NONE } from '../../shared/protocol.js';
|
||
import { PHASE } from '../../shared/constants.js';
|
||
import {
|
||
parseStored, toStored, chessOptions, effectiveChoice, setChoice, resetChoice, sanitizeEntries, rosterOf, filterRoster,
|
||
changedCount, moduleBadge, attrRows, skillTags, skillLabel, selectedSkill, selectedModule, recordsOf,
|
||
exportPayload, serializeExport, parseImport, LOADOUT_EXPORT_KIND, LOADOUT_VERSION, LOADOUT_IMPORT_MAX_BYTES,
|
||
} from '../../public/js/ui/loadoutModel.js';
|
||
import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS, applyLoadoutEntries, setEntries, loadoutStore } from '../../public/js/ui/loadoutSync.js';
|
||
import { createStore } from '../../public/js/store.js';
|
||
import { shouldAutoClose } from '../../public/js/screens/loadout.js';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||
const CHESS = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||
const BONDS = JSON.parse(readFileSync(path.join(ROOT, 'data/bonds.json'), 'utf8'));
|
||
const get = (id) => (Object.hasOwn(CHESS, id) ? CHESS[id] : null);
|
||
const getBond = (id) => (Object.hasOwn(BONDS, id) ? BONDS[id] : null);
|
||
const INSIDE = 'chess_char_1_01_a';
|
||
const SWIRE = 'chess_char_3_04_a';
|
||
const { base: IB, golden: IG } = recordsOf(INSIDE, get);
|
||
const { base: SB, golden: SG } = recordsOf(SWIRE, get);
|
||
const SWIRE_ALT = SG.modules.find((m) => !m.isDefault).uniEquipId;
|
||
|
||
test('parseStored: tolerant of junk, keeps structurally valid entries; toStored round trip', () => {
|
||
assert.deepEqual(parseStored(null), {});
|
||
assert.deepEqual(parseStored('x'), {});
|
||
assert.deepEqual(parseStored({ v: 1, entries: { [INSIDE]: { skill: 0, module: 'none', junk: 1 }, 'bad id': { skill: 1 }, [SWIRE]: { skill: -1 } } }),
|
||
{ [INSIDE]: { skill: 0, module: 'none' } });
|
||
assert.deepEqual(parseStored({ [INSIDE]: { skill: 1 } }), { [INSIDE]: { skill: 1 } }, 'bare map (older build)');
|
||
const e = { [INSIDE]: { skill: 0 } };
|
||
assert.deepEqual(parseStored(JSON.parse(JSON.stringify(toStored(e)))), e);
|
||
});
|
||
|
||
test('exportPayload / serializeExport: versioned envelope, entries copied; parseImport round trip', () => {
|
||
const entries = { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } };
|
||
const p = exportPayload(entries, { now: Date.UTC(2026, 9, 3, 4, 5, 6) });
|
||
assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'ops', 'v'], 'exactly the envelope (0.2.2: + the 潜能 / 练度 ops), no unused field');
|
||
assert.deepEqual(p.ops, {}, 'no settings: an empty map (an import then resets the settings to the defaults)');
|
||
assert.equal(p.kind, LOADOUT_EXPORT_KIND);
|
||
assert.equal(p.v, LOADOUT_VERSION);
|
||
assert.equal(p.count, 2);
|
||
assert.equal(p.exportedAt, '2026-10-03T04:05:06.000Z');
|
||
assert.deepEqual(p.entries, entries);
|
||
assert.notEqual(p.entries, entries, 'a copy — later edits must not mutate an already-built payload');
|
||
assert.notEqual(p.entries[INSIDE], entries[INSIDE]);
|
||
assert.equal(exportPayload(null).count, 0);
|
||
|
||
const back = parseImport(serializeExport(entries, { now: 0 }));
|
||
assert.equal(back.ok, true);
|
||
assert.deepEqual(back.entries, entries);
|
||
});
|
||
|
||
test('parseImport: accepts the envelope, the stored form, a bare map and text; refuses junk, newer data and hostile keys', () => {
|
||
const entries = { [INSIDE]: { skill: 0 } };
|
||
assert.deepEqual(parseImport(exportPayload(entries)).entries, entries, 'envelope');
|
||
assert.deepEqual(parseImport(toStored(entries)).entries, entries, 'stored { v, entries }');
|
||
assert.deepEqual(parseImport(entries).entries, entries, 'bare map (hand-written / older build)');
|
||
assert.deepEqual(parseImport(JSON.stringify(exportPayload(entries))).entries, entries, 'serialised text');
|
||
assert.deepEqual(parseImport(`\n ${JSON.stringify(entries)} \n`).entries, entries, 'padded text');
|
||
|
||
for (const junk of ['', ' ', 'not a payload', '{', 42, null, undefined, [], true]) {
|
||
assert.equal(parseImport(junk).ok, false, `${JSON.stringify(junk)} is refused`);
|
||
}
|
||
const newer = parseImport({ v: LOADOUT_VERSION + 1, entries });
|
||
assert.equal(newer.ok, false, 'a NEWER payload is refused, never mis-read');
|
||
assert.match(newer.error, /请先更新游戏/, 'the player is told to update the game');
|
||
assert.equal(parseImport({ kind: 'some.other.tool', entries }).ok, false, "another tool's payload");
|
||
assert.equal(parseImport({ v: LOADOUT_VERSION, entries: {} }).ok, false, 'nothing to import');
|
||
assert.equal(parseImport({ v: LOADOUT_VERSION, entries: { 'bad id': { skill: 0 } } }).ok, false, 'no structurally valid entry');
|
||
assert.equal(parseImport('x'.repeat(LOADOUT_IMPORT_MAX_BYTES + 1)).ok, false, 'an oversized payload is refused before parsing');
|
||
|
||
// hostile keys: JSON.parse keeps `__proto__` as an own key, and assigning it would rewrite an object's prototype
|
||
const hostile = JSON.parse(`{"entries":{"__proto__":{"skill":0},"constructor":{"skill":0},"prototype":{"skill":0},"${INSIDE}":{"skill":0}}}`);
|
||
assert.deepEqual(Object.keys(hostile.entries), ['__proto__', 'constructor', 'prototype', INSIDE], 'fixture: parsed as own keys');
|
||
const safe = parseImport(hostile);
|
||
assert.deepEqual(safe.entries, { [INSIDE]: { skill: 0 } }, 'only the real chess survives');
|
||
assert.equal(Object.getPrototypeOf(safe.entries), Object.prototype, 'the entry map keeps a clean prototype');
|
||
assert.equal({}.skill, undefined, 'Object.prototype was not polluted');
|
||
});
|
||
|
||
test('applyLoadoutEntries: sanitises against the loaded data and reports what was dropped', () => {
|
||
const before = loadoutStore.get().entries;
|
||
try {
|
||
const res = applyLoadoutEntries({ [INSIDE]: { skill: 0 }, chess_nope_999: { skill: 0 } }, get);
|
||
assert.deepEqual(res, { applied: 1, dropped: 1 }, 'the unknown chess is dropped, the real one applied');
|
||
assert.deepEqual(loadoutStore.get().entries, { [INSIDE]: { skill: 0 } }, 'the store got the sanitised entries');
|
||
} finally {
|
||
setEntries(before);
|
||
}
|
||
});
|
||
|
||
test('applyLoadoutEntries: an import that keeps nothing changes nothing (review: it used to wipe the loadout)', () => {
|
||
setEntries({ [INSIDE]: { skill: 0 } });
|
||
const before = loadoutStore.get().entries;
|
||
try {
|
||
assert.deepEqual(applyLoadoutEntries({ chess_nope_999: { skill: 0 } }, get), { applied: 0, dropped: 1 }, 'every chess unknown');
|
||
assert.equal(loadoutStore.get().entries, before, 'the current loadout is untouched (not even replaced by an equal one)');
|
||
const def = IB.skills.find((s) => s.isDefault).index;
|
||
assert.deepEqual(applyLoadoutEntries({ [INSIDE]: { skill: def } }, get), { applied: 0, dropped: 1 }, 'every choice already the default');
|
||
assert.equal(loadoutStore.get().entries, before, 'still untouched');
|
||
} finally {
|
||
setEntries(before);
|
||
}
|
||
});
|
||
|
||
test('options: skill records at Lv4 (normal) and Lv7 (elite); modules + 不装备 with defaults flagged', () => {
|
||
const o = chessOptions(IB, IG);
|
||
assert.deepEqual(o.skillOptions.map((s) => s.index), [0, 1]);
|
||
assert.equal(o.skillOptions.find((s) => s.isDefault).index, 1);
|
||
for (const s of o.skillOptions) {
|
||
assert.equal(s.normal.level, IB.status.skillLevel, 'normal record at the normal skill level');
|
||
assert.equal(s.elite.level, IG.status.skillLevel, 'elite record at the elite skill level');
|
||
assert.equal(s.normal.skillId, s.elite.skillId);
|
||
}
|
||
assert.deepEqual(o.moduleOptions.map((m) => m.id), ['uniequip_002_inside', MODULE_NONE]);
|
||
assert.ok(o.moduleOptions[0].isDefault && o.moduleOptions[0].rec.typeName === 'MAR-X');
|
||
assert.equal(o.moduleOptions[1].rec, null);
|
||
const s = chessOptions(SB, SG);
|
||
assert.equal(s.moduleOptions.length, SG.modules.length + 1);
|
||
});
|
||
|
||
test('choices: set / reset keep only non-default entries; effectiveChoice falls back to defaults', () => {
|
||
let e = {};
|
||
assert.deepEqual(effectiveChoice(e, IB, IG), { skill: 1, module: 'uniequip_002_inside', changed: false });
|
||
e = setChoice(e, IB, IG, { skill: 0 });
|
||
assert.deepEqual(e, { [INSIDE]: { skill: 0 } });
|
||
e = setChoice(e, IB, IG, { module: MODULE_NONE });
|
||
assert.deepEqual(e, { [INSIDE]: { skill: 0, module: MODULE_NONE } });
|
||
assert.deepEqual(effectiveChoice(e, IB, IG), { skill: 0, module: MODULE_NONE, changed: true });
|
||
e = setChoice(e, IB, IG, { skill: 1, module: 'uniequip_002_inside' });
|
||
assert.deepEqual(e, {}, 'back to the defaults ⇒ no entry');
|
||
e = setChoice(e, IB, IG, { skill: 5 }); // not selectable: ignored
|
||
assert.deepEqual(e, {});
|
||
e = setChoice(setChoice({}, SB, SG, { module: SWIRE_ALT }), IB, IG, { skill: 0 });
|
||
assert.equal(changedCount(e, get), 2);
|
||
e = resetChoice(e, SWIRE);
|
||
assert.deepEqual(e, { [INSIDE]: { skill: 0 } });
|
||
// stale entries resolve to the defaults
|
||
assert.deepEqual(effectiveChoice({ [INSIDE]: { skill: 9, module: 'gone' } }, IB, IG), { skill: 1, module: 'uniequip_002_inside', changed: false });
|
||
});
|
||
|
||
test('sanitizeEntries: drops unknown chess / illegal parts one by one, and the result always passes checkLoadout', () => {
|
||
const stale = {
|
||
[INSIDE]: { skill: 0, module: SWIRE_ALT }, // the module belongs to another character: the skill part survives
|
||
[SWIRE]: { module: SWIRE_ALT },
|
||
chess_char_9_99_a: { skill: 0 }, // removed from the data
|
||
[IG.chessId]: { skill: 0 }, // an elite id
|
||
[SB.chessId + 'x']: { skill: 1 },
|
||
};
|
||
const out = sanitizeEntries(stale, get);
|
||
assert.deepEqual(out, { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } });
|
||
assert.ok(checkLoadout(out, get).ok);
|
||
// every visible chess × every choice sanitises to an accepted loadout
|
||
const all = {};
|
||
for (const c of rosterOf(Object.values(CHESS))) {
|
||
const o = chessOptions(c, get(c.goldenId));
|
||
all[c.chessId] = { skill: o.skills.find((i) => i !== o.defaultSkill), module: o.modules[o.modules.length - 1] };
|
||
}
|
||
const s = sanitizeEntries(all, get);
|
||
assert.equal(Object.keys(s).length, 112);
|
||
assert.ok(checkLoadout(s, get).ok);
|
||
});
|
||
|
||
test('selectedSkill / selectedModule for the in-match UI (shop cards, detail panel)', () => {
|
||
const lo = checkLoadout({ [INSIDE]: { skill: 0, module: MODULE_NONE } }, get).loadout;
|
||
assert.equal(selectedSkill(lo, IB, get).index, 0);
|
||
assert.equal(selectedSkill(lo, IG, get).level, IG.status.skillLevel);
|
||
assert.equal(selectedModule(lo, IG, get), null);
|
||
assert.equal(selectedModule({}, IG, get).uniEquipId, 'uniequip_002_inside');
|
||
assert.equal(selectedModule(lo, IB, get), null, 'normal chess have no module');
|
||
assert.equal(selectedSkill(null, SB, get).index, SB.skill.index);
|
||
});
|
||
|
||
test('roster and filters: 112 visible chess in shop order; tier / class / bond / search / changed-only', () => {
|
||
const roster = rosterOf(Object.values(CHESS));
|
||
assert.equal(roster.length, 112);
|
||
assert.ok(roster.every((c) => !c.isGolden && c.visible));
|
||
for (let i = 1; i < roster.length; i++) assert.ok(roster[i - 1].tier <= roster[i].tier);
|
||
const t3 = filterRoster(roster, { tier: 3 }, {}, get, getBond);
|
||
assert.ok(t3.length > 0 && t3.every((c) => c.tier === 3));
|
||
const snipers = filterRoster(roster, { prof: 'SNIPER' }, {}, get, getBond);
|
||
assert.ok(snipers.length > 0 && snipers.every((c) => c.profession === 'SNIPER'));
|
||
const bond = IB.bonds[0];
|
||
assert.ok(filterRoster(roster, { bond }, {}, get, getBond).every((c) => c.bonds.includes(bond)));
|
||
assert.deepEqual(filterRoster(roster, { query: '隐现' }, {}, get, getBond).map((c) => c.chessId), [INSIDE]);
|
||
const bondName = getBond(bond).name;
|
||
assert.ok(filterRoster(roster, { query: bondName }, {}, get, getBond).some((c) => c.chessId === INSIDE), 'search matches bond names');
|
||
assert.deepEqual(filterRoster(roster, { changedOnly: true }, { [INSIDE]: { skill: 0 }, [SWIRE]: { skill: SB.skill.index } }, get, getBond).map((c) => c.chessId), [INSIDE]);
|
||
});
|
||
|
||
test('display helpers: module badge, stat rows, skill tags', () => {
|
||
assert.equal(moduleBadge({ typeName: 'MAR-X' }), 'X');
|
||
assert.equal(moduleBadge({ typeName: 'ISW-α' }), 'α');
|
||
assert.equal(moduleBadge(null, MODULE_NONE), '—');
|
||
assert.deepEqual(attrRows({ maxHp: 80, atk: 22, def: 0 }).map((r) => [r.label, r.text, r.positive]), [['生命上限', '+80', true], ['攻击力', '+22', true]]);
|
||
assert.deepEqual(attrRows({ cost: -1, respawnTime: -4 }).map((r) => [r.label, r.text, r.positive]), [['部署费用', '-1', true], ['再部署时间', '-4秒', true]]);
|
||
assert.deepEqual(attrRows(null), []);
|
||
const t = skillTags(IB.skills[1]);
|
||
assert.equal(t.sp, '自动回复');
|
||
assert.equal(t.cost, IB.skills[1].spCost);
|
||
assert.equal(t.duration, '弹药');
|
||
assert.equal(skillLabel(2), 'S3');
|
||
const passive = skillTags({ skillType: 'PASSIVE', spType: 8 });
|
||
assert.equal(passive.passive, true);
|
||
assert.equal(passive.cost, null);
|
||
});
|
||
|
||
// ---- sync ---------------------------------------------------------------------------------------------------------------
|
||
|
||
function fakeNet() {
|
||
const listeners = new Map();
|
||
const net = {
|
||
status: 'online',
|
||
sent: [],
|
||
replies: [],
|
||
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||
request(t, fields) {
|
||
net.sent.push({ t, ...fields });
|
||
const r = net.replies.shift();
|
||
return r && r.error ? Promise.reject(Object.assign(new Error(r.error), { code: r.error })) : Promise.resolve({ t: 'ok' });
|
||
},
|
||
};
|
||
return net;
|
||
}
|
||
function fakeTimers() {
|
||
let now = 0;
|
||
let seq = 0;
|
||
const q = new Map();
|
||
return {
|
||
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
||
clearTimeout: (id) => q.delete(id),
|
||
async advance(ms) {
|
||
const end = now + ms;
|
||
for (;;) {
|
||
const next = [...q.entries()].filter(([, t]) => t.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||
if (!next) break;
|
||
q.delete(next[0]);
|
||
now = next[1].at;
|
||
next[1].fn();
|
||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||
}
|
||
now = end;
|
||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||
},
|
||
};
|
||
}
|
||
const syncStore = (entries = {}) => createStore({ entries, open: false, from: null, sel: null, filters: {}, sync: 'idle' });
|
||
|
||
test('sync: welcome sends the sanitised loadout; edits are debounced; identical content is not resent', async () => {
|
||
const net = fakeNet();
|
||
const T = fakeTimers();
|
||
const target = syncStore({ [INSIDE]: { skill: 0 }, chess_char_9_99_a: { skill: 1 } });
|
||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||
net.emit('welcome', {});
|
||
await T.advance(100);
|
||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: {} }], 'stale entry dropped (no 潜能 / 练度 set: ops {})');
|
||
assert.equal(target.get().sync, 'synced');
|
||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } } });
|
||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } } });
|
||
await T.advance(SYNC_DEBOUNCE_MS - 10);
|
||
assert.equal(net.sent.length, 1, 'debounced');
|
||
await T.advance(20);
|
||
assert.equal(net.sent.length, 2);
|
||
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } });
|
||
target.set({ entries: { ...target.get().entries } }); // same content, new object
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 2, 'unchanged content is not resent');
|
||
net.emit('welcome', {}); // a new / resumed session always gets it again
|
||
await T.advance(100);
|
||
assert.equal(net.sent.length, 3);
|
||
s.dispose();
|
||
});
|
||
|
||
test('sync: locked match (WRONG_PHASE) is not an error; RATE retries; offline waits for the next welcome; missing data never sends', async () => {
|
||
const net = fakeNet();
|
||
const T = fakeTimers();
|
||
const target = syncStore({ [INSIDE]: { skill: 0 } });
|
||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||
net.replies.push({ error: 'WRONG_PHASE' });
|
||
net.emit('welcome', {});
|
||
await T.advance(100);
|
||
assert.equal(target.get().sync, 'locked');
|
||
// back in the lobby (room.state without a match): sent again
|
||
net.emit('room.state', { inMatch: false });
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 2);
|
||
assert.equal(target.get().sync, 'synced');
|
||
// RATE → retried after RETRY_MS
|
||
net.replies.push({ error: 'RATE' });
|
||
target.set({ entries: {} });
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 3);
|
||
assert.equal(target.get().sync, 'pending');
|
||
await T.advance(RETRY_MS + 10);
|
||
assert.equal(net.sent.length, 4);
|
||
assert.deepEqual(net.sent[3].entries, {});
|
||
assert.equal(target.get().sync, 'synced');
|
||
// offline: nothing sent until the next welcome
|
||
net.status = 'reconnecting';
|
||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 4);
|
||
net.status = 'online';
|
||
net.emit('welcome', {});
|
||
await T.advance(100);
|
||
assert.equal(net.sent.length, 5);
|
||
s.dispose();
|
||
// chess data missing: never sanitise everything away (that would clear the server's copy)
|
||
const net2 = fakeNet();
|
||
const t2 = syncStore({ [INSIDE]: { skill: 0 } });
|
||
const s2 = installLoadoutSync({ net: net2, timers: T, target: t2, getChessReady: async () => null, lookupChess: () => null });
|
||
net2.emit('welcome', {});
|
||
await T.advance(100);
|
||
assert.equal(net2.sent.length, 0);
|
||
assert.equal(t2.get().sync, 'error');
|
||
s2.dispose();
|
||
});
|
||
|
||
test('sync: closing the overlay sends a pending edit at once (the next click — 准备就绪 / 开始模拟 — must not overtake it)', async () => {
|
||
const net = fakeNet();
|
||
const T = fakeTimers();
|
||
const target = syncStore({});
|
||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||
net.emit('welcome', {});
|
||
await T.advance(100);
|
||
assert.equal(net.sent.length, 1);
|
||
target.set({ open: true, from: 'briefing' });
|
||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||
await T.advance(50); // well inside the debounce
|
||
assert.equal(net.sent.length, 1, 'still debounced while the overlay is open');
|
||
target.set({ open: false });
|
||
for (let i = 0; i < 5; i++) await Promise.resolve(); // microtasks only: no timer ran
|
||
assert.equal(net.sent.length, 2, 'sent on close, before any later task (e.g. the g.infoReady click)');
|
||
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0 } });
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 2, 'the cancelled debounce does not send it twice');
|
||
// closing without a pending edit sends nothing
|
||
target.set({ open: true });
|
||
target.set({ open: false });
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 2);
|
||
s.dispose();
|
||
});
|
||
|
||
test('overlay auto-close: briefing entry closes when INFO_CHECK ends; lobby / room entry closes when a match starts', () => {
|
||
const open = (from) => ({ open: true, from });
|
||
assert.equal(shouldAutoClose(open('briefing'), PHASE.INFO_CHECK, true, true), false);
|
||
assert.equal(shouldAutoClose(open('briefing'), PHASE.BAND_DRAFT, true, true), true);
|
||
assert.equal(shouldAutoClose(open('room'), null, false, false), false);
|
||
assert.equal(shouldAutoClose(open('room'), null, true, false), true);
|
||
assert.equal(shouldAutoClose(open('lobby'), PHASE.INFO_CHECK, true, true), false, 'reopened during the briefing from elsewhere');
|
||
assert.equal(shouldAutoClose({ open: false, from: 'briefing' }, PHASE.PREP, true, true), false);
|
||
});
|
||
|
||
test('untimed phases show no countdown: the "无倒计时" placeholder of Countdown is hidden (user playtest #11)', () => {
|
||
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8');
|
||
assert.match(css, /\.countdown\[aria-label="无倒计时"\]\s*\{\s*display:\s*none;/);
|
||
const html = readFileSync(path.join(ROOT, 'public/index.html'), 'utf8');
|
||
assert.match(html, /<link rel="stylesheet" href="\/css\/screens\/loadout\.css" \/>/, 'loaded on every page');
|
||
// contract with ui/components.js: a missing deadline renders the Countdown with exactly that aria-label (or nothing)
|
||
const comp = readFileSync(path.join(ROOT, 'public/js/ui/components.js'), 'utf8');
|
||
const fn = comp.slice(comp.indexOf('export function Countdown'), comp.indexOf('export function Countdown') + 2500);
|
||
assert.ok(/'无倒计时'/.test(fn) || /return null/.test(fn), 'Countdown marks (or skips) the untimed state');
|
||
});
|
||
|
||
test('the background layer (.lo__bg: mint glow + grid) keeps position: absolute — no later rule of the same specificity overrides it (PR #14)', () => {
|
||
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
|
||
assert.match(css, /\.lo__bg\s*\{[^}]*position:\s*absolute;/);
|
||
// `.lo > *` (one class, after .lo__bg) used to set position: relative on it, a 0-px flex item that never drew
|
||
assert.ok(!/\.lo\s*>\s*\*\s*\{[^}]*position/.test(css), 'no universal child rule setting position');
|
||
assert.match(css, /\.lo > :where\(:not\(\.lo__bg\)\) \{ position: relative; \}/, 'the other layers still stack above it, at one class of specificity');
|
||
const after = css.slice(css.indexOf('.lo__bg'));
|
||
for (const m of after.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
|
||
if (!/\.lo__bg\b/.test(m[1]) || /:not\(\.lo__bg\)/.test(m[1])) continue;
|
||
for (const v of m[2].matchAll(/position:\s*([a-z-]+)/g)) assert.equal(v[1], 'absolute', `${m[1].trim()} changes the layer's position`);
|
||
}
|
||
});
|
||
|
||
// ---- review fixes (adversarial review of the loadout workstream) -------------------------------------------------------
|
||
|
||
/** A net whose replies are released by hand (the socket is ordered: replies come back in request order). */
|
||
function deferredNet() {
|
||
const listeners = new Map();
|
||
const net = {
|
||
status: 'online',
|
||
sent: [],
|
||
pending: [],
|
||
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||
request(t, fields) {
|
||
net.sent.push({ t, ...fields });
|
||
return new Promise((resolve, reject) => net.pending.push({ resolve, reject }));
|
||
},
|
||
async reply(error = null) {
|
||
const p = net.pending.shift();
|
||
if (error) p.reject(Object.assign(new Error(error), { code: error })); else p.resolve({ t: 'ok' });
|
||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||
},
|
||
};
|
||
return net;
|
||
}
|
||
const ticks = async () => { for (let i = 0; i < 6; i++) await Promise.resolve(); };
|
||
|
||
test('sync: an edit closed while an earlier room.loadout is still in flight is sent at once, not after that reply', async () => {
|
||
const net = deferredNet();
|
||
const T = fakeTimers();
|
||
const target = syncStore({ [INSIDE]: { skill: 0 } });
|
||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get, notify: () => {} });
|
||
net.emit('welcome', {});
|
||
await T.advance(100);
|
||
assert.equal(net.sent.length, 1, 'welcome send in flight (no reply yet)');
|
||
target.set({ open: true, from: 'briefing' });
|
||
target.set({ entries: { [INSIDE]: { skill: 0, module: MODULE_NONE } } });
|
||
target.set({ open: false });
|
||
await ticks(); // microtasks only: the player's next click (准备就绪) is a later task
|
||
assert.equal(net.sent.length, 2, 'the edit went out before the first reply (the same ordered socket)');
|
||
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0, module: MODULE_NONE } });
|
||
// the older reply must not mark the newer content as synced / the newer reply wins
|
||
await net.reply();
|
||
assert.equal(target.get().sync, 'sending', 'the older reply does not settle the state');
|
||
await net.reply();
|
||
assert.equal(target.get().sync, 'synced');
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 2, 'nothing resent');
|
||
// reverting to the content of a request that is still in flight is not sent twice; reverting to the last accepted
|
||
// content while a different one is in flight is sent (the server applies the newest frame)
|
||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 3);
|
||
target.set({ entries: { [INSIDE]: { skill: 0, module: MODULE_NONE } } });
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 4, 'back to the accepted content while another is in flight: sent');
|
||
target.set({ entries: { [INSIDE]: { skill: 0, module: MODULE_NONE } } });
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(net.sent.length, 4, 'identical to the one in flight: not duplicated');
|
||
await net.reply();
|
||
await net.reply();
|
||
assert.equal(target.get().sync, 'synced');
|
||
s.dispose();
|
||
});
|
||
|
||
test('sync: an edit refused because the match locked its loadout is told to the player once; a resync is not', async () => {
|
||
const net = deferredNet();
|
||
const T = fakeTimers();
|
||
const told = [];
|
||
const target = syncStore({ [INSIDE]: { skill: 0 } });
|
||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get, notify: (t) => told.push(t) });
|
||
net.emit('welcome', {}); // reconnect during a running match: the resync is refused, nothing to tell
|
||
await T.advance(100);
|
||
await net.reply('WRONG_PHASE');
|
||
assert.equal(target.get().sync, 'locked');
|
||
assert.deepEqual(told, []);
|
||
// an edit (the briefing overlay closed by INFO_CHECK ending) arrives too late
|
||
target.set({ open: true, from: 'briefing' });
|
||
target.set({ entries: { [INSIDE]: { skill: 1 } } });
|
||
target.set({ open: false });
|
||
await ticks();
|
||
assert.equal(net.sent.length, 2);
|
||
await net.reply('WRONG_PHASE');
|
||
assert.equal(target.get().sync, 'locked');
|
||
assert.equal(told.length, 1);
|
||
assert.match(told[0], /下一局生效/);
|
||
s.dispose();
|
||
});
|
||
|
||
test('sync: an empty loadout is sent without loading chess.json (no 1.6 MB download in the lobby for it)', async () => {
|
||
const net = deferredNet();
|
||
const T = fakeTimers();
|
||
let loads = 0;
|
||
const target = syncStore({});
|
||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => { loads++; return CHESS; }, lookupChess: get, notify: () => {} });
|
||
net.emit('welcome', {});
|
||
await T.advance(100);
|
||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {}, ops: {} }]);
|
||
assert.equal(loads, 0);
|
||
await net.reply();
|
||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||
assert.equal(loads, 1, 'a real loadout is sanitised against the data');
|
||
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0 } });
|
||
s.dispose();
|
||
});
|
||
|
||
test('entry badge (review fix): counts like the screen once chess.json is loaded (stale entries of retired chess do not count)', async () => {
|
||
const { badgeCount } = await import('../../public/js/screens/loadout.js');
|
||
// unknown id, a retired (hidden) chess on a non-default skill (红豆 S1), an elite id
|
||
const entries = { [INSIDE]: { skill: 0 }, chess_char_9_99_a: { skill: 1 }, chess_char_1_05_a: { skill: 0 }, [IB.goldenId]: { skill: 0 } };
|
||
assert.equal(get('chess_char_1_05_a').isHidden, true, 'fixture: 红豆 is retired');
|
||
assert.equal(badgeCount(entries, null), 4, 'before the data: the stored entries');
|
||
assert.equal(badgeCount(entries, get), 1, 'with the data: only chess the screen shows as 已调整');
|
||
assert.equal(badgeCount(entries, get), changedCount(entries, get));
|
||
assert.equal(badgeCount({}, get), 0);
|
||
});
|