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.
156 lines
11 KiB
JavaScript
156 lines
11 KiB
JavaScript
// 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');
|
||
});
|