0.2.5-dev: a restore-defaults loadout sync is never overwritten by an older in-flight sync (PR #540 by @Xinglan233, ported); importing a loadout with ops is refused before writing when the DIY data is missing (PR #538 by @Xinglan233, ported); the player package's package.json keeps only scripts whose files ship (#531) and its docs link unshipped files to the release tag on GitHub (#530); golden: unchanged
This commit is contained in:
1 parent
7fd0478dc0
commit
52b70434d0
13 files changed
+610
-5
No files matched your search
@@ -0,0 +1,48 @@
|
||||
// GitHub #538: a loadout file that carries `ops` (including `ops: {}`) replaces 潜能 / 练度. That list includes
|
||||
// 自选 operators, which only backups.json names. Importing it while that file is missing used to drop them and still
|
||||
// save, clearing a 练度 the file did not mean to erase. The whole import is refused first, with the 自选编队 data check.
|
||||
// No browser: the refusal is a function, and ioApply must call it after a successful parse and before any write.
|
||||
import { test } 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 () => ({ ok: false, status: 404, json: async () => ({}) });
|
||||
const warn = console.warn;
|
||||
console.warn = (...a) => { if (!String(a[0]).startsWith('[data]')) warn(...a); };
|
||||
|
||||
const { loadoutOpsImportRefusal } = await import('../../public/js/screens/loadout.js');
|
||||
|
||||
const NOT_LOADED = '导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络';
|
||||
const LOADING = '干员数据仍在载入,请稍候再导入';
|
||||
const statusOf = (map, dflt = 'ready') => (name) => map[name] ?? dflt;
|
||||
|
||||
test('a loadout ops payload is refused with the DIY data check; entries-only is not', () => {
|
||||
const noBackups = statusOf({ backups: 'missing' });
|
||||
const bothMissing = statusOf({ chess: 'missing', backups: 'missing' });
|
||||
const ready = statusOf({});
|
||||
assert.equal(loadoutOpsImportRefusal(null, true, noBackups), null, 'no ops key: current 练度 stays');
|
||||
assert.equal(loadoutOpsImportRefusal(undefined, true, noBackups), null);
|
||||
assert.deepEqual(loadoutOpsImportRefusal({}, true, noBackups), { text: NOT_LOADED, tone: 'error' }, 'ops: {} still replaces 练度');
|
||||
assert.deepEqual(loadoutOpsImportRefusal({ char_112_siege: { potential: 1 } }, true, noBackups), { text: NOT_LOADED, tone: 'error' });
|
||||
assert.deepEqual(loadoutOpsImportRefusal({ char_112_siege: { potential: 1 } }, true, bothMissing), { text: NOT_LOADED, tone: 'error' });
|
||||
assert.deepEqual(loadoutOpsImportRefusal({}, false, ready), { text: LOADING, tone: 'warn' }, 'still loading: nothing is written');
|
||||
assert.equal(loadoutOpsImportRefusal({ char_112_siege: { potential: 1 } }, true, ready), null, 'both files ready: the import may proceed');
|
||||
assert.equal(loadoutOpsImportRefusal(null, false, noBackups), null, 'entries-only does not wait on backups');
|
||||
});
|
||||
|
||||
test('ioApply asks loadoutOpsImportRefusal after parseImport and before cultivationCharIds or applyLoadoutEntries', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/screens/loadout.js'), 'utf8');
|
||||
const at = src.indexOf('const ioApply = () => {');
|
||||
assert.ok(at > 0, 'ioApply');
|
||||
const body = src.slice(at, src.indexOf('\n };\n', at));
|
||||
const parsed = body.indexOf('if (!res.ok)');
|
||||
const guard = body.indexOf('loadoutOpsImportRefusal(res.ops, ready)');
|
||||
const ids = body.indexOf('cultivationCharIds(');
|
||||
const apply = body.indexOf('applyLoadoutEntries(');
|
||||
assert.ok(parsed > 0 && guard > parsed, 'refuse only a payload that parsed');
|
||||
assert.ok(ids > guard && apply > guard, 'no write and no DIY id list until the refusal returns null');
|
||||
assert.match(body.slice(guard), /^loadoutOpsImportRefusal\(res\.ops, ready\);\s*if \(opsRefused\) \{ toast\(opsRefused\.text, opsRefused\.tone\); return; \}/);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// Awaiting game data must never let an obsolete loadout overwrite a later edit/reset.
|
||||
// These are controlled readiness inputs; the real screen route is the same prepare/flush path.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createStore } from '../../public/js/store.js';
|
||||
import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS } from '../../public/js/ui/loadoutSync.js';
|
||||
import { cultivationCharIds } from '../../shared/protocol.js';
|
||||
|
||||
const CHESS = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url)));
|
||||
const BACKUPS = JSON.parse(readFileSync(new URL('../../data/backups.json', import.meta.url)));
|
||||
const INSIDE = 'chess_char_1_01_a';
|
||||
const CHAR = CHESS[INSIDE].charId;
|
||||
const ENTRIES = { [INSIDE]: { skill: 0 } };
|
||||
const OPS = { [CHAR]: { potential: 1 } };
|
||||
const IDS = cultivationCharIds(CHESS, BACKUPS);
|
||||
const ticks = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function waitingData() {
|
||||
const started = deferred(), result = deferred();
|
||||
return { started: started.promise, ...result, ready() { started.resolve(); return result.promise; } };
|
||||
}
|
||||
function harness({ chess = async () => CHESS, backups = async () => BACKUPS } = {}) {
|
||||
const listeners = new Map(), pending = new Map();
|
||||
let time = 0, timerId = 0;
|
||||
const timers = {
|
||||
setTimeout(fn, ms) { const id = ++timerId; pending.set(id, { fn, at: time + ms }); return id; },
|
||||
clearTimeout(id) { pending.delete(id); },
|
||||
async advance(ms) {
|
||||
time += ms;
|
||||
for (const [id, task] of [...pending]) if (task.at <= time) { pending.delete(id); task.fn(); await ticks(); }
|
||||
await ticks();
|
||||
},
|
||||
};
|
||||
const net = {
|
||||
status: 'online', sent: [], failures: [],
|
||||
on(name, fn) { listeners.set(name, fn); return () => listeners.delete(name); },
|
||||
emit(name, msg = {}) { listeners.get(name)?.(msg); },
|
||||
async request(t, fields) {
|
||||
net.sent.push({ t, ...fields });
|
||||
const code = net.failures.shift();
|
||||
if (code) throw Object.assign(new Error(code), { code });
|
||||
return { t: 'ok' };
|
||||
},
|
||||
};
|
||||
const target = createStore({ entries: ENTRIES, ops: OPS, open: true, sync: 'idle' });
|
||||
const sync = installLoadoutSync({ net, timers, target, getChessReady: chess, getBackupsReady: backups,
|
||||
lookupChess: (id) => CHESS[id], operatorIds: () => IDS });
|
||||
return { net, timers, target, ...sync };
|
||||
}
|
||||
const empty = { t: 'room.loadout', entries: {}, ops: {} };
|
||||
|
||||
test('sync: a reset supersedes the old loadout awaiting backups, even after the reset was accepted', async (t) => {
|
||||
const wait = waitingData(), h = harness({ backups: wait.ready });
|
||||
t.after(h.dispose);
|
||||
const old = h.flush();
|
||||
await wait.started;
|
||||
// The screen resets ops and entries separately, then the debounced flush sends the empty configuration.
|
||||
h.target.set({ ops: {} });
|
||||
h.target.set({ entries: {} });
|
||||
await h.timers.advance(SYNC_DEBOUNCE_MS);
|
||||
assert.deepEqual(h.net.sent, [empty]);
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
wait.resolve(BACKUPS);
|
||||
await old;
|
||||
assert.deepEqual(h.net.sent, [empty], 'the late old skill must never go back to the server');
|
||||
assert.deepEqual(h.target.get().entries, {});
|
||||
assert.deepEqual(h.target.get().ops, {});
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
});
|
||||
|
||||
test('sync: a later nonempty edit wins while the old backups preparation is waiting', async (t) => {
|
||||
const wait = waitingData(); let calls = 0;
|
||||
const h = harness({ backups: () => ++calls === 1 ? wait.ready() : Promise.resolve(BACKUPS) });
|
||||
t.after(h.dispose);
|
||||
const old = h.flush();
|
||||
await wait.started;
|
||||
h.target.set({ entries: { [INSIDE]: { skill: 0, module: 'none' } }, ops: { [CHAR]: { potential: 2 } } });
|
||||
await h.flush();
|
||||
wait.resolve(BACKUPS);
|
||||
await old;
|
||||
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0, module: 'none' } }, ops: { [CHAR]: { potential: 2 } } }]);
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
});
|
||||
|
||||
test('sync: an edit invalidates preparation before its debounce runs', async (t) => {
|
||||
const wait = waitingData(), h = harness({ backups: wait.ready });
|
||||
t.after(h.dispose);
|
||||
const old = h.flush();
|
||||
await wait.started;
|
||||
h.target.set({ entries: {}, ops: {} });
|
||||
wait.resolve(BACKUPS);
|
||||
await old;
|
||||
assert.deepEqual(h.net.sent, [], 'no stale message during the new edit debounce');
|
||||
assert.equal(h.target.get().sync, 'pending');
|
||||
await h.timers.advance(SYNC_DEBOUNCE_MS);
|
||||
assert.deepEqual(h.net.sent, [empty]);
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
});
|
||||
|
||||
for (const failure of ['missing', 'rejected']) test(`sync: obsolete ${failure} data does not replace the accepted reset status`, async (t) => {
|
||||
const wait = waitingData(), h = harness({ backups: wait.ready });
|
||||
t.after(h.dispose);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const old = h.flush();
|
||||
await wait.started;
|
||||
h.target.set({ entries: {}, ops: {} });
|
||||
await h.flush();
|
||||
if (failure === 'missing') wait.resolve(null); else wait.reject(new Error('download failed'));
|
||||
await old;
|
||||
assert.deepEqual(h.net.sent, [empty]);
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
});
|
||||
|
||||
test('sync: welcome invalidates preparation from the earlier session', async (t) => {
|
||||
const wait = waitingData(); let calls = 0;
|
||||
const h = harness({ backups: () => ++calls === 1 ? wait.ready() : Promise.resolve(BACKUPS) });
|
||||
t.after(h.dispose);
|
||||
const old = h.flush();
|
||||
await wait.started;
|
||||
h.net.emit('welcome');
|
||||
await h.timers.advance(50);
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
wait.resolve(null);
|
||||
await old;
|
||||
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]);
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
});
|
||||
|
||||
test('sync: an obsolete result does not swallow the newest reset RATE retry', async (t) => {
|
||||
const wait = waitingData(), h = harness({ backups: wait.ready });
|
||||
t.after(h.dispose);
|
||||
const old = h.flush();
|
||||
await wait.started;
|
||||
h.target.set({ entries: {}, ops: {} });
|
||||
h.net.failures.push('RATE');
|
||||
await h.flush();
|
||||
assert.equal(h.target.get().sync, 'pending');
|
||||
wait.resolve(BACKUPS);
|
||||
await old;
|
||||
assert.equal(h.target.get().sync, 'pending');
|
||||
await h.timers.advance(RETRY_MS);
|
||||
assert.deepEqual(h.net.sent, [empty, empty]);
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
});
|
||||
|
||||
test('sync: a current missing-data preparation remains an error and can be retried', async (t) => {
|
||||
let missing = true;
|
||||
const h = harness({ backups: async () => missing ? null : BACKUPS });
|
||||
t.after(h.dispose);
|
||||
await h.flush();
|
||||
assert.deepEqual(h.net.sent, []);
|
||||
assert.equal(h.target.get().sync, 'error');
|
||||
missing = false;
|
||||
await h.flush();
|
||||
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]);
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
});
|
||||
|
||||
test('sync: unrelated screen state changes do not cancel a current preparation', async (t) => {
|
||||
const wait = waitingData(), h = harness({ backups: wait.ready });
|
||||
t.after(h.dispose);
|
||||
const current = h.flush();
|
||||
await wait.started;
|
||||
h.target.set({ sel: INSIDE });
|
||||
wait.resolve(BACKUPS);
|
||||
await current;
|
||||
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]);
|
||||
assert.equal(h.target.get().sync, 'synced');
|
||||
});
|
||||
Reference in new issue
Block a user