Counting rule (ui/stats.js countsTowardStats, the owner's decision of 2026-10-08: do not count the matches entered and left right away, but keep them in the history): a settled match (m.result arrived) always counts; a match given up (放弃模拟) counts once at least one of the player's battles had finished — a round cleared (the server's own rule, platform.js _quit: leaving while round k runs has passed k - 1, in its settlement k) or already eliminated. Anything earlier is history only: marked 中途退出 and 不计入统计, dimmed, listed in 对局记录, left out of every aggregate (games, wins, win rate, strategy table, titles, rounds, time, combat totals); 总览 says how many were left out. - A leaver gets no m.result, so the page records the match itself: quitMatch (放弃模拟) → recordQuit builds an `end: 'quit'` record from the app state and a tracker (observeMatch, installed in main.js) that remembers when the page first saw the match and the last round the player was seen alive in. No record for a spectator seat, a match with its settlement already there, a match the page never saw; matches the server ended and closed tabs are not recorded. Defects of PR #323 as submitted, reproduced (scratchpad/w7-stats-notes/repro-pr-bugs.mjs, loads-check*.mjs) and fixed: - the title screen loaded all 16 game data files (5.3 MB) at boot: <StatsHost/> called useGameData() at mount; now the host renders nothing until opened, the overview reads config / bands / assets and the settlement re-view loads the rest when opened (the title screen loads the same 3 files as before); - importing a file put the list oldest first (the page shows the first 20 as the newest and the cap would drop the newest); the list is newest first everywhere, also when loaded from storage; - recordResult wrote over the envelope of a NEWER build (a downgrade, against the PR's own rule); it refuses now, as saveStats does; - 1000 four-player records are ~7.2 M characters (the PR's ~1.5 kB/record is a solo match) against a ~2.5 M-character quota: MAX_RECORDS 1000 stays, plus a 800 000-character budget (the oldest go first), and only the newest 30 records keep their rows (lineup, bonds, teammates' names; the replay needs them) — older ones are compacted to the player's own summary row (~620 characters), a refused write retries with half the history; unknown fields and oversized values are dropped (a strict schema), import files are bounded (8 MB); - the re-view clobbered the app store (match.public / match.result, the route, the music); ResultScreen is split into ResultScreen (the store) and ResultView (props: result, myId, back label), so the stats page renders a stored match itself; a 自选 pick and a 补位 stand-in were dropped from the stored lineup (the replayed card drew the plain operator); DIFFICULTY_NAMES were not translated; a band cell was a display:flex <td>; the overlay stayed open when a co-op match started; the 清空 two-step blink was replaced by the shared confirm dialog. - Corrupt storage: unreadable data is moved to sp.pref.stats.damaged and the page says so; damaged rows are dropped and counted; duplicates, an oversized or unsorted list are repaired on load; a pre-`end` (PR-era) envelope reads as settled. UI: a full-screen overlay in the grammar of 干员调配 (top bar with 返回 / four tabs 总览 · 策略 · 战斗累计 · 对局记录 / 导出 导入 清空, a note line saying the data stays in this browser and that teammates' names are stored too), text sizes with max(.xxrem, N px) for phones in landscape, a 50-row history page with 显示更多, a pass-rate bar, a per-difficulty table in order; the entries use a new `chart` icon (not the ping bars), the title screen's sits beside the language menu (the connection row has no room: it squeezed the gear and wrapped the status text), the lobby / room buttons show the icon alone below 700 px (the room's title ran under the label at 640 px). docs/PLAYING.md §12, README row; 41 + 25 UI strings in en / ja / ko / zh-TW (the PR's four unused ones removed). Tests: test/ui/stats.test.js 44 / 44 (counting rule per phase, quit records, bounds, corrupt / old / newer storage, import order, aggregation, the replay payload); test/ui/stats.e2e.test.js (opt-in SP_E2E=1, real server, headless Chrome: seeded history on 1920×1080 and 844×390, the re-view, export / import / clear, corrupt and newer storage, and the real flows: a solo match given up in the briefing is history only, one given up after round 1 counts, a settled match is recorded once and not again after a reload) 6 / 6; lint 0 errors, i18n extract 0 / check --all --strict clean, check:imports, typecheck, docs and version tests, golden unchanged.
728 lines
38 KiB
JavaScript
728 lines
38 KiB
JavaScript
// Unit tests (Node) for the 本机对局统计 record store (public/js/ui/stats.js; PR #323 by @2321Robin, reworked for 0.2.2):
|
||
// record building from a raw m.result (spectator copies and player-less error results build nothing),
|
||
// replay dedupe by content id (the lobby re-pushes m.result on reconnect / reload),
|
||
// the counting rule (a match left before a round was cleared is history only — the owner's decision of 2026-10-08),
|
||
// the match left early (放弃模拟): quit records built from the page's view of the match,
|
||
// bounded storage (MAX_RECORDS, MAX_CHARS, the compacted older records, a refused write),
|
||
// corrupt / old storage (unreadable JSON, damaged rows, a pre-`end` envelope, a newer one never overwritten),
|
||
// import merge semantics (newest first) and the aggregate the stats page renders.
|
||
// localStorage is faked per test (Node has none); recordResult / recordQuit / loadStats / saveStats go through it.
|
||
|
||
import { test, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
STATS_VERSION, MAX_RECORDS, MAX_CHARS, FULL_KEEP, emptyStats, buildRecord, recordId, appendRecord, recordResult, recordQuit,
|
||
loadStats, saveStats, migrateStats, normalizeRecord, importStats, exportStats, aggregateStats, selfRowOf, roomModeOf,
|
||
recordToResult, compactRecord, fitToBudget, countsTowardStats, roundsOf, clearedRounds, createLiveMatch, observeMatch,
|
||
buildQuitRecord, installStatsRecorder, liveMatch, MIGRATIONS,
|
||
} from '../../public/js/ui/stats.js';
|
||
import { createStore, emptyMatch } from '../../public/js/store.js';
|
||
import { normalizeResult } from '../../public/js/ui/gameLogic.js';
|
||
import { PHASE } from '../../shared/constants.js';
|
||
|
||
/** A realistic m.result in the shape server/match/results.js buildResult emits (replayed byte-identically). */
|
||
function baseResult(over = {}) {
|
||
return {
|
||
t: 'm.result',
|
||
victory: true, roundsPassed: 15, hiddenReached: true, hiddenCleared: true, reason: 'hidden_cleared',
|
||
teamLp: 24, modeId: 'mode_single_hard', difficulty: 'HARD', stageId: 'stage_hard', bossId: 'enemy_boss', hiddenBossId: 'enemy_hidden',
|
||
seed: 123456789, durationMs: 952123,
|
||
players: [
|
||
{
|
||
playerId: 'p1', seat: 0, name: '罗宾', isBot: false, left: false, alive: true, victory: true,
|
||
roundsPassed: 16, eliminatedRound: null, lp: 24, bandId: 'band_amiya',
|
||
title: { id: 'comment_1', name: '卫戍之星', picId: 'comment_icon_1', text: '对敌方领袖造成伤害最高(仅胜利时)' },
|
||
trophies: 30, reward: 88,
|
||
lineup: [{ id: 'char_290_vigna', golden: false, tier: 2, row: 10, col: 5, items: ['item_eq_1'] }],
|
||
bonds: [{ bondId: 'steadShip', count: 3, tier: 1, layers: 12, active: true }],
|
||
stats: { dmgDealt: 15234.7, kills: 41, leaks: 2, gold: 880, refreshes: 5, merges: 6, itemsEquipped: 4, bossDamage: 5000.5, activatedLayers: 99, lpLost: 3, perfectRounds: 7 },
|
||
},
|
||
{
|
||
playerId: 'p2', seat: 1, name: '队友B', isBot: false, left: false, alive: false, victory: false,
|
||
roundsPassed: 9, eliminatedRound: 10, lp: 0, bandId: 'band_fang', title: null, trophies: 0, reward: 40,
|
||
lineup: [], bonds: [], stats: { kills: 3 },
|
||
},
|
||
],
|
||
...over,
|
||
};
|
||
}
|
||
|
||
/** Fake localStorage: Map-backed, with an optional byte budget that makes setItem throw like a full quota. */
|
||
function fakeStorage({ budget = Infinity } = {}) {
|
||
const m = new Map();
|
||
return {
|
||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||
setItem(k, v) { if (String(v).length > budget) { const e = new Error('quota'); e.name = 'QuotaExceededError'; throw e; } m.set(k, String(v)); },
|
||
removeItem: (k) => { m.delete(k); },
|
||
get raw() { return m.get('sp.pref.stats') ?? null; },
|
||
get damaged() { return m.get('sp.pref.stats.damaged') ?? null; },
|
||
};
|
||
}
|
||
/** Run `fn` with a fake localStorage installed. */
|
||
function withStorage(opts, fn) {
|
||
if (typeof opts === 'function') { fn = opts; opts = {}; }
|
||
const ls = fakeStorage(opts);
|
||
globalThis.localStorage = ls;
|
||
try { return fn(ls); } finally { delete globalThis.localStorage; }
|
||
}
|
||
/** n settled records, newest first, t = n−1 … 0 */
|
||
function manyRecords(n, mk = (i) => baseResult({ seed: i })) {
|
||
let stats = emptyStats();
|
||
for (let i = 0; i < n; i++) stats = appendRecord(stats, buildRecord(mk(i), { myId: 'p1', now: i })).stats;
|
||
return stats;
|
||
}
|
||
|
||
// ---- the match on screen, as the app store holds it ---------------------------------------------------------------------
|
||
|
||
/** An app state with a running match: `phase` of `round`, the local player p1 (alive unless `alive: false`). */
|
||
function liveState({ phase = PHASE.PREP, round = 1, alive = true, spectator = false, result = null, roomMode = 'solo', difficulty = 'HARD' } = {}) {
|
||
const players = spectator ? [{ playerId: 'p9', seat: 0, name: '别人', alive: true, lp: 20, bandId: 'band_x' }]
|
||
: [{ playerId: 'p1', seat: 0, name: '罗宾', alive, lp: alive ? 22 : 0, bandId: 'band_amiya' }];
|
||
return {
|
||
me: { playerId: 'p1', name: '罗宾' },
|
||
room: { code: 'ABCD', mode: roomMode },
|
||
match: {
|
||
public: { phase, round, lastRound: 14, modeId: roomMode === 'solo' ? `mode_single_${difficulty.toLowerCase()}` : `mode_multi_${difficulty.toLowerCase()}`, difficulty, stageId: 'stage_a', bossId: 'boss_2', hiddenBossId: 'boss_10', players },
|
||
private: null, field: null, result, battle: null,
|
||
},
|
||
};
|
||
}
|
||
/** The tracker as it would stand after watching `states` (one `now` per state). */
|
||
function watched(states, t0 = 1_000_000) {
|
||
const live = createLiveMatch();
|
||
states.forEach((s, i) => observeMatch(live, s, t0 + i * 1000));
|
||
return live;
|
||
}
|
||
|
||
describe('local stats: record building', () => {
|
||
test('builds one full record from a raw m.result (frame + players + self marking)', () => {
|
||
const rec = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1700000000000 });
|
||
assert.ok(rec);
|
||
assert.equal(rec.v, STATS_VERSION);
|
||
assert.equal(rec.end, 'settled');
|
||
assert.equal(rec.t, 1700000000000);
|
||
assert.equal(rec.selfId, 'p1');
|
||
assert.equal(rec.victory, true);
|
||
assert.equal(rec.difficulty, 'HARD');
|
||
assert.equal(rec.seed, 123456789);
|
||
assert.equal(rec.durationMs, 952123);
|
||
assert.equal(rec.roomMode, 'solo');
|
||
assert.equal(rec.id, recordId(baseResult(), 'p1'));
|
||
assert.equal(rec.players.length, 2);
|
||
const self = rec.players[0];
|
||
assert.equal(self.bandId, 'band_amiya');
|
||
assert.deepEqual(self.title, { id: 'comment_1' }, 'the title is kept by id: its texts come from the game data');
|
||
assert.equal(self.stats.bossDamage, 5000.5);
|
||
assert.deepEqual(self.lineup, [{ id: 'char_290_vigna', golden: false, tier: 2, items: ['item_eq_1'] }]);
|
||
assert.deepEqual(self.bonds, [{ bondId: 'steadShip', layers: 12, active: true }]);
|
||
});
|
||
|
||
test('a 自选 pick and a 补位 stand-in survive in the lineup, or the replayed card would draw the plain operator', () => {
|
||
const res = baseResult();
|
||
res.players[0].lineup = [
|
||
{ id: 'chess_diy_a', golden: true, tier: 5, row: 1, col: 1, items: [], diy: { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege', extra: 'x' } },
|
||
{ id: 'chess_x', golden: false, tier: 1, items: [], standInFor: 'char_002_amiya' },
|
||
{ id: 'tok_1', kind: 'token', golden: false, tier: 0, items: [] },
|
||
];
|
||
const back = buildRecord(res, { myId: 'p1', now: 1 }).players[0].lineup;
|
||
assert.deepEqual(back[0].diy, { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' });
|
||
assert.equal(back[1].standInFor, 'char_002_amiya');
|
||
assert.equal(back[2].kind, 'token');
|
||
});
|
||
|
||
test('a spectator seat copy (my id not among players) records nothing; so do player-less error results', () => {
|
||
assert.equal(buildRecord(baseResult(), { myId: 'spec9', now: 1 }), null);
|
||
assert.equal(buildRecord(baseResult({ players: [] }), { myId: 'p1', now: 1 }), null);
|
||
assert.equal(buildRecord(null, { myId: 'p1' }), null);
|
||
});
|
||
|
||
test('roomMode falls back to the modeId (single → solo, multi → coop, else null)', () => {
|
||
assert.equal(roomModeOf('mode_multi_funny', null), 'coop');
|
||
assert.equal(roomModeOf('mode_single_normal', 'coop'), 'coop'); // explicit room mode wins
|
||
assert.equal(roomModeOf('mode_training_1', null), null);
|
||
assert.equal(buildRecord(baseResult({ modeId: 'mode_multi_hard' }), { myId: 'p1', now: 1 }).roomMode, 'coop');
|
||
});
|
||
});
|
||
|
||
describe('local stats: replay dedupe', () => {
|
||
test('the replayed m.result (same content) appends once; a different seed is a new game', () => {
|
||
const stats = emptyStats();
|
||
const res = baseResult();
|
||
const first = appendRecord(stats, buildRecord(res, { myId: 'p1', now: 1000 }));
|
||
assert.equal(first.added, true);
|
||
const replay = appendRecord(first.stats, buildRecord(res, { myId: 'p1', now: 999000 }));
|
||
assert.equal(replay.added, false); // the reload / reconnect replay
|
||
assert.equal(replay.stats.records.length, 1);
|
||
assert.equal(replay.stats.records[0].t, 1000); // the FIRST arrival's timestamp is kept
|
||
const other = appendRecord(replay.stats, buildRecord(baseResult({ seed: 42, durationMs: 55555 }), { myId: 'p1', now: 2000 }));
|
||
assert.equal(other.added, true);
|
||
assert.equal(other.stats.records.length, 2);
|
||
assert.equal(other.stats.records[0].id, recordId(baseResult({ seed: 42, durationMs: 55555 }), 'p1'));
|
||
});
|
||
|
||
test('recordResult end-to-end over faked localStorage: replay arrives, nothing is written twice', () => {
|
||
withStorage(() => {
|
||
const res = baseResult();
|
||
const rec = recordResult(res, { myId: 'p1', roomMode: 'solo', now: 1000 });
|
||
assert.ok(rec);
|
||
assert.equal(recordResult(res, { myId: 'p1', roomMode: 'solo', now: 2000 }), null); // replay dropped
|
||
const loaded = loadStats();
|
||
assert.equal(loaded.records.length, 1);
|
||
assert.equal(loaded.records[0].id, rec.id);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('local stats: the counting rule (a match entered and left right away does not count)', () => {
|
||
/** A quit record whose player left in `phase` of `round` (alive unless told otherwise). */
|
||
const quitAt = (phase, round, o = {}) => {
|
||
const s = liveState({ phase, round, ...o });
|
||
return buildQuitRecord(s, { live: watched([liveState({ phase: PHASE.INFO_CHECK, round: 0 }), s]), now: 5_000_000 });
|
||
};
|
||
|
||
test('a settled match always counts — even one lost in the first rounds', () => {
|
||
const rec = buildRecord(baseResult({ victory: false, roundsPassed: 0, players: [{ ...baseResult().players[0], victory: false, alive: false, roundsPassed: 0 }] }), { myId: 'p1', now: 1 });
|
||
assert.equal(rec.end, 'settled');
|
||
assert.equal(countsTowardStats(rec), true);
|
||
});
|
||
|
||
test('left before the first round was cleared: history only; once a round is cleared it counts', () => {
|
||
// the server's rule (platform.js _quit): leaving while round k runs has passed k − 1 rounds, leaving in its settlement k
|
||
for (const [phase, round, passed, counts] of [
|
||
[PHASE.INFO_CHECK, 0, 0, false], [PHASE.BAND_DRAFT, 0, 0, false], [PHASE.PREP, 1, 0, false], [PHASE.COMBAT, 1, 0, false],
|
||
[PHASE.SETTLE, 1, 1, true], [PHASE.PREP, 2, 1, true], [PHASE.COMBAT, 9, 8, true], [PHASE.FINAL_ASSAULT, 14, 13, true],
|
||
]) {
|
||
const rec = quitAt(phase, round);
|
||
assert.equal(rec.end, 'quit', `${phase} ${round}`);
|
||
assert.equal(rec.players[0].roundsPassed, passed, `${phase} ${round}: rounds cleared`);
|
||
assert.equal(clearedRounds(phase, round), passed);
|
||
assert.equal(countsTowardStats(rec), counts, `${phase} ${round}: counts`);
|
||
}
|
||
});
|
||
|
||
test('left after being eliminated: it counts (the player lost it), the rounds are those it was seen alive in, less one', () => {
|
||
const s = [liveState({ phase: PHASE.INFO_CHECK, round: 0 }), liveState({ round: 4 }), liveState({ round: 5 }), liveState({ phase: PHASE.SETTLE, round: 5, alive: false }), liveState({ phase: PHASE.PREP, round: 8, alive: false })];
|
||
const live = watched(s);
|
||
const rec = buildQuitRecord(s[4], { live, now: 9e6 });
|
||
assert.equal(rec.players[0].alive, false);
|
||
assert.equal(rec.players[0].eliminatedRound, 5);
|
||
assert.equal(rec.players[0].roundsPassed, 4, 'eliminated in round 5 ⇒ 4 passed (the round 8 it left in is not its)');
|
||
assert.equal(roundsOf(rec), 4);
|
||
assert.equal(countsTowardStats(rec), true);
|
||
// a page that never saw it alive (a reload after the elimination) does not know the rounds — it still counts
|
||
const unknown = buildQuitRecord(s[4], { live: watched([s[4]]), now: 9e6 });
|
||
assert.equal(unknown.players[0].roundsPassed, null);
|
||
assert.equal(roundsOf(unknown), 0);
|
||
assert.equal(countsTowardStats(unknown), true);
|
||
});
|
||
|
||
test('the aggregates take only the records that count; total / excluded say what was left out', () => {
|
||
const settled = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 3000 });
|
||
const peek = quitAt(PHASE.PREP, 1); // entered, left in the first round
|
||
const played = quitAt(PHASE.PREP, 6); // 5 rounds cleared, then gave up
|
||
const agg = aggregateStats([settled, peek, played]);
|
||
assert.equal(agg.total, 3);
|
||
assert.equal(agg.count, 2);
|
||
assert.equal(agg.excluded, 1);
|
||
assert.equal(agg.wins, 1);
|
||
assert.equal(agg.rounds.max, 16, 'the best of the player\'s own rounds');
|
||
assert.equal(agg.rounds.total, 16 + 5, 'the peek adds nothing');
|
||
assert.equal(agg.byDifficulty.HARD.games, 2);
|
||
assert.deepEqual(agg.bands.band_amiya, { games: 2, wins: 1 });
|
||
assert.equal(agg.byMode.solo, 2);
|
||
});
|
||
|
||
test('combat totals read the settled records only (a quit record has no stat block)', () => {
|
||
const agg = aggregateStats([buildRecord(baseResult(), { myId: 'p1', now: 1 }), quitAt(PHASE.PREP, 6)]);
|
||
assert.equal(agg.sums.kills, 41);
|
||
assert.equal(agg.count, 2);
|
||
});
|
||
|
||
test('the history keeps every record, counted or not: the page filters nothing out of the list', () => {
|
||
const peek = quitAt(PHASE.INFO_CHECK, 0);
|
||
assert.equal(countsTowardStats(peek), false);
|
||
withStorage(() => {
|
||
let stats = emptyStats();
|
||
stats = appendRecord(stats, peek).stats;
|
||
stats = appendRecord(stats, buildRecord(baseResult(), { myId: 'p1', now: 7e6 })).stats;
|
||
assert.equal(saveStats(stats), true);
|
||
const back = loadStats();
|
||
assert.equal(back.records.length, 2);
|
||
assert.deepEqual(back.records.map((r) => r.end).sort(), ['quit', 'settled']);
|
||
assert.equal(back.records.find((r) => r.end === 'quit').players[0].roundsPassed, 0);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('local stats: the match left early (放弃模拟)', () => {
|
||
test('builds a quit record from the page\'s view: frame, own row, no teammates, no stats, no replay', () => {
|
||
const s = liveState({ phase: PHASE.PREP, round: 3, roomMode: 'coop', difficulty: 'ABYSS' });
|
||
const rec = buildQuitRecord(s, { live: watched([liveState({ phase: PHASE.INFO_CHECK, round: 0, roomMode: 'coop', difficulty: 'ABYSS' }), s]), now: 2_000_000 });
|
||
assert.equal(rec.end, 'quit');
|
||
assert.equal(rec.reason, 'quit');
|
||
assert.equal(rec.victory, false);
|
||
assert.equal(rec.difficulty, 'ABYSS');
|
||
assert.equal(rec.roomMode, 'coop');
|
||
assert.equal(rec.modeId, 'mode_multi_abyss');
|
||
assert.equal(rec.selfId, 'p1');
|
||
assert.equal(rec.players.length, 1);
|
||
assert.equal(rec.players[0].left, true);
|
||
assert.equal(rec.players[0].bandId, 'band_amiya');
|
||
assert.deepEqual(rec.players[0].stats, {});
|
||
assert.equal(rec.durationMs, 1_000_000, 'seen from the start: now − when the page first saw it');
|
||
assert.equal(recordToResult(rec), null, 'nothing was settled: no settlement screen to show');
|
||
assert.match(rec.id, /^q\./);
|
||
});
|
||
|
||
test('no record for: no match on screen, a settlement already there, a spectator seat, a match the page never saw', () => {
|
||
const s = liveState({ round: 2 });
|
||
const live = watched([s]);
|
||
assert.equal(buildQuitRecord({ ...s, match: { ...s.match, public: null } }, { live }), null);
|
||
assert.equal(buildQuitRecord(liveState({ round: 2, result: { victory: true, players: [] } }), { live }), null);
|
||
assert.equal(buildQuitRecord(liveState({ round: 2, spectator: true }), { live: watched([liveState({ round: 2, spectator: true })]) }), null);
|
||
assert.equal(buildQuitRecord(s, { live: createLiveMatch() }), null);
|
||
});
|
||
|
||
test('a match first seen mid-way (a reload) keeps no duration; the tracker restarts for the next match', () => {
|
||
const s = liveState({ phase: PHASE.PREP, round: 6 });
|
||
const live = watched([s]);
|
||
assert.equal(live.sawStart, false);
|
||
assert.equal(buildQuitRecord(s, { live, now: 9e6 }).durationMs, null);
|
||
// the match ends (result) and a new one starts: a fresh start
|
||
observeMatch(live, liveState({ phase: PHASE.RESULT, round: 14, result: { victory: true } }), 1);
|
||
assert.equal(live.active, false);
|
||
observeMatch(live, liveState({ phase: PHASE.INFO_CHECK, round: 0 }), 2);
|
||
assert.equal(live.active, true);
|
||
assert.equal(live.sawStart, true);
|
||
});
|
||
|
||
test('recordQuit stores it through localStorage once; the settlement of the same match is a different record', () => {
|
||
withStorage(() => {
|
||
const s = liveState({ phase: PHASE.PREP, round: 4 });
|
||
const live = watched([liveState({ phase: PHASE.INFO_CHECK, round: 0 }), s]);
|
||
const rec = recordQuit(s, { live, now: 3e6 });
|
||
assert.ok(rec);
|
||
assert.equal(live.active, false, 'one record per match');
|
||
assert.equal(recordQuit(s, { live, now: 3e6 }), null);
|
||
assert.equal(loadStats().records.length, 1);
|
||
assert.equal(loadStats().records[0].players[0].roundsPassed, 3);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('local stats: following the app store', () => {
|
||
test('installStatsRecorder watches a match from its start; a quit is recorded with its duration, then the tracker rests', () => {
|
||
withStorage(() => {
|
||
const first = liveState({ phase: PHASE.INFO_CHECK, round: 0 });
|
||
const store = createStore({ me: first.me, room: first.room, match: emptyMatch() });
|
||
const off = installStatsRecorder(store);
|
||
try {
|
||
assert.equal(liveMatch.active, false);
|
||
store.patch('match', { public: first.match.public });
|
||
assert.equal(liveMatch.active, true);
|
||
assert.equal(liveMatch.sawStart, true);
|
||
store.patch('match', { public: liveState({ phase: PHASE.PREP, round: 3 }).match.public });
|
||
const rec = recordQuit(store.get());
|
||
assert.ok(rec);
|
||
assert.equal(rec.players[0].roundsPassed, 2);
|
||
assert.ok(rec.durationMs >= 0);
|
||
assert.equal(liveMatch.active, false, 'recorded once');
|
||
// the app clears the match: still at rest; a new match is a fresh start
|
||
store.set({ match: emptyMatch() });
|
||
assert.equal(liveMatch.active, false);
|
||
store.patch('match', { public: first.match.public });
|
||
assert.equal(liveMatch.active, true);
|
||
} finally { off(); }
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('local stats: bounded storage', () => {
|
||
test(`the store caps at MAX_RECORDS (${MAX_RECORDS}), dropping the oldest`, () => {
|
||
const stats = manyRecords(MAX_RECORDS + 30);
|
||
assert.equal(stats.records.length, MAX_RECORDS);
|
||
assert.equal(stats.records[0].t, MAX_RECORDS + 29); // newest first
|
||
assert.equal(stats.records.at(-1).t, 30); // oldest 30 dropped
|
||
});
|
||
|
||
test(`only the newest ${FULL_KEEP} records keep their rows: the older ones are compacted to the player's own summary`, () => {
|
||
withStorage((ls) => {
|
||
assert.equal(saveStats(manyRecords(FULL_KEEP + 20)), true);
|
||
const stored = JSON.parse(ls.raw).records;
|
||
assert.equal(stored.length, FULL_KEEP + 20);
|
||
assert.equal(stored.slice(0, FULL_KEEP).every((r) => !r.compact && r.players.length === 2), true);
|
||
const old = stored.at(-1);
|
||
assert.equal(old.compact, true);
|
||
assert.equal(old.players.length, 1, 'no teammates');
|
||
assert.equal(old.players[0].name, undefined, 'no names');
|
||
assert.equal(old.players[0].lineup, undefined);
|
||
// what the aggregates read is all still there
|
||
const back = loadStats().records;
|
||
assert.deepEqual(aggregateStats(back).bands, aggregateStats(manyRecords(FULL_KEEP + 20).records).bands);
|
||
assert.equal(aggregateStats(back).sums.kills, 41 * (FULL_KEEP + 20));
|
||
// …and the settlement of a compacted record cannot be re-viewed
|
||
assert.equal(recordToResult(back.at(-1)), null);
|
||
assert.ok(recordToResult(back[0]));
|
||
});
|
||
});
|
||
|
||
test('compactRecord is idempotent and keeps the frame', () => {
|
||
const rec = buildRecord(baseResult(), { myId: 'p1', now: 5 });
|
||
const c = compactRecord(rec);
|
||
assert.equal(JSON.stringify(compactRecord(normalizeRecord(c))), JSON.stringify(c));
|
||
assert.equal(c.difficulty, 'HARD');
|
||
assert.equal(c.durationMs, 952123);
|
||
assert.equal(selfRowOf(normalizeRecord(c)).bandId, 'band_amiya');
|
||
});
|
||
|
||
test(`a store of ${MAX_RECORDS} real-size co-op matches fits MAX_CHARS (${MAX_CHARS}) — the oldest few go; solo matches all stay`, () => {
|
||
// a 4-player settlement with 10-unit lineups, 12 bonds and every stat, as server/match/results.js builds it
|
||
const row = (i) => ({
|
||
playerId: `p${i}`, seat: i, name: `博士${i}`, isBot: i > 1, left: false, alive: true, victory: true, roundsPassed: 14, eliminatedRound: null, lp: 12,
|
||
bandId: `band_${i}`, title: { id: 'comment_3', name: '坚若磐石', picId: 'comment_icon_3', text: '目标生命值损失最少' }, trophies: 5, reward: 425,
|
||
lineup: Array.from({ length: 10 }, (_, k) => ({ id: `chess_char_${k}_0${i}_a`, golden: k % 2 === 0, tier: 1 + (k % 6), row: 12, col: k, items: ['chess_item_4_08_e_a', 'chess_item_4_07_e_a'] })),
|
||
bonds: Array.from({ length: 12 }, (_, k) => ({ bondId: `bond${k}Ship`, count: 3, tier: 1, layers: 100 + k, active: true })),
|
||
stats: { dmgDealt: 6871190, kills: 393, leaks: 21, gold: 148, refreshes: 23, merges: 3, itemsEquipped: 33, bossDamage: 945575, activatedLayers: 246, lpLost: 10, perfectRounds: 7 },
|
||
});
|
||
const coop = (i) => baseResult({ seed: i, modeId: 'mode_multi_hard', players: [row(1), row(2), row(3), row(4)].map((r, k) => (k === 0 ? { ...r, playerId: 'p1' } : r)) });
|
||
const stats = manyRecords(MAX_RECORDS, coop);
|
||
const fit = fitToBudget(stats.records);
|
||
assert.ok(fit.length >= 900 && fit.length <= MAX_RECORDS, `kept ${fit.length}`);
|
||
assert.equal(fit[0].t, MAX_RECORDS - 1, 'the newest first');
|
||
assert.ok(JSON.stringify({ v: 1, records: fit }).length <= MAX_CHARS);
|
||
withStorage((ls) => { assert.equal(saveStats(stats), true); assert.ok(ls.raw.length <= MAX_CHARS); });
|
||
const solo = manyRecords(MAX_RECORDS, (i) => baseResult({ seed: i, players: [baseResult().players[0]] }));
|
||
assert.equal(fitToBudget(solo.records).length, MAX_RECORDS);
|
||
});
|
||
|
||
test('over the character budget the oldest records go first; the newest is always kept', () => {
|
||
const stats = manyRecords(200);
|
||
const fit = fitToBudget(stats.records, 60_000);
|
||
assert.ok(fit.length < 200 && fit.length > FULL_KEEP, `kept ${fit.length}`);
|
||
assert.equal(fit[0].t, 199);
|
||
assert.equal(fit.at(-1).t, 200 - fit.length);
|
||
assert.ok(JSON.stringify({ v: 1, records: fit }).length <= 60_000);
|
||
// a budget below one record still keeps the newest
|
||
assert.deepEqual(fitToBudget(stats.records, 10).map((r) => r.t), [199]);
|
||
});
|
||
|
||
test('a write the browser refuses (quota) is retried with half the history; a hopeless one reports failure instead of throwing', () => {
|
||
const probe = JSON.stringify({ v: STATS_VERSION, records: [compactRecord(buildRecord(baseResult(), { myId: 'p1', now: 1 }))] }).length;
|
||
withStorage({ budget: probe * 40 }, (ls) => {
|
||
const stats = manyRecords(300);
|
||
assert.equal(saveStats(stats), true);
|
||
const back = JSON.parse(ls.raw).records;
|
||
assert.ok(back.length < 300 && back.length >= 8, `shrank to ${back.length}`);
|
||
assert.equal(back[0].t, 299, 'the newest kept');
|
||
});
|
||
withStorage({ budget: 10 }, () => assert.equal(saveStats(manyRecords(20)), false));
|
||
// no localStorage at all (a locked-down browser): quiet failure
|
||
assert.equal(saveStats(manyRecords(2)), false);
|
||
assert.deepEqual(loadStats().records, []);
|
||
});
|
||
|
||
test('every string and list of an (imported) record is bounded', () => {
|
||
const rec = normalizeRecord({
|
||
players: Array.from({ length: 30 }, (_, i) => ({
|
||
playerId: `p${i}`, name: 'N'.repeat(500), bandId: 'b'.repeat(500),
|
||
lineup: Array.from({ length: 100 }, (_, k) => ({ id: `u${k}`, items: Array(50).fill('x'.repeat(500)) })),
|
||
bonds: Array.from({ length: 100 }, (_, k) => ({ bondId: `b${k}`, layers: 1 })),
|
||
stats: Object.fromEntries(Array.from({ length: 200 }, (_, k) => [`stat${k}`, k])),
|
||
bigUnknown: 'z'.repeat(1e5),
|
||
})),
|
||
bigUnknown: 'y'.repeat(1e6),
|
||
});
|
||
assert.ok(JSON.stringify(rec).length < 40_000, `${JSON.stringify(rec).length} chars`);
|
||
assert.equal(rec.players.length, 6);
|
||
assert.equal(rec.players[0].lineup.length, 12);
|
||
assert.equal(rec.players[0].lineup[0].items.length, 4);
|
||
assert.equal(rec.players[0].name.length, 24);
|
||
assert.equal(Object.keys(rec.players[0].stats).length, 24);
|
||
assert.equal(rec.bigUnknown, undefined);
|
||
});
|
||
});
|
||
|
||
describe('local stats: corrupt and old storage', () => {
|
||
test('an unversioned PR-era envelope (no `end`) normalizes with defaults as settled records and self-heals on load', () => {
|
||
withStorage((ls) => {
|
||
const old = { records: [{ players: [{ playerId: 'p1', stats: { kills: 2 }, futureField: { x: 1 } }], futureTop: 1 }] };
|
||
ls.setItem('sp.pref.stats', JSON.stringify(old));
|
||
const loaded = loadStats();
|
||
assert.equal(loaded.v, STATS_VERSION);
|
||
assert.equal(loaded.damaged, false);
|
||
assert.equal(loaded.records.length, 1);
|
||
const rec = loaded.records[0];
|
||
assert.equal(rec.end, 'settled');
|
||
assert.equal(countsTowardStats(rec), true);
|
||
assert.equal(rec.victory, false);
|
||
assert.equal(rec.roundsPassed, 0);
|
||
assert.equal(rec.selfId, null);
|
||
assert.equal(rec.players[0].roundsPassed, 0);
|
||
assert.equal(rec.players[0].alive, true);
|
||
assert.deepEqual(rec.players[0].stats, { kills: 2 });
|
||
assert.equal(rec.players[0].futureField, undefined, 'a strict schema: unknown fields do not survive (a newer build bumps the version instead)');
|
||
assert.equal(rec.futureTop, undefined);
|
||
assert.match(rec.id, /^c\./, 'an id-less row gets a content id');
|
||
const rewritten = JSON.parse(ls.raw);
|
||
assert.equal(rewritten.v, STATS_VERSION); // self-healed back to storage
|
||
assert.equal(rewritten.records[0].id, rec.id);
|
||
});
|
||
});
|
||
|
||
test('unreadable storage (not JSON, not an envelope) is set aside as a backup and the history starts again', () => {
|
||
for (const bad of ['{"v":1,"records":[', 'null', '[1,2,3]', '"hello"', '{"v":1}', '{"records":"nope"}']) {
|
||
withStorage((ls) => {
|
||
ls.setItem('sp.pref.stats', bad);
|
||
const loaded = loadStats();
|
||
assert.equal(loaded.damaged, true, bad);
|
||
assert.deepEqual(loaded.records, [], bad);
|
||
assert.equal(ls.damaged, bad, `${bad}: kept where a person can find it`);
|
||
assert.equal(ls.raw, null);
|
||
// and recording works again straight away
|
||
assert.ok(recordResult(baseResult(), { myId: 'p1', now: 1 }));
|
||
assert.equal(loadStats().records.length, 1);
|
||
assert.equal(loadStats().damaged, false);
|
||
});
|
||
}
|
||
});
|
||
|
||
test('damaged rows are dropped and counted, the good ones kept (in order, newest first); the storage is rewritten without them', () => {
|
||
withStorage((ls) => {
|
||
const good = (seed, t) => ({ ...buildRecord(baseResult({ seed }), { myId: 'p1', now: t }) });
|
||
ls.setItem('sp.pref.stats', JSON.stringify({
|
||
v: STATS_VERSION,
|
||
records: [good(1, 100), null, 42, 'junk', { nope: 1 }, { players: [] }, { players: [null, 3] }, good(2, 300), good(3, 200)],
|
||
}));
|
||
const loaded = loadStats();
|
||
assert.deepEqual(loaded.records.map((r) => r.t), [300, 200, 100], 'sorted newest first');
|
||
assert.equal(loaded.dropped, 6);
|
||
assert.equal(JSON.parse(ls.raw).records.length, 3, 'self-healed');
|
||
assert.equal(loadStats().dropped, 0);
|
||
});
|
||
});
|
||
|
||
test('a record listed twice (hand-merged data) counts once', () => {
|
||
withStorage((ls) => {
|
||
const rec = buildRecord(baseResult(), { myId: 'p1', now: 100 });
|
||
ls.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: [rec, { ...rec }, rec] }));
|
||
const loaded = loadStats();
|
||
assert.equal(loaded.records.length, 1);
|
||
assert.equal(loaded.dropped, 2);
|
||
assert.equal(aggregateStats(loaded.records).count, 1);
|
||
});
|
||
});
|
||
|
||
test('numbers that are not numbers, wrong types and absurd values normalize instead of crashing the page', () => {
|
||
const rec = normalizeRecord({
|
||
t: 'yesterday', victory: 'yes', roundsPassed: NaN, durationMs: '5', difficulty: 12, roomMode: 'both', end: 'whatever',
|
||
players: [{ playerId: 7, seat: 'x', name: {}, alive: 'no', stats: { kills: '3', gold: NaN, dmgDealt: 5, deep: { a: 1 } }, lineup: [1, null, { id: 5 }], bonds: 'many', title: 'comment_1' }],
|
||
});
|
||
assert.equal(rec.end, 'settled');
|
||
assert.equal(rec.t, 0);
|
||
assert.equal(rec.victory, false);
|
||
assert.equal(rec.roundsPassed, 0);
|
||
assert.equal(rec.durationMs, null);
|
||
assert.equal(rec.difficulty, null);
|
||
assert.equal(rec.roomMode, null);
|
||
const p = rec.players[0];
|
||
assert.equal(p.playerId, '');
|
||
assert.equal(p.alive, true);
|
||
assert.deepEqual(p.stats, { dmgDealt: 5 });
|
||
assert.deepEqual(p.lineup, []);
|
||
assert.deepEqual(p.bonds, []);
|
||
assert.equal(p.title, null);
|
||
// and the whole page reads it
|
||
assert.doesNotThrow(() => aggregateStats([rec]));
|
||
assert.doesNotThrow(() => recordToResult(rec));
|
||
});
|
||
|
||
test('a NEWER envelope is returned untouched and flagged — nothing records over it', () => {
|
||
withStorage((ls) => {
|
||
const newer = JSON.stringify({ v: STATS_VERSION + 5, records: [{ players: [], brandNew: true }] });
|
||
ls.setItem('sp.pref.stats', newer);
|
||
const loaded = loadStats();
|
||
assert.equal(loaded.newer, true);
|
||
assert.equal(ls.raw, newer);
|
||
assert.equal(recordResult(baseResult(), { myId: 'p1', now: 1 }), null, 'a result is not written over a newer build\'s data');
|
||
assert.equal(recordQuit(liveState({ round: 3 }), { live: watched([liveState({ round: 3 })]) }), null);
|
||
assert.equal(saveStats(loaded), false);
|
||
assert.equal(ls.raw, newer, 'still byte for byte');
|
||
const mig = migrateStats(JSON.parse(newer));
|
||
assert.equal(mig.newer, true);
|
||
assert.equal(mig.changed, false);
|
||
});
|
||
});
|
||
|
||
test('a list longer than MAX_RECORDS (hand-edited, merged) is cut to the newest on load', () => {
|
||
withStorage((ls) => {
|
||
const recs = Array.from({ length: MAX_RECORDS + 50 }, (_, i) => buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i }));
|
||
ls.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: recs }));
|
||
const loaded = loadStats();
|
||
assert.equal(loaded.records.length, MAX_RECORDS);
|
||
assert.equal(loaded.records[0].t, MAX_RECORDS + 49);
|
||
assert.equal(loaded.dropped, 50);
|
||
});
|
||
});
|
||
|
||
test('the MIGRATIONS hook runs: v → v+1 steps apply in order (dry-run with a temporary step)', () => {
|
||
const original = MIGRATIONS[0];
|
||
MIGRATIONS[0] = ({ records }) => ({ v: 1, records: records.map((r) => ({ ...r, victory: true })) });
|
||
try {
|
||
const { stats, changed } = migrateStats({ v: 0, records: [{ players: [{ playerId: 'p1' }] }] });
|
||
assert.equal(changed, true);
|
||
assert.equal(stats.records[0].victory, true);
|
||
assert.equal(stats.v, STATS_VERSION);
|
||
} finally {
|
||
if (original === undefined) delete MIGRATIONS[0]; else MIGRATIONS[0] = original;
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('local stats: export / import merge', () => {
|
||
test('export → import round-trips; merge unions by id, newest first, and counts added / skipped', () => {
|
||
withStorage(() => {
|
||
const rec = recordResult(baseResult(), { myId: 'p1', now: 1000 });
|
||
const exported = exportStats(loadStats());
|
||
assert.equal(exported.kind, 'local-stats');
|
||
assert.equal(exported.records.length, 1);
|
||
assert.equal(exported.records[0].id, rec.id);
|
||
|
||
// importing into the SAME store: everything is a duplicate
|
||
const same = importStats(exported, loadStats());
|
||
assert.equal(same.added, 0);
|
||
assert.equal(same.skipped, 1);
|
||
assert.equal(same.stats.records.length, 1);
|
||
|
||
// a second device's store (one shared + one newer record) merges by id
|
||
const theirs = exportStats({ records: [buildRecord(baseResult({ seed: 777 }), { myId: 'p1', now: 5000 }), rec] });
|
||
const merged = importStats(JSON.parse(JSON.stringify(theirs)), loadStats());
|
||
assert.equal(merged.added, 1);
|
||
assert.equal(merged.skipped, 1);
|
||
assert.equal(merged.stats.records.length, 2);
|
||
assert.equal(merged.stats.records[0].t, 5000, 'newest first: the history list shows the latest matches at the top');
|
||
saveStats(merged.stats);
|
||
assert.deepEqual(loadStats().records.map((r) => r.t), [5000, 1000]);
|
||
// a match recorded after the import goes on top, and the cap would drop the oldest, not the newest
|
||
recordResult(baseResult({ seed: 5 }), { myId: 'p1', now: 9000 });
|
||
assert.deepEqual(loadStats().records.map((r) => r.t), [9000, 5000, 1000]);
|
||
});
|
||
});
|
||
|
||
test('an import over the cap keeps the newest MAX_RECORDS', () => {
|
||
const a = manyRecords(MAX_RECORDS);
|
||
const b = { v: 1, kind: 'local-stats', records: Array.from({ length: 40 }, (_, i) => buildRecord(baseResult({ seed: 5000 + i }), { myId: 'p1', now: 10_000 + i })) };
|
||
const out = importStats(JSON.parse(JSON.stringify(b)), a);
|
||
assert.equal(out.added, 40);
|
||
assert.equal(out.stats.records.length, MAX_RECORDS);
|
||
assert.equal(out.stats.records[0].t, 10_039);
|
||
});
|
||
|
||
test('importing a NEWER export, another tool\'s file or junk is refused with a code, nothing is merged', () => {
|
||
assert.throws(() => importStats({ v: STATS_VERSION + 1, records: [] }, emptyStats()), (e) => e.code === 'stats-newer-version');
|
||
assert.throws(() => importStats({ kind: 'stronghold.loadout', v: 1, records: [] }, emptyStats()), (e) => e.code === 'stats-bad-file');
|
||
for (const junk of [{ hello: 'world' }, [], 'text', 42, null]) {
|
||
assert.throws(() => importStats(junk, emptyStats()), (e) => e.code === 'stats-bad-file', JSON.stringify(junk));
|
||
}
|
||
});
|
||
|
||
test('rows of an import that cannot be read are dropped and reported; the rest merge', () => {
|
||
const out = importStats({ kind: 'local-stats', v: 1, records: [buildRecord(baseResult(), { myId: 'p1', now: 1 }), null, { players: [] }, 'x'] }, emptyStats());
|
||
assert.equal(out.added, 1);
|
||
assert.equal(out.dropped, 3);
|
||
assert.equal(out.stats.records.length, 1);
|
||
});
|
||
});
|
||
|
||
describe('local stats: aggregation (what the page renders)', () => {
|
||
const records = [
|
||
buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1000 }),
|
||
buildRecord(baseResult({ victory: false, roundsPassed: 8, hiddenReached: false, hiddenCleared: false, difficulty: 'NORMAL', modeId: 'mode_multi_normal', durationMs: 300000, seed: 2,
|
||
players: [
|
||
{ ...baseResult().players[0], alive: true, victory: false, roundsPassed: 8, title: null, stats: { kills: 10, gold: 200 } },
|
||
baseResult().players[1],
|
||
] }), { myId: 'p1', roomMode: 'coop', now: 2000 }),
|
||
];
|
||
const agg = aggregateStats(records);
|
||
|
||
test('totals, wins (per-row victory), rounds and duration', () => {
|
||
assert.equal(agg.count, 2);
|
||
assert.equal(agg.total, 2);
|
||
assert.equal(agg.excluded, 0);
|
||
assert.equal(agg.wins, 1); // game 2 was a loss
|
||
assert.equal(agg.rounds.max, 16, 'the player\'s own rounds (the settlement prints them per player)');
|
||
assert.equal(agg.rounds.total, 16 + 8);
|
||
assert.equal(agg.duration.totalMs, 952123 + 300000);
|
||
assert.equal(agg.hidden.reached, 1);
|
||
assert.equal(agg.hidden.cleared, 1);
|
||
assert.equal(agg.byMode.solo, 1);
|
||
assert.equal(agg.byMode.coop, 1);
|
||
});
|
||
|
||
test('per-difficulty rows; an unknown difficulty is keyed UNKNOWN (never a translated text)', () => {
|
||
assert.deepEqual(agg.byDifficulty.HARD, { games: 1, wins: 1, hiddenCleared: 1 });
|
||
assert.deepEqual(agg.byDifficulty.NORMAL, { games: 1, wins: 0, hiddenCleared: 0 });
|
||
const odd = aggregateStats([normalizeRecord({ players: [{ playerId: 'p1' }] })]);
|
||
assert.deepEqual(Object.keys(odd.byDifficulty), ['UNKNOWN']);
|
||
});
|
||
|
||
test('per-band games / passed and self titles', () => {
|
||
assert.deepEqual(agg.bands.band_amiya, { games: 2, wins: 1 });
|
||
assert.equal(agg.titles.comment_1.count, 1); // only the win granted 卫戍之星
|
||
});
|
||
|
||
test('combat sums over the self player only', () => {
|
||
assert.equal(agg.sums.kills, 41 + 10);
|
||
assert.equal(agg.sums.gold, 880 + 200);
|
||
assert.equal(agg.sums.leaks, 2); // game 2's self row has no leaks → skipped, not NaN
|
||
});
|
||
|
||
test('selfRowOf fallbacks: selfId, else first human, else first row; anonymous record → null', () => {
|
||
assert.equal(selfRowOf(records[0]).playerId, 'p1');
|
||
const noSelf = normalizeRecord({ players: [{ playerId: 'a', isBot: true }, { playerId: 'b', isBot: false }] });
|
||
assert.equal(selfRowOf(noSelf).playerId, 'b');
|
||
assert.equal(selfRowOf({ players: [] }), null);
|
||
});
|
||
|
||
test('an empty list aggregates to zeros', () => {
|
||
const e = aggregateStats([]);
|
||
assert.equal(e.count, 0);
|
||
assert.equal(e.rounds.max, 0);
|
||
assert.deepEqual(e.byDifficulty, {});
|
||
assert.equal(aggregateStats(null).total, 0);
|
||
});
|
||
});
|
||
|
||
describe('local stats: recordToResult (history row → settlement re-view)', () => {
|
||
test('record → m.result payload → normalizeResult renders the same settlement the live push did', () => {
|
||
const rec = buildRecord(baseResult(), { myId: 'p1', roomMode: 'solo', now: 1000 });
|
||
const res = recordToResult(rec);
|
||
assert.ok(res);
|
||
const view = normalizeResult(res, null);
|
||
assert.equal(view.victory, true);
|
||
assert.equal(view.difficulty, 'HARD');
|
||
assert.equal(view.modeId, 'mode_single_hard');
|
||
assert.equal(view.roundsPassed, 15);
|
||
assert.equal(view.hiddenReached, true);
|
||
assert.equal(view.hiddenCleared, true);
|
||
assert.equal(view.durationMs, 952123);
|
||
assert.equal(view.players.length, 2);
|
||
const self = view.players.find((p) => p.playerId === 'p1');
|
||
assert.equal(self.bandId, 'band_amiya');
|
||
assert.equal(self.title.id, 'comment_1');
|
||
assert.equal(self.stats.kills, 41);
|
||
assert.equal(self.stats.bossDamage, 5000.5);
|
||
assert.equal(self.lineup[0].id, 'char_290_vigna');
|
||
assert.equal(self.lineup[0].golden, false);
|
||
const mate = view.players.find((p) => p.playerId === 'p2');
|
||
assert.equal(mate.alive, false);
|
||
assert.equal(mate.roundsPassed, 9);
|
||
});
|
||
|
||
test('a null lastRound is passed through as absent (normalizeResult defaults it); garbage → null', () => {
|
||
const rec = buildRecord(baseResult({ lastRound: undefined }), { myId: 'p1', now: 1 });
|
||
assert.equal('lastRound' in recordToResult(rec), false);
|
||
assert.equal(recordToResult(null), null);
|
||
assert.equal(recordToResult({ players: [] }), null);
|
||
});
|
||
});
|