mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
// FIRMS click routes: sprite AND card must both select for detail and hand the
|
|
// camera over (pre-launch defect #4 + P1-3). Driven through the production
|
|
// click handler and the production card-render path; only the viewer, the
|
|
// event handler, the overlay host and the world→window projection are stubbed.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as Cesium from 'cesium';
|
|
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
|
|
import { fireDetectionKey } from './firmsLabels.js';
|
|
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
|
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const LAYER_SOURCE = readLayerSource(
|
|
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
|
|
'utf8',
|
|
);
|
|
|
|
function makeFire(overrides = {}) {
|
|
return {
|
|
index: 7,
|
|
lat: 30.51,
|
|
lon: -98.21,
|
|
frp: 1520.4,
|
|
confidence: 0.9,
|
|
satellite: 'N21',
|
|
sensor: 'VIIRS',
|
|
acqMs: 1_753_600_000_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stand the layer up far enough to drive one click. `cardHit` is what the
|
|
* overlay host reports under the click position — the real host only publishes
|
|
* rects for `interactive` entries, so a stub hit is the contract a painted,
|
|
* actionable card presents.
|
|
*/
|
|
function harness({
|
|
fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null,
|
|
withDataSource = false, feed = null,
|
|
} = {}) {
|
|
const hadWindow = Object.hasOwn(globalThis, 'window');
|
|
const priorWindow = globalThis.window;
|
|
const priorProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
|
const windowTarget = new EventTarget();
|
|
globalThis.window = windowTarget;
|
|
// Spread projections so the greedy screen-separation declutter accepts every
|
|
// seeded detection; co-located fires would otherwise collide by design.
|
|
let projected = 0;
|
|
Cesium.SceneTransforms.worldToWindowCoordinates = () => {
|
|
projected += 1;
|
|
return { x: 200 * projected, y: 300 };
|
|
};
|
|
|
|
const published = [];
|
|
let handler = null;
|
|
const layer = createFirmsHeatmapLayer({
|
|
...(feed ? { feed } : {}),
|
|
id: 'firms-test',
|
|
name: 'Fires',
|
|
overlayHost: {
|
|
setEntries: (sourceId, entries) => published.push(...entries),
|
|
setVisible() {},
|
|
clearSource() {},
|
|
hitTest: () => (cardHit ? { sourceId: 'firms', entryId: cardHit } : null),
|
|
},
|
|
screenSpaceEventHandlerFactory: () => {
|
|
handler = { click: null, setInputAction(cb) { this.click = cb; }, destroy() {} };
|
|
return handler;
|
|
},
|
|
});
|
|
|
|
const viewer = {
|
|
scene: {
|
|
pick: () => picked,
|
|
canvas: { clientWidth: 1280, clientHeight: 800 },
|
|
camera: cameraPosition ? { positionWC: cameraPosition } : undefined,
|
|
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
|
},
|
|
dataSources: { add() {}, remove() {} },
|
|
};
|
|
// The LOD rebuild (and with it the context-registration sweep) is inert
|
|
// without a data source, so tests that drive a real refresh need one.
|
|
if (withDataSource) layer.init(viewer);
|
|
layer._bindInteractionForTest(viewer, fires);
|
|
|
|
const requests = [];
|
|
windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (e) => requests.push(e.detail));
|
|
|
|
return {
|
|
layer,
|
|
viewer,
|
|
windowTarget,
|
|
requests,
|
|
published,
|
|
fires,
|
|
click: () => handler.click({ position: { x: 400, y: 300 } }),
|
|
cleanup() {
|
|
Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection;
|
|
if (hadWindow) globalThis.window = priorWindow;
|
|
else delete globalThis.window;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('fire detection key survives a refetch that renumbers the index', () => {
|
|
const fire = makeFire();
|
|
assert.equal(fireDetectionKey(fire), fireDetectionKey({ ...fire, index: 999 }));
|
|
// Different pass over the same pixel is a different detection.
|
|
assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, acqMs: fire.acqMs + 1 }));
|
|
assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, lat: 31.0 }));
|
|
assert.match(fireDetectionKey({}), /^firms:x:x:0:x$/);
|
|
});
|
|
|
|
test('two satellites over the same pixel at the same time stay distinct', () => {
|
|
// The proxy merges SNPP + NOAA-20 + NOAA-21 without dedup and their orbits
|
|
// overlap, so position and time alone are NOT unique. Collapsing them let a
|
|
// restored selection land on the other satellite's record.
|
|
const base = makeFire({ satellite: 'N20' });
|
|
const twin = makeFire({ satellite: 'N21' });
|
|
const suomi = makeFire({ satellite: 'N' });
|
|
assert.notEqual(fireDetectionKey(base), fireDetectionKey(twin));
|
|
assert.notEqual(fireDetectionKey(base), fireDetectionKey(suomi));
|
|
assert.notEqual(fireDetectionKey(twin), fireDetectionKey(suomi));
|
|
// Satellite naming variants normalize to one identity, not three.
|
|
assert.equal(fireDetectionKey(base), fireDetectionKey(makeFire({ satellite: 'NOAA-20' })));
|
|
assert.equal(fireDetectionKey(suomi), fireDetectionKey(makeFire({ satellite: 'Suomi NPP' })));
|
|
// A record with no satellite falls back to its sensor, not to a shared blank.
|
|
const sensorOnly = makeFire({ satellite: '', sensor: 'MODIS' });
|
|
assert.notEqual(fireDetectionKey(sensorOnly), fireDetectionKey(makeFire({ satellite: '', sensor: 'VIIRS' })));
|
|
});
|
|
|
|
test('duplicate-coordinate detections get their own cards and focus targets', () => {
|
|
const n20 = makeFire({ index: 0, satellite: 'N20' });
|
|
const n21 = makeFire({ index: 1, satellite: 'N21' });
|
|
const h = harness({
|
|
fires: [n20, n21],
|
|
picked: { primitive: {}, content: {}, featureId: 0 },
|
|
cardHit: `fire:${fireDetectionKey(n21)}`,
|
|
});
|
|
try {
|
|
const cardIds = h.published.filter((e) => e.id.startsWith('fire:')).map((e) => e.id);
|
|
assert.equal(new Set(cardIds).size, 2, 'co-located detections must not collapse to one card');
|
|
h.click();
|
|
assert.equal(h.requests.length, 1);
|
|
assert.equal(h.requests[0].id, fireDetectionKey(n21), 'the clicked card wins, not its twin');
|
|
} finally {
|
|
h.cleanup();
|
|
}
|
|
});
|
|
|
|
test('selection restoration uses the same key the cards and focus use', () => {
|
|
// Restoration ran on raw lat/lon/acqMs, which collapses co-located
|
|
// detections from different satellites — the selection could come back on
|
|
// the wrong record. Reachable only through the 30-minute refetch, so this is
|
|
// a source contract; the key's own behavior is proven above.
|
|
const match = LAYER_SOURCE.match(/function findMatchingFire\(previous\) \{([\s\S]*?)\n \}\n/);
|
|
assert.ok(match, 'findMatchingFire is missing');
|
|
assert.match(match[1], /fireDetectionKey\(previous\)/);
|
|
assert.match(match[1], /fireDetectionKey\(fire\) === key/);
|
|
assert.doesNotMatch(match[1], /fire\.acqMs === previous\.acqMs/);
|
|
});
|
|
|
|
test('painted detection cards are keyed by the stable detection key', () => {
|
|
const h = harness();
|
|
try {
|
|
const card = h.published.find((entry) => entry.id.startsWith('fire:'));
|
|
assert.ok(card, 'a detection card was painted');
|
|
assert.equal(card.id, `fire:${fireDetectionKey(h.fires[0])}`);
|
|
assert.equal(card.interactive, true, 'a detection card must publish a hit rect');
|
|
assert.match(card.accessibilityLabel, /^Focus fire detection /);
|
|
assert.equal(card.activate(), true);
|
|
assert.equal(h.requests.length, 1);
|
|
assert.equal(h.requests[0].id, fireDetectionKey(h.fires[0]));
|
|
} finally {
|
|
h.cleanup();
|
|
}
|
|
});
|
|
|
|
test('an aggregated cell card is never actionable — it is not one fire', () => {
|
|
const cell = buildCellCard(
|
|
{ cell: { count: 14, maxFrp: 210, newestAcqMs: 0, latCell: 30, lonCell: -98 }, position: {} },
|
|
Date.now(),
|
|
);
|
|
assert.equal(applyFirmsOverlayPolicy(cell, 1e6).interactive, false);
|
|
});
|
|
|
|
test('far-side detections cannot consume the bounded ambient-card cohort', () => {
|
|
const front = makeFire({ index: 1, lat: 30.51, lon: -98.21, frp: 100 });
|
|
const rear = makeFire({ index: 2, lat: -30.51, lon: 81.79, frp: 1000 });
|
|
const cameraPosition = Cesium.Cartesian3.fromDegrees(front.lon, front.lat, 10_000_000);
|
|
const h = harness({ fires: [rear, front], cameraPosition });
|
|
try {
|
|
const cardIds = h.published.filter((entry) => entry.id.startsWith('fire:')).map((entry) => entry.id);
|
|
assert.deepEqual(cardIds, [`fire:${fireDetectionKey(front)}`]);
|
|
} finally {
|
|
h.cleanup();
|
|
}
|
|
});
|
|
|
|
test('clicking a fire sprite selects it and requests the camera transfer', () => {
|
|
const fire = makeFire();
|
|
const h = harness({ fires: [fire], picked: { id: 'firms-7' } });
|
|
try {
|
|
h.click();
|
|
assert.equal(h.requests.length, 1);
|
|
assert.equal(h.requests[0].kind, 'fire');
|
|
assert.equal(h.requests[0].id, fireDetectionKey(fire));
|
|
assert.ok(h.requests[0].position, 'the transfer needs a world position');
|
|
} finally {
|
|
h.cleanup();
|
|
}
|
|
});
|
|
|
|
test('clicking a fire CARD behaves exactly like clicking its sprite', () => {
|
|
const fire = makeFire();
|
|
// The click lands on the card, but the scene pick returns the 3D-Tiles
|
|
// surface behind it — the case that used to clear the selection.
|
|
const h = harness({
|
|
fires: [fire],
|
|
picked: { primitive: {}, content: {}, featureId: 0 },
|
|
cardHit: `fire:${fireDetectionKey(fire)}`,
|
|
});
|
|
try {
|
|
h.click();
|
|
assert.equal(h.requests.length, 1);
|
|
assert.equal(h.requests[0].kind, 'fire');
|
|
assert.equal(h.requests[0].id, fireDetectionKey(fire));
|
|
} finally {
|
|
h.cleanup();
|
|
}
|
|
});
|
|
|
|
test('a click on neither sprite nor card clears without moving the camera', () => {
|
|
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
|
|
try {
|
|
h.click();
|
|
assert.equal(h.requests.length, 0);
|
|
} finally {
|
|
h.cleanup();
|
|
}
|
|
});
|
|
|
|
// Sibling layers surface heterogeneous pick shapes. Reading `picked.id`
|
|
// directly recognized only bare strings, so an AIS sprite (or satellite, or
|
|
// entity) under a fire card let BOTH layers issue a camera command.
|
|
const SIBLING_PICKS = [
|
|
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
|
['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }],
|
|
['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }],
|
|
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
|
|
['satellites', '25544', { id: 25544 }],
|
|
];
|
|
|
|
for (const [layerId, ownedId, picked] of SIBLING_PICKS) {
|
|
test(`a ${layerId} pick shaped ${JSON.stringify(picked)} wins over a card hit`, () => {
|
|
const fire = makeFire();
|
|
registerPickOwner(layerId, (pickedId) => pickedId === ownedId);
|
|
const h = harness({
|
|
fires: [fire],
|
|
picked,
|
|
cardHit: `fire:${fireDetectionKey(fire)}`,
|
|
});
|
|
try {
|
|
h.click();
|
|
assert.equal(h.requests.length, 0, `the ${layerId} layer is already acting on this click`);
|
|
} finally {
|
|
h.cleanup();
|
|
unregisterPickOwner(layerId);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('a stale card id resolves to nothing instead of an arbitrary fire', () => {
|
|
const h = harness({
|
|
picked: { primitive: {}, content: {}, featureId: 0 },
|
|
cardHit: 'fire:firms:1.0000:1.0000:1:N20',
|
|
});
|
|
try {
|
|
h.click();
|
|
assert.equal(h.requests.length, 0);
|
|
} finally {
|
|
h.cleanup();
|
|
}
|
|
});
|
|
|
|
test('a refresh that drops the selected fire emits an eviction the readout can act on', async () => {
|
|
// Behavioral, not a source pin: the tag was present and correct while the
|
|
// event never fired, because the LOD rebuild deletes the selected context
|
|
// record first and the clear then fails its ownership guard silently.
|
|
const kept = makeFire({ index: 0 });
|
|
const dropped = makeFire({
|
|
index: 1, lat: 31.77, lon: -99.02, acqMs: 1_753_600_100_000, satellite: 'N20',
|
|
});
|
|
const h = harness({
|
|
fires: [dropped, kept],
|
|
cardHit: `fire:${fireDetectionKey(dropped)}`,
|
|
withDataSource: true,
|
|
});
|
|
const cleared = [];
|
|
h.windowTarget.addEventListener('gev:entity-selection-cleared', (e) => cleared.push(e.detail));
|
|
const priorFetch = globalThis.fetch;
|
|
|
|
try {
|
|
h.click();
|
|
assert.equal(h.requests.length, 1, 'the click must select the fire we are about to drop');
|
|
assert.equal(h.requests[0].id, fireDetectionKey(dropped));
|
|
assert.deepEqual(cleared, [], 'selecting must not clear anything yet');
|
|
|
|
// A normal, NON-EMPTY refresh that simply no longer carries that detection.
|
|
globalThis.fetch = async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
fetchedAt: Date.now(),
|
|
stale: false,
|
|
fires: [{
|
|
lat: kept.lat,
|
|
lon: kept.lon,
|
|
frp: kept.frp,
|
|
confidence: 'h',
|
|
brightness: 330,
|
|
daynight: 'D',
|
|
acqDate: '2025-07-27',
|
|
acqTime: '0412',
|
|
instrument: 'VIIRS',
|
|
satellite: kept.satellite,
|
|
}],
|
|
}),
|
|
});
|
|
await h.layer.update();
|
|
|
|
assert.equal(
|
|
cleared.length,
|
|
1,
|
|
'a vanished selection must reach consumers as an event, not as silence',
|
|
);
|
|
assert.equal(cleared[0].layerId, 'firms-test');
|
|
assert.equal(
|
|
cleared[0].reason,
|
|
'evicted',
|
|
'the detection left the feed — it was not deselected',
|
|
);
|
|
} finally {
|
|
if (priorFetch === undefined) delete globalThis.fetch;
|
|
else globalThis.fetch = priorFetch;
|
|
h.cleanup();
|
|
}
|
|
});
|
|
|
|
test('disable cancels a pending source and a late response cannot repopulate the layer', async () => {
|
|
let resolve, signal;
|
|
const h = harness({ withDataSource: true, feed: { getSnapshot(options) {
|
|
signal = options.signal; return new Promise(done => { resolve = done; });
|
|
} } });
|
|
try {
|
|
const before = h.layer.getStats();
|
|
const pending = h.layer.update();
|
|
h.layer.disable();
|
|
assert.equal(signal.aborted, true);
|
|
resolve({ fires: [], fetchedAt: Date.now() });
|
|
await pending;
|
|
assert.equal(h.layer.getStats().count, before.count);
|
|
assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate);
|
|
assert.equal(h.layer.getStats().loading, false);
|
|
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
|
|
});
|