mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Directions, the Contacts marker and panel, placed annotation labels, the drawn-line measure, the Cockpit and formatRouteDistance each chose the unit and precision on their own, and most chose before rounding: #1450, #1433 and formatDistanceText or kilometreDigits from src/data/distanceFormat.js, which cut after rounding. Each keeps its own metre step and rendering. That also fixes the 10 km cut the Contacts marker and panel, the drawn-line measure and the Cockpit still had (9,960 m read 10.0 km). The helper returns kilometres already rounded, so the plain marker and the localized panel no longer disagree on half-tenths such as 1,450 m. A negative annotation distance now leaves the label without a distance, and a drawn line with a coordinate that is not a number shows no measure, rather than printing 0 m or NaN km. The HUD altitude tag and the spoken view height keep their own unit choice.
4146 lines
125 KiB
JavaScript
4146 lines
125 KiB
JavaScript
import { readFileSync } from 'node:fs';
|
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as Cesium from 'cesium';
|
|
import militaryAwarenessLayer, {
|
|
_getAwarenessNavigationStateForTest,
|
|
_getAwarenessPanelRendersForTest,
|
|
awarenessClearMatchesSubject,
|
|
awarenessNeedsContinuousRender,
|
|
awarenessRefreshDecision,
|
|
awarenessRefreshIntervalMs,
|
|
awarenessRefreshRequired,
|
|
awarenessResultsAreLive,
|
|
buildAwarenessContextSnapshot,
|
|
canNavigateAwarenessNext,
|
|
contactsWindowFromSnapshot,
|
|
contextTargetFlyToAllowed,
|
|
summarizeInstallationViewport,
|
|
findCompatibleHistoryIndex,
|
|
AWARENESS_QUERY_LIMIT,
|
|
historySubjectSnapshot,
|
|
} from './militaryAwareness.js';
|
|
import flightsLayer, {
|
|
_setTrackedFlightRefreshStateForTest,
|
|
} from './flights.js';
|
|
import militaryFlightsLayer, {
|
|
_setTrackedMilitaryRefreshStateForTest,
|
|
} from './militaryFlights.js';
|
|
import aisLiveVesselsLayer, {
|
|
_setVesselStateForTest,
|
|
} from './aisLiveVessels.js';
|
|
import militaryInstallationsLayer from './militaryInstallations.js';
|
|
import {
|
|
_resetRenderGovernorForTest,
|
|
getRenderGovernorDiagnostics,
|
|
installRenderGovernor,
|
|
} from '../renderGovernor.js';
|
|
import {
|
|
AWARENESS_RADIUS_M,
|
|
formatAwarenessLabel,
|
|
getAwarenessNavigationTargets,
|
|
} from './militaryAwarenessEngine.js';
|
|
import {
|
|
formatAwarenessCount,
|
|
presentAwarenessDistance,
|
|
} from '../layers/awareness/panel.js';
|
|
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
|
|
|
const militaryAwarenessSource = readLayerSource(
|
|
new URL('./militaryAwareness.js', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const awarenessPanelSource = readFileSync(
|
|
new URL('../ui/views/panels/AwarenessPanel.vue', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
const AWARENESS_DEPENDENCIES = [
|
|
'flights',
|
|
'military',
|
|
'ais-live-vessels',
|
|
'military-installations',
|
|
];
|
|
|
|
test('Contacts labels a capped cohort as a lower bound', () => {
|
|
assert.equal(
|
|
formatAwarenessCount({ count: 20_000, complete: false, truncated: true }),
|
|
'At least 20,000',
|
|
);
|
|
assert.equal(
|
|
formatAwarenessCount({ count: 19_999, complete: true, truncated: false }),
|
|
'19,999',
|
|
);
|
|
assert.equal(formatAwarenessCount({ count: null }), '?');
|
|
});
|
|
|
|
test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => {
|
|
assert.equal(presentAwarenessDistance(999.4), '999\u00a0m');
|
|
assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km');
|
|
assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km');
|
|
assert.equal(presentAwarenessDistance(9949), '9.9\u00a0km');
|
|
assert.equal(presentAwarenessDistance(9950), '10\u00a0km');
|
|
assert.equal(presentAwarenessDistance(9960), '10\u00a0km');
|
|
});
|
|
|
|
function installAwarenessRuntime({
|
|
isEnabled = () => true,
|
|
isEffectivelyEnabled = isEnabled,
|
|
setEnabled = async () => true,
|
|
getLayerLifecycleState = (layerId) => ({
|
|
enabled: isEnabled(layerId),
|
|
lifecycleState: isEnabled(layerId) ? 'enabled' : 'disabled',
|
|
uncertain: false,
|
|
}),
|
|
// Per-layer getStats override, keyed by layer id. Whatever a caller puts here
|
|
// must be a shape the REAL module actually returns — a fixture that invents a
|
|
// status its module never emits guards nothing. See the settled-but-fetching
|
|
// tests below, which use militaryInstallations' genuine first-fetch readout.
|
|
layerStats = {},
|
|
} = {}) {
|
|
const originalWindow = globalThis.window;
|
|
const originalDocument = globalThis.document;
|
|
const listeners = new Map();
|
|
let nextTimer = 1;
|
|
let preRenderListener = null;
|
|
|
|
class FakeElement {
|
|
constructor() {
|
|
this.classList = { contains: () => false };
|
|
this.style = { setProperty() {} };
|
|
this.hidden = false;
|
|
}
|
|
|
|
setAttribute() {}
|
|
addEventListener() {}
|
|
removeEventListener() {}
|
|
appendChild() {}
|
|
append() {}
|
|
remove() {}
|
|
replaceChildren() {}
|
|
}
|
|
|
|
const fakeWindow = {
|
|
addEventListener(type, listener) {
|
|
if (!listeners.has(type)) listeners.set(type, new Set());
|
|
listeners.get(type).add(listener);
|
|
},
|
|
removeEventListener(type, listener) {
|
|
listeners.get(type)?.delete(listener);
|
|
},
|
|
setInterval() {
|
|
return nextTimer++;
|
|
},
|
|
clearInterval() {},
|
|
requestAnimationFrame() {
|
|
return nextTimer++;
|
|
},
|
|
cancelAnimationFrame() {},
|
|
dispatch(type, detail) {
|
|
for (const listener of listeners.get(type) || []) listener({ detail });
|
|
},
|
|
// Real dispatch surface: the layer announces navigation authority through
|
|
// it before any camera flight that does not assign a tracked entity.
|
|
dispatchEvent(event) {
|
|
for (const listener of listeners.get(event?.type) || []) listener(event);
|
|
return true;
|
|
},
|
|
};
|
|
const fakeBody = new FakeElement();
|
|
const renderRequests = [];
|
|
const cameraFlights = [];
|
|
const viewer = {
|
|
camera: {
|
|
flyToBoundingSphere(sphere, options) {
|
|
cameraFlights.push({ sphere, options });
|
|
},
|
|
cancelFlight() {},
|
|
},
|
|
scene: {
|
|
// Enough surface for the real render governor to drive this viewer.
|
|
requestRenderMode: false,
|
|
maximumRenderTimeChange: 0,
|
|
requestRender() {
|
|
renderRequests.push(Date.now());
|
|
},
|
|
preRender: {
|
|
addEventListener(listener) {
|
|
preRenderListener = listener;
|
|
return () => {
|
|
preRenderListener = null;
|
|
};
|
|
},
|
|
},
|
|
},
|
|
entities: {
|
|
add(entity) {
|
|
return entity;
|
|
},
|
|
remove() {},
|
|
},
|
|
container: new FakeElement(),
|
|
};
|
|
const layers = new Map(
|
|
AWARENESS_DEPENDENCIES.map((layerId) => [
|
|
layerId,
|
|
{
|
|
module: {
|
|
getStats() {
|
|
const override = layerStats[layerId];
|
|
if (override)
|
|
return typeof override === 'function' ? override() : override;
|
|
return { count: 4, status: 'ready', stale: false, error: null };
|
|
},
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
|
|
globalThis.window = fakeWindow;
|
|
globalThis.document = {
|
|
body: fakeBody,
|
|
getElementById() {
|
|
return null;
|
|
},
|
|
createElement() {
|
|
return new FakeElement();
|
|
},
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
};
|
|
militaryAwarenessLayer.attachDataManager({
|
|
isEnabled,
|
|
isEffectivelyEnabled,
|
|
setEnabled,
|
|
getLayerLifecycleState,
|
|
layers,
|
|
});
|
|
militaryAwarenessLayer.setParams({ passive: true });
|
|
militaryAwarenessLayer.init(viewer);
|
|
const panelViews = [];
|
|
const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) =>
|
|
panelViews.push(view),
|
|
);
|
|
militaryAwarenessLayer.enable();
|
|
|
|
return {
|
|
dispatch: fakeWindow.dispatch.bind(fakeWindow),
|
|
tick() {
|
|
preRenderListener?.();
|
|
},
|
|
viewer,
|
|
renderRequests,
|
|
cameraFlights,
|
|
window: fakeWindow,
|
|
/** Record every navigation-authority announcement, in order. */
|
|
captureAuthority() {
|
|
const taken = [];
|
|
fakeWindow.addEventListener(NAVIGATION_AUTHORITY_EVENT, (event) =>
|
|
taken.push(event?.detail?.reason || 'unknown'),
|
|
);
|
|
return taken;
|
|
},
|
|
/** The Contacts panel as the layer publishes it to the view. */
|
|
panel() {
|
|
return {
|
|
view: panelViews.at(-1) ?? null,
|
|
publishes: panelViews.length,
|
|
renders: _getAwarenessPanelRendersForTest(),
|
|
};
|
|
},
|
|
restore() {
|
|
unsubscribePanel();
|
|
militaryAwarenessLayer.destroy();
|
|
globalThis.window = originalWindow;
|
|
globalThis.document = originalDocument;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The count a published Contacts panel shows for one cohort row. */
|
|
function panelCount(runtime, label) {
|
|
return runtime.panel().view?.rows?.find((row) => row.label === label)?.count;
|
|
}
|
|
|
|
test('Contacts continuation target is the visible explanatory note', () => {
|
|
assert.match(
|
|
awarenessPanelSource,
|
|
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\{\{ view\.note \}\}<\/p>/,
|
|
);
|
|
assert.doesNotMatch(
|
|
awarenessPanelSource,
|
|
/<span[^>]*data-awareness-focus-continuation/,
|
|
);
|
|
assert.match(
|
|
militaryAwarenessSource,
|
|
/'Open-source mapped\/observed context\./,
|
|
);
|
|
});
|
|
|
|
test('awareness disable settles every owned dependency release before resolving', async () => {
|
|
let releasing = false;
|
|
let releaseDependencies;
|
|
const releaseGate = new Promise((resolve) => {
|
|
releaseDependencies = resolve;
|
|
});
|
|
const calls = [];
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => false,
|
|
setEnabled: async (layerId, enabled) => {
|
|
calls.push({ layerId, enabled });
|
|
if (!enabled) await releaseGate;
|
|
return true;
|
|
},
|
|
});
|
|
|
|
try {
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
militaryAwarenessLayer.enable();
|
|
await nextTurn();
|
|
const disabling = militaryAwarenessLayer.disable().then(() => {
|
|
releasing = true;
|
|
});
|
|
await nextTurn();
|
|
assert.equal(
|
|
releasing,
|
|
false,
|
|
'coordinator OFF waits for owned dependency releases',
|
|
);
|
|
assert.deepEqual(
|
|
calls
|
|
.filter(({ enabled }) => !enabled)
|
|
.map(({ layerId }) => layerId)
|
|
.sort(),
|
|
[...AWARENESS_DEPENDENCIES].sort(),
|
|
);
|
|
releaseDependencies();
|
|
await disabling;
|
|
assert.equal(releasing, true);
|
|
} finally {
|
|
releaseDependencies();
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('same-turn Contacts OFF registers after every owned dependency ON', async () => {
|
|
const calls = [];
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => false,
|
|
isEffectivelyEnabled: () => false,
|
|
setEnabled: (layerId, enabled) => {
|
|
calls.push({ layerId, enabled });
|
|
return Promise.resolve(true);
|
|
},
|
|
});
|
|
|
|
try {
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
const disabling = militaryAwarenessLayer.disable();
|
|
assert.deepEqual(
|
|
calls.map(
|
|
({ layerId, enabled }) => `${layerId}:${enabled ? 'on' : 'off'}`,
|
|
),
|
|
[
|
|
...AWARENESS_DEPENDENCIES.map((layerId) => `${layerId}:on`),
|
|
...AWARENESS_DEPENDENCIES.map((layerId) => `${layerId}:off`),
|
|
],
|
|
'OFF must be the newest manager intent before this JavaScript turn yields',
|
|
);
|
|
await disabling;
|
|
await nextTurn();
|
|
assert.equal(
|
|
calls.slice(AWARENESS_DEPENDENCIES.length).some(({ enabled }) => enabled),
|
|
false,
|
|
'no deferred ON may run after release',
|
|
);
|
|
} finally {
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('Contacts does not claim a dependency already enabling for the user', async () => {
|
|
const calls = [];
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => false,
|
|
isEffectivelyEnabled: (layerId) => layerId === 'flights',
|
|
setEnabled: (layerId, enabled) => {
|
|
calls.push({ layerId, enabled });
|
|
return Promise.resolve(true);
|
|
},
|
|
});
|
|
|
|
try {
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await militaryAwarenessLayer.disable();
|
|
assert.equal(
|
|
calls.some(({ layerId }) => layerId === 'flights'),
|
|
false,
|
|
'an in-flight user enable stays outside Contacts ownership',
|
|
);
|
|
} finally {
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
|
|
let enabled = false;
|
|
let releaseAircraft;
|
|
let releaseDeferred;
|
|
const aircraftGate = new Promise((resolve) => {
|
|
releaseAircraft = resolve;
|
|
});
|
|
const deferredGate = new Promise((resolve) => {
|
|
releaseDeferred = resolve;
|
|
});
|
|
const calls = [];
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => false,
|
|
setEnabled: async (layerId, nextEnabled) => {
|
|
calls.push({ layerId, enabled: nextEnabled });
|
|
if (nextEnabled) {
|
|
await (['flights', 'military'].includes(layerId)
|
|
? aircraftGate
|
|
: deferredGate);
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
|
|
try {
|
|
await militaryAwarenessLayer.disable();
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
const enabling = militaryAwarenessLayer.enable().then(() => {
|
|
enabled = true;
|
|
});
|
|
await nextTurn();
|
|
assert.equal(enabled, false, 'coordinator ON waits for aircraft readiness');
|
|
assert.deepEqual(
|
|
calls
|
|
.filter(({ enabled: nextEnabled }) => nextEnabled)
|
|
.map(({ layerId }) => layerId)
|
|
.sort(),
|
|
[...AWARENESS_DEPENDENCIES].sort(),
|
|
);
|
|
releaseAircraft();
|
|
await enabling;
|
|
assert.equal(
|
|
enabled,
|
|
true,
|
|
'deferred vessels/installations do not block Contacts',
|
|
);
|
|
assert.equal(
|
|
calls.filter(({ layerId }) =>
|
|
['ais-live-vessels', 'military-installations'].includes(layerId),
|
|
).length,
|
|
2,
|
|
'deferred requests were started in the same activation',
|
|
);
|
|
} finally {
|
|
releaseAircraft();
|
|
releaseDeferred();
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('pending mapped installations render as unknown instead of a false zero', async () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
|
|
const restoreCollections = stubAwarenessCollections({});
|
|
const runtime = installAwarenessRuntime({
|
|
// Deliberately inconsistent visibility intent exercises the defensive
|
|
// lifecycle guard: `enabling` must win over a stale true response.
|
|
isEnabled: () => true,
|
|
getLayerLifecycleState: (layerId) =>
|
|
layerId === 'military-installations'
|
|
? { enabled: false, lifecycleState: 'enabling', uncertain: false }
|
|
: { enabled: true, lifecycleState: 'enabled', uncertain: false },
|
|
});
|
|
|
|
try {
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'pending-site', position),
|
|
);
|
|
await nextTurn();
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
const installations = snapshot?.cohorts.find(
|
|
({ id }) => id === 'military-installations',
|
|
);
|
|
assert.equal(
|
|
installations?.count,
|
|
null,
|
|
'loading is not an observed empty cohort',
|
|
);
|
|
assert.match(installations?.reason || '', /unavailable/i);
|
|
assert.equal(
|
|
panelCount(runtime, 'Mapped installations'),
|
|
'?',
|
|
'the operator sees ? rather than an all-clear 0 while installations load',
|
|
);
|
|
} finally {
|
|
restoreCollections();
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
// ── The `loading === true && !lastUpdate` predicate in sourceState ────────────
|
|
//
|
|
// It is a CONTRACT over the whole dependency list, not a fix for one layer, and
|
|
// the dependencies reach it by different routes:
|
|
//
|
|
// - ais-live-vessels is its REACHABLE producer. enable()/update() both resolve
|
|
// as soon as the first /api/vessels poll answers, so the manager's
|
|
// lifecycle settles to `enabled` — but until the server-side socket delivers
|
|
// a position, firstConnectPhase stays 'loading' and the module reports busy,
|
|
// no lastUpdate, count 0, and an UNDEFINED status, so the status list alone
|
|
// never catches it either.
|
|
// - military-installations never reaches this window, and these pins are not
|
|
// about it. Its enable() is synchronous and the manager awaits update(),
|
|
// which owns the first Overpass fetch, so the lifecycle stays `enabling`
|
|
// throughout and the sibling `enabling` test above is what covers it.
|
|
// Confirmed live on :4272 across a held 17 s first fetch (34 samples,
|
|
// `enabling` the whole way) and across a failing one.
|
|
//
|
|
// The readouts below are taken FROM the real module rather than hand-written. A
|
|
// fixture that invents a shape its module never emits guards nothing — that is
|
|
// exactly how a hole in this gate survived a passing suite once already.
|
|
|
|
/** Drive aisLiveVessels into a state and return the readout IT produces. */
|
|
function aisStatsFor(options) {
|
|
_setVesselStateForTest(options);
|
|
return Object.freeze(aisLiveVesselsLayer.getStats());
|
|
}
|
|
|
|
/** Socket connecting: busy, never answered, undefined status. */
|
|
const AIS_FIRST_CONNECT_STATS = aisStatsFor({
|
|
enabled: true,
|
|
transportStatus: 'connecting',
|
|
firstConnectPhase: 'loading',
|
|
lastUpdate: null,
|
|
});
|
|
|
|
/** The same module once the socket answers with an honestly empty viewport. */
|
|
const AIS_SETTLED_EMPTY_STATS = aisStatsFor({
|
|
enabled: true,
|
|
transportStatus: 'open',
|
|
firstConnectPhase: 'ready',
|
|
lastUpdate: 1_700_000_000_000,
|
|
});
|
|
|
|
/** Answered once, and busy again on a later refresh poll. */
|
|
const AIS_REFRESHING_ANSWERED_STATS = aisStatsFor({
|
|
enabled: true,
|
|
loading: true,
|
|
transportStatus: 'open',
|
|
firstConnectPhase: 'ready',
|
|
lastUpdate: 1_700_000_000_000,
|
|
records: [{ mmsi: '1' }, { mmsi: '2' }, { mmsi: '3' }],
|
|
});
|
|
|
|
// Leave the shared module neutral for every test below.
|
|
_setVesselStateForTest({ enabled: false });
|
|
|
|
test('the first-connect readout is the real one, and is exactly what the predicate keys on', () => {
|
|
assert.equal(
|
|
AIS_FIRST_CONNECT_STATS.loading,
|
|
true,
|
|
'the module reports itself busy while connecting',
|
|
);
|
|
assert.equal(
|
|
AIS_FIRST_CONNECT_STATS.lastUpdate,
|
|
null,
|
|
'and has never answered',
|
|
);
|
|
assert.equal(
|
|
AIS_FIRST_CONNECT_STATS.count,
|
|
0,
|
|
'with a zero that means nothing yet',
|
|
);
|
|
assert.equal(
|
|
AIS_FIRST_CONNECT_STATS.status,
|
|
undefined,
|
|
'and no status at all — the status list cannot be what catches this',
|
|
);
|
|
assert.equal(
|
|
AIS_SETTLED_EMPTY_STATS.loading,
|
|
false,
|
|
'an answered socket is not busy',
|
|
);
|
|
assert.ok(
|
|
AIS_SETTLED_EMPTY_STATS.lastUpdate,
|
|
'and carries the moment it answered',
|
|
);
|
|
assert.equal(
|
|
AIS_REFRESHING_ANSWERED_STATS.loading,
|
|
true,
|
|
'a refresh poll is busy again',
|
|
);
|
|
assert.ok(
|
|
AIS_REFRESHING_ANSWERED_STATS.lastUpdate,
|
|
'without erasing the answer already given',
|
|
);
|
|
});
|
|
|
|
test('a vessel feed still connecting after the lifecycle settles reads as unknown, not an all-clear', async () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
|
|
const restoreCollections = stubAwarenessCollections({});
|
|
// The window this pins: enable() and the first poll have RESOLVED, so the
|
|
// manager reports a fully settled `enabled` lifecycle, while the socket has
|
|
// still never delivered a position.
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => true,
|
|
getLayerLifecycleState: () => ({
|
|
enabled: true,
|
|
lifecycleState: 'enabled',
|
|
uncertain: false,
|
|
}),
|
|
layerStats: { 'ais-live-vessels': AIS_FIRST_CONNECT_STATS },
|
|
});
|
|
|
|
try {
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'connecting-site', position),
|
|
);
|
|
await nextTurn();
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
const vessels = snapshot?.cohorts.find(
|
|
({ id }) => id === 'ais-live-vessels',
|
|
);
|
|
assert.equal(
|
|
vessels?.count,
|
|
null,
|
|
'a source that has never answered has told us nothing — 0 would be a fabricated all-clear',
|
|
);
|
|
assert.match(vessels?.reason || '', /unavailable/i);
|
|
assert.equal(
|
|
panelCount(runtime, 'AIS vessels'),
|
|
'?',
|
|
'the panel prints ? for the whole settled-but-connecting window, never an all-clear 0',
|
|
);
|
|
} finally {
|
|
restoreCollections();
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('a settled vessel feed reporting a real empty viewport recovers to 0', async () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
|
|
const restoreCollections = stubAwarenessCollections({});
|
|
// Same lifecycle, but the socket answered: `lastUpdate` is set and the module
|
|
// is no longer busy, so this zero is an OBSERVATION and must be shown as one.
|
|
// Without this pin the fix above could degenerate into a permanent `?`.
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => true,
|
|
getLayerLifecycleState: () => ({
|
|
enabled: true,
|
|
lifecycleState: 'enabled',
|
|
uncertain: false,
|
|
}),
|
|
layerStats: { 'ais-live-vessels': AIS_SETTLED_EMPTY_STATS },
|
|
});
|
|
|
|
try {
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'settled-site', position),
|
|
);
|
|
await nextTurn();
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
const vessels = snapshot?.cohorts.find(
|
|
({ id }) => id === 'ais-live-vessels',
|
|
);
|
|
assert.equal(
|
|
vessels?.count,
|
|
0,
|
|
'an answered empty viewport is a real observation',
|
|
);
|
|
assert.equal(
|
|
panelCount(runtime, 'AIS vessels'),
|
|
'0',
|
|
'the operator sees the real count once the feed answers',
|
|
);
|
|
} finally {
|
|
restoreCollections();
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('a still-answered feed keeps its count through later polls that set loading again', async () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
|
|
const restoreCollections = stubAwarenessCollections({});
|
|
// A refresh poll sets `loading` true again while `lastUpdate` stays set. The
|
|
// guard is deliberately "never answered", not "busy": blanking to ? on every
|
|
// refresh would be its own dishonesty, and a flickering panel besides.
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => true,
|
|
getLayerLifecycleState: () => ({
|
|
enabled: true,
|
|
lifecycleState: 'enabled',
|
|
uncertain: false,
|
|
}),
|
|
layerStats: { 'ais-live-vessels': AIS_REFRESHING_ANSWERED_STATS },
|
|
});
|
|
|
|
try {
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'refreshing-site', position),
|
|
);
|
|
await nextTurn();
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
const vessels = snapshot?.cohorts.find(
|
|
({ id }) => id === 'ais-live-vessels',
|
|
);
|
|
assert.notEqual(
|
|
vessels?.count,
|
|
null,
|
|
'a refresh poll does not erase an answer already given',
|
|
);
|
|
} finally {
|
|
restoreCollections();
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('voice reports a still-connecting vessel feed as unknown rather than zero', async () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
|
|
const restoreCollections = stubAwarenessCollections({});
|
|
// The same real first-connect readout the panel pins above. The voice surface
|
|
// reads the same cohort the panel does, so it inherited the same fabricated
|
|
// zero and needs its own pin.
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => true,
|
|
getLayerLifecycleState: () => ({
|
|
enabled: true,
|
|
lifecycleState: 'enabled',
|
|
uncertain: false,
|
|
}),
|
|
layerStats: { 'ais-live-vessels': AIS_FIRST_CONNECT_STATS },
|
|
});
|
|
|
|
try {
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'voice-site', position),
|
|
);
|
|
await nextTurn();
|
|
|
|
const spoken = contactsWindowFromSnapshot(
|
|
militaryAwarenessLayer.getContextSnapshot(),
|
|
);
|
|
assert.equal(
|
|
spoken?.vessels,
|
|
'unknown',
|
|
'voice must not speak a zero the feed never reported',
|
|
);
|
|
} finally {
|
|
restoreCollections();
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
function nextTurn() {
|
|
return new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
function replaceMethod(target, name, replacement, restores) {
|
|
const original = target[name];
|
|
target[name] = replacement;
|
|
restores.push(() => {
|
|
target[name] = original;
|
|
});
|
|
}
|
|
|
|
function stubAwarenessCollections({
|
|
flights = [],
|
|
military = [],
|
|
vessels = [],
|
|
installations = [],
|
|
}) {
|
|
const restores = [];
|
|
replaceMethod(flightsLayer, 'getNearby', () => flights, restores);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'getAllPositions',
|
|
() => flights.map((item) => ({ ...item, id: item.icao24 })),
|
|
restores,
|
|
);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => military, restores);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'getAllPositions',
|
|
() => military.map((item) => ({ ...item, id: item.icao24 })),
|
|
restores,
|
|
);
|
|
replaceMethod(aisLiveVesselsLayer, 'getNearby', () => vessels, restores);
|
|
replaceMethod(
|
|
aisLiveVesselsLayer,
|
|
'getAllPositions',
|
|
() => vessels.map((item) => ({ ...item, id: item.mmsi })),
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryInstallationsLayer,
|
|
'getNearby',
|
|
() => installations,
|
|
restores,
|
|
);
|
|
return () => restores.reverse().forEach((restore) => restore());
|
|
}
|
|
|
|
function awarenessSubject(layerId, id, position) {
|
|
return { layerId, id, label: id, position };
|
|
}
|
|
|
|
for (const fixture of [
|
|
{
|
|
name: 'civilian',
|
|
layerId: 'flights',
|
|
layer: flightsLayer,
|
|
other: militaryFlightsLayer,
|
|
},
|
|
{
|
|
name: 'military',
|
|
layerId: 'military',
|
|
layer: militaryFlightsLayer,
|
|
other: flightsLayer,
|
|
},
|
|
]) {
|
|
test(`Contacts activation adopts an already tracked ${fixture.name} flight`, async () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const subject = awarenessSubject(
|
|
fixture.layerId,
|
|
`${fixture.name}-tracked`,
|
|
position,
|
|
);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
let retrackCalls = 0;
|
|
|
|
try {
|
|
const trackedEntity = {
|
|
gevTrackedId: `${subject.layerId}:${subject.id}`,
|
|
};
|
|
runtime.viewer.trackedEntity = trackedEntity;
|
|
replaceMethod(
|
|
fixture.layer,
|
|
'getTrackedSubject',
|
|
() => subject,
|
|
restores,
|
|
);
|
|
replaceMethod(fixture.other, 'getTrackedSubject', () => null, restores);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
() => {
|
|
retrackCalls += 1;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'trackById',
|
|
() => {
|
|
retrackCalls += 1;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
assert.equal(snapshot?.subject.layerId, subject.layerId);
|
|
assert.equal(snapshot?.subject.id, subject.id);
|
|
assert.equal(snapshot?.navigation.canPrevious, false);
|
|
assert.equal(snapshot?.navigation.canNext, false);
|
|
assert.equal(
|
|
retrackCalls,
|
|
0,
|
|
'adoption must not recreate the existing track',
|
|
);
|
|
assert.equal(
|
|
runtime.viewer.trackedEntity,
|
|
trackedEntity,
|
|
'adoption must not replace the source-owned tracked entity',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
}
|
|
|
|
test('Contacts activation adopts the production military tracked-subject descriptor', async () => {
|
|
const id = 'ae01ce';
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.03, 31.05, 8_534.4);
|
|
const sourceViewer = { scene: { primitives: { remove() {} } } };
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
let retrackCalls = 0;
|
|
|
|
try {
|
|
_setTrackedMilitaryRefreshStateForTest({
|
|
icao24: id,
|
|
entity: null,
|
|
billboard: { position, show: true },
|
|
billboardCollection: { show: true, remove() {} },
|
|
viewer: sourceViewer,
|
|
meta: {
|
|
callsign: 'RCH451 ',
|
|
altitudeFt: 28_000,
|
|
renderAltitudeM: 8_534.4,
|
|
speedMps: 231.5,
|
|
track: 92.1,
|
|
onGround: false,
|
|
},
|
|
});
|
|
const accessorSubject = militaryFlightsLayer.getTrackedSubject();
|
|
assert.equal(accessorSubject?.layerId, 'military');
|
|
assert.equal(accessorSubject?.id, id);
|
|
assert.equal(accessorSubject?.label, 'RCH451');
|
|
assert.notEqual(
|
|
accessorSubject?.position,
|
|
position,
|
|
'the production accessor must detach its Cartesian position',
|
|
);
|
|
assert.ok(
|
|
Cesium.Cartesian3.equalsEpsilon(
|
|
accessorSubject?.position,
|
|
position,
|
|
Cesium.Math.EPSILON7,
|
|
),
|
|
);
|
|
accessorSubject.position.x += 100;
|
|
assert.ok(
|
|
Cesium.Cartesian3.equalsEpsilon(
|
|
militaryFlightsLayer.getTrackedSubject()?.position,
|
|
position,
|
|
Cesium.Math.EPSILON7,
|
|
),
|
|
'mutating one accessor result must not alter the tracked source position',
|
|
);
|
|
runtime.viewer.trackedEntity = { gevTrackedId: `military:${id}` };
|
|
replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
() => {
|
|
retrackCalls += 1;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'trackById',
|
|
() => {
|
|
retrackCalls += 1;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
assert.equal(snapshot?.subject.layerId, 'military');
|
|
assert.equal(snapshot?.subject.id, id);
|
|
assert.equal(snapshot?.subject.label, 'RCH451');
|
|
assert.ok(
|
|
Cesium.Cartesian3.equalsEpsilon(
|
|
snapshot?.subject.position,
|
|
position,
|
|
Cesium.Math.EPSILON7,
|
|
),
|
|
);
|
|
assert.equal(
|
|
retrackCalls,
|
|
0,
|
|
'production military adoption must preserve tracker ownership',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
_setTrackedMilitaryRefreshStateForTest({
|
|
icao24: id,
|
|
entity: null,
|
|
billboard: { position, show: true },
|
|
billboardCollection: { show: true, remove() {} },
|
|
viewer: sourceViewer,
|
|
meta: {},
|
|
tracked: false,
|
|
});
|
|
militaryFlightsLayer.destroy(sourceViewer);
|
|
assert.deepEqual(
|
|
militaryFlightsLayer.getAllPositions(),
|
|
[],
|
|
'production military test state must be fully cleared',
|
|
);
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('production military tracked-subject label falls back to registration before the ICAO hex', async () => {
|
|
const id = 'ae02df';
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.03, 31.05, 6_400);
|
|
const sourceViewer = { scene: { primitives: { remove() {} } } };
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
|
|
try {
|
|
_setTrackedMilitaryRefreshStateForTest({
|
|
icao24: id,
|
|
entity: null,
|
|
billboard: { position, show: true },
|
|
billboardCollection: { show: true, remove() {} },
|
|
viewer: sourceViewer,
|
|
meta: {
|
|
callsign: ' ',
|
|
registration: 'N123AB ',
|
|
altitudeFt: 21_000,
|
|
renderAltitudeM: 6_400,
|
|
speedMps: 180.2,
|
|
track: 271.4,
|
|
onGround: false,
|
|
},
|
|
});
|
|
// The proximity list already labels a callsign-less contact by registration;
|
|
// the tracked-subject descriptor must use the same chain instead of the hex.
|
|
assert.equal(
|
|
militaryFlightsLayer.getNearby(position, 5_000)[0]?.id,
|
|
'N123AB',
|
|
);
|
|
assert.equal(militaryFlightsLayer.getTrackedSubject()?.label, 'N123AB');
|
|
// getAllPositions was the last military surface still reading as raw hex.
|
|
const snapshotRow = militaryFlightsLayer.getAllPositions(10)[0];
|
|
assert.equal(
|
|
snapshotRow?.label,
|
|
'N123AB',
|
|
'getAllPositions label must use the same chain',
|
|
);
|
|
assert.equal(
|
|
snapshotRow?.id,
|
|
id,
|
|
'getAllPositions id stays the identity hex',
|
|
);
|
|
|
|
runtime.viewer.trackedEntity = { gevTrackedId: `military:${id}` };
|
|
replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores);
|
|
replaceMethod(flightsLayer, 'trackById', () => true, restores);
|
|
replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
assert.equal(snapshot?.subject.id, id);
|
|
assert.equal(
|
|
snapshot?.subject.label,
|
|
'N123AB',
|
|
'an adopted callsign-less contact must not surface its raw ICAO hex',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
_setTrackedMilitaryRefreshStateForTest({
|
|
icao24: id,
|
|
entity: null,
|
|
billboard: { position, show: true },
|
|
billboardCollection: { show: true, remove() {} },
|
|
viewer: sourceViewer,
|
|
meta: {},
|
|
tracked: false,
|
|
});
|
|
militaryFlightsLayer.destroy(sourceViewer);
|
|
assert.deepEqual(
|
|
militaryFlightsLayer.getAllPositions(),
|
|
[],
|
|
'production military test state must be fully cleared',
|
|
);
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('a cached Context subject re-reads its label when enrichment lands after selection', async () => {
|
|
// The subject snapshot is captured once at selection. Selecting a
|
|
// callsign-less aircraft BEFORE adsbdb enrichment answers used to freeze the
|
|
// ICAO hex into Context while every other surface later swapped to the
|
|
// registration. The refresh path must re-resolve the label, never the id.
|
|
const id = 'ae1fa4';
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
|
|
const sourceViewer = { camera: { positionCartographic: null }, scene: {} };
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
// Held by reference so the enrichment step can mutate it in place, exactly
|
|
// as _requestTypeEnrichment's callback does (`meta.registration = …`).
|
|
const meta = {
|
|
callsign: ' ',
|
|
altitude: 10_668,
|
|
renderAltitudeM: 10_700,
|
|
velocity: 250,
|
|
true_track: 95,
|
|
klass: 'airliner',
|
|
onGround: false,
|
|
wasAirborne: true,
|
|
turnRateDps: 0,
|
|
rawLat: 30.21,
|
|
rawLon: -97.71,
|
|
};
|
|
|
|
try {
|
|
_setTrackedFlightRefreshStateForTest({
|
|
icao24: id,
|
|
entity: null,
|
|
billboard: { position, color: Cesium.Color.WHITE, show: true },
|
|
billboardCollection: { show: true, remove() {} },
|
|
viewer: sourceViewer,
|
|
meta,
|
|
});
|
|
|
|
runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` };
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'getTrackedSubject',
|
|
() => null,
|
|
restores,
|
|
);
|
|
replaceMethod(flightsLayer, 'trackById', () => true, restores);
|
|
replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
|
|
// Pre-enrichment: nothing but the hex is known, so the hex is correct here.
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subject.label,
|
|
id,
|
|
);
|
|
const panel = runtime.panel();
|
|
assert.equal(panel.view?.kind, 'results', 'the Context panel must exist');
|
|
assert.match(panel.view.subject, /ae1fa4/, 'panel starts on the hex');
|
|
|
|
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
|
|
const settledRenders = runtime.panel().renders;
|
|
const settledPublishes = runtime.panel().publishes;
|
|
await militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
runtime.panel().renders,
|
|
settledRenders,
|
|
'an unchanged label must not trigger a panel repaint',
|
|
);
|
|
assert.equal(
|
|
runtime.panel().publishes,
|
|
settledPublishes,
|
|
'an unchanged refresh publishes no new panel, results or standby',
|
|
);
|
|
|
|
// Enrichment answers. The contact has NOT moved and no source revision
|
|
// changed, so the label is the only thing making this render-worthy.
|
|
meta.registration = 'N123AB ';
|
|
await militaryAwarenessLayer.update();
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
assert.equal(
|
|
snapshot?.subject.label,
|
|
'N123AB',
|
|
'Context must adopt the registration once enrichment supplies it',
|
|
);
|
|
assert.equal(
|
|
snapshot?.subject.id,
|
|
id,
|
|
'subject identity must stay the ICAO hex',
|
|
);
|
|
// The RENDERED panel is the surface the operator actually reads.
|
|
const repainted = runtime.panel();
|
|
assert.ok(
|
|
repainted.publishes > settledPublishes,
|
|
'a changed label must repaint the standalone Context panel',
|
|
);
|
|
assert.match(
|
|
repainted.view.subject,
|
|
/N123AB/,
|
|
'the rendered Context panel must show the registration without a position change',
|
|
);
|
|
assert.doesNotMatch(
|
|
JSON.stringify(repainted.view),
|
|
/ae1fa4/,
|
|
'the hex must be gone from the panel',
|
|
);
|
|
|
|
// The Cockpit signal list builds its nearby-contact titles with this exact
|
|
// helper over the cohort rows, so the same fixture pins that surface too.
|
|
assert.equal(
|
|
formatAwarenessLabel(flightsLayer.getNearby(position, 250_000, 5)[0]),
|
|
'N123AB',
|
|
'cockpit signal titles must resolve to the registration, not the hex',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
_setTrackedFlightRefreshStateForTest({
|
|
icao24: id,
|
|
entity: null,
|
|
billboard: { position, show: true },
|
|
billboardCollection: { show: false, remove() {} },
|
|
viewer: sourceViewer,
|
|
meta: {},
|
|
tracked: false,
|
|
});
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('Contacts activation adopts the production civilian tracked-subject descriptor', async () => {
|
|
const id = 'a1b2c3';
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.67, 30.19, 10_668);
|
|
const sourceViewer = { scene: { primitives: { remove() {} } } };
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
let retrackCalls = 0;
|
|
|
|
try {
|
|
_setTrackedFlightRefreshStateForTest({
|
|
icao24: id,
|
|
entity: null,
|
|
billboard: { position, show: true },
|
|
billboardCollection: { show: true, remove() {} },
|
|
viewer: sourceViewer,
|
|
meta: {
|
|
callsign: 'DAL123 ',
|
|
altitude: 10_668,
|
|
renderAltitudeM: 10_700,
|
|
velocity: 250,
|
|
true_track: 95,
|
|
onGround: false,
|
|
rawLat: 30.19,
|
|
rawLon: -97.67,
|
|
},
|
|
});
|
|
const accessorSubject = flightsLayer.getTrackedSubject();
|
|
assert.equal(accessorSubject?.layerId, 'flights');
|
|
assert.equal(accessorSubject?.id, id);
|
|
assert.equal(accessorSubject?.label, 'DAL123');
|
|
assert.notEqual(
|
|
accessorSubject?.position,
|
|
position,
|
|
'the production accessor must detach its Cartesian position',
|
|
);
|
|
assert.ok(
|
|
Cesium.Cartesian3.equalsEpsilon(
|
|
accessorSubject?.position,
|
|
position,
|
|
Cesium.Math.EPSILON7,
|
|
),
|
|
);
|
|
accessorSubject.position.x += 100;
|
|
assert.ok(
|
|
Cesium.Cartesian3.equalsEpsilon(
|
|
flightsLayer.getTrackedSubject()?.position,
|
|
position,
|
|
Cesium.Math.EPSILON7,
|
|
),
|
|
'mutating one accessor result must not alter the tracked source position',
|
|
);
|
|
runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` };
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'getTrackedSubject',
|
|
() => null,
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
() => {
|
|
retrackCalls += 1;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'trackById',
|
|
() => {
|
|
retrackCalls += 1;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
assert.equal(snapshot?.subject.layerId, 'flights');
|
|
assert.equal(snapshot?.subject.id, id);
|
|
assert.equal(snapshot?.subject.label, 'DAL123');
|
|
assert.ok(
|
|
Cesium.Cartesian3.equalsEpsilon(
|
|
snapshot?.subject.position,
|
|
position,
|
|
Cesium.Math.EPSILON7,
|
|
),
|
|
);
|
|
assert.equal(
|
|
retrackCalls,
|
|
0,
|
|
'production civilian adoption must preserve tracker ownership',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
_setTrackedFlightRefreshStateForTest({
|
|
icao24: id,
|
|
entity: null,
|
|
billboard: { position, show: true },
|
|
billboardCollection: { show: true, remove() {} },
|
|
viewer: sourceViewer,
|
|
meta: {},
|
|
tracked: false,
|
|
});
|
|
flightsLayer.destroy(sourceViewer);
|
|
assert.deepEqual(
|
|
flightsLayer.getAllPositions(),
|
|
[],
|
|
'production civilian test state must be fully cleared',
|
|
);
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('Contacts activation reconciles to a newer cross-layer tracked flight after dependencies settle', async () => {
|
|
let releaseDependencies;
|
|
const dependencyGate = new Promise((resolve) => {
|
|
releaseDependencies = resolve;
|
|
});
|
|
const positions = {
|
|
first: Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000),
|
|
latest: Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1200),
|
|
};
|
|
let civilianTracked = awarenessSubject('flights', 'first', positions.first);
|
|
let militaryTracked = null;
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => false,
|
|
setEnabled: async (_layerId, enabled) => {
|
|
if (enabled) await dependencyGate;
|
|
return true;
|
|
},
|
|
});
|
|
const restores = [];
|
|
|
|
try {
|
|
runtime.viewer.trackedEntity = { gevTrackedId: 'flights:first' };
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'getTrackedSubject',
|
|
() => civilianTracked,
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'getTrackedSubject',
|
|
() => militaryTracked,
|
|
restores,
|
|
);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subject.id,
|
|
'first',
|
|
);
|
|
|
|
civilianTracked = null;
|
|
militaryTracked = awarenessSubject('military', 'latest', positions.latest);
|
|
runtime.viewer.trackedEntity = { gevTrackedId: 'military:latest' };
|
|
releaseDependencies();
|
|
await nextTurn();
|
|
await nextTurn();
|
|
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subject.layerId,
|
|
'military',
|
|
);
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subject.id,
|
|
'latest',
|
|
);
|
|
} finally {
|
|
releaseDependencies();
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('tracked flight cleared during Contacts activation suppresses fallback autofocus', async () => {
|
|
let releaseDependencies;
|
|
const dependencyGate = new Promise((resolve) => {
|
|
releaseDependencies = resolve;
|
|
});
|
|
const selectedPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const fallbackPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1200);
|
|
let tracked = awarenessSubject('flights', 'selected', selectedPosition);
|
|
const restoreCollections = stubAwarenessCollections({
|
|
flights: [
|
|
{ icao24: 'fallback', position: fallbackPosition, distanceM: 1000 },
|
|
],
|
|
});
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: () => false,
|
|
setEnabled: async (_layerId, enabled) => {
|
|
if (enabled) await dependencyGate;
|
|
return true;
|
|
},
|
|
});
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
runtime.viewer.trackedEntity = { gevTrackedId: 'flights:selected' };
|
|
replaceMethod(flightsLayer, 'getTrackedSubject', () => tracked, restores);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'getTrackedSubject',
|
|
() => null,
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => focused.push(id) > 0,
|
|
restores,
|
|
);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subject.id,
|
|
'selected',
|
|
);
|
|
|
|
tracked = null;
|
|
runtime.viewer.trackedEntity = undefined;
|
|
// Deliberately untagged: this is the DESELECT case, and an untagged clear
|
|
// must default to deliberate. An eviction (`reason: 'evicted'`) instead
|
|
// keeps the subject as CONTACT LOST — see the eviction tests below — but
|
|
// must not weaken this one: a real deselect during settlement still has to
|
|
// clear AND suppress the fallback autofocus.
|
|
runtime.dispatch('gev:awareness-subject-cleared', {
|
|
layerId: 'flights',
|
|
id: 'selected',
|
|
});
|
|
releaseDependencies();
|
|
await nextTurn();
|
|
await nextTurn();
|
|
|
|
assert.equal(militaryAwarenessLayer.getContextSnapshot(), null);
|
|
assert.deepEqual(focused, []);
|
|
} finally {
|
|
releaseDependencies();
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('awareness clears are scoped to the selected source layer', () => {
|
|
const subject = { layerId: 'ais-live-vessels', id: '123' };
|
|
assert.equal(
|
|
awarenessClearMatchesSubject(subject, {
|
|
layerId: 'military-installations',
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
awarenessClearMatchesSubject(subject, { layerId: 'ais-live-vessels' }),
|
|
true,
|
|
);
|
|
assert.equal(awarenessClearMatchesSubject(subject, null), false);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// BEGIN Contact-readout presence block — fix/context-panel-next-subjects.
|
|
// Integrators: this whole delimited block belongs to the Contact-panel
|
|
// CONTACT LOST work. Keep it intact and keep any concurrent branch's own
|
|
// additions at the END of the file, so the two never collide.
|
|
// ===========================================================================
|
|
|
|
/** Live/absent/unknown presence stub matching the layer `hasContact` contract. */
|
|
function stubPresence(layer, verdict, restores) {
|
|
replaceMethod(layer, 'hasContact', () => verdict, restores);
|
|
}
|
|
|
|
test('a fast-culled subject is reported absent so the readout can hold last-known', () => {
|
|
// The Contact panel keeps a culled subject on screen as CONTACT LOST rather
|
|
// than collapsing, so it needs the snapshot to say whether the subject is
|
|
// still carried by its source.
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
let present = true;
|
|
|
|
try {
|
|
replaceMethod(aisLiveVesselsLayer, 'hasContact', () => present, restores);
|
|
replaceMethod(
|
|
aisLiveVesselsLayer,
|
|
'getAllPositions',
|
|
() => [{ id: '353136000', position }],
|
|
restores,
|
|
);
|
|
replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(flightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryInstallationsLayer, 'getNearby', () => [], restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
runtime.dispatch('gev:awareness-subject-selected', {
|
|
layerId: 'ais-live-vessels',
|
|
id: '353136000',
|
|
label: 'MAERSK DETROIT',
|
|
position,
|
|
});
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subjectPresent,
|
|
true,
|
|
);
|
|
|
|
present = false;
|
|
militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subjectPresent,
|
|
false,
|
|
'a subject its layer no longer carries is absent',
|
|
);
|
|
|
|
present = null; // layer disabled or not yet loaded: it cannot answer
|
|
militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subjectPresent,
|
|
false,
|
|
'an unanswerable tick never revives a subject already known absent',
|
|
);
|
|
|
|
present = true;
|
|
militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subjectPresent,
|
|
true,
|
|
'a returning subject clears the absent state',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('a layer that cannot answer is never read as a cull', () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
|
|
try {
|
|
// `hasContact` returns null while the layer is disabled or unloaded.
|
|
stubPresence(aisLiveVesselsLayer, null, restores);
|
|
replaceMethod(aisLiveVesselsLayer, 'getAllPositions', () => [], restores);
|
|
replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(flightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryInstallationsLayer, 'getNearby', () => [], restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
runtime.dispatch('gev:awareness-subject-selected', {
|
|
layerId: 'ais-live-vessels',
|
|
id: '353136000',
|
|
label: 'MAERSK DETROIT',
|
|
position,
|
|
});
|
|
militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subjectPresent,
|
|
true,
|
|
'a silent layer must not fabricate a CONTACT LOST cue',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('presence never comes from the capped position rows', () => {
|
|
// getAllPositions(limit) breaks at its cap and the live flights layer runs
|
|
// ~11k contacts against a 1,000 cap, so "not in the rows" is not "gone".
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const saturated = Array.from({ length: 1000 }, (unused, index) => ({
|
|
id: `filler-${index}`,
|
|
position,
|
|
}));
|
|
|
|
try {
|
|
stubPresence(flightsLayer, true, restores);
|
|
replaceMethod(flightsLayer, 'getAllPositions', () => saturated, restores);
|
|
replaceMethod(flightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryInstallationsLayer, 'getNearby', () => [], restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
runtime.dispatch('gev:awareness-subject-selected', {
|
|
layerId: 'flights',
|
|
id: 'ab4991',
|
|
label: 'N627CT',
|
|
position,
|
|
});
|
|
militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subjectPresent,
|
|
true,
|
|
'a contact past the row cap is still present',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('a mapped installation subject is always present — static data is never culled', () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.78, 31.13, 0);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
|
|
try {
|
|
replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(flightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryInstallationsLayer, 'getNearby', () => [], restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
runtime.dispatch('gev:awareness-subject-selected', {
|
|
layerId: 'military-installations',
|
|
id: 'fort-cavazos',
|
|
label: 'FORT CAVAZOS',
|
|
position,
|
|
});
|
|
militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subjectPresent,
|
|
true,
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('layer hasContact answers presence in O(1) and declines when unloaded', () => {
|
|
for (const layer of [
|
|
flightsLayer,
|
|
militaryFlightsLayer,
|
|
aisLiveVesselsLayer,
|
|
]) {
|
|
assert.equal(
|
|
typeof layer.hasContact,
|
|
'function',
|
|
'every presence-bearing source layer must expose hasContact',
|
|
);
|
|
// Unloaded layers cannot answer, and must say so rather than guessing.
|
|
assert.equal(layer.hasContact('definitely-not-here'), null);
|
|
}
|
|
});
|
|
|
|
test('hasContact declines while a layer is disabled, whatever its maps still hold', () => {
|
|
// disable() hides the collection but keeps the records, so a map lookup
|
|
// alone would report a preserved subject as FRESH from hidden stale data.
|
|
for (const [name, source, guard] of [
|
|
[
|
|
'flights',
|
|
readLayerSource(new URL('./flights.js', import.meta.url)),
|
|
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
|
],
|
|
[
|
|
'militaryFlights',
|
|
readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
|
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
|
],
|
|
[
|
|
'aisLiveVessels',
|
|
readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
|
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
|
],
|
|
]) {
|
|
assert.match(
|
|
source,
|
|
guard,
|
|
`${name}.hasContact must decline while the layer is disabled`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a disabled layer leaves the presence verdict untouched', () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
let verdict = true;
|
|
|
|
try {
|
|
replaceMethod(aisLiveVesselsLayer, 'hasContact', () => verdict, restores);
|
|
replaceMethod(
|
|
aisLiveVesselsLayer,
|
|
'getAllPositions',
|
|
() => [{ id: '353136000', position }],
|
|
restores,
|
|
);
|
|
replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(flightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryInstallationsLayer, 'getNearby', () => [], restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
runtime.dispatch('gev:awareness-subject-selected', {
|
|
layerId: 'ais-live-vessels',
|
|
id: '353136000',
|
|
label: 'MAERSK DETROIT',
|
|
position,
|
|
});
|
|
|
|
verdict = false;
|
|
militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subjectPresent,
|
|
false,
|
|
);
|
|
|
|
// Layer switched off: null must hold the last verdict, not revive it.
|
|
verdict = null;
|
|
militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subjectPresent,
|
|
false,
|
|
'a disabled layer cannot resurrect a contact already known absent',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
// --- the real eviction path (Finding 1) ------------------------------------
|
|
// An aged-out contact does not merely vanish from its collection: the owning
|
|
// layer dispatches a selection-clear. Routed as a deliberate clear that nulls
|
|
// the snapshot, the panel collapses before the lost-state resolver ever runs.
|
|
|
|
function selectFlightSubject(runtime, restores, id = 'ab4991') {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 5000);
|
|
replaceMethod(flightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryInstallationsLayer, 'getNearby', () => [], restores);
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
runtime.dispatch('gev:awareness-subject-selected', {
|
|
layerId: 'flights',
|
|
id,
|
|
label: 'N627CT',
|
|
position,
|
|
});
|
|
return position;
|
|
}
|
|
|
|
test('an evicted aircraft becomes CONTACT LOST instead of collapsing the panel', () => {
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
try {
|
|
stubPresence(flightsLayer, true, restores);
|
|
selectFlightSubject(runtime, restores);
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subject.id,
|
|
'ab4991',
|
|
);
|
|
|
|
// Exactly what flights.js emits when the poll ages a tracked plane out.
|
|
stubPresence(flightsLayer, false, restores);
|
|
runtime.dispatch('gev:awareness-subject-cleared', {
|
|
layerId: 'flights',
|
|
id: 'ab4991',
|
|
reason: 'evicted',
|
|
});
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
assert.ok(
|
|
snapshot,
|
|
'an eviction must not null the snapshot — that hides the panel',
|
|
);
|
|
assert.equal(
|
|
snapshot.subject.id,
|
|
'ab4991',
|
|
'the lost contact keeps its identity',
|
|
);
|
|
assert.equal(snapshot.subjectPresent, false);
|
|
assert.equal(
|
|
typeof snapshot.navigation?.canNext,
|
|
'boolean',
|
|
'navigation stays resolvable so PREVIOUS/NEXT remain operable',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('an evicted vessel becomes CONTACT LOST instead of collapsing the panel', () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
try {
|
|
replaceMethod(aisLiveVesselsLayer, 'hasContact', () => true, restores);
|
|
replaceMethod(
|
|
aisLiveVesselsLayer,
|
|
'getAllPositions',
|
|
() => [{ id: '353136000', position }],
|
|
restores,
|
|
);
|
|
replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(flightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryInstallationsLayer, 'getNearby', () => [], restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
runtime.dispatch('gev:awareness-subject-selected', {
|
|
layerId: 'ais-live-vessels',
|
|
id: '353136000',
|
|
label: 'MAERSK DETROIT',
|
|
position,
|
|
});
|
|
|
|
// What aisLiveVessels.js emits once a selected vessel exhausts its pin.
|
|
runtime.dispatch('gev:entity-selection-cleared', {
|
|
layerId: 'ais-live-vessels',
|
|
reason: 'evicted',
|
|
});
|
|
|
|
const snapshot = militaryAwarenessLayer.getContextSnapshot();
|
|
assert.ok(
|
|
snapshot,
|
|
'an eviction must not null the snapshot — that hides the panel',
|
|
);
|
|
assert.equal(snapshot.subject.id, '353136000');
|
|
assert.equal(snapshot.subjectPresent, false);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('a deliberate clear still fully clears the subject', () => {
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
try {
|
|
stubPresence(flightsLayer, true, restores);
|
|
selectFlightSubject(runtime, restores);
|
|
assert.ok(militaryAwarenessLayer.getContextSnapshot());
|
|
|
|
// No eviction origin: click-empty-space, Escape, voice stop, layer disable.
|
|
runtime.dispatch('gev:awareness-subject-cleared', {
|
|
layerId: 'flights',
|
|
id: 'ab4991',
|
|
});
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot(),
|
|
null,
|
|
'a deliberate clear must still take the panel down',
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('a deliberate source clear still fully clears the subject', () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
try {
|
|
replaceMethod(aisLiveVesselsLayer, 'hasContact', () => true, restores);
|
|
replaceMethod(
|
|
aisLiveVesselsLayer,
|
|
'getAllPositions',
|
|
() => [{ id: '353136000', position }],
|
|
restores,
|
|
);
|
|
replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(flightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryInstallationsLayer, 'getNearby', () => [], restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
runtime.dispatch('gev:awareness-subject-selected', {
|
|
layerId: 'ais-live-vessels',
|
|
id: '353136000',
|
|
label: 'MAERSK DETROIT',
|
|
position,
|
|
});
|
|
runtime.dispatch('gev:entity-selection-cleared', {
|
|
layerId: 'ais-live-vessels',
|
|
});
|
|
assert.equal(militaryAwarenessLayer.getContextSnapshot(), null);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('production eviction sites actually tag their clears', () => {
|
|
// The event contract above is worthless if the real cull paths never set the
|
|
// origin, so pin the three production call sites.
|
|
const flightsSource = readLayerSource(
|
|
new URL('./flights.js', import.meta.url),
|
|
);
|
|
const militarySource = readLayerSource(
|
|
new URL('./militaryFlights.js', import.meta.url),
|
|
);
|
|
const vesselsSource = readLayerSource(
|
|
new URL('./aisLiveVessels.js', import.meta.url),
|
|
);
|
|
|
|
for (const [name, source] of [
|
|
['flights', flightsSource],
|
|
['militaryFlights', militarySource],
|
|
]) {
|
|
assert.match(
|
|
source,
|
|
/if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:(?:parts\.)?tracking\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/,
|
|
`${name} must mark its aged-out cull as an eviction`,
|
|
);
|
|
assert.match(
|
|
source,
|
|
/reason: evicted \? 'evicted' : 'deliberate'/,
|
|
`${name} must carry the clear origin on the dispatched event`,
|
|
);
|
|
}
|
|
assert.match(
|
|
vesselsSource,
|
|
/clearVesselInspection\(\{ evicted: true \}\)/,
|
|
'aisLiveVessels must mark its aged-out selected vessel as an eviction',
|
|
);
|
|
// Fourth cull site: a FIRMS refresh whose new payload no longer carries the
|
|
// selected fire. The fire did not get deselected — it left the feed.
|
|
// The tag alone is not enough — see the behavioral test in
|
|
// firmsInteraction.test.mjs. The clear must also run BEFORE renderCurrentLod,
|
|
// whose registration sweep deletes the record the clear needs to see.
|
|
const firmsSource = readLayerSource(
|
|
new URL('./firmsHeatmap.js', import.meta.url),
|
|
);
|
|
const evictedClear = firmsSource.indexOf(
|
|
'clearSelectedEntityContextForLayer(id, { evicted: true });',
|
|
);
|
|
const lodRebuild = firmsSource.indexOf(
|
|
'components.rendering.renderCurrentLod(true);\n if (reselected) components.selection.selectFire(reselected, false);',
|
|
);
|
|
assert.ok(
|
|
evictedClear > 0,
|
|
'FIRMS must mark a refresh-vanished selection as an eviction',
|
|
);
|
|
assert.ok(
|
|
lodRebuild > 0,
|
|
'the FIRMS refresh must settle its selection before rebuilding',
|
|
);
|
|
assert.ok(
|
|
evictedClear < lodRebuild,
|
|
'the eviction clear must precede the LOD rebuild or it emits nothing at all',
|
|
);
|
|
// …and the deliberate FIRMS paths (layer disable, destroy, deselect) stay untagged.
|
|
assert.equal(
|
|
(firmsSource.match(/clearSelectedEntityContextForLayer\(id\);/g) || [])
|
|
.length,
|
|
3,
|
|
'only the refresh-vanish site is an eviction; disable/destroy/deselect stay deliberate',
|
|
);
|
|
assert.match(
|
|
militaryAwarenessSource,
|
|
/awarenessClearIsEviction/,
|
|
'awareness must branch on the clear origin rather than always clearing',
|
|
);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// END Contact-readout presence block — fix/context-panel-next-subjects.
|
|
// ===========================================================================
|
|
|
|
test('cockpit blocks only non-aircraft Context camera flights', () => {
|
|
const cockpitBody = {
|
|
classList: { contains: (name) => name === 'cockpit-mode' },
|
|
};
|
|
assert.equal(
|
|
contextTargetFlyToAllowed('ais-live-vessels', cockpitBody),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
contextTargetFlyToAllowed('military-installations', cockpitBody),
|
|
false,
|
|
);
|
|
assert.equal(contextTargetFlyToAllowed('flights', cockpitBody), true);
|
|
assert.equal(
|
|
contextTargetFlyToAllowed('ais-live-vessels', {
|
|
classList: { contains: () => false },
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('vessel entry and selection framing both use the 3 km focus radius', () => {
|
|
assert.match(
|
|
militaryAwarenessSource,
|
|
/const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/,
|
|
);
|
|
const focusRadiusUses =
|
|
militaryAwarenessSource.match(
|
|
/new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g,
|
|
) || [];
|
|
assert.equal(focusRadiusUses.length, 2);
|
|
});
|
|
|
|
test('awareness rescans only for invalidation, source change, or meaningful movement', () => {
|
|
assert.equal(
|
|
awarenessRefreshRequired({
|
|
force: false,
|
|
hasResults: true,
|
|
movementM: 10,
|
|
sourceRevisionChanged: false,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
awarenessRefreshRequired({
|
|
force: false,
|
|
hasResults: true,
|
|
movementM: 250,
|
|
sourceRevisionChanged: false,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
awarenessRefreshRequired({
|
|
force: false,
|
|
hasResults: true,
|
|
movementM: 0,
|
|
sourceRevisionChanged: true,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('installation summaries disclose viewport-scoped coverage', () => {
|
|
const empty = summarizeInstallationViewport([], {
|
|
available: true,
|
|
stale: false,
|
|
});
|
|
assert.match(empty.reason, /viewport feed/i);
|
|
assert.match(empty.reason, /not a complete 250 km survey/i);
|
|
|
|
const unavailable = summarizeInstallationViewport([], {
|
|
available: false,
|
|
stale: false,
|
|
});
|
|
assert.equal(unavailable.reason, 'feed unavailable');
|
|
const retrying = summarizeInstallationViewport([], {
|
|
available: false,
|
|
stats: {
|
|
statusMessage: 'Overpass temporarily unavailable — retrying in 30s',
|
|
},
|
|
});
|
|
assert.equal(
|
|
retrying.count,
|
|
null,
|
|
'retrying is not a claim of zero mapped sites',
|
|
);
|
|
assert.equal(
|
|
retrying.reason,
|
|
'Overpass temporarily unavailable — retrying in 30s',
|
|
);
|
|
});
|
|
|
|
test('installation summaries name a subject window instead of the viewport', () => {
|
|
const stats = { coverage: { kind: 'subject', radiusM: 100_000 } };
|
|
const empty = summarizeInstallationViewport([], {
|
|
available: true,
|
|
stale: false,
|
|
stats,
|
|
});
|
|
assert.equal(empty.count, 0);
|
|
assert.match(empty.reason, /none mapped within 100 km/);
|
|
assert.match(empty.reason, /not a complete 250 km survey/);
|
|
const found = summarizeInstallationViewport(
|
|
[{ id: 'ofm:installation:1', label: 'Military area', distanceM: 1200 }],
|
|
{ available: true, stale: false, stats },
|
|
);
|
|
assert.equal(found.count, 1);
|
|
assert.equal(found.reason, 'mapped matches within 100 km of the subject');
|
|
});
|
|
|
|
test('compact Context snapshots retain installation coverage', () => {
|
|
const snapshot = buildAwarenessContextSnapshot(
|
|
{
|
|
subject: { layerId: 'flights', id: 'abc' },
|
|
evaluatedAt: 123,
|
|
radiusM: 250000,
|
|
cohorts: [
|
|
{
|
|
id: 'military-installations',
|
|
label: 'Mapped installations',
|
|
source: 'OpenStreetMap',
|
|
coverage: 'CURRENT VIEWPORT ONLY',
|
|
summary: {
|
|
relationship: 'NEARBY',
|
|
count: 2,
|
|
reason: 'mapped matches',
|
|
nearest: [],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{ canNext: true },
|
|
);
|
|
|
|
assert.equal(snapshot.cohorts[0].coverage, 'CURRENT VIEWPORT ONLY');
|
|
assert.deepEqual(snapshot.navigation, { canNext: true });
|
|
});
|
|
|
|
test('expanded Context results omit the redundant status heading', () => {
|
|
assert.doesNotMatch(militaryAwarenessSource, /military-awareness-heading/);
|
|
assert.doesNotMatch(
|
|
militaryAwarenessSource,
|
|
/GLOBAL CONTEXT <span>CONTEXT ONLY<\/span>/,
|
|
);
|
|
assert.doesNotMatch(awarenessPanelSource, /military-awareness-heading/);
|
|
assert.match(awarenessPanelSource, /military-awareness-subject/);
|
|
assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/);
|
|
assert.match(awarenessPanelSource, /military-awareness-controls/);
|
|
});
|
|
|
|
test('Context entry tracks a nearer civilian aircraft over a farther military aircraft', async () => {
|
|
const camera = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const civilianPosition = Cesium.Cartesian3.fromDegrees(-97.73, 30.27, 1000);
|
|
const militaryPosition = Cesium.Cartesian3.fromDegrees(-98.5, 30.27, 1000);
|
|
const restoreCollections = stubAwarenessCollections({
|
|
flights: [
|
|
{ icao24: 'civilian', position: civilianPosition, distanceM: 1000 },
|
|
],
|
|
military: [
|
|
{ icao24: 'military', position: militaryPosition, distanceM: 70000 },
|
|
],
|
|
});
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
runtime.viewer.camera = { positionWC: camera };
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => focused.push(`flights:${id}`) > 0,
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'trackById',
|
|
(id) => focused.push(`military:${id}`) > 0,
|
|
restores,
|
|
);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
|
|
assert.deepEqual(focused, ['flights:civilian']);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('Context entry prefers military aircraft on an exact nearest-distance tie', async () => {
|
|
const camera = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const tiedPosition = Cesium.Cartesian3.fromDegrees(-97.73, 30.27, 1000);
|
|
const restoreCollections = stubAwarenessCollections({
|
|
flights: [{ icao24: 'civilian', position: tiedPosition, distanceM: 1000 }],
|
|
military: [{ icao24: 'military', position: tiedPosition, distanceM: 1000 }],
|
|
});
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
runtime.viewer.camera = { positionWC: camera };
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => focused.push(`flights:${id}`) > 0,
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'trackById',
|
|
(id) => focused.push(`military:${id}`) > 0,
|
|
restores,
|
|
);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
|
|
assert.deepEqual(focused, ['military:military']);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('Context entry retries once on the next refresh after initially empty feeds', async () => {
|
|
const flights = [];
|
|
const camera = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.73, 30.27, 1000);
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
runtime.viewer.camera = { positionWC: camera };
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => focused.push(id) > 0,
|
|
restores,
|
|
);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
assert.deepEqual(focused, []);
|
|
|
|
flights.push({ icao24: 'recovered', position, distanceM: 1000 });
|
|
runtime.tick();
|
|
assert.deepEqual(focused, ['recovered']);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('user deselect cancels a pending Context entry auto-focus retry', async () => {
|
|
const flights = [];
|
|
const camera = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const selectedPosition = Cesium.Cartesian3.fromDegrees(-97.72, 30.27, 1000);
|
|
const replacementPosition = Cesium.Cartesian3.fromDegrees(
|
|
-97.73,
|
|
30.27,
|
|
1000,
|
|
);
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
runtime.viewer.camera = { positionWC: camera };
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => focused.push(id) > 0,
|
|
restores,
|
|
);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
await nextTurn();
|
|
assert.deepEqual(focused, []);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'user-selected', selectedPosition),
|
|
);
|
|
runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' });
|
|
flights.push({
|
|
icao24: 'replacement',
|
|
position: replacementPosition,
|
|
distanceM: 1000,
|
|
});
|
|
runtime.tick();
|
|
|
|
assert.deepEqual(focused, []);
|
|
assert.equal(militaryAwarenessLayer.getContextSnapshot(), null);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('reset camera release preserves the selected Contact for explicit refocus', () => {
|
|
const selectedPosition = Cesium.Cartesian3.fromDegrees(-97.72, 30.27, 1000);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const released = [];
|
|
const releaseOptions = [];
|
|
|
|
try {
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'selected-flight', selectedPosition),
|
|
);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'stopTracking',
|
|
(options) => {
|
|
released.push('flights');
|
|
releaseOptions.push(['flights', options]);
|
|
runtime.dispatch('gev:awareness-subject-cleared', {
|
|
layerId: 'flights',
|
|
id: 'selected-flight',
|
|
});
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'stopTracking',
|
|
(options) => {
|
|
released.push('military');
|
|
releaseOptions.push(['military', options]);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
aisLiveVesselsLayer,
|
|
'clearSelection',
|
|
() => released.push('ais') > 0,
|
|
restores,
|
|
);
|
|
|
|
assert.equal(
|
|
militaryAwarenessLayer.releaseCameraOwnership({ origin: 'tool' }),
|
|
true,
|
|
);
|
|
|
|
assert.deepEqual(released, ['flights', 'military', 'ais']);
|
|
assert.deepEqual(releaseOptions, [
|
|
['flights', { origin: 'tool' }],
|
|
['military', { origin: 'tool' }],
|
|
]);
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.subject.id,
|
|
'selected-flight',
|
|
);
|
|
assert.equal(
|
|
_getAwarenessNavigationStateForTest().pendingSelectionKey,
|
|
null,
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('same-layer selection clear is suppressed during a synchronous navigation reselect', () => {
|
|
const positions = {
|
|
a: Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000),
|
|
b: Cesium.Cartesian3.fromDegrees(-97.73, 30.28, 1000),
|
|
};
|
|
const flights = Object.entries(positions).map(([id, position], index) => ({
|
|
icao24: id,
|
|
callsign: id.toUpperCase(),
|
|
position,
|
|
distanceM: (index + 1) * 1000,
|
|
}));
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
|
|
try {
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
() => {
|
|
runtime.dispatch('gev:entity-selection-cleared', {
|
|
layerId: 'flights',
|
|
});
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'a', positions.a),
|
|
);
|
|
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subject.id, 'a');
|
|
assert.deepEqual(_getAwarenessNavigationStateForTest(), {
|
|
historyKeys: ['flights:a'],
|
|
navigationVisitedKeys: ['flights:a'],
|
|
historyLength: 1,
|
|
navigationIndex: 0,
|
|
suppressedHistoryKey: null,
|
|
pendingSelectionKey: null,
|
|
});
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('FOCUS on an already-tracked subject clears its suppression keys before a later deselect', () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const flights = [
|
|
{
|
|
icao24: 'a',
|
|
callsign: 'A',
|
|
position,
|
|
distanceM: 0,
|
|
},
|
|
];
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
|
|
try {
|
|
let refocusCalls = 0;
|
|
let trackCalls = 0;
|
|
let refocusOptions = null;
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'refocusTrackedById',
|
|
(_id, options) => {
|
|
refocusCalls += 1;
|
|
refocusOptions = options;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
() => {
|
|
trackCalls += 1;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'a', position),
|
|
);
|
|
|
|
assert.equal(
|
|
militaryAwarenessLayer.focusCurrent({ origin: 'voice' }),
|
|
true,
|
|
);
|
|
assert.equal(refocusCalls, 1);
|
|
assert.deepEqual(refocusOptions, { origin: 'voice' });
|
|
assert.equal(
|
|
trackCalls,
|
|
0,
|
|
'an owned Contact follow should reframe without recreating selection',
|
|
);
|
|
assert.deepEqual(_getAwarenessNavigationStateForTest(), {
|
|
historyKeys: ['flights:a'],
|
|
navigationVisitedKeys: ['flights:a'],
|
|
historyLength: 1,
|
|
navigationIndex: 0,
|
|
suppressedHistoryKey: null,
|
|
pendingSelectionKey: null,
|
|
});
|
|
|
|
runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' });
|
|
assert.equal(militaryAwarenessLayer.getContextSnapshot(), null);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('NEXT wraps through a fresh visited cycle instead of ping-ponging after exhaustion', () => {
|
|
const positions = Object.fromEntries(
|
|
['a', 'b', 'c', 'd'].map((id, index) => [
|
|
id,
|
|
Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000),
|
|
]),
|
|
);
|
|
const flights = Object.entries(positions).map(([id, position], index) => ({
|
|
icao24: id,
|
|
callsign: id.toUpperCase(),
|
|
position,
|
|
distanceM: (index + 1) * 1000,
|
|
}));
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => {
|
|
focused.push(id);
|
|
runtime.dispatch('gev:entity-selection-cleared', {
|
|
layerId: 'flights',
|
|
});
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', id, positions[id]),
|
|
);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'a', positions.a),
|
|
);
|
|
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.deepEqual(_getAwarenessNavigationStateForTest(), {
|
|
historyKeys: ['flights:a', 'flights:b'],
|
|
navigationVisitedKeys: ['flights:a', 'flights:b'],
|
|
historyLength: 2,
|
|
navigationIndex: 1,
|
|
suppressedHistoryKey: null,
|
|
pendingSelectionKey: null,
|
|
});
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.deepEqual(focused, ['b', 'c', 'd']);
|
|
assert.deepEqual(
|
|
_getAwarenessNavigationStateForTest().navigationVisitedKeys,
|
|
['flights:a', 'flights:b', 'flights:c', 'flights:d'],
|
|
);
|
|
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.deepEqual(focused, ['b', 'c', 'd', 'a']);
|
|
assert.deepEqual(
|
|
_getAwarenessNavigationStateForTest().navigationVisitedKeys,
|
|
['flights:d', 'flights:a'],
|
|
);
|
|
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.deepEqual(focused, ['b', 'c', 'd', 'a', 'b', 'c']);
|
|
assert.notDeepEqual(focused.slice(-4), ['a', 'b', 'a', 'b']);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('NEXT reaches beyond the panel cap when more nearby targets exist', () => {
|
|
const ids = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'];
|
|
const positions = Object.fromEntries(
|
|
ids.map((id, index) => [
|
|
id,
|
|
Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.001, 30.27, 1000),
|
|
]),
|
|
);
|
|
const flights = Object.entries(positions).map(([id, position], index) => ({
|
|
icao24: id,
|
|
callsign: id.toUpperCase(),
|
|
position,
|
|
distanceM: (index + 1) * 1000,
|
|
}));
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => {
|
|
focused.push(id);
|
|
runtime.dispatch('gev:entity-selection-cleared', {
|
|
layerId: 'flights',
|
|
});
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', id, positions[id]),
|
|
);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'a', positions.a),
|
|
);
|
|
|
|
for (let index = 0; index < 11; index += 1) {
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
}
|
|
|
|
assert.deepEqual(focused[focused.length - 1], 'l');
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('NEXT jumps to expanded flight search after fully cycling nearby flight candidates', () => {
|
|
const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const nearby = {
|
|
b: Cesium.Cartesian3.fromDegrees(-97.739, 30.2702, 1000),
|
|
c: Cesium.Cartesian3.fromDegrees(-97.738, 30.2704, 1000),
|
|
};
|
|
const expanded = {
|
|
d: Cesium.Cartesian3.fromDegrees(-97.0, 30.8, 1000),
|
|
};
|
|
const nearbyFlights = Object.entries(nearby).map(([id, position], index) => ({
|
|
icao24: id,
|
|
callsign: id.toUpperCase(),
|
|
position,
|
|
distanceM: (index + 2) * 1000,
|
|
}));
|
|
const farFlight = {
|
|
icao24: 'd',
|
|
callsign: 'D',
|
|
position: expanded.d,
|
|
distanceM: AWARENESS_RADIUS_M + 1000,
|
|
};
|
|
|
|
const restoreCollections = stubAwarenessCollections({
|
|
flights: nearbyFlights,
|
|
});
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
let getNearbyCalls = 0;
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'getNearby',
|
|
(position, radius) => {
|
|
getNearbyCalls += 1;
|
|
if (radius <= AWARENESS_RADIUS_M) {
|
|
return nearbyFlights;
|
|
}
|
|
return [...nearbyFlights, farFlight];
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => {
|
|
focused.push(id);
|
|
runtime.dispatch('gev:entity-selection-cleared', {
|
|
layerId: 'flights',
|
|
});
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject(
|
|
'flights',
|
|
id,
|
|
id === 'a'
|
|
? subjectPosition
|
|
: { b: nearby.b, c: nearby.c, d: expanded.d }[id],
|
|
),
|
|
);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'a', subjectPosition),
|
|
);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
|
|
assert.deepEqual(focused, ['b', 'c', 'd']);
|
|
assert.equal(getNearbyCalls >= 3, true);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('NEXT can restrict navigation to a requested layer', () => {
|
|
const flights = [
|
|
{ icao24: 'f1', callsign: 'F1', distanceM: 1000 },
|
|
{ icao24: 'f2', callsign: 'F2', distanceM: 1500 },
|
|
];
|
|
const military = [{ icao24: 'm1', callsign: 'M1', distanceM: 700 }];
|
|
const restoreCollections = stubAwarenessCollections({ flights, military });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => {
|
|
focused.push(`flights:${id}`);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'trackById',
|
|
(id, options) => {
|
|
focused.push({ target: `military:${id}`, options });
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'f1', Cesium.Cartesian3.ZERO),
|
|
);
|
|
assert.equal(
|
|
militaryAwarenessLayer.navigateNext({
|
|
targetLayer: 'military',
|
|
origin: 'voice',
|
|
}),
|
|
true,
|
|
);
|
|
assert.deepEqual(focused, [
|
|
{
|
|
target: 'military:m1',
|
|
options: { origin: 'voice' },
|
|
},
|
|
]);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('NEXT can restrict navigation to an aircraftClass', () => {
|
|
const flights = [
|
|
{
|
|
icao24: 'f1',
|
|
callsign: 'F1',
|
|
distanceM: 1000,
|
|
aircraftClass: 'airliner',
|
|
},
|
|
{
|
|
icao24: 'f2',
|
|
callsign: 'F2',
|
|
distanceM: 1500,
|
|
aircraftClass: 'helicopter',
|
|
},
|
|
{
|
|
icao24: 'f3',
|
|
callsign: 'F3',
|
|
distanceM: 2000,
|
|
aircraftClass: 'airliner',
|
|
},
|
|
];
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => {
|
|
focused.push(id);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'f1', Cesium.Cartesian3.ZERO),
|
|
);
|
|
assert.equal(
|
|
militaryAwarenessLayer.navigateNext({ aircraftClass: 'helicopter' }),
|
|
true,
|
|
);
|
|
assert.deepEqual(focused, ['f2']);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('Cockpit NEXT ignores a nearer vessel and selects the next aircraft', () => {
|
|
const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const nextPosition = Cesium.Cartesian3.fromDegrees(-97.73, 30.28, 1200);
|
|
const vesselPosition = Cesium.Cartesian3.fromDegrees(-97.739, 30.271, 0);
|
|
const flights = [
|
|
{
|
|
icao24: 'f2',
|
|
callsign: 'F2',
|
|
distanceM: 1500,
|
|
position: nextPosition,
|
|
},
|
|
];
|
|
const vessels = [
|
|
{
|
|
mmsi: 'v1',
|
|
id: 'v1',
|
|
name: 'VESSEL',
|
|
distanceM: 100,
|
|
position: vesselPosition,
|
|
},
|
|
];
|
|
const restoreCollections = stubAwarenessCollections({ flights, vessels });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
|
|
try {
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => {
|
|
focused.push(`flights:${id}`);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
aisLiveVesselsLayer,
|
|
'selectById',
|
|
(id) => {
|
|
focused.push(`vessel:${id}`);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'f1', subjectPosition),
|
|
);
|
|
assert.equal(
|
|
militaryAwarenessLayer.navigateNext({ aircraftOnly: true }),
|
|
true,
|
|
);
|
|
assert.deepEqual(focused, ['flights:f2']);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('history navigation applies layer and aircraft-class filters in both directions', () => {
|
|
const history = [
|
|
{ layerId: 'flights', id: 'airliner', aircraftClass: 'airliner' },
|
|
{ layerId: 'military', id: 'fighter', aircraftClass: 'fighter' },
|
|
{ layerId: 'military', id: 'helo', aircraftClass: 'helicopter' },
|
|
];
|
|
assert.equal(
|
|
findCompatibleHistoryIndex(history, 0, 1, {
|
|
targetLayer: 'military',
|
|
aircraftClass: 'helicopter',
|
|
}),
|
|
2,
|
|
);
|
|
assert.equal(
|
|
findCompatibleHistoryIndex(history, 2, -1, {
|
|
targetLayer: 'flights',
|
|
aircraftClass: 'airliner',
|
|
}),
|
|
0,
|
|
);
|
|
assert.equal(
|
|
findCompatibleHistoryIndex(history, 2, -1, {
|
|
targetLayer: 'military',
|
|
aircraftClass: 'helicopter',
|
|
}),
|
|
-1,
|
|
);
|
|
});
|
|
|
|
test('Cockpit history navigation skips vessels and installations in both directions', () => {
|
|
const history = [
|
|
{ layerId: 'flights', id: 'airliner' },
|
|
{ layerId: 'ais-live-vessels', id: 'vessel' },
|
|
{ layerId: 'military-installations', id: 'base' },
|
|
{ layerId: 'military', id: 'fighter' },
|
|
];
|
|
assert.equal(
|
|
findCompatibleHistoryIndex(history, 0, 1, { aircraftOnly: true }),
|
|
3,
|
|
);
|
|
assert.equal(
|
|
findCompatibleHistoryIndex(history, 3, -1, { aircraftOnly: true }),
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('production-shaped history snapshots retain aircraft class from the selected source record', () => {
|
|
const subject = {
|
|
layerId: 'military',
|
|
id: 'helo-1',
|
|
label: 'HELO1',
|
|
position: {},
|
|
};
|
|
const snapshot = historySubjectSnapshot(subject, {
|
|
icao24: 'helo-1',
|
|
aircraftClass: 'helicopter',
|
|
});
|
|
assert.equal(snapshot.aircraftClass, 'helicopter');
|
|
assert.equal(subject.aircraftClass, undefined);
|
|
});
|
|
|
|
test('NEXT steps over a history contact whose layer has since evicted it', () => {
|
|
const positions = Object.fromEntries(
|
|
['a', 'b', 'c'].map((id, index) => [
|
|
id,
|
|
Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000),
|
|
]),
|
|
);
|
|
const flights = Object.entries(positions).map(([id, position], index) => ({
|
|
icao24: id,
|
|
callsign: id.toUpperCase(),
|
|
position,
|
|
distanceM: (index + 1) * 1000,
|
|
}));
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
const evicted = new Set();
|
|
|
|
try {
|
|
replaceMethod(flightsLayer, 'refocusTrackedById', () => false, restores);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => {
|
|
// An evicted contact is gone from the layer: it cannot be tracked, and
|
|
// it announces no selection.
|
|
if (evicted.has(id)) return false;
|
|
focused.push(id);
|
|
runtime.dispatch('gev:entity-selection-cleared', {
|
|
layerId: 'flights',
|
|
});
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', id, positions[id]),
|
|
);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'a', positions.a),
|
|
);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.navigatePrevious(), true);
|
|
assert.equal(militaryAwarenessLayer.navigatePrevious(), true);
|
|
assert.deepEqual(_getAwarenessNavigationStateForTest().historyKeys, [
|
|
'flights:a',
|
|
'flights:b',
|
|
'flights:c',
|
|
]);
|
|
assert.equal(_getAwarenessNavigationStateForTest().navigationIndex, 0);
|
|
|
|
// CONTACT LOST: the cull evicts b while it still sits in navigation history.
|
|
evicted.add('b');
|
|
focused.length = 0;
|
|
|
|
// NEXT must step over the lost entry and reach c, not dead-end on b and
|
|
// report "no further target" for the rest of the session.
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.deepEqual(focused, ['c']);
|
|
assert.equal(_getAwarenessNavigationStateForTest().navigationIndex, 2);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('a fully evicted forward history falls through to the live cohort', () => {
|
|
const positions = Object.fromEntries(
|
|
['a', 'b', 'c'].map((id, index) => [
|
|
id,
|
|
Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000),
|
|
]),
|
|
);
|
|
const flights = Object.entries(positions).map(([id, position], index) => ({
|
|
icao24: id,
|
|
callsign: id.toUpperCase(),
|
|
position,
|
|
distanceM: (index + 1) * 1000,
|
|
}));
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const focused = [];
|
|
const evicted = new Set();
|
|
|
|
try {
|
|
replaceMethod(flightsLayer, 'refocusTrackedById', () => false, restores);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => {
|
|
if (evicted.has(id)) return false;
|
|
focused.push(id);
|
|
runtime.dispatch('gev:entity-selection-cleared', {
|
|
layerId: 'flights',
|
|
});
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', id, positions[id]),
|
|
);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'a', positions.a),
|
|
);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.navigatePrevious(), true);
|
|
assert.equal(_getAwarenessNavigationStateForTest().navigationIndex, 0);
|
|
|
|
// Every remaining forward history entry is lost.
|
|
evicted.add('b');
|
|
focused.length = 0;
|
|
|
|
// With no reachable history left, NEXT resumes the live cohort search
|
|
// rather than reporting exhaustion.
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.deepEqual(focused, ['c']);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('every Context camera flight without a tracked entity takes navigation authority', () => {
|
|
// Aircraft focus stamps for free (it assigns viewer.trackedEntity, and the UI
|
|
// stamps on trackedEntityChanged). Vessels and installations fly WITHOUT ever
|
|
// setting one, so an earlier deferred geocode would still match the
|
|
// generation it captured and could resolve on top of the new Context focus.
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const vessels = [
|
|
{ mmsi: '111', id: '111', name: 'VESSEL', position, distanceM: 900 },
|
|
];
|
|
const installations = [
|
|
{ id: 'inst-1', name: 'BASE', position, distanceM: 1200 },
|
|
];
|
|
const restoreCollections = stubAwarenessCollections({
|
|
vessels,
|
|
installations,
|
|
});
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const taken = runtime.captureAuthority();
|
|
|
|
try {
|
|
replaceMethod(aisLiveVesselsLayer, 'selectById', () => true, restores);
|
|
replaceMethod(
|
|
aisLiveVesselsLayer,
|
|
'getAllPositions',
|
|
() => [{ id: '111', position }],
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
militaryInstallationsLayer,
|
|
'focusById',
|
|
() => true,
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject-a', position),
|
|
);
|
|
|
|
militaryAwarenessLayer.focusTarget('ais-live-vessels', '111');
|
|
assert.deepEqual(taken, ['context-vessel-focus']);
|
|
// The announcement precedes the flight, never trails it.
|
|
assert.equal(runtime.cameraFlights.length, 1);
|
|
|
|
militaryAwarenessLayer.focusTarget('military-installations', 'inst-1');
|
|
assert.deepEqual(taken, [
|
|
'context-vessel-focus',
|
|
'context-installation-focus',
|
|
]);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('Contacts vessel autofocus takes navigation authority before it frames', async () => {
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const restoreCollections = stubAwarenessCollections({});
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
const taken = [];
|
|
runtime.window.addEventListener(NAVIGATION_AUTHORITY_EVENT, (event) =>
|
|
taken.push(event?.detail),
|
|
);
|
|
|
|
try {
|
|
replaceMethod(militaryFlightsLayer, 'getAllPositions', () => [], restores);
|
|
replaceMethod(flightsLayer, 'getAllPositions', () => [], restores);
|
|
replaceMethod(
|
|
aisLiveVesselsLayer,
|
|
'getAllPositions',
|
|
() => [{ id: '222', position }],
|
|
restores,
|
|
);
|
|
replaceMethod(aisLiveVesselsLayer, 'selectById', () => true, restores);
|
|
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
// Activation enables dependencies before it auto-focuses.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(runtime.cameraFlights.length, 1, 'autofocus framed a vessel');
|
|
assert.deepEqual(taken, [
|
|
{
|
|
reason: 'context-vessel-autofocus',
|
|
cancelPendingSelection: false,
|
|
},
|
|
]);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('NEXT reaches every layer before repeating any contact', () => {
|
|
// The comparator used to rank subject-layer affinity above visited state, so
|
|
// an already-visited same-layer contact outranked every unvisited contact in
|
|
// the other three layers and NEXT ping-ponged inside one layer forever.
|
|
const cohorts = [
|
|
{
|
|
id: 'flights',
|
|
summary: {
|
|
nearest: [
|
|
{ icao24: 'f1', distanceM: 100 },
|
|
{ icao24: 'f2', distanceM: 200 },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: 'military',
|
|
summary: { nearest: [{ icao24: 'm1', distanceM: 300 }] },
|
|
},
|
|
{
|
|
id: 'ais-live-vessels',
|
|
summary: { nearest: [{ mmsi: 'v1', distanceM: 400 }] },
|
|
},
|
|
{
|
|
id: 'military-installations',
|
|
summary: { nearest: [{ id: 'i1', distanceM: 500 }] },
|
|
},
|
|
];
|
|
const subject = { layerId: 'flights', id: 'f0' };
|
|
const visited = new Set(['flights:f0']);
|
|
const reached = [];
|
|
|
|
// Walk the whole cohort. Under the old comparator step 3 handed back the
|
|
// already-visited f1, because subject-layer affinity outranked visited state.
|
|
for (let step = 0; step < 5; step += 1) {
|
|
const [next] = getAwarenessNavigationTargets(cohorts, subject, [
|
|
...visited,
|
|
]);
|
|
assert.ok(next, `step ${step} produced no target`);
|
|
const key = `${next.layerId}:${next.id}`;
|
|
assert.equal(visited.has(key), false, `step ${step} repeated ${key}`);
|
|
visited.add(key);
|
|
reached.push(next.layerId);
|
|
}
|
|
|
|
assert.deepEqual(
|
|
[...new Set(reached)].sort(),
|
|
['ais-live-vessels', 'flights', 'military', 'military-installations'],
|
|
'a full cohort walk must reach all four layers, not loop inside one',
|
|
);
|
|
});
|
|
|
|
test('the navigable cohort is generous but finite, and the count stays exact', () => {
|
|
// Caps guard materialization only. A cohort larger than the navigable window
|
|
// must still report its true size — a capped COUNT would be a lie.
|
|
const items = Array.from({ length: 12000 }, (_, index) => ({
|
|
id: `installation-${index}`,
|
|
distanceM: index,
|
|
}));
|
|
const summary = summarizeInstallationViewport(items, {
|
|
available: true,
|
|
stale: false,
|
|
});
|
|
assert.equal(summary.count, 12000, 'the count reflects every in-range item');
|
|
assert.equal(summary.nearest.length, 10, 'the panel still shows ten');
|
|
assert.ok(
|
|
Number.isFinite(AWARENESS_QUERY_LIMIT),
|
|
'the fetch limit is finite',
|
|
);
|
|
assert.ok(
|
|
summary.navigationNearest.length < 12000,
|
|
'the navigable window is bounded',
|
|
);
|
|
assert.ok(
|
|
summary.navigationNearest.length >= 5001,
|
|
'and still far beyond the former 5,000 cap',
|
|
);
|
|
});
|
|
|
|
test('walking a cohort does not rescan the source layer per selection', () => {
|
|
// Each selection used to pay a fresh full-layer proximity scan just to recover
|
|
// the aircraft class for its history snapshot. The sweep already holds that
|
|
// record, so a NEXT burst must not grow the scan count.
|
|
const positions = Object.fromEntries(
|
|
['a', 'b', 'c', 'd'].map((id, index) => [
|
|
id,
|
|
Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000),
|
|
]),
|
|
);
|
|
const flights = Object.entries(positions).map(([id, position], index) => ({
|
|
icao24: id,
|
|
callsign: id.toUpperCase(),
|
|
position,
|
|
distanceM: (index + 1) * 1000,
|
|
aircraftClass: 'airliner',
|
|
}));
|
|
const restoreCollections = stubAwarenessCollections({ flights });
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
let historyScans = 0;
|
|
|
|
try {
|
|
const scanning = flightsLayer.getNearby;
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'getNearby',
|
|
(...args) => {
|
|
// The history-snapshot lookup has a signature all its own: a tight 1 km
|
|
// radius with a 25-row cap. Cohort sweeps use the awareness radius.
|
|
const [, range, maxCount] = args;
|
|
if (range === 1000 && maxCount === 25) historyScans += 1;
|
|
return scanning.apply(flightsLayer, args);
|
|
},
|
|
restores,
|
|
);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'trackById',
|
|
(id) => {
|
|
runtime.dispatch('gev:entity-selection-cleared', {
|
|
layerId: 'flights',
|
|
});
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', id, positions[id]),
|
|
);
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'a', positions.a),
|
|
);
|
|
historyScans = 0;
|
|
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
|
|
// Every contact in this burst is already in the sweep, so the snapshot
|
|
// reads it from there and the extra scans are zero.
|
|
assert.equal(
|
|
historyScans,
|
|
0,
|
|
`a three-step NEXT burst paid ${historyScans} extra layer scans (want 0)`,
|
|
);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('the contacts window reports exactly the counts the panel renders', () => {
|
|
// Field case: the panel showed 42 while analyst_query reported 8 for the same
|
|
// 250 km question — the panel counts the awareness cohort, analyst counts
|
|
// CURRENTLY-LOADED records, and the flights layer reloads by viewport. Both
|
|
// honest. The window block exists so the panel's numbers travel with the
|
|
// answer, so it must be derived from the panel's own snapshot, not recomputed.
|
|
const results = {
|
|
subject: {
|
|
layerId: 'flights',
|
|
id: 'a52e54',
|
|
label: 'ASA635',
|
|
position: Cesium.Cartesian3.ZERO,
|
|
},
|
|
evaluatedAt: Date.now(),
|
|
radiusM: AWARENESS_RADIUS_M,
|
|
cohorts: [
|
|
{
|
|
id: 'flights',
|
|
label: 'Flights',
|
|
source: 'OpenSky',
|
|
summary: {
|
|
relationship: 'nearby',
|
|
count: 42,
|
|
reason: 'observed',
|
|
nearest: [],
|
|
},
|
|
},
|
|
{
|
|
id: 'military',
|
|
label: 'Military flights',
|
|
source: 'adsb.lol',
|
|
summary: {
|
|
relationship: 'nearby',
|
|
count: 13,
|
|
reason: 'observed',
|
|
nearest: [],
|
|
},
|
|
},
|
|
{
|
|
id: 'ais-live-vessels',
|
|
label: 'AIS vessels',
|
|
source: 'AISStream',
|
|
summary: {
|
|
relationship: 'unknown',
|
|
count: null,
|
|
reason: 'feed unavailable',
|
|
nearest: [],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
const snapshot = buildAwarenessContextSnapshot(results, {});
|
|
const window = contactsWindowFromSnapshot(snapshot);
|
|
|
|
// Same fixture, both derivations: the window equals the rendered cohort counts.
|
|
const rendered = Object.fromEntries(
|
|
snapshot.cohorts.map((cohort) => [cohort.id, cohort.count]),
|
|
);
|
|
assert.equal(window.flights, rendered.flights);
|
|
assert.equal(window.military, rendered.military);
|
|
assert.equal(window.flights, 42);
|
|
assert.equal(window.military, 13);
|
|
assert.equal(window.aircraft, 55);
|
|
assert.equal(window.complete, true);
|
|
assert.equal(window.centeredOn, 'ASA635');
|
|
assert.equal(window.radiusKm, Math.round(AWARENESS_RADIUS_M / 1000));
|
|
// A feed that cannot answer says so rather than reporting a confident zero.
|
|
assert.equal(rendered['ais-live-vessels'], null);
|
|
assert.equal(window.vessels, 'unknown');
|
|
});
|
|
|
|
test('the voice query snapshot is a detached copy of the rendered aircraft cohort', async () => {
|
|
const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
|
|
const nearbyPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1_500);
|
|
const restoreCollections = stubAwarenessCollections({
|
|
flights: [
|
|
{
|
|
icao24: 'subject',
|
|
id: 'SUBJECT',
|
|
position: subjectPosition,
|
|
distance: 0,
|
|
},
|
|
{
|
|
icao24: 'nearby',
|
|
id: 'NEARBY',
|
|
position: nearbyPosition,
|
|
distance: 5_000,
|
|
},
|
|
],
|
|
});
|
|
const runtime = installAwarenessRuntime();
|
|
try {
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject', subjectPosition),
|
|
);
|
|
await nextTurn();
|
|
const first = militaryAwarenessLayer.getAircraftQuerySnapshot();
|
|
assert.equal(first.cohorts.flights.count, 1);
|
|
assert.equal(first.cohorts.flights.complete, true);
|
|
assert.equal(first.cohorts.flights.provenance.id, 'flights');
|
|
assert.equal(first.cohorts.flights.provenance.enabled, true);
|
|
assert.equal(first.cohorts.flights.items[0].icao24, 'nearby');
|
|
first.cohorts.flights.items[0].icao24 = 'mutated';
|
|
first.cohorts.flights.items[0].position.x = 99;
|
|
first.cohorts.flights.provenance.source = 'mutated';
|
|
first.cohorts.flights.provenance.stats.coverage = { radiusM: 1 };
|
|
first.subject.position.x = 77;
|
|
const second = militaryAwarenessLayer.getAircraftQuerySnapshot();
|
|
assert.equal(second.cohorts.flights.items[0].icao24, 'nearby');
|
|
assert.equal(second.cohorts.flights.items[0].position.x, nearbyPosition.x);
|
|
assert.notEqual(second.cohorts.flights.provenance.source, 'mutated');
|
|
assert.notDeepEqual(second.cohorts.flights.provenance.stats.coverage, {
|
|
radiusM: 1,
|
|
});
|
|
assert.equal(second.subject.position.x, subjectPosition.x);
|
|
} finally {
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('there is no contacts window without a subject', () => {
|
|
assert.equal(contactsWindowFromSnapshot(null), null);
|
|
assert.equal(contactsWindowFromSnapshot({ cohorts: [] }), null);
|
|
});
|
|
|
|
test('installation summaries keep the full navigation cohort beyond the former 5,000 cap', () => {
|
|
const items = Array.from({ length: 5001 }, (_, index) => ({
|
|
id: `installation-${index}`,
|
|
distanceM: index * 1000,
|
|
}));
|
|
const summary = summarizeInstallationViewport(items, {
|
|
available: true,
|
|
stale: false,
|
|
});
|
|
assert.equal(summary.nearest.length, 10);
|
|
assert.equal(summary.navigationNearest.length, 5001);
|
|
assert.equal(AWARENESS_QUERY_LIMIT >= 5001, true);
|
|
});
|
|
|
|
test('unknown subject cohort blocks cross-layer NEXT navigation and availability', () => {
|
|
const vesselPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0);
|
|
const militaryPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1000);
|
|
const restoreCollections = stubAwarenessCollections({
|
|
military: [
|
|
{
|
|
icao24: 'm1',
|
|
callsign: 'M1',
|
|
position: militaryPosition,
|
|
distanceM: 5000,
|
|
},
|
|
],
|
|
});
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: (layerId) => layerId !== 'ais-live-vessels',
|
|
});
|
|
const restores = [];
|
|
let focusCalls = 0;
|
|
|
|
try {
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'trackById',
|
|
() => {
|
|
focusCalls += 1;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('ais-live-vessels', 'v1', vesselPosition),
|
|
);
|
|
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext,
|
|
false,
|
|
);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), false);
|
|
assert.equal(focusCalls, 0);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('NEXT availability helper gates only new-target branches on an unknown cohort', () => {
|
|
assert.equal(
|
|
canNavigateAwarenessNext({
|
|
hasForwardHistory: true,
|
|
hasExpandedFlightTarget: true,
|
|
hasNearbyTarget: true,
|
|
subjectCohortUnknown: true,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
canNavigateAwarenessNext({
|
|
hasExpandedFlightTarget: true,
|
|
subjectCohortUnknown: true,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
canNavigateAwarenessNext({
|
|
hasNearbyTarget: true,
|
|
subjectCohortUnknown: true,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
canNavigateAwarenessNext({
|
|
hasExpandedFlightTarget: true,
|
|
subjectCohortUnknown: false,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('canNext agrees with NEXT for unknown, healthy-empty, and recovered flight feeds', () => {
|
|
const positions = {
|
|
subject: Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000),
|
|
contact: Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1000),
|
|
};
|
|
const military = [
|
|
{
|
|
icao24: 'm1',
|
|
callsign: 'M1',
|
|
position: positions.contact,
|
|
distanceM: 5000,
|
|
},
|
|
];
|
|
let flightsEnabled = false;
|
|
const restoreCollections = stubAwarenessCollections({ military });
|
|
const runtime = installAwarenessRuntime({
|
|
isEnabled: (layerId) => layerId !== 'flights' || flightsEnabled,
|
|
});
|
|
const restores = [];
|
|
let focusCalls = 0;
|
|
|
|
try {
|
|
replaceMethod(
|
|
militaryFlightsLayer,
|
|
'trackById',
|
|
() => {
|
|
focusCalls += 1;
|
|
return true;
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject', positions.subject),
|
|
);
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext,
|
|
false,
|
|
);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), false);
|
|
assert.equal(focusCalls, 0);
|
|
|
|
flightsEnabled = true;
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject', positions.subject),
|
|
);
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext,
|
|
true,
|
|
);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(focusCalls, 1);
|
|
|
|
flightsEnabled = false;
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject', positions.subject),
|
|
);
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext,
|
|
false,
|
|
);
|
|
|
|
flightsEnabled = true;
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject', positions.subject),
|
|
);
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext,
|
|
true,
|
|
);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), true);
|
|
assert.equal(focusCalls, 2);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('far-side hidden aircraft cannot enable NEXT when navigation cannot see them', () => {
|
|
const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const hiddenPosition = Cesium.Cartesian3.fromDegrees(82.26, -30.27, 1000);
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
|
|
try {
|
|
replaceMethod(flightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores);
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'getAllPositions',
|
|
() => [
|
|
{
|
|
id: 'hidden',
|
|
position: hiddenPosition,
|
|
},
|
|
],
|
|
restores,
|
|
);
|
|
replaceMethod(militaryFlightsLayer, 'getAllPositions', () => [], restores);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject', subjectPosition),
|
|
);
|
|
|
|
assert.equal(
|
|
militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext,
|
|
false,
|
|
);
|
|
assert.equal(militaryAwarenessLayer.navigateNext(), false);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|
|
|
|
test('runtime listeners exist only while the awareness layer is enabled', () => {
|
|
const originalWindow = globalThis.window;
|
|
const originalDocument = globalThis.document;
|
|
const listeners = new Map();
|
|
let preRenderListeners = 0;
|
|
let nextTimer = 1;
|
|
|
|
class FakeElement {
|
|
constructor() {
|
|
this.classList = { contains: () => false };
|
|
this.style = { setProperty() {} };
|
|
this.hidden = false;
|
|
this.innerHTML = '';
|
|
}
|
|
|
|
setAttribute() {}
|
|
addEventListener() {}
|
|
removeEventListener() {}
|
|
appendChild() {}
|
|
append() {}
|
|
remove() {}
|
|
replaceChildren() {}
|
|
}
|
|
|
|
const fakeWindow = {
|
|
addEventListener(type, listener) {
|
|
if (!listeners.has(type)) listeners.set(type, new Set());
|
|
listeners.get(type).add(listener);
|
|
},
|
|
removeEventListener(type, listener) {
|
|
listeners.get(type)?.delete(listener);
|
|
},
|
|
setInterval() {
|
|
return nextTimer++;
|
|
},
|
|
clearInterval() {},
|
|
requestAnimationFrame(callback) {
|
|
return nextTimer++;
|
|
},
|
|
cancelAnimationFrame() {},
|
|
};
|
|
const fakeBody = new FakeElement();
|
|
const viewer = {
|
|
scene: {
|
|
preRender: {
|
|
addEventListener() {
|
|
preRenderListeners += 1;
|
|
return () => {
|
|
preRenderListeners -= 1;
|
|
};
|
|
},
|
|
},
|
|
},
|
|
entities: { remove() {} },
|
|
container: new FakeElement(),
|
|
};
|
|
|
|
globalThis.window = fakeWindow;
|
|
globalThis.document = {
|
|
body: fakeBody,
|
|
getElementById() {
|
|
return null;
|
|
},
|
|
createElement() {
|
|
return new FakeElement();
|
|
},
|
|
};
|
|
|
|
try {
|
|
militaryAwarenessLayer.setParams({ passive: true });
|
|
militaryAwarenessLayer.init(viewer);
|
|
assert.equal(
|
|
[...listeners.values()].reduce((count, set) => count + set.size, 0),
|
|
0,
|
|
);
|
|
assert.equal(preRenderListeners, 0);
|
|
|
|
militaryAwarenessLayer.enable();
|
|
assert.equal(
|
|
[...listeners.values()].reduce((count, set) => count + set.size, 0),
|
|
4,
|
|
);
|
|
assert.equal(preRenderListeners, 1);
|
|
|
|
militaryAwarenessLayer.disable();
|
|
assert.equal(
|
|
[...listeners.values()].reduce((count, set) => count + set.size, 0),
|
|
0,
|
|
);
|
|
assert.equal(preRenderListeners, 0);
|
|
|
|
militaryAwarenessLayer.enable();
|
|
militaryAwarenessLayer.destroy();
|
|
assert.equal(
|
|
[...listeners.values()].reduce((count, set) => count + set.size, 0),
|
|
0,
|
|
);
|
|
assert.equal(preRenderListeners, 0);
|
|
} finally {
|
|
globalThis.window = originalWindow;
|
|
globalThis.document = originalDocument;
|
|
}
|
|
});
|
|
|
|
test('awareness runtime parameters return a detached snapshot', () => {
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
const snapshot = militaryAwarenessLayer.getParams();
|
|
militaryAwarenessLayer.setParams({ passive: true });
|
|
assert.deepEqual(snapshot, { passive: false });
|
|
});
|
|
|
|
test('Contacts refresh cadence is snappy while the camera moves and parked otherwise', () => {
|
|
assert.equal(awarenessRefreshIntervalMs(false), 750);
|
|
assert.ok(
|
|
awarenessRefreshIntervalMs(true) >= 100 &&
|
|
awarenessRefreshIntervalMs(true) <= 200,
|
|
'a moving camera earns a ~150-200 ms cadence',
|
|
);
|
|
});
|
|
|
|
test('intermittent pose-bin crossings cannot refresh faster than the motion floor', () => {
|
|
// The adversarial pattern: a SLOW continuous drag. The pose signature is
|
|
// quantized, so it crosses a bin only every fourth frame — which a per-frame
|
|
// "unchanged means parked" test reads as a motion-end four times a frame-quad,
|
|
// refreshing at nearly display rate.
|
|
let lastRefreshMs = 0;
|
|
let lastPoseChangeMs = 0;
|
|
let wasMoving = false;
|
|
let refreshes = 0;
|
|
const frames = 60;
|
|
const frameMs = 16;
|
|
for (let frame = 0; frame < frames; frame += 1) {
|
|
const nowMs = 1_000 + frame * frameMs;
|
|
if (frame % 4 === 0) lastPoseChangeMs = nowMs;
|
|
const decision = awarenessRefreshDecision({
|
|
nowMs,
|
|
lastRefreshMs,
|
|
lastPoseChangeMs,
|
|
wasMoving,
|
|
});
|
|
wasMoving = decision.moving;
|
|
if (decision.refresh) {
|
|
refreshes += 1;
|
|
lastRefreshMs = nowMs;
|
|
}
|
|
}
|
|
// 960 ms of motion against a 175 ms floor tops out at six refreshes.
|
|
const ceiling = Math.ceil(
|
|
(frames * frameMs) / awarenessRefreshIntervalMs(true),
|
|
);
|
|
assert.ok(
|
|
refreshes <= ceiling,
|
|
`intermittent bin crossings must respect the ${awarenessRefreshIntervalMs(true)} ms floor: ` +
|
|
`expected at most ${ceiling} refreshes in ${frames} frames, got ${refreshes}`,
|
|
);
|
|
assert.ok(refreshes >= 4, `motion must still be snappy, got ${refreshes}`);
|
|
});
|
|
|
|
test('motion ends only after the pose has been unchanged through the settle window', () => {
|
|
const parked = (nowMs, lastPoseChangeMs, wasMoving = true) =>
|
|
awarenessRefreshDecision({
|
|
nowMs,
|
|
lastRefreshMs: nowMs - 10_000,
|
|
lastPoseChangeMs,
|
|
wasMoving,
|
|
}).moving;
|
|
assert.equal(
|
|
parked(1_000, 1_000),
|
|
true,
|
|
'the frame the pose changed is moving',
|
|
);
|
|
assert.equal(
|
|
parked(1_200, 1_000),
|
|
true,
|
|
'a 200 ms gap between bin crossings is still moving',
|
|
);
|
|
assert.equal(
|
|
parked(1_249, 1_000),
|
|
true,
|
|
'just inside the settle window is still moving',
|
|
);
|
|
assert.equal(
|
|
parked(1_250, 1_000),
|
|
false,
|
|
'the settle window expiring ends motion',
|
|
);
|
|
});
|
|
|
|
test('the motion-end settle refresh still respects the motion floor', () => {
|
|
// Motion ends 20 ms after a refresh: the settle must NOT jump the floor.
|
|
const tooSoon = awarenessRefreshDecision({
|
|
nowMs: 2_000,
|
|
lastRefreshMs: 1_980,
|
|
lastPoseChangeMs: 1_700,
|
|
wasMoving: true,
|
|
});
|
|
assert.deepEqual(tooSoon, { moving: false, refresh: false });
|
|
const allowed = awarenessRefreshDecision({
|
|
nowMs: 2_000,
|
|
lastRefreshMs: 1_800,
|
|
lastPoseChangeMs: 1_700,
|
|
wasMoving: true,
|
|
});
|
|
assert.deepEqual(allowed, { moving: false, refresh: true });
|
|
});
|
|
|
|
test('parked Contacts takes no continuous-render hold and requests at most the parked cadence', () => {
|
|
const realNow = Date.now;
|
|
let clockMs = 1_700_000_000_000;
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const neighbour = Cesium.Cartesian3.fromDegrees(-97.7, 30.27, 1000);
|
|
const restoreCollections = stubAwarenessCollections({
|
|
// A real neighbouring contact, so the cohort is genuinely LIVE — an empty
|
|
// or failed cohort must not take the hold (see the failed-feed test below).
|
|
flights: [
|
|
{ icao24: 'subject-flight', position, distanceM: 0 },
|
|
{ icao24: 'neighbour-flight', position: neighbour, distanceM: 4000 },
|
|
],
|
|
});
|
|
_resetRenderGovernorForTest();
|
|
const runtime = installAwarenessRuntime();
|
|
const holds = () => getRenderGovernorDiagnostics().holds;
|
|
|
|
try {
|
|
Date.now = () => clockMs;
|
|
installRenderGovernor(runtime.viewer);
|
|
runtime.viewer.camera = {
|
|
positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000),
|
|
heading: 0,
|
|
pitch: -0.4,
|
|
roll: 0,
|
|
};
|
|
|
|
// 1. Enabled, nothing selected, camera parked — Contacts must not be a
|
|
// reason the whole scene keeps repainting.
|
|
assert.ok(
|
|
!holds().includes('military-awareness'),
|
|
'enable() must not hold outright',
|
|
);
|
|
for (let frame = 0; frame < 60; frame += 1) {
|
|
clockMs += 16;
|
|
runtime.tick();
|
|
}
|
|
assert.ok(
|
|
!holds().includes('military-awareness'),
|
|
'a parked empty panel holds nothing',
|
|
);
|
|
|
|
// 2. A live subject, still parked: one ring paints, then quiet.
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject-flight', position),
|
|
);
|
|
const requestsAfterSelect = runtime.renderRequests.length;
|
|
for (let frame = 0; frame < 60; frame += 1) {
|
|
clockMs += 16;
|
|
runtime.tick();
|
|
}
|
|
assert.ok(
|
|
!holds().includes('military-awareness'),
|
|
'a parked panel holds nothing even with a subject',
|
|
);
|
|
const parkedRequests = runtime.renderRequests.length - requestsAfterSelect;
|
|
// 960 ms parked = at most two 750 ms-cadence frames' worth of requests.
|
|
assert.ok(
|
|
parkedRequests <= 2,
|
|
`parked Contacts requested ${parkedRequests} renders in 960 ms`,
|
|
);
|
|
|
|
// 3. Moving: the arrows are screen-projected, so per-frame work is real.
|
|
clockMs += 16;
|
|
runtime.viewer.camera.heading += 0.5;
|
|
runtime.tick();
|
|
assert.ok(
|
|
holds().includes('military-awareness'),
|
|
'a moving view earns the hold',
|
|
);
|
|
|
|
// 4. Parked again: the hold is dropped on a frame the hold itself bought.
|
|
clockMs += 300;
|
|
runtime.tick();
|
|
assert.ok(
|
|
!holds().includes('military-awareness'),
|
|
'settling releases the hold',
|
|
);
|
|
|
|
// 5. Teardown releases even without another frame.
|
|
runtime.viewer.camera.heading += 0.5;
|
|
clockMs += 16;
|
|
runtime.tick();
|
|
assert.ok(holds().includes('military-awareness'));
|
|
militaryAwarenessLayer.disable();
|
|
assert.ok(
|
|
!holds().includes('military-awareness'),
|
|
'disable() drops the hold',
|
|
);
|
|
} finally {
|
|
Date.now = realNow;
|
|
runtime.restore();
|
|
restoreCollections();
|
|
_resetRenderGovernorForTest();
|
|
}
|
|
});
|
|
|
|
test('Contacts needs continuous render only for a live cohort under a moving view', () => {
|
|
const need = (input) => awarenessNeedsContinuousRender(input);
|
|
assert.equal(
|
|
need({ cameraMoving: true, hasSubject: true, hasLiveResults: true }),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
need({ cameraMoving: false, hasSubject: true, hasLiveResults: true }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
need({ cameraMoving: true, hasSubject: false, hasLiveResults: false }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
need({ cameraMoving: true, hasSubject: true, hasLiveResults: false }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('empty and failed cohorts are not "live" — there is no arrow to animate', () => {
|
|
const cohorts = (...counts) => ({
|
|
cohorts: counts.map((count) => ({ summary: { count } })),
|
|
});
|
|
assert.equal(
|
|
awarenessResultsAreLive(cohorts(3, 0, null)),
|
|
true,
|
|
'one populated cohort is enough',
|
|
);
|
|
assert.equal(
|
|
awarenessResultsAreLive(cohorts(0, 0, 0)),
|
|
false,
|
|
'healthy but empty feeds animate nothing',
|
|
);
|
|
assert.equal(
|
|
awarenessResultsAreLive(cohorts(null, null)),
|
|
false,
|
|
'unavailable/stale feeds animate nothing',
|
|
);
|
|
assert.equal(awarenessResultsAreLive(cohorts(0, null)), false);
|
|
assert.equal(awarenessResultsAreLive({ cohorts: [] }), false);
|
|
assert.equal(awarenessResultsAreLive(null), false);
|
|
});
|
|
|
|
test('a moving view over failed feeds takes no continuous-render hold', () => {
|
|
const realNow = Date.now;
|
|
let clockMs = 1_700_000_000_000;
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
// Every dependency reports unavailable, so every cohort summarizes to count
|
|
// null — the exact state the old Boolean(state.results) gate still held for.
|
|
const restoreCollections = stubAwarenessCollections({});
|
|
_resetRenderGovernorForTest();
|
|
const runtime = installAwarenessRuntime({ isEnabled: () => false });
|
|
const holds = () => getRenderGovernorDiagnostics().holds;
|
|
|
|
try {
|
|
Date.now = () => clockMs;
|
|
installRenderGovernor(runtime.viewer);
|
|
runtime.viewer.camera = {
|
|
positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000),
|
|
heading: 0,
|
|
pitch: -0.4,
|
|
roll: 0,
|
|
};
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject-flight', position),
|
|
);
|
|
const results = militaryAwarenessLayer.getContextSnapshot();
|
|
assert.ok(results, 'a subject is selected, so an evaluation exists');
|
|
assert.equal(
|
|
awarenessResultsAreLive(results),
|
|
false,
|
|
'but every cohort is empty/failed',
|
|
);
|
|
|
|
for (let frame = 0; frame < 10; frame += 1) {
|
|
clockMs += 16;
|
|
runtime.viewer.camera.heading += 0.5; // unmistakably moving
|
|
runtime.tick();
|
|
}
|
|
assert.ok(
|
|
!holds().includes('military-awareness'),
|
|
'a moving view with nothing to point at must not pin the governor',
|
|
);
|
|
} finally {
|
|
Date.now = realNow;
|
|
runtime.restore();
|
|
restoreCollections();
|
|
_resetRenderGovernorForTest();
|
|
}
|
|
});
|
|
|
|
test('a moving camera refreshes Contacts more than once inside one parked interval', () => {
|
|
const realNow = Date.now;
|
|
let clockMs = 1_700_000_000_000;
|
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000);
|
|
const restoreCollections = stubAwarenessCollections({
|
|
flights: [{ icao24: 'subject-flight', position, distanceM: 0 }],
|
|
});
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
let refreshes = 0;
|
|
|
|
try {
|
|
Date.now = () => clockMs;
|
|
runtime.viewer.camera = {
|
|
positionWC: new Cesium.Cartesian3(1_000_000, 200_000, 300_000),
|
|
heading: 0,
|
|
pitch: -0.4,
|
|
roll: 0,
|
|
};
|
|
replaceMethod(
|
|
flightsLayer,
|
|
'getAllPositions',
|
|
() => {
|
|
refreshes += 1;
|
|
return [{ id: 'subject-flight', position }];
|
|
},
|
|
restores,
|
|
);
|
|
|
|
runtime.dispatch(
|
|
'gev:awareness-subject-selected',
|
|
awarenessSubject('flights', 'subject-flight', position),
|
|
);
|
|
|
|
// 720 ms of MOVING frames at ~60 Hz — one parked interval's worth of time.
|
|
refreshes = 0;
|
|
for (let frame = 0; frame < 45; frame += 1) {
|
|
clockMs += 16;
|
|
runtime.viewer.camera.heading += 0.02; // "looking around"
|
|
runtime.tick();
|
|
}
|
|
assert.ok(
|
|
refreshes > 1,
|
|
`expected more than one refresh inside a 750 ms motion window, got ${refreshes}`,
|
|
);
|
|
assert.ok(
|
|
refreshes <= Math.ceil(720 / awarenessRefreshIntervalMs(true)),
|
|
`motion must stay under the ${awarenessRefreshIntervalMs(true)} ms floor, got ${refreshes}`,
|
|
);
|
|
|
|
// Motion stops. Hysteresis keeps the view "moving" for one settle window,
|
|
// which ends in a single settle refresh; after that the cheap parked
|
|
// cadence resumes at exactly one refresh per 750 ms.
|
|
clockMs += 300;
|
|
runtime.tick();
|
|
refreshes = 0;
|
|
for (let frame = 0; frame < 60; frame += 1) {
|
|
clockMs += 16;
|
|
runtime.tick();
|
|
}
|
|
assert.equal(
|
|
refreshes,
|
|
1,
|
|
'a parked camera keeps the single-refresh-per-750 ms cadence',
|
|
);
|
|
} finally {
|
|
Date.now = realNow;
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
restoreCollections();
|
|
}
|
|
});
|
|
|
|
test('a selected installation card keeps every member name as text', () => {
|
|
const runtime = installAwarenessRuntime();
|
|
const restores = [];
|
|
try {
|
|
for (const layer of [
|
|
aisLiveVesselsLayer,
|
|
flightsLayer,
|
|
militaryFlightsLayer,
|
|
militaryInstallationsLayer,
|
|
])
|
|
replaceMethod(layer, 'getNearby', () => [], restores);
|
|
militaryAwarenessLayer.setParams({ passive: false });
|
|
runtime.dispatch('gev:entity-selected', {
|
|
layerId: 'military-installations',
|
|
id: 'osm:military:w1',
|
|
label: 'Parent base',
|
|
latitude: 31.13,
|
|
longitude: -97.78,
|
|
properties: {
|
|
memberNames: ['Area one', 'Area two', 'Area three', '<Area four>'],
|
|
},
|
|
});
|
|
militaryAwarenessLayer.update();
|
|
// The view renders names as text; ui/awarenessPanel.test.mjs covers it.
|
|
const names = runtime.panel().view?.subjectNames;
|
|
assert.equal(names?.summary, 'Named areas (4)');
|
|
assert.deepEqual(names?.names, [
|
|
'Area one',
|
|
'Area two',
|
|
'Area three',
|
|
'<Area four>',
|
|
]);
|
|
} finally {
|
|
restores.reverse().forEach((restore) => restore());
|
|
runtime.restore();
|
|
}
|
|
});
|