Files
ark-stronghold-protocol/test/ui/loadout-rows.test.js
T
sganggs 4f8f5d2acf feedback7 R22-LOADOUT: the 潜能与练度 note says an operator with nothing set fights at the default (满潜能, 精英2 Lv.60) and that 潜能1 / 未精英化 for an unowned 特许 operator is set by hand; golden: unchanged
Grok's review of the branch (medium): the note read 「未持有的特许干员按潜能1、未精英化」, as if the game applied it, while an
operator without a setting is fielded at 潜能 6 / 精英2 Lv.60 (the owner's 「默认满潜满加成」). It now says what
docs/PLAYING.md says: the default first, then the official rule (the player's own potential; the 自持有 bonus, a
separate multiplier; an unowned 特许 operator at 潜能1 without bonus officially — set it so by hand).

- i18n: the new note in the four packs; the replaced one (never released) removed.
- Test: test/ui/loadout-rows.test.js (the detail's section) reads the note.
2026-10-08 22:19:20 +08:00

156 lines
11 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.
// test/ui/loadout-rows.test.js — the 干员调配 roster as one list (0.2.2): the quick choices of PR #301 (by @farridge) laid
// out as one row per operator with aligned columns instead of three columns of cards (the owner's decision of 2026-10-08:
// 「三个竖排有点乱影响观感」), and the per-operator 潜能 / 练度 controls (「调配干员里自己设置吧,默认满潜满加成」): the row's skill
// / module buttons and selects (state, labels, what a tap sets), the detail's section, a 自选 slot's selects, the 局内数值 at
// the settings, and the css that keeps the columns aligned and the detail sliding over the list below 1000 px. Hook-free
// views drawn as vnode trees. (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)), '../..');
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 { RosterRow, RosterHead, statsPreview, LoadoutStats } = await import('../../public/js/screens/loadout.js');
const { CultivationSelects, CultivationSection, cultivateName } = await import('../../public/js/screens/cultivation.js');
const { DiyPanelView } = await import('../../public/js/screens/diy.js');
const { data } = await import('../../public/js/data.js');
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'effects', 'backups');
const get = (id) => data.lookup('chess', id);
const INSIDE = 'chess_char_1_01_a'; // 隐现: S1 / S2 (default S2), MAR-X
const VENDLA = 'chess_char_1_06_a'; // 刺玫
const CHAR = (id) => get(id).charId;
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' && ['QuickSkill', 'QuickModule', 'CultivationSelects', 'SlotCard', 'KindTag', 'Bonds'].includes(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 find = (tree, pred) => [...walk(tree)].filter(pred);
const textOf = (v) => (v == null || typeof v === 'boolean' ? '' : typeof v !== 'object' ? String(v) : Array.isArray(v) ? v.map(textOf).join('') : textOf(v.props?.children));
const row = (props) => RosterRow({ m: null, chess: get(INSIDE), golden: get(get(INSIDE).goldenId), entries: {}, ops: {}, selected: false, onPick: () => {}, onChange: () => {}, onOps: () => {}, ...props });
test('a row: portrait / name pick, three skill slots (an empty one for a two-skill chess), 不装备 first, the equipped ones pressed', () => {
const picks = [];
const changes = [];
const r = row({ onPick: (id) => picks.push(id), onChange: (id, p) => changes.push([id, p]) });
assert.ok(hasClass(r, 'lo-card') && hasClass(r, 'lo-card--t1'));
assert.equal(r.props['data-chess'], INSIDE);
const pick = find(r, (v) => hasClass(v, 'lo-card__pick'))[0];
pick.props.onClick();
assert.deepEqual(picks, [INSIDE]);
const skills = find(r, (v) => hasClass(v, 'lo-q--skill'));
assert.deepEqual(skills.map((b) => [b.props['data-skill'], b.props['aria-pressed']]), [[0, 'false'], [1, 'true']], 'S2 is the default');
assert.equal(find(r, (v) => hasClass(v, 'lo-q--empty')).length, 1, 'the third slot stays empty');
assert.match(skills[0].props['aria-label'], /^S1 · “不惹麻烦” · /);
const mods = find(r, (v) => hasClass(v, 'lo-q--mod'));
assert.deepEqual(mods.map((b) => [b.props['data-module'], b.props['aria-pressed']]), [['none', 'false'], ['uniequip_002_inside', 'true']]);
skills[0].props.onClick();
mods[0].props.onClick();
assert.deepEqual(changes, [[INSIDE, { skill: 0 }], [INSIDE, { module: 'none' }]]);
assert.equal(find(r, (v) => hasClass(v, 'lo-card__flag')).length, 0, 'nothing changed: no flag');
// the shared preview: 精锐 shows the Lv.7 SP numbers of the same skill
const elite = get(get(INSIDE).goldenId).skills.find((s) => s.index === 0);
const e = find(row({ level: 'elite' }), (v) => hasClass(v, 'lo-q--skill'))[0];
assert.match(e.props['aria-label'], new RegExp(`消耗 ${elite.spCost}`));
assert.equal(row({ level: 'elite' }).props['data-variant'], 'elite');
});
test('a row\'s 潜能 / 练度 selects: the defaults quiet, a changed one marked; a change sets the operator\'s settings', () => {
const sets = [];
const r = row({ onOps: (id, p) => sets.push([id, p]) });
const sel = find(r, (v) => v.type === 'select');
assert.deepEqual(sel.map((s) => [s.props['data-cult'], s.props.value]), [['potential', '6'], ['cultivate', '3']]);
assert.equal(find(r, (v) => hasClass(v, 'is-off')).length, 0, 'the defaults are not marked');
sel[0].props.onChange({ currentTarget: { value: '2' } });
sel[1].props.onChange({ currentTarget: { value: '0' } });
assert.deepEqual(sets, [[CHAR(INSIDE), { potential: 2 }], [CHAR(INSIDE), { cultivate: 0 }]]);
const options = find(sel[0], (v) => v.type === 'option').map((o) => textOf(o));
assert.deepEqual(options, ['潜能 1', '潜能 2', '潜能 3', '潜能 4', '潜能 5', '潜能 6']);
assert.deepEqual(find(sel[1], (v) => v.type === 'option').map((o) => textOf(o)), ['未精英化', '精英1', '精英2', '精英2 Lv.60']);
// changed: the select is marked, the row flagged and counted as changed
const c = row({ ops: { [CHAR(INSIDE)]: { potential: 2 } } });
assert.ok(hasClass(c, 'is-changed'));
assert.equal(find(c, (v) => hasClass(v, 'lo-card__flag')).length, 1);
assert.deepEqual(find(c, (v) => hasClass(v, 'is-off')).map((v) => v.props.class.includes('lo-cult__pot')), [true]);
// a chess marked not owned: the 替补 tag, the settings say they do not apply now
const s = row({ notOwned: true });
assert.ok(hasClass(s, 'is-standin'));
assert.equal(textOf(find(s, (v) => hasClass(v, 'lo-card__sub'))[0]), '替补');
assert.match(find(s, (v) => hasClass(v, 'lo-cult'))[0].props.title, /替补干员没有潜能与练度/);
});
test('the head names the four columns; CultivationSelects without an operator draws nothing', () => {
assert.deepEqual(find(RosterHead(), (v) => hasClass(v, 'lo-list__h')).map((v) => textOf(v)), ['干员', '技能', '模组精锐', '潜能 · 练度']);
assert.equal(CultivationSelects({ charId: null, ops: {}, onSet: () => {} }), null);
});
test('the detail\'s section: 潜能 1–6 and the four official 练度 tiers, the tier\'s effect, the stand-in note', () => {
const sets = [];
const id = CHAR(VENDLA);
const sec = CultivationSection({ charId: id, ops: { [id]: { potential: 3 } }, onSet: (c, p) => sets.push([c, p]) });
const pots = find(sec, (v) => v.props && v.props['data-potential'] != null);
assert.deepEqual(pots.map((b) => [b.props['data-potential'], b.props['aria-checked']]), [[1, 'false'], [2, 'false'], [3, 'true'], [4, 'false'], [5, 'false'], [6, 'false']]);
const tiers = find(sec, (v) => v.type === 'button' && v.props['data-cultivate'] != null);
assert.deepEqual(tiers.map((b) => textOf(b)), ['未精英化', '精英阶段1', '精英阶段2', '精英阶段2-60级'], 'effects.json names');
assert.equal(tiers[3].props['aria-checked'], 'true', 'the default tier');
assert.match(textOf(find(sec, (v) => hasClass(v, 'lo-cult__eff'))[0]), /精英阶段2-60级 攻击力、防御力和最大生命值\+10%/);
pots[0].props.onClick();
tiers[1].props.onClick();
assert.deepEqual(sets, [[id, { potential: 1 }], [id, { cultivate: 1 }]]);
assert.equal(cultivateName(0), '未精英化');
// the rule line says what an operator with nothing set fights at — the default, 满潜能 / 精英2 Lv.60 — and that an unowned
// 特许 operator at the official 潜能1 / 未精英化 is the player's own setting (Grok review of the branch: it read as automatic)
const note = textOf(find(sec, (v) => hasClass(v, 'lo-cult__note'))[0]);
assert.match(note, /^默认满潜能、精英2 Lv\.60(满加成)。/);
assert.match(note, /未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。$/);
const st = CultivationSection({ charId: id, ops: {}, onSet: () => {}, standIn: true });
assert.ok(hasClass(st, 'is-moot'));
assert.match(textOf(find(st, (v) => hasClass(v, 'lo-cult__note'))[0]), /替补干员没有潜能与练度/);
});
test('自选编队: an owned pick\'s card carries its operator\'s selects, a prototype says it has neither', () => {
const sets = [];
const picks = { chess_char_6_diy1_a: { charId: 'char_003_kalts', skillIndex: 0 }, chess_char_5_diy1_a: { charId: 'char_609_acguad' } };
const tree = DiyPanelView({ m: null, picks, legal: picks, kitted: null, onSet: () => {}, picking: null, onPicking: () => {}, ops: { char_003_kalts: { cultivate: 1 } }, onOps: (c, p) => sets.push([c, p]) });
const cult = find(tree, (v) => hasClass(v, 'diy-slot__cult'));
assert.equal(cult.length, 2);
const sel = find(tree, (v) => v.type === 'select');
assert.deepEqual(sel.map((s) => s.props.value), ['6', '1'], '凯尔希: 潜能 6, 精英1');
sel[0].props.onChange({ currentTarget: { value: '4' } });
assert.deepEqual(sets, [['char_003_kalts', { potential: 4 }]]);
assert.ok(cult.some((v) => textOf(v) === '原型干员没有潜能与练度'));
});
test('局内数值 at the operator\'s settings: 刺玫 at 潜能1 / 未精英化 and at the defaults (×1.1)', () => {
const v = get(VENDLA);
const g = get(v.goldenId);
const pv = (ops, level = 'normal') => statsPreview(v, g, {}, level, get, ops).record.stats;
assert.deepEqual([pv({ [v.charId]: { potential: 1, cultivate: 0 } }).atk, pv({ [v.charId]: { potential: 1, cultivate: 0 } }).cost], [413, 17]);
assert.equal(pv({}).atk, 435 * 1.1, 'none set: 满潜 + 精英2 Lv.60');
assert.equal(pv({ [v.charId]: { potential: 1, cultivate: 0 } }, 'elite').atk, 508 + 23, 'the elite: statsBase at 潜能1 + INC-X');
assert.ok(statsPreview(v, g, {}, 'normal', get, {}).lo.cultivation);
// the caption says the numbers carry 潜能 and 练度 (an owned operator; effects.json loaded)
const cap = (tree) => textOf(find(tree, (n) => hasClass(n, 'lo-stats__cap'))[0]);
assert.match(cap(LoadoutStats({ base: v, golden: g, entries: {}, level: 'elite', onLevel() {}, getChess: get, ops: {} })), /^数值含所选模组;含潜能与练度;不含技能发动/);
});
test('css: the head and the rows share one grid (columns aligned), quick targets grow on a coarse pointer, the detail slides over below 1000 px', () => {
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8');
assert.match(css, /\.lo-list__head, \.lo \.lo-card \{ display: grid; grid-template-columns: var\(--cols\);/);
assert.match(css, /\.sp-coarse \.lo-list \{ --q: max\(\.44rem, 40px\); \}/);
const block = css.slice(css.indexOf('@media (max-width: 1000px) {\n .lo-body'));
assert.match(block, /\.lo-body \{ grid-template-columns: minmax\(0, 1fr\); \}/);
assert.match(block, /\.lo-detail-wrap \{ display: none; \}/);
assert.ok(!/\.lo-grid\b/.test(css), 'the old card grid is gone');
});