0.2.4-dev: round-29 community fixes and the official shared card pool

- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
sganggs committed 2026-10-11 01:58:52 +08:00
1 parent 1db8e51023
commit 6bfc08270c
129 files changed
+3477 -1011

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.SP_E2E === '1' && existsSync(CHROME);
describe('community UI: lobby header and shared DIY details', { skip: !enabled }, () => {
let srv, browser;
before(async () => {
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(new URL('../e2e/out/', import.meta.url), { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
test('equipment draft clears an armed choice when shared stock becomes unavailable', async () => {
const { context, page } = await open(1600);
try {
await page.evaluate(async () => {
const { render, h } = await import('/vendor/preact.module.js');
const { ChoiceOverlay } = await import('/js/ui/choiceOverlay.js');
const { data } = await import('/js/data.js');
await data.loadAll('items', 'choices', 'config');
const props = { pub: { players: [{ playerId: 'p_0', name: 'Doctor' }] }, myId: 'p_0', solo: true,
onPick: i => { (window.__picks ||= []).push(i); },
sp: { family: 'supply', name: '道具补给', turnPid: 'p_0', pickOf: new Map(), order: ['p_0'], cards: [
{ idx: 0, kind: 'item', id: 'chess_item_5_07_e_a' }, { idx: 1, kind: 'item', id: 'chess_item_1_01_e_a' },
] } };
const host = document.getElementById('app'); render(h(ChoiceOverlay, props), host);
window.__exhaustStock = () => render(h(ChoiceOverlay, { ...props,
sp: { ...props.sp, cards: props.sp.cards.map((c, i) => i ? c : { ...c, soldOut: true }) } }), host);
});
await page.waitForSelector('.spcard'); await page.click('.spcard');
await page.waitForSelector('.spcard.is-armed');
await page.evaluate(() => window.__exhaustStock());
await page.waitForFunction(() => document.querySelector('.spcard').disabled && !document.querySelector('.spcard.is-armed'));
assert.match(await page.$eval('.spcard', e => e.textContent), /库存不足/);
assert.equal(await page.evaluate(() => window.__picks?.length || 0), 0);
await page.screenshot({ path: new URL('../e2e/out/shared-stock-unavailable.png', import.meta.url).pathname });
await page.click('.spcard:nth-child(2)'); await page.click('.spcard:nth-child(2)');
assert.deepEqual(await page.evaluate(() => window.__picks), [1]);
} finally { await context.close(); }
});
async function open(width, lang = 'zh-CN') {
const context = await browser.createBrowserContext(), page = await context.newPage();
await page.setViewport({ width, height: width < 1000 ? 390 : 900, hasTouch: width < 1000 });
await page.evaluateOnNewDocument(l => {
localStorage.setItem('sp.name', '超长名称的测试博士名称'); sessionStorage.setItem('sp.entered', '1');
localStorage.setItem('sp.pref.settings', JSON.stringify({ textSize: 'xl' }));
localStorage.setItem('sp.pref.lang', JSON.stringify(l));
}, lang);
await page.goto(`http://127.0.0.1:${srv.port}/`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.lobby-screen');
return { context, page };
}
for (const width of [1600, 844]) for (const lang of ['zh-CN', 'en', 'ja', 'ko', 'zh-TW']) {
test(`lobby ${width}px ${lang}, XL text, long name, resume and install controls`, async () => {
const { context, page } = await open(width, lang);
try {
// Supply only the optional controls' eligibility; their production components, CSS and click handlers remain.
await page.evaluate(async () => {
const { identity } = await import('/js/net.js');
identity.recoverable = () => [{ id: 'layout-only', name: 'Doctor', code: 'ABCD' }];
window.dispatchEvent(new Event('focus'));
const e = new Event('beforeinstallprompt', { cancelable: true });
e.prompt = async () => {}; e.userChoice = Promise.resolve({ outcome: 'dismissed' });
window.dispatchEvent(e);
await document.fonts.ready;
});
await page.waitForSelector('[data-testid="resume-local-match"]');
await page.waitForSelector('[data-testid="pwa-install"]');
const boxes = await page.$$eval('.lobby-screen .topbar button, .lobby-screen .topbar__center', els => els.map(e => {
const r = e.getBoundingClientRect(), hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return { label: e.getAttribute('aria-label') || e.textContent.trim(), x: r.x, y: r.y, right: r.right, bottom: r.bottom,
hit: e.tagName !== 'BUTTON' || hit === e || e.contains(hit) };
}));
for (const b of boxes) {
assert.ok(b.x >= 0 && b.right <= width && b.y >= 0, JSON.stringify(b));
assert.equal(b.hit, true, JSON.stringify(b));
}
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i], b = boxes[j];
assert.ok(Math.min(a.right, b.right) - Math.max(a.x, b.x) <= 1 || Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y) <= 1,
`${a.label} overlaps ${b.label}`);
}
await page.screenshot({ path: new URL(`../e2e/out/lobby-header-${width}-${lang}.png`, import.meta.url).pathname });
await page.click('.stats-entry'); await page.waitForSelector('.st .st-tab');
await page.click('.st-back');
await page.waitForSelector('.st', { hidden: true });
await page.click('.lobby-screen [data-testid="settings-btn"]'); await page.waitForSelector('.modal');
} finally { await context.close(); }
});
}
for (const width of [1600, 844]) test(`DIY ${width}px XL: shared stats and normal/elite skill details stay usable`, async () => {
const { context, page } = await open(width);
try {
await page.click('[data-testid="loadout-open"]'); await page.click('[data-tab="diy"]');
await page.click('.diy-slot[data-slot="chess_char_6_diy1_a"] .diy-slot__fill');
const picker = '[data-testid="diy-picker"]';
await page.waitForSelector(`${picker} [data-char="char_112_siege"]`);
await page.click(`${picker} [data-char="char_112_siege"]`);
await page.waitForSelector(`${picker} .lo-sec--stats[data-variant="elite"]`);
const detail = () => page.$eval(`${picker} .diy-pick__detail`, e => ({ stats: [...e.querySelectorAll('.dstat__v')].map(n => n.textContent),
skill: [...e.querySelectorAll('.diy-choice__desc')].map(n => n.textContent), talents: e.querySelectorAll('.lo-minfo__row').length,
tags: e.querySelector('.diy-choice__tags')?.textContent }));
const elite = await detail(); assert.equal(elite.stats.length, 8); assert.ok(elite.talents >= 2); assert.match(elite.tags, /初始|被动/);
await page.click(`${picker} button[data-variant="normal"]`);
await page.waitForSelector(`${picker} .lo-sec--stats[data-variant="normal"]`);
const normal = await detail(); assert.notDeepEqual(normal.stats, elite.stats); assert.notDeepEqual(normal.skill, elite.skill);
await page.click(`${picker} button[data-variant="elite"]`);
const restored = await detail(); assert.deepEqual(restored.stats, elite.stats);
// Independent scrolling must reach all skills/modules without displacing the fixed confirm control.
await page.$eval(`${picker} .diy-pick__detail`, e => { e.scrollTop = e.scrollHeight; });
await page.screenshot({ path: new URL(`../e2e/out/diy-detail-${width}-xl.png`, import.meta.url).pathname });
const accessible = await page.$eval('[data-testid="diy-confirm"]', e => {
const r = e.getBoundingClientRect(), at = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return r.bottom <= innerHeight && (at === e || e.contains(at));
});
assert.equal(accessible, true);
await page.click('[data-testid="diy-confirm"]');
await page.waitForSelector(picker, { hidden: true });
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.diy')).picks.chess_char_6_diy1_a.charId), 'char_112_siege');
} finally { await context.close(); }
});
});
+45
View File
@@ -0,0 +1,45 @@
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const results = JSON.parse(readFileSync(new URL('../fixtures/stats-results.json', import.meta.url)));
describe('settlement return control under enlarged text (#458)', { skip: !ENABLED }, () => {
let srv, browser;
before(async () => {
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(new URL('../e2e/out/', import.meta.url), { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
for (const [width, height] of [[1366, 768], [844, 390]]) for (const textSize of ['md', 'lg', 'xl']) {
test(`${width}×${height}, ${textSize}: return is inside the viewport and clickable`, async () => {
const page = await browser.newPage();
try {
await page.setViewport({ width, height });
await page.evaluateOnNewDocument(ts => localStorage.setItem('sp.pref.settings', JSON.stringify({ textSize: ts })), textSize);
await page.goto(`http://127.0.0.1:${srv.port}/`, { waitUntil: 'networkidle0' });
// Use the production ResultView with a captured real match result; no CSS or geometry is changed by the fixture.
await page.evaluate(async res => {
const { render, h } = await import('/vendor/preact.module.js');
const { ResultView } = await import('/js/screens/result.js');
render(h(ResultView, { res, myId: 'p_0', quiet: true, backLabel: '返回同盟', onBack: () => { window.__returned = true; } }), document.getElementById('app'));
await document.fonts.ready;
}, results['coop4-hard']);
await page.waitForSelector('.result__foot button');
await page.screenshot({ path: new URL(`../e2e/out/result-text-${width}-${textSize}.png`, import.meta.url).pathname });
const bounds = await page.$eval('.result__foot button', b => {
const r = b.getBoundingClientRect(), hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, reachable: !!hit && (hit === b || b.contains(hit)) };
});
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.right <= width && bounds.bottom <= height, JSON.stringify(bounds));
assert.equal(bounds.reachable, true);
await page.click('.result__foot button'); assert.equal(await page.evaluate(() => window.__returned), true);
} finally { await page.close(); }
});
}
});
+17
View File
@@ -171,6 +171,23 @@ describe('stage device tip in the browser', { skip: !ENABLED && 'set SP_E2E=1 (a
assert.ok(text !== null, 'the tap opened a card');
assert.match(text, new RegExp(CRATE.name), text);
assert.equal(await hpText(page), '100 / 100');
// A repeated same-field meta can omit once-spawned devices. The screen's remembered device must also remain a
// device in the renderer, not an unknown enemy which intercepts the tile click before the card lookup.
const deviceId = await page.evaluate(() => globalThis.__MOCK__.S().battle.devices[0].id);
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
const { store } = globalThis.__MOCK__;
await net.request('g.watch', { fieldId: store.get().match.field.fieldId });
});
await page.waitForFunction((id) => globalThis.__SP_VIEW__?.raw?.debug.views.get(id)?.info.kind === 'device', {}, deviceId);
const p = await at();
await page.mouse.click(p.x, p.y);
await page.waitForSelector('.dpanel');
assert.match(await card(page), new RegExp(CRATE.name));
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 40)));
await page.waitForFunction(() => /60 \/ 100/.test(document.querySelector('.dpanel .dhp')?.textContent || ''));
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 100)));
await page.waitForFunction(() => !document.querySelector('.dpanel'));
assert.deepEqual(problems, []);
await page.close();
});
+10 -1
View File
@@ -18,7 +18,7 @@ import { makeBattle } from '../helpers/battleHarness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { deviceInfo, deviceTipAt, noteDeviceUnits, effectiveStage, closesOnFieldPress } = await import('../../public/js/ui/gameLogic.js');
const { deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry, effectiveStage, closesOnFieldPress } = await import('../../public/js/ui/gameLogic.js');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data', 'stages.json'), 'utf8'));
const pack = (code) => { const p = JSON.parse(readFileSync(path.join(ROOT, 'public', 'i18n', `${code}.json`), 'utf8')); delete p._meta; return p; };
@@ -267,6 +267,15 @@ test('the prep and a scouted prep board: the stage\'s own device entries answer
assert.deepEqual([...m.keys()], [2, 3]);
assert.equal(noteDeviceUnits(new Map(), {}).size, 0);
assert.equal(noteDeviceUnits(new Map(), { units: 'x', events: 5 }).size, 0);
// a same-field refresh whose meta has no device keeps spawn-noted crates; a meta that names devices replaces them;
// another field does not
const kept = deviceUnitsForEntry(m, new Map(), true);
assert.equal(kept, m);
const fresh = new Map([[9, { id: 9, kind: 'device' }]]);
assert.equal(deviceUnitsForEntry(m, fresh, true), fresh);
assert.notEqual(deviceUnitsForEntry(m, new Map(), false), m);
assert.equal(deviceUnitsForEntry(null, new Map(), true).size, 0);
assert.equal(deviceUnitsForEntry(m, null, true).size, 0);
});
test('a device target resolves into the panel card; a malformed one resolves to nothing; the card closes on a field press', () => {
+15 -13
View File
@@ -54,11 +54,12 @@ describe('自选编队 picker: reselecting retains the draft (real server, no ar
await c.click('.lobby-screen [data-testid="loadout-open"]');
await c.click('.lo .lo-tab[data-tab="diy"]');
await open();
await c.click(operator);
await c.click(`${picker} [data-skill="0"]`);
await c.click(`${picker} [data-module="none"]`);
await c.page.$eval(operator, e => e.scrollIntoView({ block: 'nearest' }));
await c.page.click(operator);
await c.page.click(`${picker} [data-skill="0"]`);
await c.page.click(`${picker} [data-module="none"]`);
await c.shot('empty-edited');
await c.click(operator);
await c.page.click(operator);
await c.shot('empty-reselected');
assert.deepEqual(await choices(), { skill: '0', module: 'none' }, 'an empty slot retains its edited draft');
// Hide the current operator, then find and reselect it through the filter/search controls.
@@ -67,15 +68,15 @@ describe('自选编队 picker: reselecting retains the draft (real server, no ar
await c.click(`${picker} [data-filter="owned"]`);
await c.click(`${picker} input[type="search"]`);
await c.page.keyboard.type('推进');
await c.click(operator);
await c.page.click(operator);
assert.deepEqual(await choices(), { skill: '0', module: 'none' });
await confirm(edited);
await c.shot('empty-saved');
// A saved non-default pick must not overwrite new edits on a repeated operator click.
await open();
await c.click(`${picker} [data-skill="1"]`);
await c.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.click(operator);
await c.page.click(`${picker} [data-skill="1"]`);
await c.page.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.page.click(operator);
assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' });
await c.shot('saved-reselected');
await c.click(`${picker} .diy-pick__foot button`, '取消');
@@ -83,9 +84,9 @@ describe('自选编队 picker: reselecting retains the draft (real server, no ar
assert.deepEqual(await saved(), edited, 'cancel leaves the saved pick unchanged');
await open();
assert.deepEqual(await choices(), { skill: '0', module: 'none' });
await c.click(`${picker} [data-skill="1"]`);
await c.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.click(operator);
await c.page.click(`${picker} [data-skill="1"]`);
await c.page.click(`${picker} [data-module="uniequip_003_siege"]`);
await c.page.click(operator);
await confirm(changed);
await c.page.reload({ waitUntil: 'domcontentloaded' });
await c.page.waitForSelector('.lobby-screen', { timeout: 20000 });
@@ -178,10 +179,11 @@ describe('0.2.0 自选编队 — a slotted operator in the own shop and battle (
await c.page.waitForSelector(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`, { visible: true, timeout: 8000 });
const listed = await c.page.evaluate(() => [...document.querySelectorAll('[data-testid="diy-picker"] .diy-opt')].map((el) => el.dataset.char));
assert.ok(listed.includes('char_609_acguad') && listed.includes('char_601_cguard'), 'the tier-5 prototypes are listed');
await c.page.$eval(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`, e => e.scrollIntoView({ block: 'nearest' }));
await c.click(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`);
await c.page.waitForSelector('[data-testid="diy-picker"] .diy-choice[data-skill="2"]', { visible: true, timeout: 5000 });
await c.click('[data-testid="diy-picker"] .diy-choice[data-skill="2"]');
await c.click('[data-testid="diy-picker"] .diy-choice[data-module="uniequip_002_siege"]');
await c.page.click('[data-testid="diy-picker"] .diy-choice[data-skill="2"]');
await c.page.click('[data-testid="diy-picker"] .diy-choice[data-module="uniequip_002_siege"]');
await c.shot('picker');
await c.click('[data-testid="diy-confirm"]');
await c.page.waitForFunction((slot, id) => document.querySelector(`.diy-slot[data-slot="${slot}"]`)?.dataset.char === id, { timeout: 4000 }, SLOT, SIEGE);
+10 -5
View File
@@ -120,7 +120,7 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
await net.request('room.join', { code }); await net.request('room.ready', { ready: true });
}, code);
await host.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK', { polling: 50 });
await guest.evaluate(() => globalThis.__SP__.net.request('g.infoReady', { setupRevision: 0 }));
await host.waitForSelector('.brief-reroll button');
await host.bringToFront();
@@ -128,20 +128,25 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
await guest.waitForSelector('.brief-reroll .btn--primary');
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), 0);
await guest.bringToFront();
await guest.click('.brief-reroll .btn--primary');
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1);
// The host is now a background tab: WebSocket/store updates continue, but animation-frame polling can stop.
// Arm its state wait before the vote so the regression covers a result arriving after the initial check.
await Promise.all([
host.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1, { polling: 50 }),
guest.click('.brief-reroll .btn--primary'),
]);
await guest.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1, { polling: 50 });
const state = await guest.evaluate(() => globalThis.__SP__.store.get().match.public);
assert.equal(state.rerollVote, null);
assert.ok(state.players.filter((p) => !p.isBot).every((p) => !p.ready));
await host.bringToFront();
await host.waitForFunction(() => !!document.querySelector('.brief-reroll button:not(:disabled)'));
// Wait out the documented server anti-burst interval before a second request.
await new Promise((resolve) => setTimeout(resolve, 350));
await host.bringToFront();
await host.click('.brief-reroll button');
await guest.waitForSelector('.brief-reroll .btn--danger');
await guest.bringToFront();
await guest.click('.brief-reroll .btn--danger');
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote);
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote, { polling: 50 });
assert.equal(await host.evaluate(() => globalThis.__SP__.store.get().match.public.setupRevision), 1);
} finally { await ctx.close(); }
});
+5 -3
View File
@@ -256,9 +256,11 @@ test('place in the detail: after the skills, before the modules; the first .lo-s
const mods = detail.indexOf('<section class="lo-sec lo-sec--mod">');
assert.ok(toggle > 0 && skills > toggle && stats > skills && mods > stats, 'skills (and their level toggle) → 局内数值 → modules');
// one record resolution, shared with the card: chessLoadout, the card's block, trait and talent helpers — no stat arithmetic of its own
assert.match(src, /import \{ chessStatsBlock, traitText, chessTalents, GarrisonBlock \} from '\.\.\/ui\/detailPanel\.js';/);
assert.match(src, /import \{ chessLoadout \} from '\.\.\/ui\/gameLogic\.js';/);
const fn = src.slice(src.indexOf('export function statsPreview'), src.indexOf('export function LoadoutStats'));
const shared = read('public/js/ui/loadoutStats.js');
assert.match(src, /import \{ LoadoutStats \} from '\.\.\/ui\/loadoutStats\.js';/);
assert.match(shared, /import \{ chessStatsBlock, traitText, chessTalents \} from '\.\/detailPanel\.js';/);
assert.match(shared, /import \{ chessLoadout \} from '\.\/gameLogic\.js';/);
const fn = shared.slice(shared.indexOf('export function statsPreview'), shared.indexOf('export function LoadoutStats'));
assert.ok(!/composeStats|attr\b|maxHp|statsBase/.test(fn), 'no stat arithmetic in the screen');
// the screen's own state: 精锐 by default, independent of the skill toggle's 普通
assert.match(src, /const \[statLevel, setStatLevel\] = useState\('elite'\)/);
+2 -2
View File
@@ -36,7 +36,7 @@ describe('the settings entry of the lobby and the room', { skip: !ENABLED && 'se
const settingsBeforeGuide = (page, screen, guideClass) => page.evaluate((s, g) => {
const btn = document.querySelector(`${s} [data-testid="settings-btn"]`);
const guide = document.querySelector(`${s} .${g}`);
return !!btn && !!guide && btn.nextElementSibling === guide && /设置/.test(btn.textContent) && btn.querySelector('svg.btn__icon') != null;
return !!btn && !!guide && btn.nextElementSibling === guide && /设置/.test(btn.getAttribute('aria-label') || btn.textContent) && btn.querySelector('svg.btn__icon') != null;
}, screen, guideClass);
const modalText = (page) => page.evaluate(() => document.querySelector('.modal')?.textContent.replace(/\s+/g, ' ').trim() ?? null);
@@ -53,7 +53,7 @@ describe('the settings entry of the lobby and the room', { skip: !ENABLED && 'se
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
await sleep(500);
assert.ok(await settingsBeforeGuide(page, '.lobby-screen .topbar__right', 'lobby-guide'), '设置 sits right before 玩法说明 in the lobby\'s top bar');
assert.ok(await settingsBeforeGuide(page, '.lobby-screen .topbar__tools', 'lobby-guide'), '设置 sits right before 玩法说明 in the lobby\'s top bar');
assert.equal(await modalText(page), null, 'closed until asked');
await page.click('.lobby-screen [data-testid="settings-btn"]');
await page.waitForSelector('.modal', { timeout: 5000 });
+3 -3
View File
@@ -88,8 +88,8 @@ describe('统计数据 page (real server, headless Chrome)', { skip: !ENABLED &&
}
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, '统计');
const openStats = async (c, from = '.lobby-screen .stats-entry') => {
await c.click(from, from.includes('stats-entry') ? null : '统计');
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());
@@ -305,7 +305,7 @@ describe('统计数据: the real flows (a fast real server, headless Chrome)', {
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.click('.lobby-screen .stats-entry');
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 局未计入统计/);