feedback7 stats: a match left right away stays in the history but does not count; bounded storage, corrupt-data handling, the page reworked as 干员调配-style tabs, the settlement re-view without the app store (PR #323 by @2321Robin, owner's decision of 2026-10-08); golden: unchanged
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.
This commit is contained in:
1 parent
438a6b521a
commit
51e0e60b6e
20 files changed
+1808
-530
No files matched your search
@@ -0,0 +1,348 @@
|
||||
// Browser E2E of the 统计数据 page (public/js/screens/stats.js, ui/stats.js; PR #323 by @2321Robin, reworked for 0.2.2)
|
||||
// against the real server, headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1 node --test test/ui/stats.e2e.test.js
|
||||
//
|
||||
// 1. A seeded history (records built from three REAL m.result payloads, test/fixtures/stats-results.json) on desktop
|
||||
// 1920×1080 and a phone in landscape 844×390: the four tabs, the counting rule on screen (the match left in the first
|
||||
// round is listed as 中途退出 / 不计入统计 and left out of the totals), the row click that re-views a settlement and
|
||||
// its 返回统计, Esc, the title screen's entry; zero console errors. Screenshots: test/e2e/out/stats-*.png
|
||||
// 2. The data actions: 导出 hands out a file the page itself imports again (nothing new), a corrupt file and a newer
|
||||
// one are refused with a message and change nothing, 清空 asks first (the confirm dialog sits above the overlay and
|
||||
// Esc closes only it), unreadable storage is set aside with a notice, a newer build's data is never overwritten.
|
||||
// 3. The real flows: a solo match given up in the briefing is history only; one given up after round 1 counts (1 round
|
||||
// cleared, the server's own rule); a match played to its settlement is recorded once — also after a reload (the
|
||||
// lobby replays m.result).
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Client, sleep, startRealServer, hasChrome, OUT } from '../e2e/client.mjs';
|
||||
import { buildRecord, buildQuitRecord, createLiveMatch, observeMatch, STATS_VERSION } from '../../public/js/ui/stats.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome();
|
||||
const FIX = JSON.parse(readFileSync(path.join(ROOT, 'test/fixtures/stats-results.json'), 'utf8'));
|
||||
const BANDS = Object.keys(JSON.parse(readFileSync(path.join(ROOT, 'data/bands.json'), 'utf8')));
|
||||
const NOW = Date.parse('2026-10-08T21:30:00');
|
||||
const DAY = 86400000;
|
||||
|
||||
/** n settled records from the real payloads (cycling solo / 4-player co-op, difficulties, outcomes, bands) + two quit records. */
|
||||
function seed(n = 24) {
|
||||
const recs = [];
|
||||
const names = ['solo-normal', 'coop4-hard', 'coop4-abyss'];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const res = JSON.parse(JSON.stringify(FIX[names[i % 3]]));
|
||||
const solo = i % 3 === 0;
|
||||
const diff = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'][i % 4];
|
||||
const win = i % 5 < 2;
|
||||
Object.assign(res, { seed: 1000 + i, difficulty: diff, modeId: `mode_${solo ? 'single' : 'multi'}_${diff.toLowerCase()}`, victory: win, reason: win ? 'victory' : 'defeat', roundsPassed: win ? 14 : 4 + (i % 8), durationMs: (8 + (i % 13)) * 60000 });
|
||||
const me = res.players.find((p) => p.playerId === 'p_0');
|
||||
Object.assign(me, { bandId: BANDS[i % 7], victory: win, alive: win, roundsPassed: res.roundsPassed, title: i % 3 ? { id: `comment_${1 + (i % 6)}` } : null });
|
||||
recs.unshift(buildRecord(res, { myId: 'p_0', roomMode: solo ? 'solo' : 'coop', now: NOW - i * 0.6 * DAY }));
|
||||
}
|
||||
const state = (phase, round) => ({
|
||||
me: { playerId: 'p_0' }, room: { code: 'ABCD', mode: 'solo' },
|
||||
match: { public: { phase, round, lastRound: 14, modeId: 'mode_single_hard', difficulty: 'HARD', players: [{ playerId: 'p_0', seat: 0, name: 'Doctor', alive: true, lp: 30, bandId: BANDS[2] }] }, result: null },
|
||||
});
|
||||
const quit = (phase, round, t) => { const live = createLiveMatch(); observeMatch(live, state('INFO_CHECK', 0), t - 600000); return buildQuitRecord(state(phase, round), { live, now: t }); };
|
||||
recs.push(quit('PREP', 1, NOW - 2 * 3600e3), quit('PREP', 7, NOW - 1.3 * DAY));
|
||||
return { v: STATS_VERSION, records: recs.sort((a, b) => b.t - a.t) };
|
||||
}
|
||||
|
||||
describe('统计数据 page (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let puppeteer;
|
||||
const clients = [];
|
||||
|
||||
before(async () => {
|
||||
puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startRealServer();
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => { for (const c of clients) await c.close(); await srv?.stop(); });
|
||||
|
||||
/** A fresh browser on `base` with `sp.pref.stats` (a string) stored before the page loads. */
|
||||
async function open({ w = 1920, h = 1080, stored = null, entered = true, base = srv.base, lang = null } = {}) {
|
||||
const c = new Client(puppeteer, base, 'stats', { prefix: 'stats', w, h });
|
||||
clients.push(c);
|
||||
c.browser = await puppeteer.launch({
|
||||
executablePath: process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', headless: true,
|
||||
args: ['--no-sandbox', '--mute-audio', '--force-device-scale-factor=1'],
|
||||
});
|
||||
const [first] = await c.browser.pages();
|
||||
c.page = first || await c.browser.newPage();
|
||||
await c.page.setViewport({ width: w, height: h, hasTouch: w < 1000, isMobile: w < 1000 });
|
||||
c.page.on('console', (m) => { if (m.type() === 'error') c.problems.push(`console: ${m.text()}`); });
|
||||
c.page.on('pageerror', (e) => c.problems.push(`pageerror: ${e.message}`));
|
||||
await c.page.evaluateOnNewDocument((stored, entered, lang) => {
|
||||
localStorage.setItem('sp.name', 'Doctor');
|
||||
if (entered) sessionStorage.setItem('sp.entered', '1');
|
||||
if (lang) localStorage.setItem('sp.pref.lang', JSON.stringify(lang));
|
||||
if (stored != null && !sessionStorage.getItem('sp.e2e.seeded')) { localStorage.setItem('sp.pref.stats', stored); sessionStorage.setItem('sp.e2e.seeded', '1'); }
|
||||
}, stored, entered, lang);
|
||||
await c.page.goto(`${base}/`, { waitUntil: 'domcontentloaded' });
|
||||
await c.page.waitForFunction((needOnline) => (!needOnline || globalThis.__SP__?.store.get().connection.status === 'online') && !!document.querySelector('.screen'), { timeout: 30000 }, entered);
|
||||
return c;
|
||||
}
|
||||
const texts = (c, sel) => c.page.$$eval(sel, (els) => els.map((e) => e.innerText.replace(/\s+/g, ' ').trim()));
|
||||
const stored = (c) => c.page.evaluate(() => { const r = localStorage.getItem('sp.pref.stats'); return r ? JSON.parse(r) : null; });
|
||||
const openStats = async (c, from = '.lobby-screen .topbar__right .btn') => {
|
||||
await c.click(from, '统计');
|
||||
await c.page.waitForSelector('.st .st-tab', { visible: true, timeout: 15000 });
|
||||
};
|
||||
const toast = (c) => c.page.waitForFunction(() => document.querySelector('.toast')?.textContent || false, { timeout: 8000 }).then((h) => h.jsonValue());
|
||||
const noProblems = (c) => assert.deepEqual(c.problems, [], 'no console errors / failed requests');
|
||||
|
||||
test('desktop: tabs, the counting rule on screen, a settlement re-viewed, Esc, the title entry', async () => {
|
||||
const env = seed();
|
||||
const c = await open({ stored: JSON.stringify(env) });
|
||||
await c.page.waitForSelector('.lobby-screen');
|
||||
await openStats(c);
|
||||
// 总览: 24 settled + the quit after 6 rounds count, the first-round one does not
|
||||
const cards = await texts(c, '.st-card__num');
|
||||
assert.equal(cards[0], '25', 'games');
|
||||
assert.match(await c.page.$eval('.st-rule', (e) => e.textContent), /另有 1 局未计入统计/);
|
||||
assert.equal(await c.page.$$eval('.st-title', (els) => els.length), 6, 'the six 评语');
|
||||
await c.page.screenshot({ path: path.join(OUT, 'stats-overview-desktop.png') });
|
||||
// 策略 / 战斗累计
|
||||
await c.click('.st-tab[data-tab="bands"]');
|
||||
assert.ok((await c.page.$$eval('.st-table tbody tr', (r) => r.length)) >= 5, 'strategy rows');
|
||||
await c.page.screenshot({ path: path.join(OUT, 'stats-bands-desktop.png') });
|
||||
await c.click('.st-tab[data-tab="totals"]');
|
||||
assert.ok((await c.page.$$eval('.st-sum', (r) => r.length)) >= 8, 'combat totals');
|
||||
// 对局记录: every record, the left-early ones marked, the first-round one dimmed, only settled rows open
|
||||
await c.click('.st-tab[data-tab="history"]');
|
||||
const rows = await texts(c, '.st-table--history tbody tr');
|
||||
assert.equal(rows.length, 26);
|
||||
assert.equal(rows.filter((r) => r.includes('中途退出')).length, 2);
|
||||
assert.equal(rows.filter((r) => r.includes('不计入统计')).length, 1);
|
||||
assert.equal(await c.page.$$eval('.st-table--history tr.is-excluded', (r) => r.length), 1);
|
||||
assert.equal(await c.page.$$eval('.st-table--history tr.is-clickable', (r) => r.length), 24);
|
||||
await c.page.screenshot({ path: path.join(OUT, 'stats-history-desktop.png') });
|
||||
// a row re-views the settlement (the real result screen), 返回统计 comes back to the list, Esc closes
|
||||
await c.click('.st-table--history tr.is-clickable');
|
||||
await c.page.waitForSelector('.st--replay .result', { visible: true, timeout: 20000 });
|
||||
await sleep(900);
|
||||
await c.page.screenshot({ path: path.join(OUT, 'stats-replay-desktop.png') });
|
||||
assert.ok(await c.page.$('.st--replay .rcard.is-self'), 'the player\'s own card is marked 你 (the record\'s own id)');
|
||||
await c.page.keyboard.press('Escape');
|
||||
await c.page.waitForSelector('.st .st-tab', { timeout: 5000 });
|
||||
assert.ok(!(await c.exists('.st--replay')), 'Esc in the re-view goes back to the list');
|
||||
await c.page.keyboard.press('Escape');
|
||||
await c.page.waitForFunction(() => !document.querySelector('.st'), { timeout: 5000 });
|
||||
assert.ok(await c.exists('.lobby-screen'));
|
||||
noProblems(c);
|
||||
await c.close();
|
||||
// the title screen's entry (not entered yet: no connection needed), in English
|
||||
const t = await open({ stored: JSON.stringify(env), entered: false, lang: 'en' });
|
||||
await t.click('.title-corner .btn', 'Stats');
|
||||
await t.page.waitForSelector('.st .st-tab', { visible: true, timeout: 15000 });
|
||||
assert.match(await t.page.$eval('.st-top', (e) => e.textContent), /Overview/);
|
||||
await t.page.screenshot({ path: path.join(OUT, 'stats-overview-title-en.png') });
|
||||
noProblems(t);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
test('phone in landscape (844×390): the page and the re-view fit; no console errors', async () => {
|
||||
const c = await open({ w: 844, h: 390, stored: JSON.stringify(seed()) });
|
||||
await c.page.waitForSelector('.lobby-screen');
|
||||
await openStats(c);
|
||||
const fits = () => c.page.evaluate(() => {
|
||||
const st = document.querySelector('.st').getBoundingClientRect();
|
||||
const top = document.querySelector('.st-top').getBoundingClientRect();
|
||||
const right = document.querySelector('.st-top__right').getBoundingClientRect();
|
||||
const tabs = document.querySelector('.st-tabs').getBoundingClientRect();
|
||||
return { w: st.width, topH: top.height, rightEdge: right.right, tabsRight: tabs.right, rightLeft: right.left, scrollW: document.documentElement.scrollWidth };
|
||||
});
|
||||
const f = await fits();
|
||||
assert.ok(f.rightEdge <= f.w + 1 && f.tabsRight <= f.rightLeft + 1, `the top bar fits: ${JSON.stringify(f)}`);
|
||||
assert.ok(f.scrollW <= 845, 'no horizontal page scroll');
|
||||
await c.page.screenshot({ path: path.join(OUT, 'stats-overview-phone.png') });
|
||||
await c.click('.st-tab[data-tab="history"]');
|
||||
await c.page.screenshot({ path: path.join(OUT, 'stats-history-phone.png') });
|
||||
await c.click('.st-table--history tr.is-clickable');
|
||||
await c.page.waitForSelector('.st--replay .result', { visible: true, timeout: 20000 });
|
||||
await sleep(900);
|
||||
await c.page.screenshot({ path: path.join(OUT, 'stats-replay-phone.png') });
|
||||
await c.click('.st--replay .btn--primary', '返回统计');
|
||||
await c.page.waitForSelector('.st .st-tab', { timeout: 5000 });
|
||||
noProblems(c);
|
||||
});
|
||||
|
||||
test('the data actions: export → import (nothing new), a corrupt file, a newer file, 清空 with its confirm dialog', async () => {
|
||||
const c = await open({ stored: JSON.stringify(seed(6)) });
|
||||
await c.page.waitForSelector('.lobby-screen');
|
||||
await openStats(c);
|
||||
// 导出: capture the blob the page hands to the browser
|
||||
await c.page.evaluate(() => {
|
||||
HTMLAnchorElement.prototype.click = function click() { globalThis.__dl = { name: this.download, href: this.href }; };
|
||||
});
|
||||
await c.click('[data-testid="stats-export"]');
|
||||
await c.page.waitForFunction(() => !!globalThis.__dl, { timeout: 5000 });
|
||||
const exported = await c.page.evaluate(async () => { const r = await fetch(globalThis.__dl.href); return { name: globalThis.__dl.name, text: await r.text() }; });
|
||||
assert.match(exported.name, /^stronghold-stats-\d{8}-\d{4}\.json$/);
|
||||
const payload = JSON.parse(exported.text);
|
||||
assert.equal(payload.kind, 'local-stats');
|
||||
assert.equal(payload.records.length, 8);
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'sp-stats-'));
|
||||
const upload = async (name, text) => {
|
||||
const file = path.join(dir, name);
|
||||
writeFileSync(file, text);
|
||||
const input = await c.page.$('.st input[type="file"]');
|
||||
await input.uploadFile(file);
|
||||
};
|
||||
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
|
||||
// 导入 the same file: nothing new
|
||||
await upload('same.json', exported.text);
|
||||
assert.match(await toast(c), /没有新记录/);
|
||||
assert.equal((await stored(c)).records.length, 8);
|
||||
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
|
||||
// a corrupt file and a newer one change nothing
|
||||
await upload('bad.json', '{"records": [1, 2');
|
||||
assert.match(await toast(c), /不是有效的统计导出文件/);
|
||||
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
|
||||
await upload('newer.json', JSON.stringify({ kind: 'local-stats', v: 99, records: [] }));
|
||||
assert.match(await toast(c), /更新版本/);
|
||||
assert.equal((await stored(c)).records.length, 8);
|
||||
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
|
||||
// 导入 a file with one new record: merged, newest first
|
||||
const extra = { ...payload.records[0], id: 'r1.cafe0001', t: NOW + DAY };
|
||||
await upload('extra.json', JSON.stringify({ ...payload, records: [extra] }));
|
||||
assert.match(await toast(c), /已导入 1 条记录/);
|
||||
assert.equal((await stored(c)).records[0].id, 'r1.cafe0001');
|
||||
// 清空: the confirm dialog is above the overlay, Esc closes only it, the second time confirms
|
||||
await c.click('[data-testid="stats-clear"]');
|
||||
await c.page.waitForSelector('.modal', { visible: true });
|
||||
assert.match(await c.page.$eval('.modal', (e) => e.textContent), /将删除本机保存的 9 条对局记录/);
|
||||
await sleep(250); // the dialog's key handler is attached by its effect, a moment after it paints
|
||||
await c.page.keyboard.press('Escape');
|
||||
await c.page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 3000 });
|
||||
assert.ok(await c.exists('.st'), 'Esc closed the dialog only');
|
||||
assert.equal((await stored(c)).records.length, 9, 'nothing deleted');
|
||||
await c.click('[data-testid="stats-clear"]');
|
||||
await c.page.waitForSelector('.modal', { visible: true });
|
||||
await c.click('.modal__actions button.btn--danger');
|
||||
await c.page.waitForFunction(() => /还没有记录/.test(document.querySelector('.st-body')?.textContent || ''), { timeout: 5000 });
|
||||
assert.deepEqual((await stored(c)).records, []);
|
||||
noProblems(c);
|
||||
});
|
||||
|
||||
test('corrupt and newer storage: a notice, no crash; the newer build\'s data is never overwritten', async () => {
|
||||
const bad = '{"v":1,"records":[{"players":[';
|
||||
const c = await open({ stored: bad });
|
||||
await c.page.waitForSelector('.lobby-screen');
|
||||
await openStats(c);
|
||||
assert.match(await c.page.$eval('.st-note.is-warn', (e) => e.textContent), /无法读取,已另存为备份/);
|
||||
assert.equal(await c.page.evaluate(() => localStorage.getItem('sp.pref.stats.damaged')), bad, 'kept as a backup');
|
||||
noProblems(c);
|
||||
await c.close();
|
||||
const newer = JSON.stringify({ v: STATS_VERSION + 3, records: [{ players: [{ playerId: 'x' }], future: true }] });
|
||||
const n = await open({ stored: newer });
|
||||
await n.page.waitForSelector('.lobby-screen');
|
||||
await openStats(n);
|
||||
assert.match(await n.page.$eval('.st-body', (e) => e.textContent), /更新版本的统计数据/);
|
||||
assert.ok(await n.page.$eval('[data-testid="stats-clear"]', (b) => b.disabled), 'no 清空 over data this build cannot read');
|
||||
assert.equal(await n.page.evaluate(() => localStorage.getItem('sp.pref.stats')), newer);
|
||||
noProblems(n);
|
||||
});
|
||||
});
|
||||
|
||||
describe('统计数据: the real flows (a fast real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let puppeteer;
|
||||
const servers = [];
|
||||
before(async () => { puppeteer = (await import('puppeteer-core')).default; });
|
||||
after(async () => { for (const s of servers) await s.stop(); });
|
||||
|
||||
const stored = (c) => c.page.evaluate(() => { const r = localStorage.getItem('sp.pref.stats'); return r ? JSON.parse(r) : null; });
|
||||
async function toSoloMatch(c) {
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await sleep(300);
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
}
|
||||
async function toPrep(c) {
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await c.click('.dband', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
await c.waitFor((s) => s.phase === 'PREP' && s.round === 1, 'prep round 1', 60000);
|
||||
await sleep(1500);
|
||||
}
|
||||
async function giveUp(c) {
|
||||
await c.click('.stephead .btn--danger, .gtop__exit', null, { timeout: 8000 });
|
||||
await c.click('.modal__actions button', '放弃模拟');
|
||||
await c.waitFor((s) => !s.room && !s.phase, 'back in the lobby', 15000);
|
||||
}
|
||||
|
||||
test('given up in the briefing: history only; given up after round 1: it counts', async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.25, combatSpeed: 8 } });
|
||||
servers.push(srv);
|
||||
const c = new Client(puppeteer, srv.base, 'stats', { prefix: 'stats', w: 1600, h: 900 });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('Doc');
|
||||
// entered and left right away
|
||||
await toSoloMatch(c);
|
||||
await giveUp(c);
|
||||
let env = await stored(c);
|
||||
assert.equal(env.records.length, 1);
|
||||
assert.equal(env.records[0].end, 'quit');
|
||||
assert.equal(env.records[0].players[0].roundsPassed, 0);
|
||||
// through round 1, then given up in round 2
|
||||
await toSoloMatch(c);
|
||||
await toPrep(c);
|
||||
await c.click('.readybtn');
|
||||
await c.waitFor((s) => s.round >= 2 && ['PREP', 'ROUND_START', 'SP_DRAFT'].includes(s.phase), 'round 2 (round 1 cleared)', 120000);
|
||||
await giveUp(c);
|
||||
env = await stored(c);
|
||||
assert.equal(env.records.length, 2);
|
||||
assert.equal(env.records[0].end, 'quit');
|
||||
assert.equal(env.records[0].players[0].roundsPassed, 1, 'one round cleared — the server\'s own count for a quit in round 2');
|
||||
assert.ok(env.records[0].players[0].bandId, 'its strategy');
|
||||
// the page: one game counted, one left out, both in the history
|
||||
await c.click('.lobby-screen .topbar__right .btn', '统计');
|
||||
await c.page.waitForSelector('.st .st-card', { visible: true });
|
||||
assert.equal((await c.page.$$eval('.st-card__num', (els) => els.map((e) => e.textContent)))[0], '1');
|
||||
assert.match(await c.page.$eval('.st-rule', (e) => e.textContent), /另有 1 局未计入统计/);
|
||||
await c.click('.st-tab[data-tab="history"]');
|
||||
const rows = await c.page.$$eval('.st-table--history tbody tr', (trs) => trs.map((tr) => tr.innerText.replace(/\s+/g, ' ').trim()));
|
||||
assert.equal(rows.length, 2);
|
||||
assert.ok(rows[0].includes('中途退出') && !rows[0].includes('不计入统计'), rows[0]);
|
||||
assert.ok(rows[1].includes('中途退出') && rows[1].includes('不计入统计'), rows[1]);
|
||||
await c.shot('history-real');
|
||||
assert.deepEqual(c.problems, []);
|
||||
} finally { await c.close(); }
|
||||
});
|
||||
|
||||
test('played to its settlement: one record, and a reload (the lobby replays m.result) adds no second one', async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.25, combatSpeed: 8, finishAfter: 1 } });
|
||||
servers.push(srv);
|
||||
const c = new Client(puppeteer, srv.base, 'stats', { prefix: 'stats', w: 1600, h: 900 });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('Doc');
|
||||
await toSoloMatch(c);
|
||||
await toPrep(c);
|
||||
await c.click('.readybtn');
|
||||
await c.waitFor((s) => s.result, 'the settlement', 120000);
|
||||
await sleep(500);
|
||||
let env = await stored(c);
|
||||
assert.equal(env.records.length, 1);
|
||||
assert.equal(env.records[0].end, 'settled');
|
||||
assert.equal(env.records[0].roomMode, 'solo');
|
||||
assert.equal(env.records[0].selfId, (await c.st()).me);
|
||||
await c.page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await c.page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
|
||||
await c.waitFor((s) => s.result, 'the replayed settlement after a reload', 30000);
|
||||
await sleep(500);
|
||||
env = await stored(c);
|
||||
assert.equal(env.records.length, 1, 'the replay is the same record');
|
||||
assert.deepEqual(c.problems, []);
|
||||
} finally { await c.close(); }
|
||||
});
|
||||
});
|
||||
+486
-77
@@ -1,18 +1,24 @@
|
||||
// Unit tests (Node) for the 本机对局统计 record store (public/js/ui/stats.js):
|
||||
// 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 MAX_RECORDS cap, migration / normalization (old + partial + unknown fields preserved, newer refused),
|
||||
// import merge semantics and the aggregate the stats page renders.
|
||||
// localStorage is faked per test (Node has none); recordResult / loadStats / saveStats go through it.
|
||||
// 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, beforeEach, afterEach } from 'node:test';
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
STATS_VERSION, MAX_RECORDS, emptyStats, buildRecord, recordId, appendRecord, recordResult,
|
||||
loadStats, saveStats, migrateStats, normalizeRecord, mergeStats, importStats, exportStats,
|
||||
aggregateStats, selfRowOf, roomModeOf, recordToResult, MIGRATIONS,
|
||||
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 = {}) {
|
||||
@@ -28,7 +34,7 @@ function baseResult(over = {}) {
|
||||
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', layers: 12, active: true }],
|
||||
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 },
|
||||
},
|
||||
{
|
||||
@@ -44,21 +50,56 @@ function baseResult(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();
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||||
setItem(k, v) { calls.push(v.length); if (v.length > budget) { const e = new Error('quota'); e.name = 'QuotaExceededError'; throw e; } m.set(k, String(v)); },
|
||||
removeItem: (k) => m.delete(k),
|
||||
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);
|
||||
@@ -70,12 +111,25 @@ describe('local stats: record building', () => {
|
||||
assert.equal(rec.players.length, 2);
|
||||
const self = rec.players[0];
|
||||
assert.equal(self.bandId, 'band_amiya');
|
||||
assert.equal(self.title.id, 'comment_1');
|
||||
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);
|
||||
@@ -90,9 +144,9 @@ describe('local stats: record building', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('local stats: replay dedupe + cap', () => {
|
||||
describe('local stats: replay dedupe', () => {
|
||||
test('the replayed m.result (same content) appends once; a different seed is a new game', () => {
|
||||
let stats = emptyStats();
|
||||
const stats = emptyStats();
|
||||
const res = baseResult();
|
||||
const first = appendRecord(stats, buildRecord(res, { myId: 'p1', now: 1000 }));
|
||||
assert.equal(first.added, true);
|
||||
@@ -107,8 +161,7 @@ describe('local stats: replay dedupe + cap', () => {
|
||||
});
|
||||
|
||||
test('recordResult end-to-end over faked localStorage: replay arrives, nothing is written twice', () => {
|
||||
globalThis.localStorage = fakeStorage();
|
||||
try {
|
||||
withStorage(() => {
|
||||
const res = baseResult();
|
||||
const rec = recordResult(res, { myId: 'p1', roomMode: 'solo', now: 1000 });
|
||||
assert.ok(rec);
|
||||
@@ -116,92 +169,418 @@ describe('local stats: replay dedupe + cap', () => {
|
||||
const loaded = loadStats();
|
||||
assert.equal(loaded.records.length, 1);
|
||||
assert.equal(loaded.records[0].id, rec.id);
|
||||
} finally { delete globalThis.localStorage; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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(`the store caps at MAX_RECORDS (${MAX_RECORDS}), dropping the oldest`, () => {
|
||||
let stats = emptyStats();
|
||||
for (let i = 0; i < MAX_RECORDS + 30; i++) {
|
||||
stats = appendRecord(stats, buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i })).stats;
|
||||
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: migration / normalization', () => {
|
||||
test('an unversioned envelope (older shape) normalizes with defaults and self-heals on load', () => {
|
||||
globalThis.localStorage = fakeStorage();
|
||||
try {
|
||||
const old = { records: [{ players: [{ playerId: 'p1', stats: { kills: 2 }, futureField: { x: 1 } }] }] };
|
||||
globalThis.localStorage.setItem('sp.pref.stats', JSON.stringify(old));
|
||||
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.deepEqual(rec.players[0].futureField, { x: 1 }); // unknown fields survive for a newer reader
|
||||
const rewritten = JSON.parse(globalThis.localStorage.getItem('sp.pref.stats'));
|
||||
assert.ok(rewritten);
|
||||
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
|
||||
} finally { delete globalThis.localStorage; }
|
||||
assert.equal(rewritten.records[0].id, rec.id);
|
||||
});
|
||||
});
|
||||
|
||||
test('broken records are dropped; a NEWER envelope is returned untouched and flagged (never saved over)', () => {
|
||||
globalThis.localStorage = fakeStorage();
|
||||
try {
|
||||
globalThis.localStorage.setItem('sp.pref.stats', JSON.stringify({ v: STATS_VERSION, records: [null, { nope: 1 }, { players: [] }, baseResult()] }));
|
||||
// baseResult is an m.result (players present) → normalizes into a record; the rest are dropped
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
const newer = { v: STATS_VERSION + 5, records: [{ players: [], brandNew: true }] };
|
||||
const mig = migrateStats(newer);
|
||||
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);
|
||||
assert.deepEqual(mig.stats, { v: STATS_VERSION + 5, records: newer.records });
|
||||
assert.equal(loadStats().newer, false);
|
||||
} finally { delete globalThis.localStorage; }
|
||||
});
|
||||
});
|
||||
|
||||
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, migrated: true })) });
|
||||
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].migrated, 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;
|
||||
}
|
||||
});
|
||||
|
||||
test('saveStats shrinks the history when the quota rejects the full write', () => {
|
||||
// budget fits ~35 records but not 60: the full write throws, two quarter-shrinks land at 33
|
||||
const probe = JSON.stringify({ v: STATS_VERSION, records: [baseResult()] });
|
||||
globalThis.localStorage = fakeStorage({ budget: probe.length * 35 });
|
||||
try {
|
||||
let stats = emptyStats();
|
||||
for (let i = 0; i < 60; i++) stats = appendRecord(stats, buildRecord(baseResult({ seed: i }), { myId: 'p1', now: i })).stats;
|
||||
assert.equal(saveStats(stats), true);
|
||||
const back = JSON.parse(globalThis.localStorage.getItem('sp.pref.stats'));
|
||||
assert.ok(back.records.length < 60 && back.records.length > 8); // shrank instead of failing
|
||||
assert.equal(back.records[0].t, 59); // newest kept, oldest dropped
|
||||
} finally { delete globalThis.localStorage; }
|
||||
});
|
||||
});
|
||||
|
||||
describe('local stats: export / import merge', () => {
|
||||
test('export → import round-trips; merge unions by id and counts added / skipped', () => {
|
||||
globalThis.localStorage = fakeStorage();
|
||||
try {
|
||||
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');
|
||||
@@ -214,25 +593,43 @@ describe('local stats: export / import merge', () => {
|
||||
assert.equal(same.skipped, 1);
|
||||
assert.equal(same.stats.records.length, 1);
|
||||
|
||||
// a second device's store (one shared + one new record) merges by id
|
||||
const theirs = exportStats({ records: [rec, buildRecord(baseResult({ seed: 777 }), { myId: 'p1', now: 5000 })] });
|
||||
const merged = importStats(theirs, loadStats());
|
||||
// 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, 1000); // chronological
|
||||
assert.equal(merged.stats.records[0].t, 5000, 'newest first: the history list shows the latest matches at the top');
|
||||
saveStats(merged.stats);
|
||||
assert.equal(loadStats().records.length, 2);
|
||||
} finally { delete globalThis.localStorage; }
|
||||
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('importing a NEWER export refuses with a clear error instead of downgrading', () => {
|
||||
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('garbage import is an ordinary empty envelope, not a crash in merge', () => {
|
||||
const out = importStats({ hello: 'world' }, emptyStats());
|
||||
assert.equal(out.added, 0);
|
||||
assert.equal(out.stats.records.length, 0);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,9 +646,11 @@ describe('local stats: aggregation (what the page renders)', () => {
|
||||
|
||||
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, 15);
|
||||
assert.equal(agg.rounds.total, 23);
|
||||
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);
|
||||
@@ -259,9 +658,11 @@ describe('local stats: aggregation (what the page renders)', () => {
|
||||
assert.equal(agg.byMode.coop, 1);
|
||||
});
|
||||
|
||||
test('per-difficulty rows', () => {
|
||||
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', () => {
|
||||
@@ -281,9 +682,17 @@ describe('local stats: aggregation (what the page renders)', () => {
|
||||
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 (最近对局行 → 结算页回看)', () => {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user