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:
1 parent
1db8e51023
commit
6bfc08270c
129 files changed
+3477
-1011
No files matched your search
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
@@ -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'\)/);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 局未计入统计/);
|
||||
|
||||
Reference in new issue
Block a user