Files
ark-stronghold-protocol/test/ui/loadout.e2e.test.js
T
sganggs dc336f24a1 feedback7 R22-LOADOUT: 干员调配's roster is one list — a row per operator with the quick skill / module choices and the 潜能 / 练度 selects in aligned columns — and the detail opens with 潜能与练度; golden: unchanged
The owner's decisions of 2026-10-08: 「D12 [PR #301] 可以做,具体布局怎么优化你来决定,他那样确实方便,但是三个竖排有点乱影响
观感」 and 「D7D8调配干员里自己设置吧,默认满潜满加成」. The quick choices are PR #301's idea (by @farridge); the layout is new:
one list instead of three columns of cards.

- screens/loadout.js: RosterHead + RosterRow — every row the same four columns under a sticky head (干员 | 技能 |
  模组 精锐 | 潜能 · 练度): portrait / name / bonds (selects, opens the detail), three skill slots (an empty one for a
  two-skill chess; the label says slot, name, SP, duration — quickSkillTags), the elite's modules (不装备 first), the
  潜能 / 练度 selects; a quick choice goes through setChoice / setEntries like the detail and keeps a phone on the list.
  The detail's 普通 / 精锐 skill toggle is one preview for the skills, the 特质 and the rows. The detail opens with the
  潜能与练度 section; 恢复默认 / 全部恢复默认 reset the settings too (全部: the roster's operators — a 自选 pick's stay
  with the 自选编队 tab); 导入 reports a settings-only import; ←/→ stay inside a focused row's choices.
- screens/cultivation.js (new): CultivationSelects (two compact selects; quiet at the defaults, mint when changed) and
  CultivationSection (潜能 1–6 and the four official tiers — effects.json names and effect texts — the rule in one line,
  the stand-in note for a chess marked not owned).
- screens/diy.js: an owned 自选 pick's slot card carries its operator's selects; a prototype says it has neither.
- css: the head and the rows share one grid (`--cols`), 32 px quick targets (40 px on a coarse pointer); the detail
  slides over the list below 1000 px (was 760 px); below 700 px the row's bonds give way to the name. Headless check at
  1920 × 1080, 1366 × 768, 844 × 390 and 667 × 375: no sideways overflow, the head's columns equal every row's.
