fix: 防止自选数据加载失败时导入养成覆盖已有设置
This commit is contained in:
1 parent
bc50cac01f
commit
70151d08ed
2 files changed
+228
No files matched your search
@@ -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) } : {});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user