294 lines
14 KiB
JavaScript
294 lines
14 KiB
JavaScript
/**
|
||
* 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((w−2p)/bw, (h−2p)/bh)');
|
||
ok(near(f.scale * 100, 362, 1e-6) && f.scale * 200 <= 778.7 + 1e-6, 'constrained axis fills plate−margin; 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);
|