- The 局内数值 caption says 含潜能与练度 when the numbers carry both (effects.json loaded); the overlay's note says the
  skills, modules, 潜能 and 练度 can be set (the operator's in-match level cannot).
- i18n: 21 strings in the four packs (the tier names, short tier labels, the notes, the reset confirm, the import toast).
- Docs: design/match.md (the list, the 潜能与练度 section, 导出's `ops`), ARCHITECTURE.md (screens/cultivation.js).
- Tests: test/ui/loadout-rows.test.js (new, 7): a row's pick / skill slots / modules / aria-pressed / callbacks and the
  shared 精锐 preview, its selects (values, options, a change, the changed mark, a stand-in), the head, the detail's
  section (tiers from effects.json, the effect text, the stand-in note), a 自选 owned pick vs a prototype, 局内数值 at
  潜能1 / 未精英化 and at the defaults with its caption, the css grid and breakpoints. e2e (not run here; their steps
  replayed with a headless page): the row pick selector, the phone test (one list, 40 px targets, the detail slides
  over, back to the list), the 局内数值 expectations ×1.1, a new 潜能 / 练度 flow (row select → stored, 部署费用 17 / 攻击
  454; the detail's 未精英化 → 413; the row follows; 恢复默认).
2026-10-08 21:38:26 +08:00

524 lines
38 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 干员调配 overlay (DESIGN §16) against the real server, headless Chrome (puppeteer-core + system
// Chrome). Opt-in: SP_E2E=1 node --test test/ui/loadout.e2e.test.js
//
// Desktop 1920×1080: lobby → 干员调配 → search 隐现 → S1 + 不装备 (persisted in localStorage, synced with room.loadout)
// → create a 独立模拟 room → start → the briefing's m.private.loadout carries the choice, the briefing entry reopens the
// overlay, the solo briefing shows no countdown value from the server (deadline 0) → 准备就绪 closes the overlay
// (auto-close when INFO_CHECK ends). Phone 844×390 (touch): the roster → tap a card → the detail slides over → back.
// Module cards: the official module type icons from the local-client art (data/local-assets.json groups.module, optional)
// or the lettered tiles without it → test/e2e/out/module-icons.png.
// Every page must finish with zero console errors / page errors / failed requests. Screenshots: test/e2e/out/loadout-*.png
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const INSIDE = 'chess_char_1_01_a';
/** Whether this machine has the extracted module type icons (local-client art is optional, DESIGN §13). */
function localModuleIcons() {
try { return !!JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8'))?.groups?.module; } catch { return false; }
}
describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
async function open({ w = 1920, h = 1080, touch = false } = {}) {
const ctx = await browser.createBrowserContext();
const page = await ctx.newPage();
await page.setViewport({ width: w, height: h, hasTouch: touch, isMobile: touch, deviceScaleFactor: 1 });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => { if (!/fonts\.(googleapis|gstatic)/.test(r.url())) problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
await page.evaluateOnNewDocument(() => {
localStorage.setItem('sp.name', '调配测试');
sessionStorage.setItem('sp.entered', '1');
});
await page.goto(`${base}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && !!document.querySelector('.lobby-screen'), { timeout: 30000 });
return { ctx, page, problems };
}
const st = (page) => page.evaluate(() => {
const s = globalThis.__SP__.store.get();
return { phase: s.match.public?.phase ?? null, deadline: s.match.public?.deadline ?? null, loadout: s.match.private?.loadout ?? null, room: s.room?.code ?? null };
});
async function waitSt(page, pred, what, timeout = 20000) {
const t0 = Date.now();
let last = null;
while (Date.now() - t0 < timeout) { last = await st(page); if (pred(last)) return last; await sleep(150); }
throw new Error(`timed out waiting for ${what}: ${JSON.stringify(last)}`);
}
/** The overlay's background layer (css/screens/loadout.css .lo__bg — the mint glow and the grid): out of the flex flow
* and as large as the overlay, every other layer stacked above it. It used to be a 0-px flex item: `.lo > *` (same
* specificity, a later rule) overrode its position: absolute (PR #14). */
const bgLayer = (page) => page.evaluate(() => {
const lo = document.querySelector('.lo');
const el = lo.querySelector(':scope > .lo__bg');
const a = lo.getBoundingClientRect(), b = el.getBoundingClientRect();
return {
position: getComputedStyle(el).position, first: lo.firstElementChild === el, rect: [b.x, b.y, b.width, b.height], lo: [a.x, a.y, a.width, a.height],
others: [...lo.children].filter((c) => c !== el).map((c) => getComputedStyle(c).position),
};
});
const assertBgLayer = async (page) => {
const bg = await bgLayer(page);
assert.equal(bg.position, 'absolute', `the background layer is out of the flex flow ${JSON.stringify(bg)}`);
assert.ok(bg.first && bg.rect[3] > 0 && JSON.stringify(bg.rect) === JSON.stringify(bg.lo), `it covers the whole overlay ${JSON.stringify(bg)}`);
assert.ok(bg.others.length >= 3 && bg.others.every((p) => p === 'relative'), `every other layer stacks above it ${JSON.stringify(bg)}`);
};
const clickSel = async (page, sel) => {
await page.waitForSelector(sel, { visible: true, timeout: 10000 });
await page.click(sel);
};
test('desktop: choose S1 + 不装备 for 隐现, persisted, synced into the solo match, briefing entry, auto-close', async () => {
const { ctx, page, problems } = await open();
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
assert.equal(await page.$$eval('.lo-card', (els) => els.length), 112, 'every visible chess');
await assertBgLayer(page);
await page.screenshot({ path: path.join(OUT, 'loadout-desktop.png') });
// search → one card
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
await page.click('.lo-detail .lo-skill[data-skill="0"]');
await page.click('.lo-detail .lo-mod[data-module="none"]');
// elite-level descriptions toggle
await page.click('.lo-seg button:nth-child(2)');
await page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'));
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
assert.deepEqual(stored.entries, { [INSIDE]: { skill: 0, module: 'none' } });
await page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 5000 });
await page.screenshot({ path: path.join(OUT, 'loadout-detail.png') });
// close (Esc), create a solo room, start
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }));
await waitSt(page, (s) => !!s.room, 'room');
await page.waitForSelector('.room-screen [data-testid="loadout-open"]', { visible: true });
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
const brief = await waitSt(page, (s) => s.phase === 'INFO_CHECK' && s.loadout, 'briefing');
assert.deepEqual(brief.loadout, { [INSIDE]: { skill: 0, module: 'none' } }, 'the match fights with the loadout');
assert.equal(brief.deadline, 0, 'solo briefing: no deadline');
assert.equal(await page.$$eval('.brief .countdown', (els) => els.filter((e) => getComputedStyle(e).display !== 'none').length), 0,
'no countdown shown in the solo briefing');
// the briefing entry reopens it; 准备就绪 (INFO_CHECK ends) closes it
await clickSel(page, '.brief [data-testid="loadout-open"]');
await page.waitForSelector('.lo', { visible: true });
await page.screenshot({ path: path.join(OUT, 'loadout-briefing.png') });
await page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 5000 });
assert.deepEqual(problems, []);
await ctx.close();
});
test('desktop: 导出 hands out a versioned payload; 导入 restores it and refuses junk', async () => {
const { ctx, page, problems } = await open();
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
// one edit, so there is something to export
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
await page.click('.lo-detail .lo-skill[data-skill="0"]');
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
assert.deepEqual(stored.entries, { [INSIDE]: { skill: 0 } });
// 导出: a centred dialog — its own full-viewport overlay, rendered NEXT TO the .lo screen rather than inside it
await clickSel(page, '[data-testid="loadout-export"]');
await page.waitForSelector('.modal [data-testid="loadout-io-text"]', { visible: true, timeout: 5000 });
// the box slides in (`modal-in`, 250 ms): measure the settled geometry, not a frame of the entry animation
await page.waitForFunction(() => document.querySelector('.modal__box')?.getAnimations().every((a) => a.playState === 'finished'), { timeout: 5000 });
const box = await page.evaluate(() => {
const m = document.querySelector('.modal');
const b = document.querySelector('.modal__box').getBoundingClientRect();
return { position: getComputedStyle(m).position, y: b.y, h: b.height, vh: innerHeight, inside: !!m.closest('.lo') };
});
assert.equal(box.position, 'fixed', 'the modal keeps components.css `position: fixed`');
assert.equal(box.inside, false, 'a sibling of .lo, so `.lo > *` needs no exception for it');
assert.ok(Math.abs(box.y + box.h / 2 - box.vh / 2) < 2, `centred vertically (${JSON.stringify(box)})`);
const payload = JSON.parse(await page.$eval('[data-testid="loadout-io-text"]', (t) => t.value));
assert.equal(payload.kind, 'stronghold.loadout');
assert.equal(payload.v, 1);
assert.deepEqual(payload.entries, stored.entries, 'what is exported is what is stored');
await page.screenshot({ path: path.join(OUT, 'loadout-export.png') });
await page.evaluate(() => [...document.querySelectorAll('.modal__actions .btn')].find((b) => b.textContent.trim() === '关闭').click());
await page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 3000 });
// wipe it, then 导入 the payload back
await page.evaluate(() => localStorage.setItem('sp.pref.loadout', JSON.stringify({ v: 1, entries: {} })));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && !!document.querySelector('.lobby-screen'), { timeout: 30000 });
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await clickSel(page, '[data-testid="loadout-import"]');
await page.waitForSelector('.modal [data-testid="loadout-io-text"]', { visible: true, timeout: 5000 });
await page.$eval('[data-testid="loadout-io-text"]', (t, v) => { t.value = v; t.dispatchEvent(new Event('input', { bubbles: true })); }, JSON.stringify(payload));
await page.click('[data-testid="loadout-io-apply"]');
await page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 5000 });
await sleep(300);
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')).entries), stored.entries, 'round trip');
// a payload of another tool is refused and leaves the loadout alone
await clickSel(page, '[data-testid="loadout-import"]');
await page.waitForSelector('.modal [data-testid="loadout-io-text"]', { visible: true, timeout: 5000 });
await page.$eval('[data-testid="loadout-io-text"]', (t) => { t.value = '{"kind":"other.tool","entries":{"a":{"skill":1}}}'; t.dispatchEvent(new Event('input', { bubbles: true })); });
await page.click('[data-testid="loadout-io-apply"]');
await page.waitForFunction(() => /导入失败/.test(document.body.textContent || ''), { timeout: 5000 });
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')).entries), stored.entries, 'a refused import changes nothing');
// a payload this build cannot use at all (every chess unknown) must leave the loadout alone too (review fix)
await page.$eval('[data-testid="loadout-io-text"]', (t) => { t.value = '{"v":1,"entries":{"chess_not_here":{"skill":0}}}'; t.dispatchEvent(new Event('input', { bubbles: true })); });
await page.click('[data-testid="loadout-io-apply"]');
await page.waitForFunction(() => /没有可用的调配/.test(document.body.textContent || ''), { timeout: 5000 });
assert.ok(await page.$('.modal'), 'the dialog stays open for the player to fix the payload');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')).entries), stored.entries, 'an import that keeps nothing changes nothing');
assert.deepEqual(problems, []);
await ctx.close();
});
test('solo briefing: an edit made right before 准备就绪 still applies to this match (closing the overlay sends it)', async () => {
const { ctx, page, problems } = await open();
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }));
await waitSt(page, (s) => !!s.room, 'room');
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
await clickSel(page, '.brief [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
// choose S1, close and confirm at once — well inside the sync's debounce
await page.click('.lo-detail .lo-skill[data-skill="0"]');
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
await page.click('.brief__foot .btn--primary');
const s = await waitSt(page, (x) => x.phase && x.phase !== 'INFO_CHECK' && x.loadout && x.loadout[INSIDE], 'band draft with the loadout', 8000)
.catch(async (e) => { throw new Error(`${e.message} — last loadout ${JSON.stringify((await st(page)).loadout)}`); });
assert.equal(s.loadout[INSIDE].skill, 0, 'the match fights with the edit made just before 准备就绪');
assert.deepEqual(problems, []);
await ctx.close();
});
test('co-op briefing (review fix): the overlay shows the INFO_CHECK time left; an edit that misses the lock is told, not lost silently', async () => {
const { ctx, page, problems } = await open();
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' }));
const { room } = await waitSt(page, (s) => !!s.room, 'room');
// a second human joins: a single human is untimed like solo (Match.soloUntimed, user playtest #4 item 3)
const mate = await open();
await mate.page.evaluate((code) => globalThis.__SP__.net.request('room.join', { code }), room);
await waitSt(mate.page, (s) => s.room === room, 'the teammate in the room');
await mate.page.evaluate(() => globalThis.__SP__.net.request('room.ready', { ready: true }));
await page.evaluate(() => globalThis.__SP__.net.request('room.addBot', {}));
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
const brief = await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
assert.ok(brief.deadline > 0, 'co-op briefing (two humans) is timed (25 s)');
await clickSel(page, '.brief [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
// the briefing (and its countdown) is hidden under the overlay: the overlay carries the time left
await page.waitForSelector('.lo-top .lo-deadline', { visible: true, timeout: 3000 });
assert.match(await page.$eval('.lo-top .lo-deadline', (el) => el.getAttribute('aria-label')), /剩余\d+秒/);
// review: the two new 导出 / 导入 buttons must fit next to the countdown on a narrow phone in landscape (667×375).
// 0.2.0: the third tab (自选编队) ran the right column into the tabs there, at 844×390, and in English at every size.
// Only the size changes (toggling isMobile / hasTouch would make puppeteer reload the page).
const switchLang = async (lang) => {
await page.evaluate((l) => import('/js/ui/lang.js').then((m) => m.switchLang(l)), lang);
await page.waitForFunction((l) => document.documentElement.lang.startsWith(l) && /[一-鿿]/.test(document.querySelector('.lo-tabs').textContent) === (l === 'zh'), { timeout: 5000 }, lang);
};
for (const [lang, sizes] of [['zh', [[667, 375], [844, 390]]], ['en', [[667, 375], [844, 390], [1920, 1080]]]]) {
if (lang !== 'zh') await switchLang(lang);
for (const [w, h] of sizes) {
await page.setViewport({ width: w, height: h });
const bar = await page.evaluate(() => {
const top = document.querySelector('.lo-top');
const tabs = document.querySelector('.lo-tabs');
const right = document.querySelector('.lo-top__right').getBoundingClientRect();
const center = document.querySelector('.lo-top__center').getBoundingClientRect();
const buttons = [...document.querySelectorAll('.lo-top__right .btn')].map((b) => Math.round(b.getBoundingClientRect().right));
return { overflow: top.scrollWidth - top.clientWidth, overlap: right.left < center.right, offscreen: right.right > innerWidth, tabsCut: tabs.scrollWidth - tabs.clientWidth, buttons };
});
const at = `${lang} ${w}×${h} ${JSON.stringify(bar)}`;
assert.ok(bar.overflow <= 0, `the top bar does not scroll sideways (${at})`);
assert.equal(bar.overlap, false, `the right column does not run into the title (${at})`);
assert.equal(bar.offscreen, false, `every button ends inside the viewport (${at})`);
assert.ok(bar.tabsCut <= 0, `the three tabs show in full (${at})`);
if (w === 667) await page.screenshot({ path: path.join(OUT, `loadout-narrow-toolbar${lang === 'zh' ? '' : `-${lang}`}.png`) });
}
}
await switchLang('zh');
await page.setViewport({ width: 1920, height: 1080 });
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
// edit, and the briefing ends before the debounced send (every human ready: the requests stand in for the timer)
await page.click('.lo-detail .lo-skill[data-skill="0"]');
await mate.page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
await page.evaluate(() => globalThis.__SP__.net.request('g.infoReady', {}));
await page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 5000 });
await page.waitForFunction(() => [...document.querySelectorAll('.toast__text')].some((e) => /下一局生效/.test(e.textContent)), { timeout: 5000 });
const s = await st(page);
assert.ok(!s.loadout || !s.loadout[INSIDE], 'the running match kept its locked loadout');
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
assert.deepEqual(stored.entries[INSIDE], { skill: 0 }, 'kept for the next match');
assert.deepEqual(problems, []);
assert.deepEqual(mate.problems, []);
await mate.ctx.close();
await ctx.close();
});
test('module cards: official type icons (PRI-X.png / pri-y.png, matched case-insensitively) or lettered tiles', async () => {
const { ctx, page, problems } = await open();
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await page.type('.lo-search input', '烛煌');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-mod[data-module="none"]', { visible: true });
// wait for every module tile to settle (image loaded or lettered fallback)
await page.waitForFunction(() => [...document.querySelectorAll('.lo-mod:not(.lo-mod--none) .lo-mglyph')]
.every((g) => g.querySelector('.lo-mglyph__t') || (g.querySelector('img')?.complete && g.querySelector('img').naturalWidth > 0)), { timeout: 5000 });
const tiles = await page.$$eval('.lo-mod:not(.lo-mod--none)', (els) => els.map((el) => ({
type: el.querySelector('.lo-mod__type')?.textContent || '',
src: el.querySelector('.lo-mglyph img')?.getAttribute('src') || null,
letter: el.querySelector('.lo-mglyph__t')?.textContent || null,
})));
assert.deepEqual(tiles.map((t) => t.type).sort(), ['PRI-X', 'PRI-Y']);
if (localModuleIcons()) {
for (const t of tiles) assert.match(t.src || '', new RegExp(`^/assets/local/module/${t.type}\\.png$`, 'i'), JSON.stringify(t));
assert.ok(await page.$('.lo-minfo__title .lo-minfo__icon'), 'the selected module\'s info shows its icon too');
} else {
for (const t of tiles) assert.equal(t.letter, t.type.slice(-1), JSON.stringify(t));
}
await page.hover('.lo-mod[data-module]:not(.is-on):not(.lo-mod--none)');
await page.screenshot({ path: path.join(OUT, 'module-icons.png') });
assert.deepEqual(problems, []);
await ctx.close();
});
test('phone 844×390 (touch): one list with the quick choices (0.2.2), the detail slides over it, tier / class filters, tap to choose', async () => {
const { ctx, page, problems } = await open({ w: 844, h: 390, touch: true });
await page.tap('.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await assertBgLayer(page);
await page.screenshot({ path: path.join(OUT, 'loadout-phone.png') });
// review fix: the shared Button / TextField shrink to 6–7 px text on phones — the overlay's own controls stay readable
// and tappable (返回, 全部恢复默认, the search input; the detail's 恢复默认 below)
const measure = (sels) => page.evaluate((list) => Object.fromEntries(list.map((sel) => {
const el = document.querySelector(sel);
const b = el.getBoundingClientRect();
return [sel, { h: Math.round(b.height), fs: parseFloat(getComputedStyle(el).fontSize) }];
})), sels);
const check = (ctl) => {
for (const [sel, v] of Object.entries(ctl)) {
assert.ok(v.fs >= 11, `${sel} text ≥ 11 px (${JSON.stringify(v)})`);
if (sel !== '.lo-search .field__input') assert.ok(v.h >= 28, `${sel} ≥ 28 px tall (${JSON.stringify(v)})`);
}
};
check(await measure(['.lo-back', '.lo-top__right .btn', '.lo-search .field__input']));
// the list takes the whole width (no detail beside it below 1000 px); the quick choices are 40 px touch targets
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
const q = await page.$eval('.lo-card .lo-q--skill', (el) => { const b = el.getBoundingClientRect(); return [Math.round(b.width), Math.round(b.height)]; });
assert.ok(q[0] >= 40 && q[1] >= 40, `quick targets ${q}`);
assert.equal(await page.evaluate(() => document.querySelector('.lo').scrollWidth > innerWidth + 1), false, 'no horizontal overflow');
await page.tap('.lo-chip--t6');
await page.waitForFunction(() => [...document.querySelectorAll('.lo-card')].every((c) => c.classList.contains('lo-card--t6')));
await page.tap('.lo-chip--prof[title="狙击"]');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length >= 1);
// a quick choice keeps the list
await page.tap('.lo-card .lo-q--skill[data-skill="0"]');
assert.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'a quick choice keeps the list');
await page.tap('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'the detail covers the list');
check(await measure(['.lo-dhead__reset']));
// every skill option is reachable inside the viewport (the detail body scrolls)
const r = await page.$eval('.lo-detail .lo-skill:last-child', (el) => { el.scrollIntoView({ block: 'nearest' }); const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; });
assert.ok(r.top >= 0 && r.bottom <= 390, JSON.stringify(r));
await page.tap('.lo-detail .lo-skill:last-child');
await page.waitForFunction(() => document.querySelector('.lo-detail .lo-skill:last-child').classList.contains('is-on'));
await page.screenshot({ path: path.join(OUT, 'loadout-phone-detail.png') });
await page.tap('.lo-detail-back');
await page.waitForSelector('.lo-roster', { visible: true });
assert.deepEqual(problems, []);
await ctx.close();
});
test('small phone 667×375 (touch): the detail slides over the roster and back', async () => {
const { ctx, page, problems } = await open({ w: 667, h: 375, touch: true });
await page.tap('.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
await page.tap('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'detail covers the roster');
await page.screenshot({ path: path.join(OUT, 'loadout-small-phone.png') });
await page.tap('.lo-detail-back');
await page.waitForSelector('.lo-roster', { visible: true });
assert.deepEqual(problems, []);
await ctx.close();
});
// ---- 局内数值 (GitHub issue #64) -------------------------------------------------------------------------------------
const CHESS = () => JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const fmt = (n) => Math.round(n).toLocaleString('en-US');
// 0.2.2: the numbers carry the 练度 — none set: 精英2 Lv.60, effects.json aceffect_char_4 (×1.1 ATK / DEF / max HP)
const T4 = JSON.parse(readFileSync(path.join(ROOT, 'data/effects.json'), 'utf8')).aceffect_char_4.buffs.find((b) => b.key === 'char_attribute_mul').bb;
/** The section's eight stats: label → shown value. */
const shownStats = (page) => page.$$eval('.lo-sec--stats .dstat', (els) => Object.fromEntries(els.map((e) => [e.querySelector('.dstat__k').textContent, e.querySelector('.dstat__v').textContent])));
const rangeTiles = (page) => page.$$eval('.lo-sec--stats .rgrid i.on', (els) => els.length);
async function pickChess(page, name) {
await page.$eval('.lo-search input', (el) => { el.focus(); el.select(); });
await page.keyboard.press('Backspace');
await page.type('.lo-search input', name);
await page.waitForFunction((n) => document.querySelectorAll('.lo-card').length === 1 && document.querySelector('.lo-card .lo-card__name')?.textContent === n, { timeout: 5000 }, name);
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
}
test('局内数值 (desktop): 隐现\'s stats follow the module and the 普通 / 精锐 toggle, the range follows 信仰搅拌机\'s SPT-Y', async () => {
const chess = CHESS();
const base = chess[INSIDE];
const golden = chess[base.goldenId];
const mod = golden.modules.find((m) => m.isDefault);
const { ctx, page, problems } = await open();
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await pickChess(page, '隐现');
// between the skills and the modules, 精锐 shown first: the default module's numbers, the 3 × 4 range
assert.deepEqual(await page.$$eval('.lo-detail__body > .lo-sec > header h3', (els) => els.map((e) => e.firstChild.textContent)), ['潜能与练度', '技能', '局内数值', '模组']);
const stats = await shownStats(page);
assert.deepEqual(Object.keys(stats), ['生命上限', '攻击', '防御', '法术抗性', '攻击间隔', '阻挡数', '部署费用', '再部署']);
assert.equal(stats['生命上限'], fmt((golden.statsBase.maxHp + mod.attr.maxHp) * T4.max_hp));
assert.equal(stats['攻击'], fmt((golden.statsBase.atk + mod.attr.atk) * T4.atk));
assert.equal(await rangeTiles(page), base.rangeGrid.length);
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'elite');
await page.screenshot({ path: path.join(OUT, 'loadout-stats-desktop.png') });
// 不装备 → the base numbers; the skill choice changes nothing; 普通 → the normal chess; both toggles are independent
await page.click('.lo-detail .lo-mod[data-module="none"]');
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp * T4.max_hp));
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk));
await page.click('.lo-detail .lo-skill[data-skill="0"]');
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk), 'a skill is not a stat');
await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]');
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(base.stats.maxHp * T4.max_hp));
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'normal');
assert.equal(await page.$eval('.lo-skill.is-on', (el) => el.dataset.skill), '0');
assert.equal(await page.$eval('.lo-sec:not(.lo-sec--cult) .lo-seg button.is-on', (el) => el.textContent.startsWith('普通')), true, 'the skill level toggle is its own');
// the layout: no sideways overflow, the range box beside the numbers
const box = await page.evaluate(() => {
const body = document.querySelector('.lo-detail__body');
const grid = document.querySelector('.lo-sec--stats .dstats').getBoundingClientRect();
const range = document.querySelector('.lo-sec--stats .drange').getBoundingClientRect();
return { over: body.scrollWidth - body.clientWidth, beside: range.left >= grid.right - 1 && Math.abs(range.top - grid.top) < 2 };
});
assert.ok(box.over <= 1 && box.beside, JSON.stringify(box));
// 信仰搅拌机: SPT-Y's "攻击距离+1" draws one more tile in the 精锐 view (the toggle stays where it was left: 普通 has no
// module, and says so); 不装备 goes back
await pickChess(page, '信仰搅拌机');
assert.match(await page.$eval('.lo-stats__cap', (el) => el.textContent), /普通干员没有模组/);
await page.click('.lo-sec--stats .lo-seg button[data-variant="elite"]');
const t0 = await rangeTiles(page);
await page.click('.lo-detail .lo-mod[data-module="uniequip_003_rmixer"]');
await page.waitForFunction((n) => document.querySelectorAll('.lo-sec--stats .rgrid i.on').length === n, {}, t0 + 1);
await page.click('.lo-detail .lo-mod[data-module="none"]');
await page.waitForFunction((n) => document.querySelectorAll('.lo-sec--stats .rgrid i.on').length === n, {}, t0);
assert.deepEqual(problems, []);
await ctx.close();
});
test('局内数值 (phone 844×390, touch): the numbers, the range box and the 特性 / 天赋 card stay inside the narrow detail, text keeps its floor', async () => {
const chess = CHESS();
const golden = chess[chess[INSIDE].goldenId];
const { ctx, page, problems } = await open({ w: 844, h: 390, touch: true });
await page.tap('.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.tap('.lo-card__pick');
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
await page.$eval('.lo-sec--stats', (el) => el.scrollIntoView({ block: 'start' }));
const m = await page.evaluate(() => {
const bodyEl = document.querySelector('.lo-detail__body');
const body = bodyEl.getBoundingClientRect();
const inside = (el) => { const b = el.getBoundingClientRect(); return b.left >= body.left - 1 && b.right <= body.right + 1 && b.width > 0; };
const fs = (sel) => parseFloat(getComputedStyle(document.querySelector(sel)).fontSize);
const grid = document.querySelector('.lo-sec--stats .dstats').getBoundingClientRect();
const range = document.querySelector('.lo-sec--stats .drange').getBoundingClientRect();
return {
over: bodyEl.scrollWidth - bodyEl.clientWidth,
inside: [...document.querySelectorAll('.lo-sec--stats .dstat, .lo-sec--stats .drange, .lo-sec--stats .lo-minfo--kit')].every(inside),
// beside the numbers when the column is wide enough, else under them — never over them
laidOut: range.left >= grid.right - 1 || range.top >= grid.bottom - 1,
k: fs('.lo-sec--stats .dstat__k'), v: fs('.lo-sec--stats .dstat__v'), cap: fs('.lo-stats__cap'), row: fs('.lo-sec--stats .lo-minfo__v'),
tab: Math.round(document.querySelector('.lo-sec--stats .lo-seg button').getBoundingClientRect().height),
cell: Math.min(...[...document.querySelectorAll('.lo-sec--stats .dstat')].map((c) => Math.round(c.getBoundingClientRect().width))),
clipped: [...document.querySelectorAll('.lo-sec--stats .dstat__k')].filter((e) => e.scrollWidth > e.clientWidth).map((e) => e.textContent),
};
});
assert.ok(m.over <= 1 && m.inside && m.laidOut, JSON.stringify(m));
assert.ok(m.k >= 9 && m.v >= 12 && m.cap >= 9 && m.row >= 10, `text floors ${JSON.stringify(m)}`);
assert.ok(m.tab >= 22, `the toggle stays tappable ${JSON.stringify(m)}`);
assert.deepEqual(m.clipped, [], `no stat label is cut off ${JSON.stringify(m)}`);
await page.screenshot({ path: path.join(OUT, 'loadout-stats-phone.png') });
// a choice made further down updates it (touch: the module card, 不装备)
await page.tap('.lo-detail .lo-mod[data-module="none"]');
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp * T4.max_hp));
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk));
assert.deepEqual(problems, []);
await ctx.close();
});
test('潜能 / 练度 (0.2.2): a row\'s selects and the detail\'s buttons set the operator\'s settings — stored, synced, the numbers follow', async () => {
const chess = CHESS();
const v = chess.chess_char_1_06_a; // 刺玫: 潜能1 ATK 413 / cost 17; elite statsBase 508 at 潜能1
const { ctx, page, problems } = await open();
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await pickChess(page, '刺玫');
// a row's select: 潜能 1
await page.select('.lo-card[data-chess="chess_char_1_06_a"] select[data-cult="potential"]', '1');
await page.waitForFunction((id) => JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').ops?.[id]?.potential === 1, {}, v.charId);
await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]');
await page.waitForFunction((x) => [...document.querySelectorAll('.lo-sec--stats .dstat')].some((e) => e.querySelector('.dstat__k').textContent === '部署费用' && e.querySelector('.dstat__v').textContent === x), {}, '17');
assert.equal((await shownStats(page))['攻击'], fmt(413 * T4.atk), '潜能1 at 精英2 Lv.60');
// the detail's 练度 buttons: 未精英化 → ×1
await page.click('.lo-sec--cult [data-cultivate="0"]');
await page.waitForFunction(() => document.querySelector('.lo-sec--cult [data-cultivate="0"]').getAttribute('aria-checked') === 'true');
assert.equal((await shownStats(page))['攻击'], fmt(413));
assert.equal(await page.$eval('.lo-card[data-chess="chess_char_1_06_a"] select[data-cult="cultivate"]', (el) => el.value), '0', 'the row follows');
assert.ok(await page.$eval('.lo-card[data-chess="chess_char_1_06_a"]', (el) => el.classList.contains('is-changed')), 'a changed operator');
await page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
// 恢复默认 resets them with the skill / module
await page.click('.lo-dhead__reset');
await page.waitForFunction((id) => !JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').ops?.[id], {}, v.charId);
assert.equal((await shownStats(page))['攻击'], fmt(v.stats.atk * T4.atk));
assert.deepEqual(problems, []);
await ctx.close();
});
});