新增:导出 / 导入干员调配 (#14)
This commit is contained in:
1 parent
e90e7a30fc
commit
502f183e43
9 files changed
+395
-33
No files matched your search
@@ -134,6 +134,72 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('desktop: 导出 hands out a versioned payload; 导入 restores it and refuses junk', async () => {
|
||||
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 });
|
||||
// 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.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')));
|
||||
assert.deepEqual(stored.entries, { [INSIDE]: { skill: 0 } });
|
||||
|
||||
// 导出: a centred dialog — its own full-viewport overlay, rendered NEXT TO the .lo screen rather than inside it
|
||||
await clickSel(page, '[data-testid="loadout-export"]');
|
||||
await page.waitForSelector('.modal [data-testid="loadout-io-text"]', { visible: true, timeout: 5000 });
|
||||
// the box slides in (`modal-in`, 250 ms): measure the settled geometry, not a frame of the entry animation
|
||||
await page.waitForFunction(() => document.querySelector('.modal__box')?.getAnimations().every((a) => a.playState === 'finished'), { timeout: 5000 });
|
||||
const box = await page.evaluate(() => {
|
||||
const m = document.querySelector('.modal');
|
||||
const b = document.querySelector('.modal__box').getBoundingClientRect();
|
||||
return { position: getComputedStyle(m).position, y: b.y, h: b.height, vh: innerHeight, inside: !!m.closest('.lo') };
|
||||
});
|
||||
assert.equal(box.position, 'fixed', 'the modal keeps components.css `position: fixed`');
|
||||
assert.equal(box.inside, false, 'a sibling of .lo, so `.lo > *` needs no exception for it');
|
||||
assert.ok(Math.abs(box.y + box.h / 2 - box.vh / 2) < 2, `centred vertically (${JSON.stringify(box)})`);
|
||||
const payload = JSON.parse(await page.$eval('[data-testid="loadout-io-text"]', (t) => t.value));
|
||||
assert.equal(payload.kind, 'stronghold.loadout');
|
||||
assert.equal(payload.v, 1);
|
||||
assert.deepEqual(payload.entries, stored.entries, 'what is exported is what is stored');
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-export.png') });
|
||||
await page.evaluate(() => [...document.querySelectorAll('.modal__actions .btn')].find((b) => b.textContent.trim() === '关闭').click());
|
||||
await page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 3000 });
|
||||
|
||||
// wipe it, then 导入 the payload back
|
||||
await page.evaluate(() => localStorage.setItem('sp.pref.loadout', JSON.stringify({ v: 1, entries: {} })));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && !!document.querySelector('.lobby-screen'), { timeout: 30000 });
|
||||
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
|
||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||
await clickSel(page, '[data-testid="loadout-import"]');
|
||||
await page.waitForSelector('.modal [data-testid="loadout-io-text"]', { visible: true, timeout: 5000 });
|
||||
await page.$eval('[data-testid="loadout-io-text"]', (t, v) => { t.value = v; t.dispatchEvent(new Event('input', { bubbles: true })); }, JSON.stringify(payload));
|
||||
await page.click('[data-testid="loadout-io-apply"]');
|
||||
await page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 5000 });
|
||||
await sleep(300);
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')).entries), stored.entries, 'round trip');
|
||||
|
||||
// a payload of another tool is refused and leaves the loadout alone
|
||||
await clickSel(page, '[data-testid="loadout-import"]');
|
||||
await page.waitForSelector('.modal [data-testid="loadout-io-text"]', { visible: true, timeout: 5000 });
|
||||
await page.$eval('[data-testid="loadout-io-text"]', (t) => { t.value = '{"kind":"other.tool","entries":{"a":{"skill":1}}}'; t.dispatchEvent(new Event('input', { bubbles: true })); });
|
||||
await page.click('[data-testid="loadout-io-apply"]');
|
||||
await page.waitForFunction(() => /导入失败/.test(document.body.textContent || ''), { timeout: 5000 });
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')).entries), stored.entries, 'a refused import changes nothing');
|
||||
|
||||
// a payload this build cannot use at all (every chess unknown) must leave the loadout alone too (review fix)
|
||||
await page.$eval('[data-testid="loadout-io-text"]', (t) => { t.value = '{"v":1,"entries":{"chess_not_here":{"skill":0}}}'; t.dispatchEvent(new Event('input', { bubbles: true })); });
|
||||
await page.click('[data-testid="loadout-io-apply"]');
|
||||
await page.waitForFunction(() => /没有可用的调配/.test(document.body.textContent || ''), { timeout: 5000 });
|
||||
assert.ok(await page.$('.modal'), 'the dialog stays open for the player to fix the payload');
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')).entries), stored.entries, 'an import that keeps nothing changes nothing');
|
||||
assert.deepEqual(problems, []);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('solo briefing: an edit made right before 准备就绪 still applies to this match (closing the overlay sends it)', async () => {
|
||||
const { ctx, page, problems } = await open();
|
||||
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }));
|
||||
@@ -176,6 +242,21 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
||||
// the briefing (and its countdown) is hidden under the overlay: the overlay carries the time left
|
||||
await page.waitForSelector('.lo-top .lo-deadline', { visible: true, timeout: 3000 });
|
||||
assert.match(await page.$eval('.lo-top .lo-deadline', (el) => el.getAttribute('aria-label')), /剩余\d+秒/);
|
||||
// review: the two new 导出 / 导入 buttons must fit next to the countdown on a narrow phone in landscape (667×375).
|
||||
// Only the size changes (toggling isMobile / hasTouch would make puppeteer reload the page).
|
||||
await page.setViewport({ width: 667, height: 375 });
|
||||
const bar = await page.evaluate(() => {
|
||||
const top = document.querySelector('.lo-top');
|
||||
const right = document.querySelector('.lo-top__right').getBoundingClientRect();
|
||||
const center = document.querySelector('.lo-top__center').getBoundingClientRect();
|
||||
const buttons = [...document.querySelectorAll('.lo-top__right .btn')].map((b) => Math.round(b.getBoundingClientRect().right));
|
||||
return { overflow: top.scrollWidth - top.clientWidth, overlap: right.left < center.right, offscreen: right.right > innerWidth, buttons };
|
||||
});
|
||||
assert.ok(bar.overflow <= 0, `the top bar does not scroll sideways (${JSON.stringify(bar)})`);
|
||||
assert.equal(bar.overlap, false, `the right column does not run into the title (${JSON.stringify(bar)})`);
|
||||
assert.equal(bar.offscreen, false, `every button ends inside the viewport (${JSON.stringify(bar)})`);
|
||||
await page.screenshot({ path: path.join(OUT, 'loadout-narrow-toolbar.png') });
|
||||
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');
|
||||
|
||||
+73
-1
@@ -13,8 +13,9 @@ import { PHASE } from '../../shared/constants.js';
|
||||
import {
|
||||
parseStored, toStored, chessOptions, effectiveChoice, setChoice, resetChoice, sanitizeEntries, rosterOf, filterRoster,
|
||||
changedCount, moduleBadge, attrRows, skillTags, skillLabel, selectedSkill, selectedModule, recordsOf,
|
||||
exportPayload, serializeExport, parseImport, LOADOUT_EXPORT_KIND, LOADOUT_VERSION, LOADOUT_IMPORT_MAX_BYTES,
|
||||
} from '../../public/js/ui/loadoutModel.js';
|
||||
import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS } from '../../public/js/ui/loadoutSync.js';
|
||||
import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS, applyLoadoutEntries, setEntries, loadoutStore } from '../../public/js/ui/loadoutSync.js';
|
||||
import { createStore } from '../../public/js/store.js';
|
||||
import { shouldAutoClose } from '../../public/js/screens/loadout.js';
|
||||
|
||||
@@ -39,6 +40,77 @@ test('parseStored: tolerant of junk, keeps structurally valid entries; toStored
|
||||
assert.deepEqual(parseStored(JSON.parse(JSON.stringify(toStored(e)))), e);
|
||||
});
|
||||
|
||||
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.equal(p.kind, LOADOUT_EXPORT_KIND);
|
||||
assert.equal(p.v, LOADOUT_VERSION);
|
||||
assert.equal(p.count, 2);
|
||||
assert.equal(p.exportedAt, '2026-10-03T04:05:06.000Z');
|
||||
assert.deepEqual(p.entries, entries);
|
||||
assert.notEqual(p.entries, entries, 'a copy — later edits must not mutate an already-built payload');
|
||||
assert.notEqual(p.entries[INSIDE], entries[INSIDE]);
|
||||
assert.equal(exportPayload(null).count, 0);
|
||||
|
||||
const back = parseImport(serializeExport(entries, { now: 0 }));
|
||||
assert.equal(back.ok, true);
|
||||
assert.deepEqual(back.entries, entries);
|
||||
});
|
||||
|
||||
test('parseImport: accepts the envelope, the stored form, a bare map and text; refuses junk, newer data and hostile keys', () => {
|
||||
const entries = { [INSIDE]: { skill: 0 } };
|
||||
assert.deepEqual(parseImport(exportPayload(entries)).entries, entries, 'envelope');
|
||||
assert.deepEqual(parseImport(toStored(entries)).entries, entries, 'stored { v, entries }');
|
||||
assert.deepEqual(parseImport(entries).entries, entries, 'bare map (hand-written / older build)');
|
||||
assert.deepEqual(parseImport(JSON.stringify(exportPayload(entries))).entries, entries, 'serialised text');
|
||||
assert.deepEqual(parseImport(`\n ${JSON.stringify(entries)} \n`).entries, entries, 'padded text');
|
||||
|
||||
for (const junk of ['', ' ', 'not a payload', '{', 42, null, undefined, [], true]) {
|
||||
assert.equal(parseImport(junk).ok, false, `${JSON.stringify(junk)} is refused`);
|
||||
}
|
||||
const newer = parseImport({ v: LOADOUT_VERSION + 1, entries });
|
||||
assert.equal(newer.ok, false, 'a NEWER payload is refused, never mis-read');
|
||||
assert.match(newer.error, /请先更新游戏/, 'the player is told to update the game');
|
||||
assert.equal(parseImport({ kind: 'some.other.tool', entries }).ok, false, "another tool's payload");
|
||||
assert.equal(parseImport({ v: LOADOUT_VERSION, entries: {} }).ok, false, 'nothing to import');
|
||||
assert.equal(parseImport({ v: LOADOUT_VERSION, entries: { 'bad id': { skill: 0 } } }).ok, false, 'no structurally valid entry');
|
||||
assert.equal(parseImport('x'.repeat(LOADOUT_IMPORT_MAX_BYTES + 1)).ok, false, 'an oversized payload is refused before parsing');
|
||||
|
||||
// hostile keys: JSON.parse keeps `__proto__` as an own key, and assigning it would rewrite an object's prototype
|
||||
const hostile = JSON.parse(`{"entries":{"__proto__":{"skill":0},"constructor":{"skill":0},"prototype":{"skill":0},"${INSIDE}":{"skill":0}}}`);
|
||||
assert.deepEqual(Object.keys(hostile.entries), ['__proto__', 'constructor', 'prototype', INSIDE], 'fixture: parsed as own keys');
|
||||
const safe = parseImport(hostile);
|
||||
assert.deepEqual(safe.entries, { [INSIDE]: { skill: 0 } }, 'only the real chess survives');
|
||||
assert.equal(Object.getPrototypeOf(safe.entries), Object.prototype, 'the entry map keeps a clean prototype');
|
||||
assert.equal({}.skill, undefined, 'Object.prototype was not polluted');
|
||||
});
|
||||
|
||||
test('applyLoadoutEntries: sanitises against the loaded data and reports what was dropped', () => {
|
||||
const before = loadoutStore.get().entries;
|
||||
try {
|
||||
const res = applyLoadoutEntries({ [INSIDE]: { skill: 0 }, chess_nope_999: { skill: 0 } }, get);
|
||||
assert.deepEqual(res, { applied: 1, dropped: 1 }, 'the unknown chess is dropped, the real one applied');
|
||||
assert.deepEqual(loadoutStore.get().entries, { [INSIDE]: { skill: 0 } }, 'the store got the sanitised entries');
|
||||
} finally {
|
||||
setEntries(before);
|
||||
}
|
||||
});
|
||||
|
||||
test('applyLoadoutEntries: an import that keeps nothing changes nothing (review: it used to wipe the loadout)', () => {
|
||||
setEntries({ [INSIDE]: { skill: 0 } });
|
||||
const before = loadoutStore.get().entries;
|
||||
try {
|
||||
assert.deepEqual(applyLoadoutEntries({ chess_nope_999: { skill: 0 } }, get), { applied: 0, dropped: 1 }, 'every chess unknown');
|
||||
assert.equal(loadoutStore.get().entries, before, 'the current loadout is untouched (not even replaced by an equal one)');
|
||||
const def = IB.skills.find((s) => s.isDefault).index;
|
||||
assert.deepEqual(applyLoadoutEntries({ [INSIDE]: { skill: def } }, get), { applied: 0, dropped: 1 }, 'every choice already the default');
|
||||
assert.equal(loadoutStore.get().entries, before, 'still untouched');
|
||||
} finally {
|
||||
setEntries(before);
|
||||
}
|
||||
});
|
||||
|
||||
test('options: skill records at Lv4 (normal) and Lv7 (elite); modules + 不装备 with defaults flagged', () => {
|
||||
const o = chessOptions(IB, IG);
|
||||
assert.deepEqual(o.skillOptions.map((s) => s.index), [0, 1]);
|
||||
|
||||
Reference in new issue
Block a user