orbit/dev/saves.test.mjs

286 lines
14 KiB
JavaScript

/**
* Save system test (dev tool, run with Node — no browser):
*
* node dev/saves.test.mjs
*
* Covers the storage seam + state capture (js/save/):
* - SaveManager: the 10-slot localStorage bank — list/get/put/clear,
* hasAny/filledCount, corrupt-bank recovery, failed-write surfacing,
* and exportAll (the bulk download payload);
* - SaveManager.validateRecord: the shape a save must have;
* - SaveData.captureState: a live scene → a record (round-trips);
* - SaveData.prepareLoad/consumeRestore/resetRunState: the registry
* hand-off the two scenes use (galaxy from seed, discovery restored,
* the pending restore consumed exactly once).
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(file('config/Config.js'));
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { SaveManager, SAVE_FORMAT } = await import(file('save/SaveManager.js'));
const {
captureState,
prepareLoad,
consumeRestore,
resetRunState,
PENDING_RESTORE_KEY,
} = await import(file('save/SaveData.js'));
const { Galaxy } = await import(file('galaxy/Galaxy.js'));
const { Discovery } = await import(file('galaxy/Discovery.js'));
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// A small deterministic galaxy (fast) + a fake "scene" for captureState.
const SEED = 'SAVETEST';
const galaxy = Galaxy.create(SEED, { systemCount: 8 });
const system = galaxy.byId.get(galaxy.currentSystemId); // the starting system
const fakeScene = () => ({
registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } },
galaxy,
systemRecord: { name: system.name },
ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37 },
discovery: new Discovery(540),
depletedClusters: new Set([`${system.id}:c-depleted`]), // a mined-out field
tetherField: {
tethers: [
{ id: 'home', x: 0, y: 0, level: 1, label: 'Terra' },
{ id: 'st:42', x: 300, y: 250, level: 2, label: 'Outpost' },
],
},
playTimeMs: 123456,
});
const makeRec = (over = {}) => ({
app: 'orbit',
format: SAVE_FORMAT, // a record of the CURRENT format (older builds' saves are rejected)
savedAt: '2026-07-15T00:00:00.000Z',
seed: SEED,
galaxyName: galaxy.name,
currentSystemId: system.id,
systemName: system.name,
ship: { x: 123.5, y: -77, heading: 0.72 },
discovery: { distance: 540, bySystem: {} },
tethers: [{ id: 'home', x: 0, y: 0, level: 1, label: 'Terra' }],
playTimeMs: 123456,
...over,
});
// --- a fake localStorage (in-memory, with failure modes) -------------------
const makeStorage = (fail = false) => {
const m = new Map();
return {
map: m,
fail,
getItem(k) { return m.has(k) ? m.get(k) : null; },
setItem(k, v) { if (this.fail) throw new Error('QuotaExceeded'); m.set(k, String(v)); },
removeItem(k) { m.delete(k); },
};
};
// ---------------------------------------------------------------------------
// SaveManager — the bank
// ---------------------------------------------------------------------------
{
const storage = makeStorage();
const sm = new SaveManager(storage);
check('fresh bank: hasAny() is false', sm.hasAny() === false);
check('fresh bank: filledCount() is 0', sm.filledCount() === 0);
const list0 = sm.listSlots();
check('listSlots(): 10 slots, all empty', list0.length === 10 && list0.every((s) => s.record === null));
check('listSlots(): sequential ids 1..10', list0[0].slot === 1 && list0[9].slot === 10);
const rec = makeRec();
sm.put(3, rec);
check('put(3) → get(3) round-trips', JSON.stringify(sm.get(3)) === JSON.stringify(rec));
check('put(3) → hasAny()', sm.hasAny() === true);
check('put(3) → filledCount() 1', sm.filledCount() === 1);
check('put(3) → the slot shows in listSlots', JSON.stringify(sm.listSlots()[2].record) === JSON.stringify(rec));
const rec2 = makeRec({ savedAt: '2026-07-16T00:00:00.000Z' });
sm.put(7, rec2);
check('put(7) → filledCount() 2', sm.filledCount() === 2);
sm.put(3, makeRec({ seed: SEED, ship: { x: 1, y: 2, heading: 0 } }));
check('put(3) again overwrites the slot', sm.get(3).ship.x === 1 && sm.filledCount() === 2);
sm.clear(7);
check('clear(7) empties the slot', sm.get(7) === null && sm.filledCount() === 1);
// The bank lives in storage — a second manager on the same storage sees it.
const sm2 = new SaveManager(storage);
check('the bank persists in storage (new manager sees slot 3)', sm2.get(3).seed === SEED);
// Corrupt bank → treated as empty (the UI then offers an overwrite).
storage.setItem(sm.key, '{definitely not json');
const sm3 = new SaveManager(storage);
check('corrupt bank → readBank() is empty', sm3.hasAny() === false && sm3.filledCount() === 0);
sm3.put(1, rec);
check('writing into a corrupt bank recovers it', sm3.get(1).seed === SEED && sm3.get(3) === null);
// Failed write (quota) → surfaced, the old value untouched.
storage.fail = true;
let threw = false;
try { sm3.put(2, rec); } catch { threw = true; }
check('failed write throws (the UI toasts it)', threw === true);
storage.fail = false;
// exportAll — the bulk download payload: every filled slot, nothing else.
sm3.put(10, rec2);
const json = sm3.exportAll();
let parsed = null;
try { parsed = JSON.parse(json); } catch { /* checked below */ }
check('exportAll(): parses as JSON', parsed !== null);
check('exportAll(): app/format present', parsed?.app === 'orbit' && parsed.format === SAVE_FORMAT);
check('exportAll(): exactly the filled slots', parsed && Object.keys(parsed.slots).length === 2);
check('exportAll(): slot records intact', parsed?.slots['1']?.seed === SEED && parsed?.slots['10']?.seed === SEED);
// latest() — the "Continue" target: the newest save by savedAt, not by slot.
{
const s = makeStorage();
const m = new SaveManager(s);
check('latest(): empty bank → null', m.latest() === null);
const older = makeRec({ savedAt: '2026-07-01T00:00:00.000Z' });
const newer = makeRec({ savedAt: '2026-07-02T00:00:00.000Z' });
m.put(9, older);
check('latest(): single save → that slot/record', m.latest()?.slot === 9 && JSON.stringify(m.latest()?.record) === JSON.stringify(older));
m.put(2, newer); // a NEWER save in a LOWER slot number
check('latest(): newest savedAt wins over slot order', m.latest()?.slot === 2 && JSON.stringify(m.latest()?.record) === JSON.stringify(newer));
m.clear(2);
m.put(5, makeRec({ savedAt: 'garbage' })); // unparseable timestamp
check('latest(): unparseable savedAt loses to any valid one', m.latest()?.slot === 9);
m.clear(9);
m.put(8, makeRec({ savedAt: 'garbage' })); // same (missing) timestamp → later write
check('latest(): missing-timestamp tie breaks to the higher slot', m.latest()?.slot === 8);
}
// validateRecord — the shape contract.
check('validateRecord: null rejected', SaveManager.validateRecord(null) !== null);
check('validateRecord: no seed rejected', SaveManager.validateRecord(makeRec({ seed: null })) !== null);
check('validateRecord: bad ship rejected', SaveManager.validateRecord(makeRec({ ship: { x: 1 } })) !== null);
check('validateRecord: good record accepted', SaveManager.validateRecord(rec) === null);
check('validateRecord: tolerates missing tethers/discovery', SaveManager.validateRecord(makeRec({ tethers: undefined, discovery: undefined })) === null);
// The galaxy-redesign gate: an old build's save (format 1) references a
// world that no longer exists — rejected with a clear, toast-able message.
const legacy = makeRec({ format: 1 });
const legacyErr = SaveManager.validateRecord(legacy);
check('validateRecord: a legacy (format 1) save is rejected', typeof legacyErr === 'string' && legacyErr.length > 0);
check('validateRecord: the legacy message names the format gap (the UI toasts it)',
typeof legacyErr === 'string' && legacyErr.includes('1') && legacyErr.includes(String(SAVE_FORMAT)));
}
// ---------------------------------------------------------------------------
// SaveData — capture → (travel) → prepare/consume, over a fake registry
// ---------------------------------------------------------------------------
{
const scene = fakeScene();
// Discover one object so the round-trip is non-trivial.
scene.discovery.check(system.id, 0, 0, [{ id: 'pl:0', x: 500, y: 0, radius: 12 }]);
check('setup: discovery has a hit', scene.discovery.isDiscovered(system.id, 'pl:0') === true);
const rec = captureState(scene);
check('capture: seed carried', rec.seed === SEED);
check('capture: system identity carried', rec.currentSystemId === system.id && rec.systemName === system.name);
check('capture: ship carried', rec.ship.x === 123.5 && rec.ship.y === -77 && rec.ship.heading === 0.72);
check('capture: the hold is carried (ship.minerals)', rec.ship.minerals === 37);
check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0'));
check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost');
check('capture: playtime carried', rec.playTimeMs === 123456);
check('capture: depleted fields carried', rec.depletedClusters.includes(`${system.id}:c-depleted`));
// A FRESH registry = a new browser session: prepareLoad rebuilds the
// galaxy from the seed and restores the discovery state.
const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
prepareLoad(reg, rec);
const staged = reg.get(PENDING_RESTORE_KEY);
check('prepare: galaxy rebuilt from the seed', reg.get('galaxy') instanceof Galaxy && reg.get('galaxy').seed === SEED);
check('prepare: the rebuilt galaxy is the SAME galaxy', reg.get('galaxy').name === galaxy.name
&& reg.get('galaxy').byId.get(system.id)?.name === system.name);
check('prepare: discovery restored', reg.get('discovery').isDiscovered(system.id, 'pl:0') === true);
check('prepare: depleted fields restored (the field stays gone)',
reg.get('depletedClusters') instanceof Set && reg.get('depletedClusters').has(`${system.id}:c-depleted`));
check('prepare: a NEW undiscovered object stays undiscovered',
reg.get('discovery').isDiscovered(system.id, 'pl:999999') === false);
check('prepare: the live state is staged under the pending key',
staged !== null && staged.ship.x === 123.5 && staged.playTimeMs === 123456);
check('prepare: the hold rides along with the ship state', staged.ship.minerals === 37);
// A LEGACY record (pre-minerals) still loads — no field, the restore
// side's `typeof === 'number'` guard keeps the ship at 0.
const regLegacy = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
prepareLoad(regLegacy, makeRec()); // makeRec's ship has no minerals
check('prepare: a legacy record (no minerals) still loads, field absent',
regLegacy.get(PENDING_RESTORE_KEY).ship.minerals === undefined);
check('prepare: a legacy record (no depletedClusters) stages an empty set',
regLegacy.get('depletedClusters') instanceof Set && regLegacy.get('depletedClusters').size === 0);
// BUILDS state: an in-flight build (remaining time on the loop clock)
// rides the pending restore, like research.
const buildsRec = {
built: { Terra: ['tether-l1'] },
active: { planet: 'Keth', build: 'tether-l2', startedAt: 1000, durationMs: 20000, remainingMs: 15000 },
};
const regBuilds = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
prepareLoad(regBuilds, makeRec({ builds: buildsRec }));
check('prepare: builds staged (built records + the in-flight build)',
regBuilds.get(PENDING_RESTORE_KEY).builds?.built?.Terra?.includes('tether-l1') === true
&& regBuilds.get(PENDING_RESTORE_KEY).builds?.active?.remainingMs === 15000);
const regNoBuilds = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
prepareLoad(regNoBuilds, makeRec()); // pre-build-system save
check('prepare: a legacy record (no builds field) stages null builds',
regNoBuilds.get(PENDING_RESTORE_KEY).builds === null);
// consumeRestore: exactly once.
const first = consumeRestore(reg);
const second = consumeRestore(reg);
check('consume: first read returns the staged state', first !== null && first.ship.y === -77);
check('consume: second read returns null (consumed)', second === null);
// resetRunState: New Game clears the run-state seams.
reg.set('discovery', reg.get('discovery'));
reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } });
resetRunState(reg);
check('reset: discovery cleared (fresh run)', reg.get('discovery') === null);
check('reset: depleted fields cleared (fresh run)', reg.get('depletedClusters') === null);
check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null);
}
// prepareLoad must reject a record it can't trust.
{
const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
let threw = false;
try { prepareLoad(reg, makeRec({ seed: null })); } catch { threw = true; }
check('prepare: invalid record throws', threw === true);
}
if (failures > 0) {
console.error(`\n${failures} save-system test(s) FAILED`);
process.exit(1);
}
console.log('\nsave-system: all checks passed');