import assert from 'node:assert/strict'; import test from 'node:test'; import { SATELLITE_CLASSES, SATELLITE_CLASS_ORDER, satelliteClassColor, satelliteClassLabel, satelliteClassLegend, satelliteClassOf, tallySatelliteClasses, } from './satelliteClass.js'; import satellitesLayer, { _catalogGroupForTest, _clearDenseCatalogStateForTest, _setDenseCatalogStateForTest, } from './satellites.js'; /** * A real, parseable TLE under an arbitrary 5-digit catalog number, so the dense * load exercises the production path. Both fixtures deliberately avoid 25544 — * that is the ISS, and the ISS is force-classified as a STATION everywhere. * @param {string} satnum Five-digit catalog number. * @param {string} name Object name line. */ const tleFor = (satnum, name) => [ name, `1 ${satnum}U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927`, `2 ${satnum} 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537`, ].join('\n'); const DENSE_TLE = tleFor('44444', 'STARLINK-TEST'); const ALT_TLE = tleFor('33333', 'DRIFTER-1'); const ISS_TLE = tleFor('25544', 'ISS (ZARYA)'); /** Poll the chip until the async dense load settles (or give up). */ async function settleChip(maxTicks = 50) { for (let i = 0; i < maxTicks; i++) { const chip = satellitesLayer.getRowControls().chips[0]; if (chip && !chip.busy) return chip; await new Promise((resolve) => setTimeout(resolve, 1)); } return satellitesLayer.getRowControls().chips[0]; } /** Every CelesTrak group the catalog ingests, plus the dense-mode tag. */ const INGESTED_GROUPS = ['stations', 'visual', 'gps-ops', 'glonass', 'galileo', 'geo', 'dense']; test('every ingested CelesTrak group resolves to a real class', () => { // Guards against drift: adding a group to CATALOG_GROUPS without classifying // it would silently dump those satellites into the catch-all bucket. for (const group of INGESTED_GROUPS) { const { klass } = satelliteClassOf(group); assert.ok(SATELLITE_CLASSES[klass], `${group} maps to an unknown class ${klass}`); assert.ok(SATELLITE_CLASS_ORDER.includes(klass), `${klass} is missing from the legend order`); } }); test('the three GNSS constellations share one NAV color and split by subtype', () => { const navGroups = ['gps-ops', 'glonass', 'galileo']; const colors = new Set(navGroups.map(satelliteClassColor)); assert.equal(colors.size, 1, 'GPS, GLONASS and Galileo must read as one NAV family'); assert.equal([...colors][0], SATELLITE_CLASSES.nav.color); assert.equal(satelliteClassLabel('gps-ops'), 'NAV · GPS'); assert.equal(satelliteClassLabel('glonass'), 'NAV · GLONASS'); assert.equal(satelliteClassLabel('galileo'), 'NAV · GALILEO'); }); test('class labels name the type, and the ISS names itself', () => { assert.equal(satelliteClassLabel('geo'), 'GEO'); assert.equal(satelliteClassLabel('stations'), 'STATION'); assert.equal(satelliteClassLabel('visual'), 'VISUAL'); assert.equal(satelliteClassLabel('dense'), 'COMMS · STARLINK'); assert.equal(satelliteClassLabel('stations', { isIss: true }), 'STATION · ISS'); }); test('the ISS is a STATION whichever group a partial outage ingested it from', () => { // CelesTrak lists the ISS in `visual` as well as `stations`, and a rebuild // survives a partial group failure. If the stations feed drops, the ISS is // ingested as `visual` — its card must not then read "VISUAL · ISS" beside // the red station dot. assert.equal(satelliteClassLabel('visual', { isIss: true }), 'STATION · ISS'); assert.equal(satelliteClassLabel('geo', { isIss: true }), 'STATION · ISS'); assert.equal(satelliteClassLabel(undefined, { isIss: true }), 'STATION · ISS'); // Non-ISS satellites in those groups are unaffected. assert.equal(satelliteClassLabel('visual'), 'VISUAL'); }); test('an unknown group falls back to the neutral bucket instead of vanishing', () => { assert.equal(satelliteClassOf('weather').klass, 'visual'); assert.equal(satelliteClassOf(undefined).klass, 'visual'); assert.equal(satelliteClassColor(null), SATELLITE_CLASSES.visual.color); assert.equal(satelliteClassLabel('some-new-celestrak-group'), 'VISUAL'); }); test('class colors are distinct, valid, and never borrow the military amber', () => { const colors = SATELLITE_CLASS_ORDER.map((k) => SATELLITE_CLASSES[k].color); assert.equal(new Set(colors).size, colors.length, 'two classes share a color'); for (const color of colors) { assert.match(color, /^#[0-9a-f]{6}$/, `${color} is not a 6-digit hex`); } // Amber 40-48deg is the app-wide known-military convention (flights.js // MIL_TINT #FFB800 / militaryFlights.js #FFB800 + #FFD166). A satellite // class landing there would read as a military air contact. const hueOf = (hex) => { const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); const max = Math.max(r, g, b); const min = Math.min(r, g, b); const d = max - min; if (d === 0) return 0; let h; if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; return ((h * 60) + 360) % 360; }; const saturationOf = (hex) => { const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); const max = Math.max(r, g, b); const min = Math.min(r, g, b); if (max === 0) return 0; return (max - min) / max; }; for (const klass of SATELLITE_CLASS_ORDER) { const color = SATELLITE_CLASSES[klass].color; const amberish = hueOf(color) >= 35 && hueOf(color) <= 50 && saturationOf(color) > 0.45; assert.equal(amberish, false, `${klass} (${color}) sits in the military amber band`); } }); test('the dense shell stays dimmer than the class it sits among', () => { // NVG/FLIR collapse the scene to Rec.601 luma, and DENSE mode puts thousands // of COMMS points in the same LEO volume as VISUAL. Luma separation is what // keeps the core catalog readable through the dense shell. const luma = (hex) => { const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); return (0.299 * r + 0.587 * g + 0.114 * b) / 255; }; assert.ok( luma(SATELLITE_CLASSES.visual.color) - luma(SATELLITE_CLASSES.comms.color) > 0.2, 'COMMS must stay well below VISUAL in luminance', ); assert.ok( luma(SATELLITE_CLASSES.station.color) > luma(SATELLITE_CLASSES.visual.color), 'STATION is the brightest core class', ); }); test('tallying groups counts by class, folding the GNSS constellations together', () => { const counts = tallySatelliteClasses([ 'gps-ops', 'gps-ops', 'glonass', 'galileo', 'geo', 'geo', 'geo', 'stations', 'visual', 'dense', 'dense', ]); assert.equal(counts.nav, 4, 'all three GNSS groups fold into NAV'); assert.equal(counts.geo, 3); assert.equal(counts.station, 1); assert.equal(counts.visual, 1); assert.equal(counts.comms, 2); }); test('tallying tolerates an empty or missing input', () => { assert.deepEqual({ ...tallySatelliteClasses([]) }, {}); assert.deepEqual({ ...tallySatelliteClasses(undefined) }, {}); }); test('the tally files the ISS under STATION even when a partial outage moved it', () => { // The legend and the card must never disagree. With the stations feed down, // the ISS is ingested as `visual`; counting it there would let STATION vanish // from the legend while the tracked card still reads "STATION · ISS". const outage = tallySatelliteClasses([ { group: 'visual', isIss: true }, { group: 'visual' }, { group: 'visual' }, ]); assert.equal(outage.station, 1, 'the ISS is counted as a STATION'); assert.equal(outage.visual, 2, 'and is not double-counted under VISUAL'); const legend = satelliteClassLegend(outage); assert.equal(legend.some((row) => row.klass === 'station'), true, 'STATION stays in the legend, matching the card'); // Bare group tags still work — the descriptor form is additive. assert.deepEqual({ ...tallySatelliteClasses(['visual', 'visual']) }, { visual: 2 }); assert.equal(satelliteClassOf('visual', { isIss: true }).klass, 'station', 'one helper owns the rule for every consumer'); }); test('the legend lists present classes in order and omits absent ones', () => { const legend = satelliteClassLegend(tallySatelliteClasses(['geo', 'gps-ops', 'stations'])); assert.deepEqual(legend.map((row) => row.klass), ['station', 'nav', 'geo'], 'legend follows SATELLITE_CLASS_ORDER, not input order'); // COMMS only exists in DENSE mode — the legend must not advertise a class // that has nothing on screen. assert.equal(legend.some((row) => row.klass === 'comms'), false); const row = legend.find((entry) => entry.klass === 'nav'); assert.equal(row.label, 'NAV'); assert.equal(row.color, SATELLITE_CLASSES.nav.color); assert.equal(row.count, 1); assert.ok(row.blurb.length > 0, 'every legend row carries a plain-language gloss'); }); test('the legend drops zero and negative counts', () => { assert.deepEqual(satelliteClassLegend({ nav: 0, geo: -1, station: 2 }).map((r) => r.klass), ['station']); assert.deepEqual(satelliteClassLegend(null), []); }); test('the DENSE row chip is stateless and declares the params to apply', () => { // The chip never carries its own state: it declares the params to apply, so // whatever else drives the catalog param (Space Missions capture/restore) // stays authoritative. Whether it reads ACTIVE is decided by the dense LOAD, // not the param — see the load-lifecycle tests below. try { _setDenseCatalogStateForTest({}); const chip = satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog'); assert.equal(chip.id, 'catalog'); assert.equal(chip.label, 'DENSE'); assert.equal(chip.active, false); assert.equal(chip.busy, false); assert.equal(chip.state, 'idle'); assert.deepEqual(chip.params, { catalog: 'dense' }, 'clicking from core requests dense'); assert.ok(chip.title.length > 0, 'the chip explains itself on hover'); } finally { _clearDenseCatalogStateForTest(); } }); test('an invalid catalog mode is rejected without disturbing the chip', () => { satellitesLayer.setParams({ catalog: 'core' }); satellitesLayer.setParams({ catalog: 'everything' }); assert.equal(satellitesLayer.getParams().catalog, 'core'); const chip = satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog'); assert.equal(chip.active, false); }); test('DENSE reports loading, then ACTIVE only once the points exist', async () => { const originalFetch = globalThis.fetch; const refreshes = []; try { globalThis.fetch = async () => ({ ok: true, text: async () => DENSE_TLE }); _setDenseCatalogStateForTest({}); satellitesLayer.setRowControlsListener(() => refreshes.push(satellitesLayer.getRowControls())); satellitesLayer.setParams({ catalog: 'dense' }); // The param flips synchronously, but the shell has not arrived yet — the // chip must say BUSY, never ACTIVE. const pending = satellitesLayer.getRowControls().chips[0]; assert.equal(pending.busy, true); assert.equal(pending.disabled, true); assert.equal(pending.active, false, 'never active before the points exist'); assert.equal(pending.state, 'loading'); const settled = await settleChip(); assert.equal(settled.active, true, 'active once the dense points are on screen'); assert.equal(settled.state, 'active'); assert.equal(settled.label, 'DENSE'); assert.deepEqual(settled.params, { catalog: 'core' }, 'a settled chip toggles back off'); // The completion pushed a refresh, and the legend gained COMMS with it — // without that push the row would keep the pre-load counts for 5 minutes. assert.ok(refreshes.length >= 2, 'load start and load completion each pushed a refresh'); const legend = satellitesLayer.getRowControls().legend; assert.equal(legend.some((row) => row.klass === 'comms'), true); } finally { globalThis.fetch = originalFetch; _clearDenseCatalogStateForTest(); } }); test('a failed DENSE load reverts the mode rather than leaving an active chip', async () => { const originalFetch = globalThis.fetch; const warn = console.warn; console.warn = () => {}; try { globalThis.fetch = async () => ({ ok: false, status: 502 }); _setDenseCatalogStateForTest({}); let pushes = 0; satellitesLayer.setRowControlsListener(() => { pushes += 1; }); satellitesLayer.setParams({ catalog: 'dense' }); const settled = await settleChip(); assert.equal(settled.active, false, 'a 502 must never present as a live dense catalog'); assert.equal(settled.state, 'error'); assert.equal(settled.label, 'DENSE ✕'); assert.match(settled.title, /502/, 'the chip explains why'); assert.equal(satellitesLayer.getParams().catalog, 'core', 'the mode reverts to reality'); assert.deepEqual(settled.params, { catalog: 'dense' }, 'clicking retries'); assert.equal( satellitesLayer.getRowControls().legend.some((row) => row.klass === 'comms'), false, 'no COMMS class is advertised when nothing loaded', ); assert.ok(pushes >= 2, 'the failure pushed its own refresh'); // Retrying against a healthy feed clears the error — it does not latch. globalThis.fetch = async () => ({ ok: true, text: async () => DENSE_TLE }); satellitesLayer.setParams(settled.params); const recovered = await settleChip(); assert.equal(recovered.state, 'active'); assert.equal(recovered.active, true); assert.doesNotMatch(recovered.title, /502/, 'the stale failure reason is gone'); } finally { console.warn = warn; globalThis.fetch = originalFetch; _clearDenseCatalogStateForTest(); } }); test('a 200 that yields no usable satellites is a failure, not a live catalog', async () => { // The round-1 fix caught the 502. A healthy response carrying an empty body, // an HTML error page the proxy passed through, or only satellites the core // catalog already owns reaches the same lie through a different door. const originalFetch = globalThis.fetch; const warn = console.warn; console.warn = () => {}; const bodies = { empty: '', html: '502 Bad Gateway', junk: 'NOT-A-TLE\nstill not a tle\nnope', }; try { for (const [name, body] of Object.entries(bodies)) { globalThis.fetch = async () => ({ ok: true, text: async () => body }); _setDenseCatalogStateForTest({}); satellitesLayer.setParams({ catalog: 'dense' }); const chip = await settleChip(); assert.equal(chip.active, false, `${name}: never active with zero dense points`); assert.equal(chip.state, 'error', `${name}: reported as a failure`); assert.equal(satellitesLayer.getParams().catalog, 'core', `${name}: mode reverted`); assert.match(chip.title, /no satellites/, `${name}: says why`); _clearDenseCatalogStateForTest(); } // The same guard fires when every TLE is already in the core catalog, so // "added nothing" can never masquerade as "loaded". globalThis.fetch = async () => ({ ok: true, text: async () => DENSE_TLE }); _setDenseCatalogStateForTest({}); satellitesLayer.setParams({ catalog: 'dense' }); assert.equal((await settleChip()).state, 'active', 'a real feed still loads'); _clearDenseCatalogStateForTest(); } finally { console.warn = warn; globalThis.fetch = originalFetch; _clearDenseCatalogStateForTest(); } }); test('an explicit return to core clears a failure the user never caused', async () => { // Space Missions forces dense; if that load fails it reverts the param to // core itself. The mission's restore of an already-core snapshot then changes // nothing — and used to leave DENSE ✕ latched on the user's row. const originalFetch = globalThis.fetch; const warn = console.warn; console.warn = () => {}; try { globalThis.fetch = async () => ({ ok: false, status: 502 }); _setDenseCatalogStateForTest({}); satellitesLayer.setParams({ catalog: 'dense' }); assert.equal((await settleChip()).state, 'error'); assert.equal(satellitesLayer.getParams().catalog, 'core', 'the failure already reverted it'); // A no-op restore of the pre-mission snapshot. satellitesLayer.setParams({ catalog: 'core', showPoints: true, showOrbits: true }); const restored = satellitesLayer.getRowControls().chips[0]; assert.equal(restored.state, 'idle', 'the error does not survive the restore'); assert.equal(restored.label, 'DENSE'); assert.doesNotMatch(restored.title, /502/); } finally { console.warn = warn; globalThis.fetch = originalFetch; _clearDenseCatalogStateForTest(); } }); test('a real stations-feed outage keeps STATION in the legend, matching the card', async () => { // End-to-end version of the tally rule: drive a real rebuild in which only // the `visual` group answers, so the ISS is genuinely ingested as `visual`. // The legend must still file it under STATION or it disagrees with the card. const originalFetch = globalThis.fetch; const log = console.log; const warn = console.warn; console.log = () => {}; console.warn = () => {}; try { _setDenseCatalogStateForTest({}); const viewer = { scene: { primitives: { add: (p) => p, remove() {} } } }; globalThis.fetch = async (url) => ({ ok: true, text: async () => (String(url).endsWith('/visual') ? ISS_TLE : ''), }); await satellitesLayer.update(viewer); assert.equal(_catalogGroupForTest(25544), 'visual', 'the outage really did ingest the ISS from the visual group'); const legend = satellitesLayer.getRowControls().legend; assert.equal(legend.find((row) => row.klass === 'station')?.count, 1, 'STATION survives the outage because the ISS is counted there'); assert.equal(legend.some((row) => row.klass === 'visual'), false, 'and it is not also counted under VISUAL'); assert.equal(satelliteClassLabel('visual', { isIss: true }), 'STATION · ISS', 'the card says the same thing the legend does'); } finally { console.log = log; console.warn = warn; globalThis.fetch = originalFetch; _clearDenseCatalogStateForTest(); } }); test('a catalog rebuild refreshes the detection overlay class strings', async () => { // The overlay caches one record per satellite and stamps id/class at // creation. A rebuild can re-tag a satellite (whichever group survives an // outage wins dedupe), so a cache that outlives the catalog shows the old // class forever. const originalFetch = globalThis.fetch; const log = console.log; const warn = console.warn; console.log = () => {}; console.warn = () => {}; try { _setDenseCatalogStateForTest({}); const viewer = { scene: { primitives: { remove() {} } } }; let homeGroup = 'geo'; globalThis.fetch = async (url) => ({ ok: true, text: async () => (String(url).endsWith(`/${homeGroup}`) ? ALT_TLE : ''), }); await satellitesLayer.update(viewer); const first = satellitesLayer.getDetectableObjects().find((o) => o.sourceId === 33333); assert.ok(first, 'the seeded satellite is collectable'); assert.equal(first.klass, 'GEO'); homeGroup = 'visual'; await satellitesLayer.update(viewer); const second = satellitesLayer.getDetectableObjects().find((o) => o.sourceId === 33333); assert.equal(second.klass, 'VISUAL', 'the cached record does not outlive its catalog'); } finally { console.log = log; console.warn = warn; globalThis.fetch = originalFetch; _clearDenseCatalogStateForTest(); } }); test('a dependency owner hiding the points surrenders the whole row', async () => { try { _setDenseCatalogStateForTest({}); assert.equal(satellitesLayer.getRowControls().chips.length, 1); // Space Missions borrows this layer for TLE lookup with showPoints:false. satellitesLayer.setParams({ showPoints: false }); const owned = satellitesLayer.getRowControls(); assert.deepEqual(owned.chips, [], 'no chip to click while the owner holds the params'); assert.deepEqual(owned.legend, [], 'no legend describing an empty sky'); satellitesLayer.setParams({ showPoints: true }); assert.equal(satellitesLayer.getRowControls().chips.length, 1, 'released on restore'); } finally { _clearDenseCatalogStateForTest(); } });