feedback2 WL: 干员调配 shows 局内数值 — the chosen skill / module's stats, 攻击范围, 特性 and 天赋, 精锐 first with a 普通 toggle, drawn by the card's own block (GitHub #64)

This commit is contained in:
sganggs committed 2026-10-04 02:51:21 +08:00
1 parent 41bfdb5f96
commit 4c78ab94e6
5 files changed
+470 -4

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
// GitHub issue #64 — 干员调配 showed only the skill and module sections: no stats, no 攻击范围 (the in-match detail card has
// them). The screen's 局内数值 section now draws the detail card's own stats block (ui/detailPanel.js chessStatsBlock),
// plus 特性 and 天赋, for the selected chess under its chosen skill and module — the 精锐 record by default (the module only
// exists there), the 普通 one on the toggle. Everything comes from gameLogic.chessLoadout → shared/loadoutRecord.js, the
// composition the sim fights with; nothing is recomputed in the screen. These tests: the pure part (statsPreview), the
// rendered section against the card for the same loadout, the 特性 / 天赋 rows, the toggle, the section's place in the
// detail, and the css floors that keep it readable on a phone. (The browser side: test/ui/loadout.e2e.test.js.)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { 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 read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the browser data store reads the real data files from disk (the card renders below)
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { statsPreview, LoadoutStats } = await import('../../public/js/screens/loadout.js');
const { ChessDetail, cardRangeGrid } = await import('../../public/js/ui/detailPanel.js');
const { fmtNum, attackInterval } = await import('../../public/js/ui/gameLogic.js');
const { data } = await import('../../public/js/data.js');
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
const get = (id) => data.lookup('chess', id);
const INSIDE = 'chess_char_1_01_a'; // 隐现: MAR-X (+80 生命上限 / +22 攻击) or 不装备; S1 / S2
const MIXER = 'chess_char_4_01_a'; // 信仰搅拌机: SPT-X (default) / SPT-Y "攻击距离+1"
const MOSTIMA = 'chess_char_4_02_a'; // 莫斯提马: SPC-Y (default) / SPC-X (a wider range grid)
const THORN = 'chess_char_5_15_a'; // 引星棘刺: S3 "被动效果:攻击范围扩大"
const PASSENGER = 'chess_char_6_05_a'; // 异客: SPC-X-style module that rewrites her talents
const PASSENGER_ALT = 'uniequip_003_pasngr';
const EXPAND = new Set(['Stat', 'LiveTag', 'RangeGrid']);
/** Every vnode of a preact tree (htm output), depth first; the hook-free components of EXPAND are rendered in place. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
if (typeof v.type === 'function' && EXPAND.has(v.type.name)) { yield* walk(v.type(v.props)); return; }
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
if (typeof v === 'object' && typeof v.type === 'function' && EXPAND.has(v.type.name)) return textOf(v.type(v.props));
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
/** The eight stat cells of a tree: label → shown value. */
const statValues = (tree) => Object.fromEntries([...walk(tree)].filter((n) => hasClass(n, 'dstat')).map((cell) => {
const parts = [...walk(cell)];
return [textOf(parts.find((n) => hasClass(n, 'dstat__k'))), textOf(parts.find((n) => hasClass(n, 'dstat__v')))];
}));
const onCells = (tree) => [...walk(tree)].filter((n) => n.type === 'i' && hasClass(n, 'on')).length;
const statsBlock = (tree) => [...walk(tree)].find((n) => hasClass(n, 'dstats-wrap'));
/** The 特性 / 天赋 rows of the section: { k, name, text }. */
const kitRows = (tree) => [...walk(tree)].filter((n) => hasClass(n, 'lo-minfo__row')).map((row) => {
const parts = [...walk(row)];
return {
k: textOf(parts.find((n) => hasClass(n, 'lo-minfo__k'))),
name: textOf(parts.find((n) => hasClass(n, 'lo-minfo__tname'))),
text: String(parts.find((n) => typeof n.type === 'function' && n.type.name === 'RichText')?.props.text ?? ''),
};
});
const slot = (id) => { const base = get(id); return { base, golden: base.goldenId ? get(base.goldenId) : null }; };
const preview = (id, level, entries = {}) => { const { base, golden } = slot(id); return statsPreview(base, golden, entries, level, get); };
const section = (id, level, entries = {}, extra = {}) => { const { base, golden } = slot(id); return LoadoutStats({ base, golden, entries, level, onLevel() {}, getChess: get, ...extra }); };
/** The tiles of the 攻击范围 the card / section draws for a preview. */
const rangeTiles = (pv) => cardRangeGrid(null, pv.record, pv.chess).length;
test('statsPreview: the 精锐 record under the chosen module (stats = base + the module\'s attr; 不装备 = the base stats), 普通 never has a module', () => {
const { base, golden } = slot(INSIDE);
const mod = golden.modules.find((m) => m.isDefault);
assert.deepEqual(mod.attr, { maxHp: 80, atk: 22 }, 'MAR-X');
const def = preview(INSIDE, 'elite');
assert.equal(def.elite, true);
assert.equal(def.chess, golden);
assert.equal(def.record, golden, 'the default loadout is the data record itself (the card\'s rule)');
assert.equal(def.record.stats.maxHp, golden.statsBase.maxHp + 80);
assert.equal(def.record.stats.atk, golden.statsBase.atk + 22);
const none = preview(INSIDE, 'elite', { [INSIDE]: { module: 'none' } });
assert.equal(none.record.stats.maxHp, golden.statsBase.maxHp, '不装备: the no-module stats');
assert.equal(none.record.stats.atk, golden.statsBase.atk);
assert.equal(none.lo.module.none, true);
assert.notEqual(none.record.stats.maxHp, def.record.stats.maxHp, 'switching the module changes what the section shows');
// 普通: the normal chess, whatever module is stored (modules are the elite's)
const normal = preview(INSIDE, 'normal', { [INSIDE]: { module: 'none' } });
assert.equal(normal.elite, false);
assert.equal(normal.chess, base);
assert.equal(normal.record.stats.maxHp, base.stats.maxHp);
assert.equal(normal.lo.module, null, 'no module on a normal chess');
assert.deepEqual(normal.record.stats, preview(INSIDE, 'normal').record.stats, 'the module choice does not reach it');
// a skill choice is carried (lo.skillIndex) but does not change a chess's stats
const s0 = preview(INSIDE, 'elite', { [INSIDE]: { skill: 0 } });
assert.equal(s0.lo.skillIndex, 0);
assert.equal(def.lo.skillIndex, 1, 'S2 is the default');
assert.deepEqual(s0.record.stats, def.record.stats);
// an entry of another chess, or an option the data does not offer, falls back to the defaults
assert.equal(preview(INSIDE, 'elite', { [MIXER]: { module: 'none' } }).record, golden);
assert.equal(preview(INSIDE, 'elite', { [INSIDE]: { module: 'uniequip_999_nope' } }).record, golden);
});
test('statsPreview: no elite record → the normal one; no record → null; the data records are never mutated', () => {
const { base } = slot(INSIDE);
const lone = { ...base, goldenId: null };
const pv = statsPreview(lone, null, {}, 'elite', get);
assert.equal(pv.elite, false, '精锐 asked for, none to show');
assert.equal(pv.chess, lone);
assert.equal(statsPreview(null, null, {}, 'elite', get), null);
assert.equal(section(INSIDE, 'elite', {}, { base: null, golden: null }), null);
const before = JSON.stringify([get(INSIDE), get(get(INSIDE).goldenId), get(MIXER), get(get(MIXER).goldenId)]);
for (const level of ['normal', 'elite']) {
preview(INSIDE, level, { [INSIDE]: { skill: 0, module: 'none' } });
preview(MIXER, level, { [MIXER]: { module: 'uniequip_003_rmixer' } });
}
assert.equal(JSON.stringify([get(INSIDE), get(get(INSIDE).goldenId), get(MIXER), get(get(MIXER).goldenId)]), before);
});
test('the 攻击范围 follows the chosen module and skill (the sim\'s deployment range): SPT-Y +1, SPC-X wider, 引星棘刺 S3 passive', () => {
// 信仰搅拌机: SPT-Y's 特性 "攻击距离+1" → one more tile in her row; SPT-X, 不装备 and the normal chess keep 3
assert.equal(rangeTiles(preview(MIXER, 'elite')), 3);
assert.equal(rangeTiles(preview(MIXER, 'elite', { [MIXER]: { module: 'uniequip_003_rmixer' } })), 4);
assert.equal(rangeTiles(preview(MIXER, 'elite', { [MIXER]: { module: 'none' } })), 3);
assert.equal(rangeTiles(preview(MIXER, 'normal', { [MIXER]: { module: 'uniequip_003_rmixer' } })), 3);
// 莫斯提马: SPC-X's own range grid (9 → 10 tiles)
assert.equal(rangeTiles(preview(MOSTIMA, 'elite')), 9);
assert.equal(rangeTiles(preview(MOSTIMA, 'elite', { [MOSTIMA]: { module: 'uniequip_003_mostma' } })), 10);
// 引星棘刺: the selected skill S3 "被动效果:攻击范围扩大" is her range while she carries it — at both levels
const s3 = get(THORN).skills.find((s) => s.index === 2);
assert.ok(s3.rangeGrid.length > get(THORN).rangeGrid.length && /被动效果:攻击范围扩大/.test(s3.desc));
for (const level of ['normal', 'elite']) {
assert.equal(rangeTiles(preview(THORN, level)), get(THORN).rangeGrid.length, `${level}: her own range by default (S2)`);
assert.equal(rangeTiles(preview(THORN, level, { [THORN]: { skill: 0 } })), get(THORN).rangeGrid.length);
assert.equal(rangeTiles(preview(THORN, level, { [THORN]: { skill: 2 } })), s3.rangeGrid.length, `${level}: S3's grid`);
}
});
test('the section draws the detail card\'s own stats block: same eight values and the same range tiles as ChessDetail for that chess and loadout', () => {
const cases = [
[INSIDE, 'elite', {}], [INSIDE, 'elite', { [INSIDE]: { module: 'none' } }], [INSIDE, 'elite', { [INSIDE]: { skill: 0 } }], [INSIDE, 'normal', {}],
[MIXER, 'elite', { [MIXER]: { module: 'uniequip_003_rmixer' } }], [MIXER, 'elite', { [MIXER]: { module: 'none' } }],
[MOSTIMA, 'elite', { [MOSTIMA]: { module: 'uniequip_003_mostma' } }], [MOSTIMA, 'normal', {}],
[THORN, 'elite', { [THORN]: { skill: 2 } }], [THORN, 'normal', { [THORN]: { skill: 2 } }],
[PASSENGER, 'elite', { [PASSENGER]: { module: PASSENGER_ALT } }],
];
for (const [id, level, entries] of cases) {
const pv = preview(id, level, entries);
const card = ChessDetail({ chess: pv.chess, piece: null, editable: false, bonds: [], loadout: entries }).find((b) => b.key === 'stats');
const mine = statsBlock(section(id, level, entries));
const what = `${id} ${level} ${JSON.stringify(entries)}`;
assert.ok(card && mine, what);
assert.equal(textOf(mine), textOf(card), `${what}: the card's numbers`);
assert.equal(onCells(mine), onCells(card), `${what}: the card's range tiles`);
assert.equal(onCells(mine), rangeTiles(pv), `${what}: and the loadout record's attackRangeGrid`);
}
});
test('the eight stats shown are the record\'s: 生命上限 攻击 防御 法术抗性 攻击间隔 阻挡数 部署费用 再部署; no live tag, no colours', () => {
const { golden } = slot(INSIDE);
const mod = golden.modules.find((m) => m.isDefault);
const s = golden.stats;
const vals = statValues(section(INSIDE, 'elite'));
assert.deepEqual(Object.keys(vals), ['生命上限', '攻击', '防御', '法术抗性', '攻击间隔', '阻挡数', '部署费用', '再部署']);
assert.deepEqual(vals, {
生命上限: fmtNum(golden.statsBase.maxHp + mod.attr.maxHp), 攻击: fmtNum(golden.statsBase.atk + mod.attr.atk), 防御: fmtNum(s.def),
法术抗性: String(s.res ?? 0), 攻击间隔: `${attackInterval(s.bat, s.aspd).toFixed(2)}s`, 阻挡数: String(s.blockCnt),
部署费用: String(s.cost), 再部署: `${s.respawnTime}s`,
});
// 不装备 → the base numbers; 普通 → the normal chess's (lower) ones
const none = statValues(section(INSIDE, 'elite', { [INSIDE]: { module: 'none' } }));
assert.equal(none['生命上限'], fmtNum(golden.statsBase.maxHp));
assert.equal(none['攻击'], fmtNum(golden.statsBase.atk));
const normal = statValues(section(INSIDE, 'normal'));
assert.equal(normal['生命上限'], fmtNum(slot(INSIDE).base.stats.maxHp));
assert.ok(Number(normal['生命上限'].replace(/,/g, '')) < Number(none['生命上限'].replace(/,/g, '')));
// the record's numbers, not a live entry: no 实时 / 开战时 tag and no up / down colouring
const block = statsBlock(section(INSIDE, 'elite'));
assert.ok(!hasClass(block, 'is-live') && block.props['data-live'] === undefined);
assert.ok(!/实时|开战时/.test(textOf(block)));
assert.ok(![...walk(block)].some((n) => hasClass(n, 'is-up') || hasClass(n, 'is-down')));
});
test('特性 and 天赋 follow the chosen module: 隐现\'s trait upgrade is MAR-X\'s only; 异客\'s talents are rewritten by her other module', () => {
const rows = (id, level, entries) => kitRows(section(id, level, entries));
const { golden } = slot(INSIDE);
const traitOf = (rs) => rs.find((r) => r.k === '特性');
// default module: the module's upgraded trait text; 不装备: the plain class trait
assert.equal(traitOf(rows(INSIDE, 'elite', {})).text, golden.trait.moduleDescRaw);
assert.match(traitOf(rows(INSIDE, 'elite', {})).text, /110%/);
assert.equal(traitOf(rows(INSIDE, 'elite', { [INSIDE]: { module: 'none' } })).text, golden.traitBase.descRaw);
assert.equal(traitOf(rows(INSIDE, 'normal', {})).text, slot(INSIDE).base.trait.descRaw, '普通: its own trait');
// 天赋: named and not hidden, the record's list
const talents = rows(INSIDE, 'elite', {}).filter((r) => r.k === '天赋');
assert.deepEqual(talents.map((r) => r.name), golden.talents.filter((t) => t.name && !t.hidden).map((t) => t.name));
assert.ok(talents.every((r) => r.text.length > 0));
// 异客: the default module keeps 80% / 3 s, her other module rewrites 机理分析 (70% / 4 s) — what the sim gives her
const talentText = (entries) => rows(PASSENGER, 'elite', entries).filter((r) => r.k === '天赋').map((r) => `${r.name}:${r.text}`).join('\n');
const dflt = talentText({});
const alt = talentText({ [PASSENGER]: { module: PASSENGER_ALT } });
assert.match(dflt, /机理分析:[^\n]*80%/);
assert.match(alt, /机理分析:[^\n]*70%/);
assert.notEqual(dflt, alt);
assert.equal(talentText({ [PASSENGER]: { module: PASSENGER_ALT, skill: 0 } }), alt, 'a skill choice leaves the talents alone');
// the kit card is the loadout screen's own module-info card style (a mint accent), only when there is something to say
assert.ok([...walk(section(INSIDE, 'elite'))].some((n) => hasClass(n, 'lo-minfo--kit')));
const bare = section(INSIDE, 'elite', {}, { base: { ...slot(INSIDE).base, trait: null, talents: [] }, golden: { ...golden, trait: null, talents: [], traitBase: null, statsBase: golden.stats, modules: [] } });
assert.ok(![...walk(bare)].some((n) => hasClass(n, 'lo-minfo--kit')), 'no trait, no talents: no empty card');
});
test('the 普通 / 精锐 toggle: 精锐 is the shown variant, the tabs say so, 精锐 is disabled for a chess without one, a click asks for the other', () => {
const tabs = (tree) => [...walk(tree)].filter((n) => n.type === 'button' && n.props.role === 'tab');
const elite = section(INSIDE, 'elite');
assert.equal(elite.props['data-variant'], 'elite');
const [tn, te] = tabs(elite);
assert.deepEqual([textOf(tn), textOf(te)], ['普通', '精锐']);
assert.deepEqual([tn.props['aria-selected'], te.props['aria-selected']], ['false', 'true']);
const normal = section(INSIDE, 'normal');
assert.equal(normal.props['data-variant'], 'normal');
assert.deepEqual(tabs(normal).map((t) => t.props['aria-selected']), ['true', 'false']);
const asked = [];
const sec = section(INSIDE, 'normal', {}, { onLevel: (l) => asked.push(l) });
const [bn, be] = tabs(sec);
be.props.onClick();
bn.props.onClick();
assert.deepEqual(asked, ['elite', 'normal']);
assert.ok(!be.props.disabled && !bn.props.disabled);
// the caption says what the numbers are: the chosen module's in 精锐; in 普通 that modules live in 精锐
const cap = (tree) => textOf([...walk(tree)].find((n) => hasClass(n, 'lo-stats__cap')));
assert.match(cap(elite), /^数值含所选模组;不含技能发动、装备、盟约等局内加成$/);
assert.match(cap(normal), /^普通干员没有模组,所选模组在「精锐」中生效;不含技能发动/);
// a chess without an elite record: only 普通 is selectable and shown
const lone = LoadoutStats({ base: { ...slot(INSIDE).base, goldenId: null }, golden: null, entries: {}, level: 'elite', onLevel() {}, getChess: get });
assert.equal(lone.props['data-variant'], 'normal');
assert.equal(tabs(lone)[1].props.disabled, true);
assert.match(cap(lone), /^不含技能发动、装备、盟约等局内加成$/, 'nothing to point at without an elite');
});
test('place in the detail: after the skills, before the modules; the first .lo-seg stays the skill level toggle (e2e selector)', () => {
const src = read('public/js/screens/loadout.js');
const detail = src.slice(src.indexOf('function Detail('), src.indexOf('// ---- filters'));
const toggle = detail.indexOf('aria-label="技能等级"');
const skills = detail.indexOf('aria-label="选择技能"');
const stats = detail.indexOf('<${LoadoutStats} base=${chess}');
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 \} 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'));
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'\)/);
});
test('css: the section reuses the card\'s .dstats / .drange / .rgrid, sized for the overlay with px floors (readable at 1 rem = 40 px), the range box may wrap under the numbers', () => {
const css = read('public/css/screens/loadout.css').replace(/\/\*[\s\S]*?\*\//g, '');
const rules = [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)].map((m) => ({ sel: m[1].trim(), body: m[2] }));
const mine = rules.filter((r) => /\.lo-sec--stats|\.lo-stats__cap|\.lo-minfo--kit/.test(r.sel));
assert.ok(mine.length >= 8, 'the stats rules exist');
for (const r of mine) for (const m of r.body.matchAll(/font-size:\s*([^;]+);/g)) {
assert.match(m[1], /^max\([^)]*\d+px\)$/, `${r.sel}: font-size ${m[1]} keeps a px floor`);
}
const rule = (sel) => rules.find((r) => r.sel === sel)?.body || '';
assert.match(rule('.lo-sec--stats .dstats-wrap'), /flex-wrap:\s*wrap/);
assert.match(rule('.lo-sec--stats .dstats-wrap > .dstats'), /flex:\s*1 1 max\(3\.2rem, 190px\)/);
assert.match(rule('.lo-sec--stats .rgrid'), /--rg:\s*max\(\.14rem, 7px\)/);
assert.match(rule('.lo-sec--stats .drange'), /min-width:\s*max\(1\.1rem, 56px\)/);
// the base classes come from game-panels.css, which every page loads
const panels = read('public/css/screens/game-panels.css');
for (const c of ['.dstats-wrap', '.dstats', '.dstat', '.dstat__k', '.dstat__v', '.drange', '.rgrid', '.rgrid-all']) {
assert.ok(new RegExp(`(^|\\n)${c.replace('.', '\\.')}[ ,{]`).test(panels), `${c} is defined in game-panels.css`);
}
assert.match(read('public/index.html'), /<link rel="stylesheet" href="\/css\/screens\/game-panels\.css" \/>/);
});
+108
View File
@@ -273,4 +273,112 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
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');
/** 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');
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));
assert.equal(stats['攻击'], fmt(golden.statsBase.atk + mod.attr.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));
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk));
await page.click('.lo-detail .lo-skill[data-skill="0"]');
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.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));
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-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');
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));
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk));
assert.deepEqual(problems, []);
await ctx.close();
});
});