From 70151d08edce6d9d9811e903bcffb02f172f0867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E9=97=8C?= Date: Sun, 11 Oct 2026 16:07:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=98=B2=E6=AD=A2=E8=87=AA=E9=80=89?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5=E6=97=B6?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=85=BB=E6=88=90=E8=A6=86=E7=9B=96=E5=B7=B2?= =?UTF-8?q?=E6=9C=89=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/screens/loadout.js | 4 + test/ui/loadout-ops-import.e2e.test.js | 224 +++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 test/ui/loadout-ops-import.e2e.test.js diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js index be44e06c..e5f0d839 100644 --- a/public/js/screens/loadout.js +++ b/public/js/screens/loadout.js @@ -586,6 +586,10 @@ function LoadoutScreen({ st }) { } const res = parseImport(ioText); if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; } + // Cultivation settings need the same data as DIY: backups.json supplies the owned operator pool. Refuse the + // whole import if it is missing, including an empty ops map which would replace the current settings. + const opsRefused = res.ops && importRefusal('diy', ready); + if (opsRefused) { toast(opsRefused.text, opsRefused.tone); return; } // 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone) const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null; const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {}); diff --git a/test/ui/loadout-ops-import.e2e.test.js b/test/ui/loadout-ops-import.e2e.test.js new file mode 100644 index 00000000..c4c6d7ff --- /dev/null +++ b/test/ui/loadout-ops-import.e2e.test.js @@ -0,0 +1,224 @@ +// Real-server imports of files downloaded through the loadout UI. Only backups.json's response is controlled: +// missing data must not turn a valid mixed cultivation preset into a partial replacement. No preference injection. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { Client, OUT, sleep, hasChrome, startRealServer } from '../e2e/client.mjs'; + +const ENABLED = process.env.SP_E2E === '1' && hasChrome(); +const INSIDE = 'chess_char_1_01_a'; +const SIEGE = 'char_112_siege'; +const SLOT = 'chess_char_5_diy1_a'; +const MODAL = '.modal'; +const IMPORT = '[data-testid="loadout-import"]'; +const APPLY = '[data-testid="loadout-io-apply"]'; +const TEXT = '[data-testid="loadout-io-text"]'; + +const stored = (c) => c.page.evaluate(() => ({ + loadout: localStorage.getItem('sp.pref.loadout'), diy: localStorage.getItem('sp.pref.diy'), +})); +const closeModal = async (c) => { + await c.click(`${MODAL} button`, '关闭'); + await c.page.waitForSelector(MODAL, { hidden: true }); +}; +async function openLoadout(c) { + await c.page.waitForSelector('.lobby-screen'); + await c.click('.lobby-screen [data-testid="loadout-open"]'); + await c.page.waitForSelector(`.lo-card[data-chess="${INSIDE}"]`); +} +async function ordinary(c, potential, cultivate) { + const row = `.lo-card[data-chess="${INSIDE}"]`; + await c.page.select(`${row} [data-cult="potential"]`, String(potential)); + await c.page.select(`${row} [data-cult="cultivate"]`, String(cultivate)); +} +async function selfSelected(c, potential, cultivate) { + const row = `.diy-slot[data-slot="${SLOT}"]`; + await c.page.select(`${row} [data-cult="potential"]`, String(potential)); + await c.page.select(`${row} [data-cult="cultivate"]`, String(cultivate)); +} +async function chooseSiege(c) { + await c.click('.lo-tab[data-tab="diy"]'); + await c.click(`.diy-slot[data-slot="${SLOT}"] .diy-slot__fill`); + await c.page.waitForSelector('[data-testid="diy-picker"]'); + const operator = `[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`; + await c.page.$eval(operator, (e) => e.scrollIntoView({ block: 'nearest' })); + await c.page.click(operator); + await c.click('[data-testid="diy-confirm"]'); + await c.page.waitForSelector('[data-testid="diy-picker"]', { hidden: true }); +} +async function download(c, dir) { + await c.click('[data-testid="loadout-export"]'); + await c.page.waitForSelector(TEXT); + const payload = await c.page.$eval(TEXT, (e) => e.value); + const session = await c.page.createCDPSession(); + await session.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: dir }); + await c.click('[data-testid="loadout-io-download"]'); + let file; + for (let i = 0; i < 100; i++) { + file = readdirSync(dir).find((f) => f.endsWith('.json')); + if (file) break; + await sleep(50); + } + assert.ok(file, 'the UI downloaded the preset'); + file = path.join(dir, file); + assert.equal(readFileSync(file, 'utf8'), payload); + await closeModal(c); + await session.detach(); + return { file, payload: JSON.parse(payload) }; +} +async function upload(c, file) { + await c.click(IMPORT); + const chooser = c.page.waitForFileChooser(); + await c.click('[data-testid="loadout-io-pick"]'); + await (await chooser).accept([file]); + await c.page.waitForFunction((s) => !!document.querySelector(s)?.value.trim(), {}, TEXT); +} +async function fixture(prefix) { + const srv = await startRealServer(); + const P = (await import('puppeteer-core')).default; + const c = new Client(P, srv.base, prefix, { prefix, w: 1280, h: 720 }); + mkdirSync(OUT, { recursive: true }); + const dir = mkdtempSync(path.join(OUT, `${prefix}-`)); + try { + await c.open(); + await c.enter('调配导入回归'); + await openLoadout(c); + await c.click(`.lo-card[data-chess="${INSIDE}"] [data-skill="0"]`); + return { c, srv, dir }; + } catch (error) { + await c.close(); + await srv.stop(); + throw error; + } +} + +test('mixed cultivation file: failed backups preserves existing data; reload and retry imports the whole file', + { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome)', timeout: 90000 }, async () => { + const { c, srv, dir } = await fixture('loadout-ops-missing'); + const records = {}; + let failBackups = false; + try { + await ordinary(c, 2, 1); + await chooseSiege(c); + await selfSelected(c, 3, 0); + await c.click('.lo-tab[data-tab="loadout"]'); + const { file, payload } = await download(c, dir); + records.exported = payload; + await ordinary(c, 4, 2); + await c.click(`.lo-card[data-chess="${INSIDE}"] [data-module="none"]`); + await c.click('.lo-tab[data-tab="diy"]'); + await selfSelected(c, 5, 2); + records.before = await stored(c); + await c.page.setRequestInterception(true); + c.page.on('request', (r) => failBackups && new URL(r.url()).pathname === '/data/backups.json' + ? r.respond({ status: 404, contentType: 'text/plain', body: 'Simulated backups.json failure' }) : r.continue()); + failBackups = true; + await c.page.reload({ waitUntil: 'domcontentloaded' }); + await openLoadout(c); + await upload(c, file); + await c.click(APPLY); + await sleep(100); + records.failedImport = await stored(c); + records.failureUi = await c.page.evaluate(() => ({ + modal: !!document.querySelector('.modal'), text: document.body.innerText, + })); + await c.shot('failed-import'); + assert.deepEqual(records.failedImport, records.before, 'failed validation data must not replace saved entries or ops'); + assert.equal(records.failureUi.modal, true, 'the import stays open for retry'); + assert.match(records.failureUi.text, /导入失败:游戏数据没有载入,未做任何改动/); + failBackups = false; + await c.page.reload({ waitUntil: 'domcontentloaded' }); + await openLoadout(c); + assert.deepEqual(await stored(c), records.before, 'the refusal also survives reload'); + await upload(c, file); + await c.click(APPLY); + await c.page.waitForSelector(MODAL, { hidden: true }); + records.retried = await stored(c); + const applied = JSON.parse(records.retried.loadout); + assert.deepEqual(applied.entries, payload.entries); + assert.deepEqual(applied.ops, payload.ops, 'both ordinary and self-selected settings survive'); + assert.equal(records.retried.diy, records.before.diy, 'loadout import never replaces DIY picks'); + await c.click('.lo-tab[data-tab="diy"]'); + await c.shot('retried-self-selected'); + assert.deepEqual(c.problems.filter((p) => p.startsWith('pageerror:')), []); + } finally { + writeFileSync(path.join(dir, 'observations.json'), JSON.stringify({ ...records, problems: c.problems }, null, 2)); + console.log(JSON.stringify({ evidence: dir, before: records.before, after: records.failedImport, retried: records.retried, + modal: records.failureUi?.modal, problemCount: c.problems.length, + pageErrors: c.problems.filter((p) => p.startsWith('pageerror:')) })); + await c.close(); + await srv.stop(); + } + }); + +test('pending backups keeps import disabled until its real response arrives', + { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome)', timeout: 90000 }, async () => { + const { c, srv, dir } = await fixture('loadout-ops-loading'); + let held; + try { + await ordinary(c, 2, 1); + await chooseSiege(c); + await selfSelected(c, 3, 0); + await c.click('.lo-tab[data-tab="loadout"]'); + const { file, payload } = await download(c, dir); + const before = await stored(c); + await c.page.setRequestInterception(true); + c.page.on('request', (r) => { + if (new URL(r.url()).pathname === '/data/backups.json') held = r; + else r.continue(); + }); + await c.page.reload({ waitUntil: 'domcontentloaded' }); + await c.page.waitForSelector('.lobby-screen'); + await c.click('.lobby-screen [data-testid="loadout-open"]'); + await c.page.waitForSelector('.lo-loading'); + assert.equal(await c.page.$eval(IMPORT, (e) => e.disabled), true); + assert.deepEqual(await stored(c), before); + for (let i = 0; !held && i < 100; i++) await sleep(50); + assert.ok(held, 'backups is still in flight'); + await held.continue(); + held = null; + await c.page.waitForSelector(`.lo-card[data-chess="${INSIDE}"]`); + assert.equal(await c.page.$eval(IMPORT, (e) => e.disabled), false); + await upload(c, file); + await c.click(APPLY); + await c.page.waitForSelector(MODAL, { hidden: true }); + const after = await stored(c); + assert.deepEqual(JSON.parse(after.loadout).ops, payload.ops); + assert.deepEqual(c.problems.filter((p) => p.startsWith('pageerror:')), []); + writeFileSync(path.join(dir, 'observations.json'), JSON.stringify({ before, after, payload, problems: c.problems }, null, 2)); + } finally { + if (held) await held.abort().catch(() => {}); + await c.close(); + await srv.stop(); + } + }); + +test('an exported empty ops map also needs backups before replacing existing cultivation settings', + { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome)', timeout: 90000 }, async () => { + const { c, srv, dir } = await fixture('loadout-ops-empty'); + try { + const { file, payload } = await download(c, dir); + assert.deepEqual(payload.ops, {}); + await chooseSiege(c); + await selfSelected(c, 2, 1); + const before = await stored(c); + await c.page.setRequestInterception(true); + c.page.on('request', (r) => new URL(r.url()).pathname === '/data/backups.json' + ? r.respond({ status: 404, contentType: 'text/plain', body: 'Simulated backups.json failure' }) : r.continue()); + await c.page.reload({ waitUntil: 'domcontentloaded' }); + await openLoadout(c); + await upload(c, file); + await c.click(APPLY); + await sleep(100); + const after = await stored(c); + writeFileSync(path.join(dir, 'observations.json'), JSON.stringify({ before, after, payload, problems: c.problems }, null, 2)); + await c.shot('failed-import'); + assert.deepEqual(after, before); + assert.ok(await c.page.$(MODAL)); + assert.deepEqual(c.problems.filter((p) => p.startsWith('pageerror:')), []); + } finally { + await c.close(); + await srv.stop(); + } + });