orbit/dev/route.test.mjs

170 lines
7.3 KiB
JavaScript

/**
* ROUTE test (dev tool, run with Node — no browser):
*
* node dev/route.test.mjs
*
* Pins the pure course-planning behind SET DESTINATION (js/galaxy/Route.js,
* driven by GameScene.setDestination / routeNextGate / _routeForJump):
* - the jump network is a spanning tree (data/gates.json → shortcuts:false),
* so there is EXACTLY ONE route between any two systems — planRoute must
* return a valid, connected path that honours the gate edges;
* - from === to is already-there (path [id], next null, 0 hops);
* - the path starts at `from`, ends at `to`, and every step is a real gate
* link (both directions exist — the tree is bidirected);
* - `next` is the first hop after `from` (what the compass points at) and is
* always a gate neighbour of `from`;
* - DETERMINISM: same seed + endpoints ⇒ same route; and the tree is
* symmetric, so the from→to path reversed equals the to→from path;
* - REACHABILITY: every ordered pair resolves (the network is strongly
* connected) — no null where a route must exist.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
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 { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { planRoute, nextSystem } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Route.js')).href);
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
if (!cond) failures++;
};
const SEED = 'route-test-seed';
const g = Galaxy.create(SEED);
const ids = g.records.map((r) => r.id);
const HOME = g.currentSystemId;
const adj = (id) => new Set(g.jumpNetwork.gates.get(id) ?? []);
console.log(`\ngalaxy: ${g.records.length} systems, home ${HOME}\n`);
// ----------------------------------------------------------------------
// 1. Already there — from === to
// ----------------------------------------------------------------------
{
const r = planRoute(g, HOME, HOME);
check('from===to → path [id]', Array.isArray(r?.path) && r.path.length === 1 && r.path[0] === HOME);
check('from===to → next null, 0 hops', r?.next === null && r?.hops === 0);
check('from===to → distance 0', r?.distance === 0);
}
// ----------------------------------------------------------------------
// 2. Path validity — endpoints, connectivity, gate edges
// ----------------------------------------------------------------------
{
// Pick a real destination a couple of hops out (not home, not a direct
// neighbour, so the path is non-trivial).
const homeAdj = adj(HOME);
let dest = null;
for (const id of ids) {
if (id === HOME || homeAdj.has(id)) { dest = id; break; }
}
const r = planRoute(g, HOME, dest);
check('resolves a route home → a non-neighbour system', !!r, `dest ${dest}`);
check('path starts at from', r?.path?.[0] === HOME);
check('path ends at to', r?.path?.[r.path.length - 1] === dest);
check('hops = path.length - 1', r?.hops === r.path.length - 1);
// Every step is a real gate link (tree edges run both ways).
let connected = true;
let why = '';
for (let i = 0; i + 1 < r.path.length; i++) {
const a = r.path[i];
const b = r.path[i + 1];
if (!adj(a).has(b) || !adj(b).has(a)) {
connected = false;
why = `${a} !~ ${b}`;
break;
}
}
check('every step is a bidirected gate link', connected, why);
// `next` is the first hop and a gate neighbour of from.
check('next is path[1]', r?.next === r.path[1]);
check('next is a gate neighbour of from', adj(HOME).has(r?.next));
check('distance is finite + non-negative', Number.isFinite(r?.distance) && r.distance >= 0);
}
// ----------------------------------------------------------------------
// 3. Determinism + tree symmetry (reverse path)
// ----------------------------------------------------------------------
{
const a = ids[0];
const b = ids[ids.length - 1];
const r1 = planRoute(g, a, b);
const r2 = planRoute(g, a, b);
check('deterministic: same seed ⇒ same route', JSON.stringify(r1) === JSON.stringify(r2));
const rb = planRoute(g, b, a);
const reversed = [...(r1?.path ?? [])].reverse();
check(
'tree symmetry: a→b reversed === b→a',
JSON.stringify(reversed) === JSON.stringify(rb?.path),
`${JSON.stringify(r1?.path)} vs ${JSON.stringify(rb?.path)}`,
);
}
// ----------------------------------------------------------------------
// 4. Reachability — every ordered pair resolves (strongly connected)
// ----------------------------------------------------------------------
{
let unresolved = 0;
let badNext = 0;
// Sample a slice of ordered pairs (the full N² is large; cover a wide mix
// including home and several far systems).
const sample = [HOME, ...ids.filter((id) => id !== HOME).slice(0, 24)];
for (const from of sample) {
for (const to of sample) {
if (from === to) continue;
const r = planRoute(g, from, to);
if (!r || r.path[0] !== from || r.path[r.path.length - 1] !== to) {
unresolved++;
continue;
}
// next must be a gate neighbour of from (the compass's target gate).
if (r.next !== null && !adj(from).has(r.next)) badNext++;
}
}
check('every sampled ordered pair resolves (strong connectivity)', unresolved === 0, `${unresolved} unresolved`);
check('every route\'s next is a gate neighbour of from', badNext === 0, `${badNext} bad`);
}
// ----------------------------------------------------------------------
// 5. nextSystem helper
// ----------------------------------------------------------------------
{
const dest = ids.find((id) => id !== HOME) ?? HOME;
const r = planRoute(g, HOME, dest);
check('nextSystem === planRoute.next', nextSystem(g, HOME, dest) === (r?.next ?? null));
check('nextSystem(from,from) === null', nextSystem(g, HOME, HOME) === null);
}
// ----------------------------------------------------------------------
// 6. Guard rails — unknown endpoints
// ----------------------------------------------------------------------
{
check('unknown from → null', planRoute(g, 'NOPE', HOME) === null);
check('unknown to → null', planRoute(g, HOME, 'NOPE') === null);
check('non-string → null', planRoute(g, null, HOME) === null);
// No galaxy + a real destination (from ≠ to) → no route to compute.
// (from === to is the "already there" short-circuit — valid without a
// graph, so it is asserted in section 1, not here.)
check('no galaxy + distinct endpoints → null', planRoute(null, HOME, 'S000001') === null);
check('empty galaxy + distinct endpoints → null', planRoute({ byId: new Map(), jumpNetwork: { gates: new Map() } }, 'A', 'B') === null);
}
console.log(`\n${failures === 0 ? 'ALL PASS' : failures + ' FAILURE(S)'}\n`);
process.exit(failures === 0 ? 0 : 1);