Files
ark-stronghold-protocol/test/ui/diy-ui.test.js
T
sganggs 0def7babfd feedback5 WK: 自选编队 in the browser — the 干员调配 overlay's third tab 自选编队 (5阶 ×2, 6阶 ×2 slots; the slot picker lists the kitted picks of the tier with avatar, name, class, derived bonds and a 原型 / 已持有 tag; an owned pick chooses its skill and module — 集成战略 modules left out —, a prototype shows its locked skill; sp.pref.diy, export / import, 全部清空, synced as room.diy through the shared engine, welcome.diyKitted kept), the operator on shop / reward cards, own pieces (「自选」 tag, its model on bench and board), the detail card (pick's skill and module), placement and range previews, bond popups (own and a teammate's 自选 member), the result screen; new UI strings through t() with English; the e2e fast server's SP_START_LEVEL hook
Bonds of 自选 pieces checked everywhere membership is decided (the owner's reminder of 2026-10-05): the match's counts through the player's data view (煌 in 炎 and 维多利亚, one member per slot, 绝技 elites), the sim's bond effects through def.bonds (叙拉古 on deployment, 协防 ×1.2 / elite ×1.4), the client's strip, popups, cards and detail card. Golden unchanged.
Tests: test/ui/diy-ui.test.js (15), test/match/diy-bonds.test.js (4), test/i18n.test.js pins screens/diy.js; test/ui/diy.e2e.test.js (SP_E2E=1, run once: pass).
2026-10-06 00:13:31 +08:00

341 lines
22 KiB
JavaScript

// 0.2.0 自选编队 — client side (research 0.2.0 §2; the owner's decisions of 2026-10-05): the 自选编队 tab's model (storage,
// the slots, the picker's operators with the kit list, skill / module choices without 集成战略 modules, export / import)
// and its server sync (room.diy; welcome.diyKitted), the tab's components (four slots, the picker, 确认), and the match
// UI of a 自选 piece — the shop / reward card and the detail card show the operator (name, art, class, derived bonds,
// the pick's skill and module) with a 「自选」 badge, the own pieces get a tag, placement reads the operator's position, and
// the bond popup lists the 自选 member (own and a teammate's).
import { test, describe } 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 M = await import('../../public/js/ui/diyModel.js');
const { installDiySync, SYNC_DEBOUNCE_MS } = await import('../../public/js/ui/loadoutSync.js');
const { createStore } = await import('../../public/js/store.js');
const G = await import('../../public/js/ui/gameLogic.js');
const { ChessCard } = await import('../../public/js/ui/shopBar.js');
const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { ownerBoard } = await import('../../public/js/ui/watchBonds.js');
const { diyPieces } = await import('../../public/js/screens/game/standInTags.js');
const { DiyPanelView, DiyPickerView } = await import('../../public/js/screens/diy.js');
const { data } = await import('../../public/js/data.js');
const { DATA } = await import('../match/harness.js');
const { KITTED_CHARS } = await import('../../server/sim/content/kits/index.js');
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
const D = { chess: DATA.chess, backups: DATA.backups };
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const T5A = 'chess_char_5_diy1_a';
const T5B = 'chess_char_5_diy2_a';
const T6A = 'chess_char_6_diy1_a';
const T6B = 'chess_char_6_diy2_a';
const SIEGE = 'char_112_siege';
const SHARP = 'char_609_acguad';
const KALTS = 'char_003_kalts'; // 凯尔希: an ISW-A module at both elite stages
const SIEGE_PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const KIT = [...KITTED_CHARS];
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
/** Render a component tree one level of function components deep at a time (htm vnodes with function types). */
function expand(v, depth = 6) {
if (Array.isArray(v)) return v.map((x) => expand(x, depth));
if (!v || typeof v !== 'object') return v;
if (typeof v.type === 'function' && depth > 0) {
try { return expand(v.type(v.props || {}), depth - 1); } catch { return v; }
}
const children = v.props?.children;
return children === undefined ? v : { ...v, props: { ...v.props, children: expand(children, depth) } };
}
const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])).filter((x) => typeof x === 'string' || typeof x === 'number').join('');
describe('自选编队 model (ui/diyModel.js)', () => {
test('storage: tolerant parse, junk and hostile keys dropped, round trip', () => {
assert.deepEqual(M.parseStoredDiy(null), {});
const raw = { v: 1, picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP }, __proto__x: 1, bad: { charId: 'a b' }, [T6B]: { charId: SIEGE, skillIndex: 99 } } };
assert.deepEqual(M.parseStoredDiy(raw), { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP }, [T6B]: { charId: SIEGE } });
assert.deepEqual(M.toStoredDiy({ [T5A]: SIEGE_PICK }), { v: M.DIY_VERSION, picks: { [T5A]: SIEGE_PICK } });
assert.equal(M.diyCount({ [T5A]: SIEGE_PICK, [T6A]: null }), 1);
assert.deepEqual(M.setPick({ [T5A]: SIEGE_PICK }, T5A, null), {});
assert.deepEqual(M.setPick({}, T6A, { charId: SHARP }), { [T6A]: { charId: SHARP } });
});
test('the four slots by tier, with the 调度中心 level that sells them', () => {
assert.deepEqual(M.diySlotList(D).map((s) => [s.slotId, s.tier, s.shopLevel]), [[T5A, 5, 5], [T5B, 5, 5], [T6A, 6, 6], [T6B, 6, 6]]);
});
test('the picker lists only kitted operators (prototypes first), marks one another slot holds', () => {
const opts = M.pickOptions(T5A, {}, D, KIT);
const ids = opts.map((o) => o.charId);
assert.ok(ids.includes(SIEGE) && ids.includes(SHARP) && ids.includes('char_601_cguard'));
assert.ok(ids.every((id) => KIT.includes(id)), 'no operator without a kit');
assert.ok(ids.indexOf(SHARP) < ids.indexOf(SIEGE), 'prototypes first');
assert.deepEqual(opts.find((o) => o.charId === SIEGE).bonds, ['victoriaShip']);
assert.equal(opts.find((o) => o.charId === SHARP).proto, true);
assert.ok(!M.pickOptions(T6A, {}, D, KIT).some((o) => o.charId === 'char_601_cguard'), 'a 4★ reserve is a tier-5 pick only');
// taken: an owned operator in any other slot; a prototype in the other slot of its tier (not across tiers)
const roster = { [T5B]: { charId: SHARP }, [T6A]: SIEGE_PICK };
const at5 = M.pickOptions(T5A, roster, D, KIT);
assert.equal(at5.find((o) => o.charId === SIEGE).taken, T6A);
assert.equal(at5.find((o) => o.charId === SHARP).taken, T5B);
assert.equal(M.pickOptions(T6B, roster, D, KIT).find((o) => o.charId === SHARP).taken, null, 'a prototype may fill a tier-5 and a tier-6 slot');
assert.deepEqual(M.pickOptions(T5A, {}, D, []), [], 'no kit list (not connected): nothing to pick');
});
test('choices: three skills, the elite form\'s modules without the 集成战略 (ISW-A) ones; a prototype\'s locked selection', () => {
const ch = M.pickChoices(SIEGE, T6A, D);
assert.deepEqual(ch.skills.map((s) => s.index), [0, 1, 2]);
assert.deepEqual(ch.modules.map((x) => x.uniEquipId), ['uniequip_002_siege', 'uniequip_003_siege']);
assert.equal(ch.stage, 3);
assert.equal(M.pickChoices(SIEGE, T5A, D).stage, 1);
const kalts = M.pickChoices(KALTS, T6A, D);
assert.ok(kalts.elite.modules.some((x) => x.typeName === 'ISW-A'), 'the form has one');
assert.ok(!kalts.modules.some((x) => x.rec.typeName === 'ISW-A'), 'never offered');
const proto = M.pickChoices(SHARP, T5A, D);
assert.equal(proto.proto, true);
assert.deepEqual(proto.locked, { skillIndex: 2, uniEquipId: 'uniequip_002_acguad' });
assert.deepEqual(M.defaultPick(SIEGE, T5A, D), { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, 'S3 and the first module');
assert.deepEqual(M.defaultPick(SHARP, T5A, D), { charId: SHARP });
});
test('sanitize keeps what the server keeps; export / import envelopes', () => {
assert.deepEqual(M.sanitizeDiyPicks({ [T5A]: SIEGE_PICK, [T6A]: { charId: SIEGE, skillIndex: 0 } }, D, KIT), { [T5A]: SIEGE_PICK });
const text = M.serializeDiy({ [T5A]: SIEGE_PICK }, { now: 0 });
const env = JSON.parse(text);
assert.deepEqual([env.kind, env.v, env.count, env.picks], [M.DIY_EXPORT_KIND, 1, 1, { [T5A]: SIEGE_PICK }]);
assert.deepEqual(M.parseDiyImport(text), { ok: true, picks: { [T5A]: SIEGE_PICK } });
assert.deepEqual(M.parseDiyImport({ v: 1, picks: {} }), { ok: true, picks: {} });
for (const bad of ['', '{nope', { v: 9, picks: {} }, { kind: 'stronghold.ownership', v: 1, notOwned: [] }, 'x'.repeat(M.DIY_IMPORT_MAX_BYTES + 1), [1]]) {
assert.equal(M.parseDiyImport(bad).ok, false, JSON.stringify(bad).slice(0, 30));
}
});
});
// ---- sync -------------------------------------------------------------------------------------------------------------
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 });
const r = net.replies.shift();
return r && r.error ? Promise.reject(Object.assign(new Error(r.error), { code: r.error })) : 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 < 5; i++) await Promise.resolve();
}
now = end;
for (let i = 0; i < 5; i++) await Promise.resolve();
},
};
}
test('diy sync: welcome brings the kit list and sends room.diy; edits are debounced; in a match stored for the next one (told once)', async () => {
const net = fakeNet();
const T = fakeTimers();
const told = [];
const target = createStore({ diy: { [T5A]: SIEGE_PICK }, diyKitted: null, open: false, diySync: 'idle' });
const s = installDiySync({ net, timers: T, target, notify: (x) => told.push(x) });
net.emit('welcome', { diyKitted: [SIEGE, SHARP, 7] });
assert.deepEqual(target.get().diyKitted, [SIEGE, SHARP]);
await T.advance(100);
assert.deepEqual(net.sent, [{ t: 'room.diy', picks: { [T5A]: SIEGE_PICK } }]);
assert.equal(target.get().diySync, 'synced');
target.set({ diy: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } } });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.deepEqual(net.sent[1], { t: 'room.diy', picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } } });
net.replies.push({ error: 'ROOM_STARTED' });
target.set({ diy: {} });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(target.get().diySync, 'locked');
assert.deepEqual(told, ['自选编队是局外设置,修改将在下一局生效']);
net.emit('room.state', { inMatch: false });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.deepEqual(net.sent.at(-1), { t: 'room.diy', picks: {} });
s.dispose();
});
// ---- the tab --------------------------------------------------------------------------------------------------------------
describe('自选编队 tab (screens/diy.js)', () => {
test('four slots by tier: an empty slot to fill, a filled one shows the operator, its tag, skill and module', () => {
const tree = expand(DiyPanelView({ m: data.get('assets'), picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, legal: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, kitted: KIT, onSet() {}, picking: null, onPicking() {} }));
const slots = [...walk(tree)].filter((v) => hasClass(v, 'diy-slot'));
assert.deepEqual(slots.map((v) => v.props['data-slot']), [T5A, T5B, T6A, T6B]);
const siege = slots[0];
assert.equal(siege.props['data-char'], SIEGE);
const txt = textOf(siege);
assert.ok(txt.includes(DATA.backups.units[SIEGE].name) && txt.includes('已持有') && txt.includes('S3') && txt.includes('SOL-X'), txt);
assert.ok(textOf(slots[2]).includes('原型'));
assert.ok(hasClass(slots[1], 'is-empty') && textOf(slots[1]).includes('选择干员'));
});
test('the picker: the slot\'s operators; choosing one and its skill / module confirms the pick', () => {
const done = [];
let draft = { ...SIEGE_PICK };
const view = () => expand(DiyPickerView({ m: null, slot: { slotId: T5A, tier: 5, shopLevel: 5 }, picks: { [T5A]: SIEGE_PICK }, kitted: KIT, onDone: (p) => done.push(p), onClose() {},
filter: 'all', query: '', draft, onFilter() {}, onQuery() {}, onDraft: (d) => { draft = d; } }));
let tree = view();
const opts = [...walk(tree)].filter((v) => hasClass(v, 'diy-opt'));
assert.ok(opts.some((v) => v.props['data-char'] === SIEGE) && opts.some((v) => v.props['data-char'] === SHARP));
// the current pick is shown with its three skills and its modules (none + X + Y)
const skills = [...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-skill'] != null);
assert.deepEqual(skills.map((v) => v.props['data-skill']), [0, 1, 2]);
assert.equal(skills.find((v) => v.props['aria-checked'] === 'true').props['data-skill'], 2);
const mods = [...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-module'] != null);
assert.deepEqual(mods.map((v) => v.props['data-module']), ['none', 'uniequip_002_siege', 'uniequip_003_siege']);
// another skill and no module, then 确认
skills.find((v) => v.props['data-skill'] === 0).props.onClick();
tree = view();
[...walk(tree)].find((v) => v.props?.role === 'radio' && v.props['data-module'] === 'none').props.onClick();
tree = view();
[...walk(tree)].find((v) => v.props?.['data-testid'] === 'diy-confirm').props.onClick();
assert.deepEqual(done, [{ charId: SIEGE, skillIndex: 0, uniEquipId: null }]);
// a prototype: its locked skill only; 确认 sends just the operator (the server completes the locked selection)
[...walk(tree)].find((v) => hasClass(v, 'diy-opt') && v.props['data-char'] === SHARP).props.onClick();
tree = view();
assert.deepEqual([...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-skill'] != null).map((v) => [v.props['data-skill'], v.props.disabled]), [[2, true]]);
[...walk(tree)].find((v) => v.props?.['data-testid'] === 'diy-confirm').props.onClick();
assert.deepEqual(done.at(-1), { charId: SHARP });
});
});
// ---- match UI -------------------------------------------------------------------------------------------------------------
describe('match UI of a 自选 piece', () => {
const priv = { diy: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } }, loadout: {}, standIns: [], board: [], hand: [], temp: [] };
test('gameLogic: the own pick of a slot (normal + elite), the composed record, the own getter', () => {
assert.deepEqual(G.ownDiyPick(priv, getChess(T5A)), SIEGE_PICK);
assert.deepEqual(G.ownDiyPick(priv, getChess('chess_char_5_diy1_b')), SIEGE_PICK, 'the elite too');
assert.equal(G.ownDiyPick(priv, getChess(T5B)), null, 'a slot not filled');
assert.equal(G.ownDiyPick(priv, getChess('chess_char_4_22_a')), null);
const rec = G.ownDiyRecord(getChess(T5A), priv, D);
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.tier, rec.price], [DATA.backups.units[SIEGE].name, SIEGE, T5A, ['victoriaShip'], 5, 4]);
assert.equal(G.ownDiyRecord(getChess(T5A), priv, D), rec, 'cached');
const get = G.diyGetter(getChess, priv, D);
assert.equal(get(T5A), rec);
assert.equal(get('chess_char_4_22_a'), getChess('chess_char_4_22_a'));
assert.equal(G.chessLoadout(get('chess_char_5_diy1_b'), priv.loadout, get).skill.skillId, 'skchr_siege_3', 'the pick\'s skill');
assert.equal(G.deployedModuleId(get('chess_char_5_diy1_b'), priv, get, D.backups), 'uniequip_002_siege', 'the pick\'s module on the elite');
assert.equal(G.cardDiy(getChess(T5A), { unit: { diy: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } }, data: D }).charId, SHARP, 'a teammate\'s unit says it itself');
});
test('placement reads the operator\'s position (推进之王 melee) through the own getter', () => {
const get = G.diyGetter(getChess, priv, D);
const ctx = G.placementContext({ priv, stage: null, editable: true, getChess: get, backups: D.backups });
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: T5A }), 'MELEE');
});
test('shop / reward card: the operator\'s name and bonds, the 「自选」 badge, the pick\'s skill', () => {
const card = ChessCard({ slot: { kind: 'chess', id: T5A, price: 4, basePrice: 4 }, idx: 0, priv, onBuy() {}, onDetail() {} });
const nodes = [...walk(card)];
const badge = nodes.find((v) => hasClass(v, 'scard__diy'));
assert.ok(badge);
assert.equal(badge.props['data-diy'], SIEGE);
assert.equal(badge.props.children, '自选');
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, DATA.backups.units[SIEGE].name);
assert.ok(nodes.some((v) => v.props?.bondId === 'victoriaShip'), 'the derived bond');
assert.equal(nodes.find((v) => v?.type && v.props?.lo).props.lo.skill.skillId, 'skchr_siege_3');
const other = ChessCard({ slot: { kind: 'chess', id: T5A, price: 4, basePrice: 4 }, idx: 0, priv: { diy: {} }, onBuy() {}, onDetail() {} });
assert.equal([...walk(other)].find((v) => hasClass(v, 'scard__diy')), undefined);
});
test('detail card: own piece / card / a unit carrying diy → the operator, the 「自选」 tag, the pick\'s skill and module', () => {
const piece = { uid: 7, kind: 'chess', id: 'chess_char_5_diy1_b', golden: true, items: [] };
const pieces = new Map([[7, { piece, area: 'board', row: 10, col: 4 }]]);
const r = resolveDetail({ kind: 'piece', uid: 7 }, pieces, { priv, backups: D.backups });
assert.equal(r.chess.charId, SIEGE);
assert.deepEqual(r.diy, SIEGE_PICK);
assert.equal(resolveDetail({ kind: 'chess', id: T5A }, new Map(), { priv, backups: D.backups }).chess.charId, SIEGE, 'a shop card');
assert.equal(resolveDetail({ kind: 'chess', id: T5A, foreign: true }, new Map(), { priv, backups: D.backups }).diy, undefined, 'the banned list / another player\'s card');
const u = resolveDetail({ kind: 'unit', unit: { id: 3, uid: 33, side: 'ally', kind: 'op', defId: T6A, diy: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } } }, new Map(), { priv: null, backups: D.backups });
assert.equal(u.chess.charId, SHARP);
const blocks = ChessDetail({ chess: r.chess, piece, editable: false, bonds: [], loadout: priv.loadout, diy: r.diy });
const head = blocks.find((b) => b.key === 'head');
assert.equal([...walk(head)].find((v) => hasClass(v, 'dtag-diy'))?.props.children, '自选');
assert.equal([...walk(head)].find((v) => hasClass(v, 'dhead__name')).props.children, DATA.backups.units[SIEGE].name);
const skill = blocks.find((b) => b.key === 'skill');
assert.ok(JSON.stringify(skill.props).includes('skchr_siege_3'));
const mod = blocks.find((b) => b.key === 'module');
assert.ok(JSON.stringify(mod.props).includes('uniequip_002_siege'), 'the pick\'s module (elite)');
assert.ok(!blocks.some((b) => b.key === 'garrison'), 'no 特质');
});
test('own prep pieces of a filled slot get the 「自选」 tag', () => {
const p = { ...priv, hand: [{ uid: 1, kind: 'chess', id: T5A }, { uid: 2, kind: 'chess', id: 'chess_char_4_22_a' }], temp: [], board: [{ uid: 4, kind: 'chess', id: 'chess_char_6_diy1_b', row: 10, col: 4 }] };
assert.deepEqual(diyPieces(p, getChess, D).map((x) => [x.uid, x.area, x.label, x.name]), [[1, 'hand', '自选', DATA.backups.units[SIEGE].name], [4, 'board', '自选', DATA.backups.units[SHARP].name]]);
assert.deepEqual(diyPieces({ ...p, diy: {} }, getChess, D), []);
});
test('bonds: the popup lists the own 自选 member (煌-style several bonds count each) and a teammate\'s by its unit\'s pick', () => {
const get = G.diyGetter(getChess, priv, D);
const own = { ...priv, board: [{ uid: 4, kind: 'chess', id: T5A, row: 10, col: 4 }], hand: [{ uid: 5, kind: 'chess', id: T6A }] };
const vic = G.bondMembers(DATA.bonds.victoriaShip, own, [], get);
const row = vic.find((x) => x.diy);
assert.deepEqual([row.id, row.name, row.onBoard, row.owned, row.rec.charId], [T5A, DATA.backups.units[SIEGE].name, true, true, SIEGE]);
const empty = G.bondMembers(DATA.bonds.emptyShip, own, [], get);
assert.deepEqual(empty.filter((x) => x.diy).map((x) => [x.id, x.inHand]), [[T6A, true]], 'the prototype in the hand counts for 协防');
assert.equal(G.memberHeadCount(vic.filter((x) => x.diy)), 1);
// a watched teammate: its board from the field units, their picks carried along (watchBonds ownerBoard)
const field = { units: [{ kind: 'op', ownerId: 'p_2', defId: T5B, diy: { charId: 'char_017_huang', skillIndex: 0, uniEquipId: null } }] };
const mate = ownerBoard(field, 'p_2');
assert.deepEqual(mate.board[0].diy, { charId: 'char_017_huang', skillIndex: 0, uniEquipId: null });
const pieceRecord = (p) => (p.diy ? G.diyRecordFor(getChess(p.id), p.diy, D) : null) || getChess(p.id);
for (const b of ['yanShip', 'victoriaShip']) {
const rows = G.bondMembers(DATA.bonds[b], mate, [], getChess, () => null, pieceRecord);
assert.ok(rows.some((x) => x.diy && x.rec.charId === 'char_017_huang'), `煌 is a ${b} member`);
}
// the popup itself renders the 自选 row with its tag
const pop = expand(BondPopup({ bondId: 'victoriaShip', entry: { count: 1, active: false, tier: 0, layers: 0 }, priv: own, onClose() {} }));
const mrow = [...walk(pop)].find((v) => hasClass(v, 'bpop__member') && textOf(v).includes(DATA.backups.units[SIEGE].name));
assert.ok(mrow, 'the 自选 member row');
assert.ok([...walk(mrow)].some((v) => hasClass(v, 'bpop__diy')), 'tagged 自选');
});
test('the renderer draws the operator (pieceInfo reads m.private.diy; UnitInfo diy is kept)', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /const pick = chess && chess\.isDiy \? diyPicks\[chess\.baseId \|\| chess\.chessId\] : null;/);
assert.match(src, /diyPicks = src\.diy && typeof src\.diy === 'object' \? src\.diy : \{\};/);
assert.match(readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8'), /diy: u\.diy && typeof u\.diy === 'object'/);
});
});