Files
ark-stronghold-protocol/test/ui/stats.e2e.test.js
T
sganggs 51e0e60b6e 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.
2026-10-08 20:36:41 +08:00

349 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(); }
});
});