274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import {
|
|
_setTrackedOverlayHostForTest,
|
|
cachedTrackedDisplayPosition,
|
|
cachedTrackedVisualPosition,
|
|
createTrackedOverlayEntry,
|
|
destroyTrackedReadout,
|
|
getActiveTrackedReadoutId,
|
|
initTrackedReadout,
|
|
refreshTrackedReadout,
|
|
trackedLabelModelFromText,
|
|
} from './trackedReadout.js';
|
|
|
|
function makeCesiumEvent() {
|
|
const listeners = new Set();
|
|
return {
|
|
addEventListener(callback) {
|
|
listeners.add(callback);
|
|
return () => listeners.delete(callback);
|
|
},
|
|
raise() { for (const callback of [...listeners]) callback(); },
|
|
get size() { return listeners.size; },
|
|
};
|
|
}
|
|
|
|
function makeHostRecorder() {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
host: {
|
|
setEntries(sourceId, entries, options) { calls.push({ op: 'set', sourceId, entries, options }); },
|
|
setVisible(sourceId, visible) { calls.push({ op: 'visible', sourceId, visible }); },
|
|
clearSource(sourceId) { calls.push({ op: 'clear', sourceId }); },
|
|
},
|
|
};
|
|
}
|
|
|
|
test('tracked label text becomes an explicit title/details/accent model', () => {
|
|
assert.deepEqual(
|
|
trackedLabelModelFromText('UAL123 · FL350 · 451 kts', '#39d0ff'),
|
|
{ title: 'UAL123', details: ['FL350 · 451 kts'], accent: '#39d0ff' },
|
|
);
|
|
assert.deepEqual(
|
|
trackedLabelModelFromText('RCH123\nC-17 · 01-0186\nUSAF · FL310', '#ffd166'),
|
|
{
|
|
title: 'RCH123',
|
|
details: ['C-17 · 01-0186', 'USAF · FL310'],
|
|
accent: '#ffd166',
|
|
},
|
|
);
|
|
});
|
|
|
|
test('tracked position source reads only gevDisplayPosition and never entity.position', () => {
|
|
const cached = { x: 1, y: 2, z: 3 };
|
|
let propertyReads = 0;
|
|
const entity = {
|
|
gevDisplayPosition: () => cached,
|
|
position: {
|
|
getValue() { propertyReads += 1; throw new Error('fresh position read is forbidden'); },
|
|
},
|
|
};
|
|
assert.equal(cachedTrackedDisplayPosition(entity), cached);
|
|
assert.equal(propertyReads, 0);
|
|
assert.equal(cachedTrackedDisplayPosition({ position: entity.position }), null);
|
|
assert.equal(propertyReads, 0);
|
|
});
|
|
|
|
test('the tracked card anchors to the visual position without repurposing the display accessor', () => {
|
|
// A grounded aircraft's 3D model rides a ground snap while its billboard stays
|
|
// at the reported (buried) altitude — ~100 m apart. The card must follow the
|
|
// model you can see, while `gevDisplayPosition` keeps returning the cached
|
|
// dead-reckoned value the follow camera settled on (anti-jitter contract).
|
|
const display = { x: 1, y: 2, z: 3 };
|
|
const visual = { x: 1, y: 2, z: 103 };
|
|
let displayReads = 0;
|
|
const entity = {
|
|
gevTrackedId: 'flights:aaa077',
|
|
gevDisplayPosition: () => { displayReads += 1; return display; },
|
|
gevVisualPosition: () => visual,
|
|
gevLabelModel: { title: 'SWA143', details: ['ON GROUND'], accent: '#6be8ff' },
|
|
};
|
|
assert.equal(cachedTrackedVisualPosition(entity), visual);
|
|
assert.equal(displayReads, 0, 'the visual accessor short-circuits the display read');
|
|
assert.equal(cachedTrackedDisplayPosition(entity), display,
|
|
'the display accessor is untouched and still reports the dead-reckoned position');
|
|
assert.equal(createTrackedOverlayEntry(entity).position(), visual,
|
|
'the published tracked entry anchors to the visual position');
|
|
|
|
// Layers with no 3D visual, and any layer before its model is ready, are
|
|
// unchanged: the accessor falls back to the display position.
|
|
const spriteOnly = { gevDisplayPosition: () => display };
|
|
assert.equal(cachedTrackedVisualPosition(spriteOnly), display);
|
|
assert.equal(cachedTrackedVisualPosition({ ...spriteOnly, gevVisualPosition: () => null }), display,
|
|
'a null visual position falls back rather than blanking the card');
|
|
assert.equal(
|
|
cachedTrackedVisualPosition({
|
|
gevDisplayPosition: () => display,
|
|
gevVisualPosition: () => { throw new Error('model gone'); },
|
|
}),
|
|
display,
|
|
'a throwing visual accessor falls back instead of dropping the card',
|
|
);
|
|
assert.equal(cachedTrackedVisualPosition(null), null);
|
|
});
|
|
|
|
test('tracked entry factory pins the production protected-lane policy', () => {
|
|
const display = { x: 4, y: 5, z: 6 };
|
|
const entry = createTrackedOverlayEntry({
|
|
gevTrackedId: 'satellites:25544',
|
|
gevDisplayPosition: () => display,
|
|
gevLabelModel: { title: 'ISS', details: ['420 km · NORAD 25544'], accent: '#ffd84d' },
|
|
});
|
|
assert.equal(entry.id, 'satellites:25544');
|
|
assert.equal(entry.position(), display);
|
|
assert.equal(entry.protected, true);
|
|
assert.equal(entry.paintLane, 'tracked');
|
|
assert.equal(entry.collisionGroup, 'ambient-card');
|
|
assert.equal(entry.edgeFade, 'keyhole');
|
|
});
|
|
|
|
test('tracked entity publishes a protected host entry backed by the frame cache', () => {
|
|
const originalWindow = globalThis.window;
|
|
const fakeWindow = new EventTarget();
|
|
const changed = makeCesiumEvent();
|
|
const display = { x: 10, y: 20, z: 30 };
|
|
const entity = {
|
|
id: 'generated',
|
|
gevTrackedId: 'flights:abc123',
|
|
gevDisplayPosition: () => display,
|
|
gevLabelModel: { title: 'UAL123', details: ['FL350 · 451 kts'], accent: '#39d0ff' },
|
|
};
|
|
const viewer = { trackedEntity: entity, trackedEntityChanged: changed };
|
|
const recorder = makeHostRecorder();
|
|
globalThis.window = fakeWindow;
|
|
_setTrackedOverlayHostForTest(recorder.host);
|
|
try {
|
|
initTrackedReadout(viewer);
|
|
const publication = recorder.calls.find(({ op }) => op === 'set');
|
|
assert.ok(publication);
|
|
assert.equal(publication.sourceId, 'tracked');
|
|
assert.equal(publication.entries.length, 1);
|
|
const entry = publication.entries[0];
|
|
assert.equal(entry.id, 'flights:abc123');
|
|
assert.equal(entry.variant, 'tracked');
|
|
assert.equal(entry.tracked, true);
|
|
assert.equal(entry.protected, true);
|
|
assert.equal(entry.paintLane, 'tracked');
|
|
assert.equal(entry.collisionGroup, 'ambient-card');
|
|
assert.equal(entry.position(), display);
|
|
assert.equal(publication.options.collisionCapacity, 0);
|
|
assert.equal(publication.options.hideInCockpit, true);
|
|
assert.equal(getActiveTrackedReadoutId(), 'flights:abc123');
|
|
|
|
entity.gevLabelModel = { ...entity.gevLabelModel, details: ['FL360 · 455 kts'] };
|
|
refreshTrackedReadout(entity);
|
|
assert.deepEqual(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].details, [
|
|
'FL360 · 455 kts',
|
|
]);
|
|
|
|
viewer.trackedEntity = null;
|
|
changed.raise();
|
|
assert.equal(getActiveTrackedReadoutId(), null);
|
|
assert.equal(recorder.calls.at(-1).op, 'clear');
|
|
} finally {
|
|
destroyTrackedReadout();
|
|
_setTrackedOverlayHostForTest();
|
|
globalThis.window = originalWindow;
|
|
}
|
|
});
|
|
|
|
test('selection lifecycle ignores vessels, accepts installations, and clears without stale cards', () => {
|
|
const originalWindow = globalThis.window;
|
|
const fakeWindow = new EventTarget();
|
|
const changed = makeCesiumEvent();
|
|
const viewer = { trackedEntity: null, trackedEntityChanged: changed };
|
|
const recorder = makeHostRecorder();
|
|
const installation = {
|
|
gevTrackedId: 'installations:fort-test',
|
|
gevDisplayPosition: () => ({ x: 1, y: 2, z: 3 }),
|
|
gevLabelModel: { title: 'FORT TEST', details: ['AIRFIELD'], accent: '#5aa9ff' },
|
|
};
|
|
globalThis.window = fakeWindow;
|
|
_setTrackedOverlayHostForTest(recorder.host);
|
|
try {
|
|
initTrackedReadout(viewer);
|
|
const setsBefore = recorder.calls.filter(({ op }) => op === 'set').length;
|
|
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
|
detail: { layerId: 'ais-live-vessels', entity: installation },
|
|
}));
|
|
assert.equal(recorder.calls.filter(({ op }) => op === 'set').length, setsBefore);
|
|
|
|
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
|
detail: { layerId: 'military-installations', entity: installation },
|
|
}));
|
|
assert.equal(getActiveTrackedReadoutId(), 'installations:fort-test');
|
|
assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FORT TEST');
|
|
|
|
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
|
detail: { layerId: 'ais-live-vessels', entity: installation },
|
|
}));
|
|
assert.equal(getActiveTrackedReadoutId(), null, 'sibling selection clears an installation card');
|
|
|
|
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
|
detail: { layerId: 'military-installations', entity: installation },
|
|
}));
|
|
|
|
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
|
detail: { layerId: 'military-installations' },
|
|
}));
|
|
assert.equal(getActiveTrackedReadoutId(), null);
|
|
assert.ok(recorder.calls.some(({ op }) => op === 'clear'));
|
|
|
|
const clearsBeforeDestroy = recorder.calls.filter(({ op }) => op === 'clear').length;
|
|
destroyTrackedReadout();
|
|
assert.ok(recorder.calls.filter(({ op }) => op === 'clear').length > clearsBeforeDestroy);
|
|
assert.equal(changed.size, 0, 'trackedEntityChanged listener was removed');
|
|
assert.ok(recorder.calls.some(({ op, visible }) => op === 'visible' && visible === false));
|
|
} finally {
|
|
destroyTrackedReadout();
|
|
_setTrackedOverlayHostForTest();
|
|
globalThis.window = originalWindow;
|
|
}
|
|
});
|
|
|
|
test('trackedReadout cannot resurrect a dedicated canvas or render listener', async () => {
|
|
const source = await readFile(new URL('./trackedReadout.js', import.meta.url), 'utf8');
|
|
for (const forbidden of [
|
|
'document.createElement',
|
|
"createElement('canvas')",
|
|
'postRender.addEventListener',
|
|
'worldToWindowCoordinates',
|
|
"id = 'tracked-readout'",
|
|
'keyholeLabelAlpha',
|
|
]) {
|
|
assert.equal(source.includes(forbidden), false, `dedicated renderer token returned: ${forbidden}`);
|
|
}
|
|
});
|
|
|
|
test('tracking layers write gevLabelModel and expose only their cached display positions', async () => {
|
|
const files = await Promise.all([
|
|
'flights.js',
|
|
'militaryFlights.js',
|
|
'satellites.js',
|
|
'militaryInstallations.js',
|
|
].map(async (name) => [name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')]));
|
|
const sources = Object.fromEntries(files);
|
|
for (const [name, source] of files) {
|
|
assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`);
|
|
assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`);
|
|
}
|
|
assert.ok(sources['flights.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
|
assert.ok(sources['militaryFlights.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
|
assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
|
assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false);
|
|
assert.equal(sources['militaryFlights.js'].includes('_trackedEntity.label.text'), false);
|
|
assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false);
|
|
});
|
|
|
|
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => {
|
|
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
|
|
[name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')]
|
|
)));
|
|
for (const [name, source] of files) {
|
|
assert.ok(
|
|
source.includes('const head = _trackedTrailCached() || _trackedDisplayPosition(_trackedIcao);'),
|
|
`${name} trail head uses the dedicated lower-centre model anchor`,
|
|
);
|
|
assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'),
|
|
`${name} keeps diffuse texture contribution weak through code-side MIX`);
|
|
}
|
|
});
|