Files
gods-eye-view/src/data/firmsInteraction.test.mjs
T
daikaginza 843c8b1a6b test(i18n): feed real FIRMS and ALPR labels through the voice card
The card's fire pattern is written by hand against the producers' template,
so a format change upstream would silently show English. Both locale tests
now pass the label the layer actually produced through shownEntityLabel()
under ru, and set the locale inside the try that resets it.
2026-10-18 15:22:31 +07:00

395 lines
15 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 { getSelectedEntityContext } from './contextStore.js';
import { setLocale } from '../i18n/index.js';
import { shownEntityLabel } from '../voice/speech.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();
}
});
// The voice model reads these labels as data (track_entity, get_entity_context,
// pointer_context); the voice card translates them where it shows them.
test('fire labels stay English data in a translated interface', () => {
const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } });
try {
setLocale('ru');
h.click();
assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW');
// The card's pattern must match what the producer actually writes.
assert.equal(
shownEntityLabel(getSelectedEntityContext().label),
'Пожар · FRP 1520 МВт',
);
assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW');
assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW');
} finally {
h.cleanup();
setLocale('en');
}
});
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(); }
});