/** * 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, 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'); } // ── 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);