feedback7: merge fb7-loadout — per-operator 潜能 (1–6) and 练度 (未精英化 / 精英1 / 精英2 / 精英2 Lv.60) set in 干员调配, defaults 潜能 6 and 精英2 Lv.60 (the owner's decision of 2026-10-08); PR #301 ported as one aligned list; #398 the skill frame, #400 REA-Y's heal text
- Conflicts (the branch started at ae22335c): the four i18n packs (union of keys), game.js DetailPanel props (ops + the
voices' voice=true), detailPanel.js resolveDetail (cultOpts + the voices' tap), match/views.js (both helpers),
docs/PLAYING.md and design/network.md merged line by line against the base. test/match/bosshp.test.js's ATK formula
now includes the unit's 练度 multiplier (cultMul).
- Grok review: MERGE AFTER FIXES — the note and the module card at the set potential fixed on the branch.
- Golden: every owned operator ×1.1 ATK / DEF / max HP by default — roster 49, bonds 46, fields 22, matches 18, diy 134
(stand-in battles and prototype picks unchanged). Broad node --test 5666 (0 fail after the bosshp formula; 2 skipped);
lint 0 errors; typecheck; i18n strict clean (1152 msgids).
This commit is contained in:
commit
0ced21d321
82 files changed
+9156
-6840
No files matched your search
@@ -52,7 +52,7 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
|
||||
await c.click('.lo-search input');
|
||||
await c.page.keyboard.type('野鬃');
|
||||
await c.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await c.click('.lo-card');
|
||||
await c.click('.lo-card__pick');
|
||||
await c.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await c.click('.lo-detail .lo-mod[data-module="none"]');
|
||||
await c.page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'), { timeout: 3000 });
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Quick loadout previews use the same generated records as the in-match detail, without changing a loadout.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { quickSkillTags } from '../../public/js/ui/loadoutModel.js';
|
||||
|
||||
const chess = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
|
||||
const operator = (name) => Object.values(chess).find((c) => c.name === name && !c.isGolden && !c.isDiy);
|
||||
|
||||
test('quick skill previews show the generated normal/elite SP and duration', () => {
|
||||
const base = operator('空弦');
|
||||
const elite = chess[base.goldenId];
|
||||
for (const c of [base, elite]) {
|
||||
for (const s of c.skills) {
|
||||
const tags = quickSkillTags(s);
|
||||
assert.equal(tags.recovery, '攻回');
|
||||
assert.equal(tags.init, s.initSp);
|
||||
assert.equal(tags.cost, s.spCost);
|
||||
assert.equal(tags.duration, s.duration > 0 ? `${s.duration}s` : '瞬发');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('deployment skills have no SP cycle, while permanent passives remain passive (in either language)', () => {
|
||||
for (const name of ['缄默德克萨斯', '砾', '斯卡蒂', '野鬃']) {
|
||||
const c = operator(name);
|
||||
for (const form of [c, chess[c.goldenId]]) {
|
||||
for (const s of form.skills.filter((s) => s.spType === 'ON_DEPLOY')) {
|
||||
const tags = quickSkillTags({ ...s, desc: 'localized text', descRaw: '' });
|
||||
assert.equal(tags.recovery, '—', `${name} S${s.index + 1}`);
|
||||
assert.equal(tags.init, null);
|
||||
assert.equal(tags.cost, null);
|
||||
assert.equal(tags.duration, `${s.duration > 0 ? s.duration : s.bb.duration}s`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const passive = quickSkillTags(operator('星熊').skills[1]);
|
||||
assert.equal(passive.recovery, '被动');
|
||||
assert.equal(passive.duration, '常驻');
|
||||
assert.equal(passive.cost, null);
|
||||
});
|
||||
|
||||
test('ammo and infinite skills keep their duration semantics', () => {
|
||||
assert.equal(quickSkillTags({ skillType: 'MANUAL', spType: 'INCREASE_WITH_TIME', durationType: 'AMMO' }).duration, '弹药');
|
||||
assert.equal(quickSkillTags({ skillType: 'MANUAL', spType: 'INCREASE_WITH_TIME', duration: -1 }).duration, '∞');
|
||||
assert.equal(quickSkillTags(null).recovery, '—');
|
||||
});
|
||||
|
||||
test('loadout attributes use the in-match block and keep obtain/rest triggers for both forms', async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
try {
|
||||
const value = JSON.parse(readFileSync(new URL(`../../data/${String(url).split('/').pop()}`, import.meta.url), 'utf8'));
|
||||
return { ok: true, json: async () => value };
|
||||
} catch { return { ok: false, status: 404 }; }
|
||||
};
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const { LoadoutGarrisons } = await import('../../public/js/screens/loadout.js');
|
||||
const { GarrisonBlock } = await import('../../public/js/ui/detailPanel.js');
|
||||
await data.loadAll('chess', 'garrisons');
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const n of v) yield* walk(n); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
for (const [name, trigger] of [['空弦', '<进入休整期时>'], ['星熊', '<获得时>'], ['缄默德克萨斯', '<进入休整期时>']]) {
|
||||
const base = operator(name);
|
||||
for (const rec of [base, chess[base.goldenId]]) {
|
||||
const nodes = [...walk(LoadoutGarrisons({ chess: rec, m: null }))].filter((n) => n.type === GarrisonBlock);
|
||||
assert.equal(nodes.length, rec.garrisonIds.length);
|
||||
for (const node of nodes) {
|
||||
const original = data.lookup('garrisons', rec.garrisonIds[0]);
|
||||
assert.deepEqual(node.props.garrison, original);
|
||||
const raw = [...walk(GarrisonBlock(node.props))].find((n) => n.type?.name === 'RichText').props.text;
|
||||
assert.ok(raw.startsWith(trigger), `${name}: ${raw}`);
|
||||
assert.equal(raw, original.descRaw || original.desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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, ModuleInfo } = 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('the module card reads its talent changes at the operator\'s 潜能, as 局内数值 and the battle do (余 闲云隐市: 2%(+0.5%) / 1.5%)', () => {
|
||||
// (Grok review of the branch: the card printed the full-potential line whatever the setting)
|
||||
const g = get('chess_char_6_03_b');
|
||||
const mod = g.modules.find((x) => x.uniEquipId === 'uniequip_002_yu');
|
||||
const opt = { id: mod.uniEquipId, rec: mod, isDefault: !!mod.isDefault };
|
||||
const texts = (tree) => find(tree, (v) => typeof v.type === 'function' && v.type.name === 'RichText').map((v) => String(v.props.text));
|
||||
const full = texts(ModuleInfo({ m: null, golden: g, opt }));
|
||||
assert.ok(full.some((x) => x.includes('生命上限2%<@ba.talpu>(+0.5%)</>')), 'full potential (none given)');
|
||||
assert.deepEqual(texts(ModuleInfo({ m: null, golden: g, opt, potential: 6 })), full);
|
||||
const p1 = texts(ModuleInfo({ m: null, golden: g, opt, potential: 1 }));
|
||||
assert.ok(p1.some((x) => x.includes('生命上限1.5%的生命')), '潜能1');
|
||||
assert.ok(!p1.some((x) => x.includes('(+0.5%)')));
|
||||
assert.deepEqual(texts(ModuleInfo({ m: null, golden: g, opt: { id: 'none', rec: null }, potential: 1 })), [g.traitBase.descRaw], '不装备: the base 特性');
|
||||
// 圣约送葬人 REA-Y (GitHub #400): 特性 heals 50 per enemy hit as in battle; 特性追加 is the module's ASPD line
|
||||
const ex = get('chess_char_5_01_b');
|
||||
const rea = ex.modules.find((x) => x.uniEquipId === 'uniequip_003_excu2');
|
||||
const lines = texts(ModuleInfo({ m: null, golden: ex, opt: { id: rea.uniEquipId, rec: rea }, potential: 6 }));
|
||||
assert.ok(lines.some((x) => x.includes('每攻击到一个敌人回复自身<@ba.kw>50</>生命')), lines.join(' | '));
|
||||
assert.ok(lines.some((x) => x === '攻击范围内存在2名及以上敌人时攻击速度<@ba.kw>+12</>'));
|
||||
// the detail hands the operator's 潜能 to it
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/screens/loadout.js'), 'utf8');
|
||||
assert.match(src, /<\$\{ModuleInfo\} m=\$\{m\} golden=\$\{golden\} opt=\$\{modOpt\} potential=\$\{opsOf\(ops, chess\.charId\)\.potential\} \/>/);
|
||||
});
|
||||
|
||||
test('css: the selected skill\'s frame is square around its icon (GitHub #398 / PR #399): no img max-width clamp, fill beats `.lo-sicon img`', () => {
|
||||
// theme.css `img { max-width: 100% }` held the frame's width to the icon's while its height grew by .1rem (36 × 40 px
|
||||
// on a phone, 70 × 80 at 1920 px), and the more specific `.lo-sicon img { object-fit: cover }` cropped its side lines
|
||||
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8');
|
||||
const rule = css.match(/^([^{}\n]*\.lo-sicon__outline) \{([^}]*)\}/m);
|
||||
assert.ok(rule, 'the frame rule');
|
||||
assert.equal(rule[1], '.lo-sicon .lo-sicon__outline', 'more specific than `.lo-sicon img`');
|
||||
assert.match(rule[2], /inset: -\.05rem; width: calc\(100% \+ \.1rem\) !important; height: calc\(100% \+ \.1rem\) !important;/);
|
||||
assert.match(rule[2], /max-width: none;/);
|
||||
assert.match(rule[2], /object-fit: fill;/);
|
||||
assert.match(css, /\.lo-sicon img \{ width: 100%; height: 100%; object-fit: cover;/, 'the icon itself still covers');
|
||||
assert.match(readFileSync(path.join(ROOT, 'public/css/theme.css'), 'utf8'), /\bimg \{ max-width: 100%; \}/, 'the global clamp the frame opts out of');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -256,7 +256,7 @@ 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 \} from '\.\.\/ui\/detailPanel\.js';/);
|
||||
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'));
|
||||
assert.ok(!/composeStats|attr\b|maxHp|statsBase/.test(fn), 'no stat arithmetic in the screen');
|
||||
|
||||
+72
-28
@@ -101,7 +101,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
// 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');
|
||||
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"]');
|
||||
@@ -199,7 +199,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
// 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');
|
||||
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')));
|
||||
@@ -268,7 +268,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
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');
|
||||
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"]');
|
||||
@@ -331,7 +331,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
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');
|
||||
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"]');
|
||||
@@ -355,7 +355,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
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');
|
||||
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')]
|
||||
@@ -378,36 +378,50 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('phone 844×390 (touch): roster + detail side by side, tier / class filters, tap to choose', async () => {
|
||||
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 detail's 恢复默认, the search input)
|
||||
const ctl = await page.evaluate(() => Object.fromEntries(['.lo-back', '.lo-top__right .btn', '.lo-dhead__reset', '.lo-search .field__input'].map((sel) => {
|
||||
// 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) }];
|
||||
})));
|
||||
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)})`);
|
||||
}
|
||||
})), 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);
|
||||
await page.tap('.lo-card');
|
||||
// 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.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'side by side');
|
||||
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();
|
||||
});
|
||||
@@ -417,7 +431,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
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');
|
||||
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') });
|
||||
@@ -430,6 +444,8 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
// ---- 局内数值 (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);
|
||||
@@ -438,7 +454,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
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.click('.lo-card__pick');
|
||||
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
|
||||
}
|
||||
|
||||
@@ -452,25 +468,25 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
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)), ['技能', '局内数值', '模组']);
|
||||
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(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));
|
||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk));
|
||||
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), 'a skill is not a stat');
|
||||
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));
|
||||
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-seg button.is-on', (el) => el.textContent.startsWith('普通')), true, 'the skill level toggle is its own');
|
||||
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');
|
||||
@@ -501,7 +517,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
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.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(() => {
|
||||
@@ -529,8 +545,36 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
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));
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -43,7 +43,8 @@ test('parseStored: tolerant of junk, keeps structurally valid entries; toStored
|
||||
test('exportPayload / serializeExport: versioned envelope, entries copied; parseImport round trip', () => {
|
||||
const entries = { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } };
|
||||
const p = exportPayload(entries, { now: Date.UTC(2026, 9, 3, 4, 5, 6) });
|
||||
assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'v'], 'exactly the envelope, no unused field');
|
||||
assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'ops', 'v'], 'exactly the envelope (0.2.2: + the 潜能 / 练度 ops), no unused field');
|
||||
assert.deepEqual(p.ops, {}, 'no settings: an empty map (an import then resets the settings to the defaults)');
|
||||
assert.equal(p.kind, LOADOUT_EXPORT_KIND);
|
||||
assert.equal(p.v, LOADOUT_VERSION);
|
||||
assert.equal(p.count, 2);
|
||||
@@ -262,7 +263,7 @@ test('sync: welcome sends the sanitised loadout; edits are debounced; identical
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } }], 'stale entry dropped');
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: {} }], 'stale entry dropped (no 潜能 / 练度 set: ops {})');
|
||||
assert.equal(target.get().sync, 'synced');
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } } });
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } } });
|
||||
@@ -480,7 +481,7 @@ test('sync: an empty loadout is sent without loading chess.json (no 1.6 MB downl
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => { loads++; return CHESS; }, lookupChess: get, notify: () => {} });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {} }]);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {}, ops: {} }]);
|
||||
assert.equal(loads, 0);
|
||||
await net.reply();
|
||||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// test/ui/potential-ui.test.js — the client side of the per-operator 潜能 / 练度 (0.2.2, the owner's decision of
|
||||
// 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」): the stored settings (`sp.pref.loadout` { v, entries, ops } — an
|
||||
// older stored loadout has none = the defaults), their helpers (set / reset / sanitise against the operators the server
|
||||
// knows), 导出 / 导入 with them, the sync (`room.loadout { entries, ops }`), and the cards: chessLoadout composes the record
|
||||
// at the operator's potential with its 练度 multiplier (the player's settings, a teammate's unit's own, never a stand-in's),
|
||||
// a 自选 card at its pick's potential.
|
||||
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';
|
||||
|
||||
import { cultivationCharIds } from '../../shared/protocol.js';
|
||||
import {
|
||||
parseStored, parseStoredOps, toStored, opsOf, setOps, resetOps, sanitizeOps, exportPayload, parseImport, changedCount,
|
||||
rosterOf, filterRoster,
|
||||
} from '../../public/js/ui/loadoutModel.js';
|
||||
import { installLoadoutSync, SYNC_DEBOUNCE_MS, applyLoadoutEntries, loadoutStore, setEntries, setOpsMap } from '../../public/js/ui/loadoutSync.js';
|
||||
import { createStore } from '../../public/js/store.js';
|
||||
import { chessLoadout, unitCultivation, standInLoadout, standInOf, ownDiyPick, ownDiyRecord, cardDiy } from '../../public/js/ui/gameLogic.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const load = (n) => JSON.parse(readFileSync(path.join(ROOT, `data/${n}.json`), 'utf8'));
|
||||
const CHESS = load('chess');
|
||||
const BACKUPS = load('backups');
|
||||
const EFFECTS = load('effects');
|
||||
const get = (id) => (Object.hasOwn(CHESS, id) ? CHESS[id] : null);
|
||||
const VENDLA = 'chess_char_1_06_a';
|
||||
const V = CHESS[VENDLA].charId;
|
||||
const KALTS = 'char_003_kalts';
|
||||
const IDS = cultivationCharIds(CHESS, BACKUPS);
|
||||
const isOp = (id) => IDS.has(id);
|
||||
|
||||
test('storage: ops beside the entries; junk / defaults dropped; an older stored loadout has none (the defaults)', () => {
|
||||
assert.deepEqual(parseStoredOps({ v: 1, entries: { [VENDLA]: { skill: 0 } } }), {}, 'a 0.2.1 stored loadout');
|
||||
assert.deepEqual(parseStoredOps(null), {});
|
||||
const raw = { v: 1, entries: {}, ops: { [V]: { potential: 1, cultivate: 0 }, [KALTS]: { potential: 6, cultivate: 3 }, x: 'junk', 'a b': { potential: 1 }, char_y: { potential: 9 }, __proto__: { potential: 1 } } };
|
||||
assert.deepEqual(parseStoredOps(raw), { [V]: { potential: 1, cultivate: 0 } });
|
||||
assert.deepEqual(toStored({ [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } }), { v: 1, entries: { [VENDLA]: { skill: 0 } }, ops: { [V]: { potential: 2 } } });
|
||||
assert.deepEqual(parseStored(toStored({ [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } })), { [VENDLA]: { skill: 0 } }, 'the entries read as before');
|
||||
});
|
||||
|
||||
test('helpers: opsOf / setOps / resetOps keep only non-default values; sanitizeOps keeps what the server would', () => {
|
||||
assert.deepEqual(opsOf({}, V), { potential: 6, cultivate: 3, changed: false });
|
||||
let o = setOps({}, V, { potential: 1 });
|
||||
assert.deepEqual(o, { [V]: { potential: 1 } });
|
||||
o = setOps(o, V, { cultivate: 0 });
|
||||
assert.deepEqual(opsOf(o, V), { potential: 1, cultivate: 0, changed: true });
|
||||
o = setOps(o, V, { potential: 6, cultivate: 3 });
|
||||
assert.deepEqual(o, {}, 'back to the defaults: no entry');
|
||||
o = setOps(o, V, { potential: 0 });
|
||||
assert.deepEqual(o, {}, 'an illegal value is ignored');
|
||||
assert.deepEqual(resetOps({ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 } }, V), { [KALTS]: { cultivate: 1 } });
|
||||
assert.deepEqual(sanitizeOps({ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 }, char_609_acguad: { potential: 1 }, char_999_x: { potential: 1 }, [CHESS.chess_char_1_01_a.charId]: { potential: 6 } }, isOp),
|
||||
{ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 } }, 'a prototype / unknown operator / defaults dropped');
|
||||
});
|
||||
|
||||
test('导出 / 导入: the envelope carries ops; an older export keeps the current settings; settings alone import', () => {
|
||||
const p = exportPayload({ [VENDLA]: { skill: 0 } }, { ops: { [V]: { potential: 2 } } });
|
||||
assert.deepEqual(p.ops, { [V]: { potential: 2 } });
|
||||
const back = parseImport(JSON.stringify(p));
|
||||
assert.deepEqual([back.ok, back.entries, back.ops], [true, { [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } }]);
|
||||
const old = parseImport({ kind: 'stronghold.loadout', v: 1, entries: { [VENDLA]: { skill: 0 } } });
|
||||
assert.equal('ops' in old, false, 'an older export: no ops');
|
||||
assert.equal(parseImport({ v: 1, entries: {}, ops: { [V]: { cultivate: 1 } } }).ok, true, 'settings only');
|
||||
assert.equal(parseImport({ v: 1, entries: {}, ops: {} }).ok, false, 'nothing at all');
|
||||
const before = { entries: loadoutStore.get().entries, ops: loadoutStore.get().ops };
|
||||
try {
|
||||
setOpsMap({ [KALTS]: { cultivate: 1 } });
|
||||
const r1 = applyLoadoutEntries({ [VENDLA]: { skill: 0 } }, get);
|
||||
assert.deepEqual(r1, { applied: 1, dropped: 0 });
|
||||
assert.deepEqual(loadoutStore.get().ops, { [KALTS]: { cultivate: 1 } }, 'no ops in the import: the settings stay');
|
||||
const r2 = applyLoadoutEntries({}, get, { ops: { [V]: { potential: 3 }, char_609_acguad: { potential: 1 } }, isOperator: isOp });
|
||||
assert.deepEqual(r2, { applied: 0, dropped: 1, ops: 1 });
|
||||
assert.deepEqual(loadoutStore.get().ops, { [V]: { potential: 3 } }, 'the imported settings replace the current ones');
|
||||
} finally {
|
||||
setEntries(before.entries);
|
||||
setOpsMap(before.ops);
|
||||
}
|
||||
});
|
||||
|
||||
test('the 已调整 count and 仅看已调整 include an operator whose 潜能 / 练度 differ', () => {
|
||||
const roster = rosterOf(Object.values(CHESS));
|
||||
assert.equal(changedCount({}, get, { [V]: { potential: 2 } }, roster), 1);
|
||||
assert.equal(changedCount({ [VENDLA]: { skill: 0 } }, get, { [V]: { potential: 2 } }, roster), 1, 'one operator');
|
||||
assert.equal(changedCount({}, get, {}, roster), 0);
|
||||
const only = filterRoster(roster, { changedOnly: true }, {}, get, () => null, { [V]: { cultivate: 0 } });
|
||||
assert.deepEqual(only.map((c) => c.chessId), [VENDLA]);
|
||||
});
|
||||
|
||||
function fakeNet() {
|
||||
const listeners = new Map();
|
||||
const net = {
|
||||
status: 'online', sent: [], replies: [],
|
||||
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||||
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||||
request(t, fields) { net.sent.push({ t, ...fields }); return Promise.resolve({ t: 'ok' }); },
|
||||
};
|
||||
return net;
|
||||
}
|
||||
function fakeTimers() {
|
||||
let now = 0; let seq = 0;
|
||||
const q = new Map();
|
||||
return {
|
||||
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
||||
clearTimeout: (id) => q.delete(id),
|
||||
async advance(ms) {
|
||||
const end = now + ms;
|
||||
for (;;) {
|
||||
const next = [...q.entries()].filter(([, x]) => x.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
q.delete(next[0]); now = next[1].at; next[1].fn();
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
now = end;
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('sync: room.loadout carries the sanitised ops; an edit of the settings alone is sent; none set ⇒ ops {} without a download', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
let backups = 0;
|
||||
const target = createStore({ entries: {}, ops: { [V]: { potential: 1 }, char_609_acguad: { potential: 1 } }, open: false, sync: 'idle' });
|
||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, getBackupsReady: async () => { backups++; return BACKUPS; }, lookupChess: get, operatorIds: () => IDS });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {}, ops: { [V]: { potential: 1 } } }], 'a prototype dropped');
|
||||
assert.equal(backups, 1, 'the 自选 pool needs backups.json');
|
||||
target.set({ ops: { [V]: { potential: 1, cultivate: 2 } } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent[1], { t: 'room.loadout', entries: {}, ops: { [V]: { potential: 1, cultivate: 2 } } });
|
||||
s.dispose();
|
||||
// nothing set: nothing to sanitise, no data loaded
|
||||
const net2 = fakeNet();
|
||||
let loads = 0;
|
||||
const t2 = createStore({ entries: {}, ops: {}, open: false, sync: 'idle' });
|
||||
const s2 = installLoadoutSync({ net: net2, timers: T, target: t2, getChessReady: async () => { loads++; return CHESS; }, getBackupsReady: async () => { loads++; return BACKUPS; }, lookupChess: get, operatorIds: () => IDS });
|
||||
net2.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual([net2.sent, loads], [[{ t: 'room.loadout', entries: {}, ops: {} }], 0]);
|
||||
s2.dispose();
|
||||
});
|
||||
|
||||
test('cards: the record at the player\'s 潜能 with its 练度 multiplier; a teammate\'s unit its own; a stand-in neither', () => {
|
||||
const v = CHESS[VENDLA];
|
||||
const fx = { effects: EFFECTS };
|
||||
const at = (opts) => chessLoadout(v, null, get, { ...fx, ...opts }).record.stats;
|
||||
assert.deepEqual([at({}).atk, at({}).cost], [435 * 1.1, 15], 'no settings: 满潜 + 精英2 Lv.60');
|
||||
assert.deepEqual([at({ ops: { [V]: { potential: 1, cultivate: 0 } } }).atk, at({ ops: { [V]: { potential: 1, cultivate: 0 } } }).cost], [413, 17]);
|
||||
assert.deepEqual([at({ ops: { [V]: { cultivate: 1 } } }).atk, at({ ops: { [V]: { cultivate: 1 } } }).def], [435 * 1.05, 77 * 1.05]);
|
||||
assert.equal(chessLoadout(v, null, get, { ops: { [V]: { potential: 4 } }, ...fx }).record.talents.find((t) => t.name === '土壤基肥改良').bb.heal_scale, 1.08);
|
||||
assert.deepEqual(chessLoadout(v, null, get, { ops: { [V]: { potential: 2 } }, ...fx }).cultivation, { potential: 2, cultivate: 3 });
|
||||
// a teammate's unit: its UnitInfo
|
||||
assert.deepEqual(unitCultivation({ potential: 3, cultivate: 1 }), { potential: 3, cultivate: 1 });
|
||||
assert.equal(unitCultivation({}), null, 'neither: a raw / stand-in unit');
|
||||
assert.equal(at({ cultivation: unitCultivation({ cultivate: 0 }) }).atk, 435);
|
||||
assert.equal(at({ cultivation: null }).atk, 435, 'none');
|
||||
// a stand-in (补位): neither, whatever the settings say
|
||||
const normal = Object.values(CHESS).find((c) => c.chessType === 'NORMAL' && !c.isGolden && c.visible);
|
||||
const st = standInOf(normal, BACKUPS);
|
||||
const si = standInLoadout(st, get, BACKUPS);
|
||||
assert.ok(st && st.standInFor === normal.charId);
|
||||
assert.equal(si.cultivation, null);
|
||||
assert.deepEqual(si.record.stats, st.stats, 'the stand-in\'s own numbers');
|
||||
assert.equal(chessLoadout(st, null, get, { ops: { [normal.charId]: { potential: 1, cultivate: 0 }, [st.charId]: { potential: 1 } }, ...fx }).cultivation, null);
|
||||
});
|
||||
|
||||
test('自选 cards: the player\'s own pick at its 潜能 (m.private.ops), another player\'s unit at its UnitInfo potential', () => {
|
||||
const T6 = 'chess_char_6_diy1_a';
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
const priv = { diy: { [T6]: { charId: KALTS, skillIndex: 0, uniEquipId: null } }, ops: { [KALTS]: { potential: 1 } } };
|
||||
assert.deepEqual(ownDiyPick(priv, CHESS[T6]), { charId: KALTS, skillIndex: 0, uniEquipId: null, potential: 1 });
|
||||
const rec = ownDiyRecord(CHESS[T6], priv, data);
|
||||
assert.deepEqual([rec.stats.atk, rec.stats.cost], [392, 20], '凯尔希 潜能1');
|
||||
const full = ownDiyRecord(CHESS[T6], { ...priv, ops: {} }, data);
|
||||
assert.deepEqual([full.stats.atk, full.stats.cost], [417, 18]);
|
||||
const mate = cardDiy(CHESS[T6], { unit: { diy: { charId: KALTS, skillIndex: 0, uniEquipId: null }, potential: 1, cultivate: 3 }, data });
|
||||
assert.equal(mate.stats.atk, 392);
|
||||
// the card multiplies the 练度 on top (the composed record is already at its potential)
|
||||
assert.equal(chessLoadout(rec, null, get, { ops: priv.ops, effects: EFFECTS }).record.stats.atk, 392 * 1.1);
|
||||
});
|
||||
@@ -58,7 +58,7 @@ describe('DESIGN §16 — a teammate\'s unit shows its owner\'s loadout (real se
|
||||
await guest.click('.lo-search input');
|
||||
await guest.page.keyboard.type('野鬃');
|
||||
await guest.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||
await guest.click('.lo-card');
|
||||
await guest.click('.lo-card__pick');
|
||||
await guest.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||
await guest.click('.lo-detail .lo-mod[data-module="none"]');
|
||||
await guest.page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
|
||||
|
||||
Reference in new issue
Block a user