orbit/dev/system-chart.test.mjs

294 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SystemChart test (dev tool, run with Node — no browser needed):
*
* node dev/system-chart.test.mjs
*
* Asserts the pure geometry + stats behind the deck's MAP console
* (js/galaxy/SystemChart.js):
* - chartBounds: the furthest X/Y extents of the object set (object
* EDGE = center ± radius), symmetric padding on every edge, the
* degenerate empty-set case (a 2×padding box about the origin);
* - fitToRect: uniform scale that fits the frame into the plate,
* centred — toX/toY land the bounds centre on the plate centre, the
* whole frame inside the plate, aspect preserved;
* - navDiscoveryStats: the SYSTEM DISCOVERY share over the system's NAV
* points (planets + space stations + gates) MINUS the central body
* ('home') — found/total + pct; 0 total → pct 0;
* - resourceStats: the SYSTEM RESOURCES share over the asteroid fields
* (content.asteroids) — found/total + pct; missing list → 0/0.
*/
import { chartBounds, fitPadded, fitToRect, navDiscoveryStats, resourceStats, systemChartSnapshot } from '../js/galaxy/SystemChart.js';
let passed = 0;
let failed = 0;
function ok(cond, label) {
if (cond) {
passed++;
console.log(` ok ${label}`);
} else {
failed++;
console.error(`FAIL ${label}`);
}
}
function near(a, b, eps = 1e-9) {
return Math.abs(a - b) <= eps;
}
// ── chartBounds ────────────────────────────────────────────────────────────
console.log('chartBounds');
{
const b = chartBounds(
[
{ x: 100, y: -50, radius: 30 },
{ x: -200, y: 40, radius: 10 },
{ x: 0, y: 500, radius: 200 },
],
1000
);
ok(near(b.minX, -210 - 1000), 'minX = leftmost edge padding');
ok(near(b.maxX, 0 + 200 + 1000), 'maxX = rightmost edge (the 200-radius object) + padding');
ok(near(b.minY, -50 - 30 - 1000), 'minY = top edge padding');
ok(near(b.maxY, 500 + 200 + 1000), 'maxY = bottom edge + padding');
ok(near(b.w, b.maxX - b.minX) && near(b.h, b.maxY - b.minY), 'w/h consistent');
ok(near(b.cx, (b.minX + b.maxX) / 2) && near(b.cy, (b.minY + b.maxY) / 2), 'centre = midpoint');
}
{
// default padding (the design rule: ~1024 px on every edge)
const b = chartBounds([{ x: 0, y: 0, radius: 500 }], 1024);
ok(near(b.minX, -1524) && near(b.maxX, 1524), '±(radius + 1024) about the origin');
}
{
const b = chartBounds([], 512);
ok(near(b.minX, -512) && near(b.maxX, 512) && near(b.minY, -512) && near(b.maxY, 512), 'empty set → a 2×padding box about the origin');
}
{
const b = chartBounds([{ x: 0, y: 0, radius: 10 }, { bogus: true }], 100);
ok(near(b.minX, -110), 'non-object entries ignored');
}
// ── fitToRect ──────────────────────────────────────────────────────────────
console.log('fitToRect');
{
const bounds = { minX: -1000, minY: -500, maxX: 3000, maxY: 1500, w: 4000, h: 2000, cx: 1000, cy: 500 };
const tf = fitToRect(bounds, 800, 600);
// uniform scale = min(800,600)/max(4000,2000) = 600/4000 = 0.15
ok(near(tf.scale, 0.15), 'scale = min(w,h) / max(bounds w,h)');
ok(near(tf.toX(1000), 400), 'toX: bounds centre → plate centre x');
ok(near(tf.toY(500), 300), 'toY: bounds centre → plate centre y');
ok(tf.toX(-1000) >= 0 && tf.toX(3000) <= 800, 'frame x inside plate');
ok(tf.toY(-500) >= 0 && tf.toY(1500) <= 600, 'frame y inside plate');
// aspect preserved: a 100×100 world square maps to a 15×15 px square
const x0 = tf.toX(0);
const y0 = tf.toY(0);
ok(near(tf.toX(100) - x0, tf.toY(100) - y0, 1e-9), 'uniform scale — x and y move equally');
}
{
// wider-than-tall plate: the frame is limited by WIDTH
const bounds = { minX: -100, minY: -100, maxX: 100, maxY: 100, w: 200, h: 200, cx: 0, cy: 0 };
const tf = fitToRect(bounds, 1000, 200);
ok(near(tf.scale, 1), 'width-constrained: scale = h / bounds.h');
ok(near(tf.toX(0), 500) && near(tf.toY(0), 100), 'centred');
ok(near(tf.toX(100) - tf.toX(-100), 200), 'frame width preserved');
}
// ── fitPadded (the galaxy plate's framing) ────────────────────────────────
console.log('fitPadded');
{
// a 2:1 box into the 2:1 map plate (830.7×414 at the 1280×720 design
// size) with a 26-plate-px margin — a TRUE rectangle fit: the
// constrained axis fills the plate minus exactly padPx, the other axis
// keeps ≥ padPx. (fitToRect would inscribe the box's bounding square
// and leave most of the plate empty.)
const bounds = { minX: -100, minY: -50, maxX: 100, maxY: 50, w: 200, h: 100, cx: 0, cy: 0 };
const f = fitPadded(bounds, 830.7, 414, 26);
ok(near(f.scale, Math.min(778.7 / 200, 362 / 100)), 'scale = min((w2p)/bw, (h2p)/bh)');
ok(near(f.scale * 100, 362, 1e-6) && f.scale * 200 <= 778.7 + 1e-6, 'constrained axis fills platemargin; the other axis stays inside');
ok(near(f.scale * f.bounds.h, 414, 1e-6), 'inflated bounds (the pan/zoom clamp) reach exactly the plate edge on the constrained axis — the margin survives panning');
ok(f.scale * f.bounds.w <= 830.7 + 1e-6, '...and never overflow the plate');
ok(near(f.bounds.cx, 0) && near(f.bounds.cy, 0), 'centre preserved');
}
{
// zero padding → plain true rectangle fit
const bounds = { minX: 0, minY: 0, maxX: 100, maxY: 100, w: 100, h: 100, cx: 50, cy: 50 };
const f = fitPadded(bounds, 830, 414, 0);
ok(near(f.scale, 414 / 100), 'square bounds into a wide plate: limited by the shorter side (like fitToRect)');
}
// ── navDiscoveryStats ──────────────────────────────────────────────────────
console.log('navDiscoveryStats');
{
const content = {
planets: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],
settlements: [
{ id: 's1', anchor: { type: 'space' } },
{ id: 's2', anchor: { type: 'surface' } }, // not a NAV point (planet-anchored)
],
jumps: [{ id: 'g1' }],
};
const foundSet = new Set(['A', 'g1']);
const discovery = { isDiscovered: (sysId, id) => foundSet.has(id) };
const st = navDiscoveryStats(discovery, 'sys1', content);
ok(st.total === 5, 'total = 3 planets + 1 space station + 1 gate (central body excluded, surface settlement excluded)');
ok(st.found === 2, 'found counts the discovered NAV points');
ok(near(st.pct, 2 / 5), 'pct = found/total');
}
{
const st = navDiscoveryStats({ isDiscovered: () => true }, 'sys', { planets: [], settlements: [], jumps: [] });
ok(st.total === 0 && st.found === 0 && st.pct === 0, 'no NAV points → 0/0, pct 0');
}
{
// the central body is discoverable ('home') but NOT counted — the
// readout rule: planets + stations + gates only.
const content = { planets: [{ name: 'A' }], settlements: [], jumps: [] };
const st = navDiscoveryStats({ isDiscovered: () => true }, 'sys', content);
ok(st.total === 1, 'home body never enters the total');
}
{
const st = navDiscoveryStats(null, 'sys', { planets: [{ name: 'A' }] });
ok(st.total === 1 && st.found === 0 && st.pct === 0, 'null discovery state → nothing found, no crash');
}
// ── resourceStats ──────────────────────────────────────────────────────────
console.log('resourceStats');
{
const content = { asteroids: [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }] };
const foundSet = new Set(['r2']);
const st = resourceStats({ isDiscovered: (sysId, id) => foundSet.has(id) }, 'sys', content);
ok(st.total === 3, 'total = asteroid cluster count');
ok(st.found === 1 && near(st.pct, 1 / 3), 'found/pct over the clusters');
}
{
const st = resourceStats({ isDiscovered: () => true }, 'sys', {});
ok(st.total === 0 && st.pct === 0, 'no asteroid fields → 0/0');
}
{
const st = resourceStats(null, 'sys', { asteroids: [{ id: 'r1' }] });
ok(st.total === 1 && st.found === 0, 'null discovery state → nothing found');
}
// ── systemChartSnapshot (the SYSTEM tab) ──────────────────────────────────
console.log('systemChartSnapshot');
{
// Real config + a real generated galaxy (the snapshot's true inputs).
const fs = await import('node:fs');
const { pathToFileURL, fileURLToPath } = await import('node:url');
const { dirname, join } = await import('node:path');
const here = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(here, '../js/config/Config.js')).href);
const configData = {};
for (const f of fs.readdirSync(join(here, '../data'))) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(here, '../data', f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(here, '../js/galaxy/Galaxy.js')).href);
const g = Galaxy.create('system-tab-test', { systemCount: 40 });
let checked = 0;
let shapeOk = true;
let coverageOk = true;
let laidOk = true;
let discoverOk = true;
let statsOk = true;
for (const rec of g.records) {
if (rec.id === g.homeSystemId) continue; // the home chart is a different path
const content = g.contentOf(rec.id);
// Discover exactly the first planet + first cluster (if the system has them).
const found = new Set();
const firstPlanet = (content.planets ?? [])[0]?.name;
const firstCluster = (content.asteroids ?? [])[0]?.id;
if (firstPlanet) found.add(firstPlanet);
if (firstCluster) found.add(firstCluster);
const isDisc = (id) => found.has(id);
const discovery = { isDiscovered: (sysId, id) => (sysId === rec.id ? isDisc(id) : false) };
const snap = systemChartSnapshot(rec.id, content, { discovery, isDiscovered: isDisc });
checked++;
// the shape contract the SYSTEM tab relies on
if (
!snap ||
snap.systemId !== rec.id ||
snap.systemName !== (content.name ?? rec.id) ||
snap.isHome !== false ||
snap.ship !== null ||
!Array.isArray(snap.tethers) ||
snap.tethers.length !== 0 ||
!snap.central ||
snap.central.isHome !== false ||
typeof snap.central.radius !== 'number' ||
typeof snap.central.typeLabel !== 'string' ||
!snap.stats ||
typeof snap.stats.nav.total !== 'number' ||
typeof snap.stats.res.total !== 'number'
)
shapeOk = false;
// coverage: EVERY laid-out object appears exactly once, by its
// discovery identity (planets by name; stations/gates/clusters by id)
const expected = new Set();
for (const p of content.planets ?? []) if (typeof p.x === 'number') expected.add(p.name);
for (const s of content.settlements ?? [])
if (s.anchor?.type === 'space' && typeof s.x === 'number') expected.add(s.id);
for (const j of content.jumps ?? []) if (typeof j.x === 'number') expected.add(j.id);
for (const c of content.asteroids ?? []) if (typeof c.x === 'number') expected.add(c.id);
const ids = snap.objects.map((o) => o.id);
if (new Set(ids).size !== ids.length) coverageOk = false; // duplicates
for (const id of expected) if (!ids.includes(id)) coverageOk = false;
if (ids.length !== expected.size) coverageOk = false;
// every object is laid out (x/y/radius) and label-ready (name/kind)
for (const o of snap.objects) {
if (typeof o.x !== 'number' || typeof o.y !== 'number' || !(o.radius > 0) || typeof o.name !== 'string' || typeof o.kind !== 'string') laidOk = false;
}
// discovered flags track the Discovery footprint exactly
for (const o of snap.objects) if (o.discovered !== isDisc(o.id)) discoverOk = false;
// the readout = the nav/resource shares over the same footprint
let navTotal = 0;
let navFound = 0;
for (const p of content.planets ?? []) {
navTotal++;
if (isDisc(p.name)) navFound++;
}
for (const s of content.settlements ?? []) {
if (s.anchor?.type !== 'space') continue;
navTotal++;
if (isDisc(s.id)) navFound++;
}
for (const j of content.jumps ?? []) {
navTotal++;
if (isDisc(j.id)) navFound++;
}
const resTotal = (content.asteroids ?? []).length;
const resFound = (content.asteroids ?? []).filter((c) => isDisc(c.id)).length;
if (snap.stats.nav.total !== navTotal || snap.stats.nav.found !== navFound || snap.stats.res.total !== resTotal || snap.stats.res.found !== resFound)
statsOk = false;
}
ok(checked >= 10, `checked ${checked} remote systems`);
ok(shapeOk, 'shape: remote snapshot (ship null, tethers [], isHome false, central non-home, stats present)');
ok(coverageOk, 'objects = every laid-out planet/station/gate/cluster, by discovery id, no duplicates');
ok(laidOk, 'every object has x/y/radius>0/name/kind');
ok(discoverOk, 'discovered flags track the Discovery footprint');
ok(statsOk, 'stats = the nav/resource shares over the same footprint');
// determinism: same seed ⇒ identical snapshot (the chart is the data)
const remote = g.records.find((r) => r.id !== g.homeSystemId);
const a = systemChartSnapshot(remote.id, g.contentOf(remote.id), { isDiscovered: () => true });
const g2 = Galaxy.create('system-tab-test', { systemCount: 40 });
const b = systemChartSnapshot(remote.id, g2.contentOf(remote.id), { isDiscovered: () => true });
ok(JSON.stringify(a) === JSON.stringify(b), 'deterministic per seed (fresh galaxy, same content)');
// no discovery state at all → everything undiscovered, no crash
const c = systemChartSnapshot(remote.id, g.contentOf(remote.id), {});
ok(c.objects.every((o) => o.discovered === false) && c.stats.nav.found === 0 && c.stats.res.found === 0, 'no discovery state → nothing discovered');
// missing inputs
ok(systemChartSnapshot(null, { planets: [] }) === null, 'null systemId → null');
ok(systemChartSnapshot('sys', null) === null, 'null content → null');
}
// ── result ─────────────────────────────────────────────────────────────────
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);