mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
3063 lines
117 KiB
JavaScript
3063 lines
117 KiB
JavaScript
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import * as Cesium from 'cesium';
|
|
import militaryAwarenessLayer, {
|
|
_getAwarenessNavigationStateForTest,
|
|
awarenessClearMatchesSubject,
|
|
awarenessNeedsContinuousRender,
|
|
awarenessRefreshDecision,
|
|
awarenessRefreshIntervalMs,
|
|
awarenessRefreshRequired,
|
|
awarenessResultsAreLive,
|
|
awarenessPanelControlKey,
|
|
buildAwarenessContextSnapshot,
|
|
canNavigateAwarenessNext,
|
|
contactsWindowFromSnapshot,
|
|
contextTargetFlyToAllowed,
|
|
captureAwarenessPanelFocus,
|
|
summarizeInstallationViewport,
|
|
findCompatibleHistoryIndex,
|
|
AWARENESS_QUERY_LIMIT,
|
|
historySubjectSnapshot,
|
|
restoreAwarenessPanelFocus,
|
|
} 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 { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
|
|
|
const militaryAwarenessSource = readLayerSource(
|
|
new URL('./militaryAwareness.js', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
const AWARENESS_DEPENDENCIES = [
|
|
'flights',
|
|
'military',
|
|
'ais-live-vessels',
|
|
'military-installations',
|
|
];
|
|
|
|
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;
|
|
const createdElements = [];
|
|
|
|
class FakeElement {
|
|
constructor() {
|
|
this.classList = { contains: () => false };
|
|
this.style = { setProperty() {} };
|
|
this._hidden = false;
|
|
// renderResults() writes `hidden = false` on EVERY call, ahead of its
|
|
// markup diff, so counting these writes observes "was the panel
|
|
// repaint path entered?" independently of whether the markup changed.
|
|
this.hiddenWrites = 0;
|
|
this.innerHTML = '';
|
|
}
|
|
|
|
get hidden() { return this._hidden; }
|
|
|
|
set hidden(value) {
|
|
this._hidden = value;
|
|
this.hiddenWrites += 1;
|
|
}
|
|
|
|
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() {
|
|
const element = new FakeElement();
|
|
createdElements.push(element);
|
|
return element;
|
|
},
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
};
|
|
militaryAwarenessLayer.attachDataManager({
|
|
isEnabled,
|
|
isEffectivelyEnabled,
|
|
setEnabled,
|
|
getLayerLifecycleState,
|
|
layers,
|
|
});
|
|
militaryAwarenessLayer.setParams({ passive: true });
|
|
militaryAwarenessLayer.init(viewer);
|
|
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 standalone Context panel element renderResults() paints into. */
|
|
panel() {
|
|
return createdElements.find((element) => element.id === 'military-awareness-panel') || null;
|
|
},
|
|
restore() {
|
|
militaryAwarenessLayer.destroy();
|
|
globalThis.window = originalWindow;
|
|
globalThis.document = originalDocument;
|
|
},
|
|
};
|
|
}
|
|
|
|
function awarenessControl(dataset, { disabled = false } = {}) {
|
|
return {
|
|
dataset,
|
|
disabled,
|
|
focusCalls: 0,
|
|
closest(selector) {
|
|
if (selector === '[data-awareness-focus-continuation]') {
|
|
return this.isContinuation ? this : null;
|
|
}
|
|
return selector.includes('button') && !this.isContinuation ? this : null;
|
|
},
|
|
matches(selector) {
|
|
return selector === '[data-awareness-focus-continuation]' && this.isContinuation;
|
|
},
|
|
focus(options) {
|
|
this.focusCalls += 1;
|
|
this.focusOptions = options;
|
|
},
|
|
};
|
|
}
|
|
|
|
function awarenessPanel(controls) {
|
|
const continuation = awarenessControl({});
|
|
continuation.isContinuation = true;
|
|
return {
|
|
controls,
|
|
continuation,
|
|
contains(element) { return controls.includes(element) || element === continuation; },
|
|
querySelectorAll() { return this.controls; },
|
|
querySelector(selector) {
|
|
return selector === '[data-awareness-focus-continuation]' ? continuation : null;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
|
|
const previous = awarenessControl({ awarenessAction: 'previous' });
|
|
const focus = awarenessControl({ awarenessAction: 'focus' });
|
|
const next = awarenessControl({ awarenessAction: 'next' });
|
|
const alpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
|
|
const bravo = awarenessControl({ awarenessLayer: 'military', awarenessId: 'def456' });
|
|
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
|
|
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
|
|
|
|
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
|
|
assert.equal(awarenessPanelControlKey(next), 'action:next');
|
|
|
|
const retainedAlpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
|
|
const retained = awarenessPanel([previous, focus, next, bravo, retainedAlpha]);
|
|
assert.equal(restoreAwarenessPanelFocus(retained, alphaSnapshot), retainedAlpha);
|
|
assert.equal(retainedAlpha.focusCalls, 1, 'identity wins even when live distance reorders rows');
|
|
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
|
|
|
|
const forward = awarenessControl({ awarenessLayer: 'ais-live-vessels', awarenessId: '789' });
|
|
const departed = awarenessPanel([previous, focus, next, forward]);
|
|
assert.equal(restoreAwarenessPanelFocus(departed, alphaSnapshot), departed.continuation);
|
|
assert.equal(forward.focusCalls, 0, 'automatic paging must not transfer focus to another contact');
|
|
assert.equal(departed.continuation.focusCalls, 1);
|
|
|
|
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
|
|
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
|
|
assert.equal(
|
|
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
|
|
noRemainingRow.continuation,
|
|
);
|
|
assert.equal(noRemainingRow.continuation.focusCalls, 1, 'the end sentinel continues Tab beyond the list');
|
|
|
|
const continuationSnapshot = captureAwarenessPanelFocus(departed, departed.continuation);
|
|
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
|
|
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
|
|
assert.equal(
|
|
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
|
|
repaintedAgain.continuation,
|
|
);
|
|
assert.equal(
|
|
repaintedAgain.continuation.focusCalls,
|
|
1,
|
|
'later live repaints keep focus on the continuation point until the user tabs onward',
|
|
);
|
|
});
|
|
|
|
test('Contacts continuation target is the visible explanatory note', () => {
|
|
const source = readLayerSource(new URL('./militaryAwareness.js', import.meta.url));
|
|
assert.match(
|
|
source,
|
|
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped\/observed context\./,
|
|
);
|
|
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
|
|
});
|
|
|
|
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.match(
|
|
runtime.panel()?.innerHTML || '',
|
|
/Mapped installations<\/strong><b aria-live="polite">\?<\/b>/,
|
|
'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/ais-live 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.match(
|
|
runtime.panel()?.innerHTML || '',
|
|
/AIS vessels<\/strong><b aria-live="polite">\?<\/b>/,
|
|
'the panel prints ? for the whole settled-but-connecting window',
|
|
);
|
|
assert.doesNotMatch(
|
|
runtime.panel()?.innerHTML || '',
|
|
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
|
|
'the panel must never print an all-clear 0 before the feed has answered once',
|
|
);
|
|
} 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.match(
|
|
runtime.panel()?.innerHTML || '',
|
|
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
|
|
'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.ok(panel, 'the Context panel must exist');
|
|
assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex');
|
|
|
|
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
|
|
const settledWrites = panel.hiddenWrites;
|
|
await militaryAwarenessLayer.update();
|
|
assert.equal(
|
|
panel.hiddenWrites,
|
|
settledWrites,
|
|
'an unchanged label must not trigger a panel repaint',
|
|
);
|
|
|
|
// 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.
|
|
assert.ok(
|
|
panel.hiddenWrites > settledWrites,
|
|
'a changed label must repaint the standalone Context panel',
|
|
);
|
|
assert.match(
|
|
panel.innerHTML,
|
|
/N123AB/,
|
|
'the rendered Context panel must show the registration without a position change',
|
|
);
|
|
assert.doesNotMatch(panel.innerHTML, /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.70, 30.30, 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.70, 30.30, 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('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.match(militaryAwarenessSource, /military-awareness-subject/);
|
|
assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/);
|
|
assert.match(militaryAwarenessSource, /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.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('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();
|
|
}
|
|
});
|