From 25bcdff3a79a80ed6e957739224d8b6cefdb1ee9 Mon Sep 17 00:00:00 2001 From: Xinglan233 Date: Thu, 8 Oct 2026 18:41:36 +0800 Subject: [PATCH] feedback7 G-UI: reselecting the same operator keeps its draft skill and module (PR #306 by @Xinglan233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. Clicking the operator already in the 自选 draft no longer rebuilds the draft from the saved pick or the default, so an in-progress skill or module edit stays. Choosing a different operator still loads that operator's saved pick or defaultPick. Tests: node --test test/ui/diy-ui.test.js test/ui/feedback5-module-trait.test.js test/ui/diy.e2e.test.js — 25 pass, 0 fail. The diy.e2e suites skipped because SP_E2E is unset. --- public/js/screens/diy.js | 5 ++- test/ui/diy-ui.test.js | 44 +++++++++++++++++++++ test/ui/diy.e2e.test.js | 82 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/public/js/screens/diy.js b/public/js/screens/diy.js index 381788c3..d3a3ec85 100644 --- a/public/js/screens/diy.js +++ b/public/js/screens/diy.js @@ -168,7 +168,10 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto) && (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q)))); const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null; - const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D)); + const sel = (charId) => { + if (draft?.charId === charId) return; + setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D)); + }; const lk = ch?.locked || null; const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null; const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null; diff --git a/test/ui/diy-ui.test.js b/test/ui/diy-ui.test.js index 3b91f0eb..4355cb16 100644 --- a/test/ui/diy-ui.test.js +++ b/test/ui/diy-ui.test.js @@ -197,6 +197,50 @@ test('diy sync: welcome brings the kit list and sends room.diy; edits are deboun // ---- the tab -------------------------------------------------------------------------------------------------------------- describe('自选编队 tab (screens/diy.js)', () => { + for (const slotId of [T5A, T6A]) { + for (const saved of [null, SIEGE_PICK]) { + test(`the picker: ${slotId}, ${saved ? 'saved' : 'empty'} slot retains edits when reselecting its operator`, () => { + const picks = saved ? { [slotId]: saved } : {}; + const done = []; + let draft = saved ? { ...saved } : null; + let filter = 'all'; + let query = ''; + const view = () => expand(DiyPickerView({ m: null, slot: { slotId }, picks, kitted: KIT, + onDone: (p) => done.push(p), onClose() {}, filter, query, draft, + onFilter: (f) => { filter = f; }, onQuery: (q) => { query = q; }, onDraft: (d) => { draft = d; } })); + const click = (key, value) => { + const node = [...walk(view())].find((v) => v.props?.[key] === value && typeof v.props.onClick === 'function'); + assert.ok(node, `${key}=${value} is clickable`); + node.props.onClick(); + }; + click('data-char', SIEGE); + click('data-skill', 0); + click('data-module', 'none'); + const edited = { charId: SIEGE, skillIndex: 0, uniEquipId: null }; + assert.deepEqual(draft, edited); + click('data-char', SIEGE); + assert.deepEqual(draft, edited, 'reselecting keeps both the skill and explicit no-module choice'); + // Filtering/searching the operator out and back in must not change the draft either. + filter = 'owned'; + query = 'no matching operator'; + assert.ok(![...walk(view())].some((v) => hasClass(v, 'diy-opt'))); + query = '推进'; + click('data-char', SIEGE); + assert.deepEqual(draft, edited); + click('data-testid', 'diy-confirm'); + assert.deepEqual(done, [edited]); + assert.deepEqual(picks, saved ? { [slotId]: saved } : {}, 'the saved picks stay unchanged until the parent saves'); + // Switching operators still initializes the new pick, and returning restores the saved/default pick. + filter = 'all'; + query = ''; + click('data-char', SHARP); + assert.deepEqual(draft, M.defaultPick(SHARP, slotId, D)); + click('data-char', SIEGE); + assert.deepEqual(draft, saved || M.defaultPick(SIEGE, slotId, D)); + }); + } + } + 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')); diff --git a/test/ui/diy.e2e.test.js b/test/ui/diy.e2e.test.js index 3976721c..00127367 100644 --- a/test/ui/diy.e2e.test.js +++ b/test/ui/diy.e2e.test.js @@ -23,6 +23,88 @@ const SLOT = 'chess_char_5_diy1_a'; const SIEGE = 'char_112_siege'; const PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }; +describe('自选编队 picker: reselecting retains the draft (real server, no art needed)', { skip: !UI_ENABLED && 'set SP_E2E=1 (and have Chrome)' }, () => { + for (const slot of [SLOT, 'chess_char_6_diy1_a']) { + test(`${slot}: same operator retains skill/module edits; cancel, confirm and reload keep their semantics`, { timeout: 90000 }, async () => { + const srv = await startRealServer(); + const P = (await import('puppeteer-core')).default; + const c = new Client(P, srv.base, 'diy-reselect', { prefix: `diy-reselect-${slot}`, w: 1280, h: 720 }); + const picker = '[data-testid="diy-picker"]'; + const operator = `${picker} .diy-opt[data-char="${SIEGE}"]`; + const saved = () => c.page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.diy') || 'null')?.picks?.[id] ?? null, slot); + const choices = () => c.page.evaluate((selector) => { + const el = document.querySelector(selector); + return { skill: el?.querySelector('[data-skill][aria-checked="true"]')?.dataset.skill, + module: el?.querySelector('[data-module][aria-checked="true"]')?.dataset.module }; + }, picker); + const edited = { charId: SIEGE, skillIndex: 0, uniEquipId: null }; + const changed = { charId: SIEGE, skillIndex: 1, uniEquipId: 'uniequip_003_siege' }; + const open = async () => { + await c.click(`.diy-slot[data-slot="${slot}"] :is(.diy-slot__fill, [data-testid="diy-change"])`); + await c.page.waitForSelector(picker, { visible: true, timeout: 8000 }); + }; + const confirm = async (expected) => { + await c.click('[data-testid="diy-confirm"]'); + await c.page.waitForSelector(picker, { hidden: true, timeout: 3000 }); + assert.deepEqual(await saved(), expected); + }; + try { + await c.open(); + await c.enter('自选配置回归'); + await c.click('.lobby-screen [data-testid="loadout-open"]'); + await c.click('.lo .lo-tab[data-tab="diy"]'); + await open(); + await c.click(operator); + await c.click(`${picker} [data-skill="0"]`); + await c.click(`${picker} [data-module="none"]`); + await c.shot('empty-edited'); + await c.click(operator); + await c.shot('empty-reselected'); + assert.deepEqual(await choices(), { skill: '0', module: 'none' }, 'an empty slot retains its edited draft'); + // Hide the current operator, then find and reselect it through the filter/search controls. + await c.click(`${picker} [data-filter="proto"]`); + assert.equal(await c.page.$(operator), null); + await c.click(`${picker} [data-filter="owned"]`); + await c.click(`${picker} input[type="search"]`); + await c.page.keyboard.type('推进'); + await c.click(operator); + assert.deepEqual(await choices(), { skill: '0', module: 'none' }); + await confirm(edited); + await c.shot('empty-saved'); + // A saved non-default pick must not overwrite new edits on a repeated operator click. + await open(); + await c.click(`${picker} [data-skill="1"]`); + await c.click(`${picker} [data-module="uniequip_003_siege"]`); + await c.click(operator); + assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' }); + await c.shot('saved-reselected'); + await c.click(`${picker} .diy-pick__foot button`, '取消'); + await c.page.waitForSelector(picker, { hidden: true, timeout: 3000 }); + assert.deepEqual(await saved(), edited, 'cancel leaves the saved pick unchanged'); + await open(); + assert.deepEqual(await choices(), { skill: '0', module: 'none' }); + await c.click(`${picker} [data-skill="1"]`); + await c.click(`${picker} [data-module="uniequip_003_siege"]`); + await c.click(operator); + await confirm(changed); + await c.page.reload({ waitUntil: 'domcontentloaded' }); + await c.page.waitForSelector('.lobby-screen', { timeout: 20000 }); + await c.click('.lobby-screen [data-testid="loadout-open"]'); + await c.click('.lo .lo-tab[data-tab="diy"]'); + await open(); + assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' }); + assert.deepEqual(await saved(), changed, 'confirm persists the current draft across reload'); + await c.shot('reload'); + assert.deepEqual(c.problems.filter((p) => p.startsWith('pageerror:')), []); + } finally { + console.log(JSON.stringify({ slot, problems: problemsOf([c]) })); + await c.close(); + await srv.stop(); + } + }); + } +}); + // GitHub #284 (idea from PR #286): Esc in the picker cancels only the picker, like its 取消, also from its search // field — the 干员调配 overlay and its four slots stay, and the saved picks do not change; the next Esc closes the overlay describe('自选编队 picker: Esc closes only the picker (real server, no art needed)', { skip: !UI_ENABLED && 'set SP_E2E=1 (and have Chrome)' }, () => {