Files
gods-eye-view/src/layers/flights/clockFollow.test.mjs
T
Sameh Khamis 6f830a2e70 Let a host application place live contacts
createApplicationCatalog takes an optional placement provider. Its
layer(kind) hands Flights, Military Flights, AIS Vessels, Transit and Rail
a place(id, wallMs), and optionally observe(id, fix) and forget(id). While
live, each layer tells it every fix it accepts and draws a contact where it
places it: position, height (the layer's own when none is given), course
and speed. A null answer, a provider that throws, a kind it leaves out, or
no provider at all keeps the layer's own motion unchanged, and following
the world clock the layers never ask it. A transit vehicle or train it
holds still lets the render loop idle and is asked again each second;
vessels ask for frames only while it places one, and stop when the layer
turns off.

Tests show the layers draw and reconcile exactly as before without a
provider and with one that places nothing, draw where a provider places
them, and idle while it holds them still.
2026-11-14 15:07:25 -08:00

250 lines
8.4 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { FlightRecords } from './records.js';
import { createIngestion } from './ingestion.js';
import { createClockFollow } from './clockFollow.js';
import { displayDelayMs, followsClock, RENDER_DELAY_SEC } from './policy.js';
// Flights follow the world clock only while their source declares a timeline
// (src/time/snapshotTimeline.js); these cover the layer's side of it.
const T = 1_800_000_000_000;
const following = { timeline: { active: () => true } };
const live = { timeline: { active: () => false } };
function records(now = () => T) {
return new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
now,
});
}
const view = () => ({
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
});
const observation = (fields = {}) => ({
id: 'abc123',
reference: 'abc123',
longitude: -77,
latitude: 39,
callsign: 'TEST1',
baroAltitudeM: 1000,
onGround: false,
speedMps: 120,
courseDeg: 90,
positionTimeMs: T - 5_000,
...fields,
});
test('contacts are drawn behind the live edge, and at the time following the clock', () => {
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
assert.equal(displayDelayMs({}), RENDER_DELAY_SEC * 1000);
assert.equal(displayDelayMs(live), RENDER_DELAY_SEC * 1000);
assert.equal(displayDelayMs(following), 0);
assert.equal(followsClock({}), false);
assert.equal(followsClock(following), true);
});
test('civil records judge absence and receipt at the layer clock', () => {
let now = T;
const store = records(() => now);
const { meta } = store.receive(observation({ positionTimeMs: null }), view());
assert.equal(meta.observedReceiptMs, T);
assert.equal(meta.newestFixMs, T, 'an undated fix is the clock time');
assert.equal(
store.absence('abc123', { complete: true, tracked: true }),
'stale',
);
assert.equal(
store.absence('abc123', {
complete: true,
tracked: true,
nowMs: T + 11 * 60_000,
}),
'remove',
'a given time judges retention',
);
store.receive(observation(), view());
now = T + 11 * 60_000;
assert.equal(
store.absence('abc123', { complete: true, tracked: true }),
'remove',
'the clock judges retention when no time is given',
);
});
test('a records reset forgets where airframes were and keeps what they are', () => {
const store = records();
const first = store.receive(observation(), view());
first.meta.typeCode = 'B738';
first.meta.registration = 'N123';
first.meta.photo = { src: 'photo.jpg' };
store.absence('abc123', { complete: true });
store.beginSnapshot(T);
store.reset();
assert.equal(store.data.size, 0);
assert.equal(store.missingPolls.size, 0);
assert.equal(store.lastSnapshotMs, null);
const again = store.receive(
observation({ positionTimeMs: T - 600_000, callsign: 'OTHER' }),
view(),
);
assert.equal(again.prevMeta, undefined, 'no motion carries across');
assert.equal(again.groundFlipped, false);
assert.equal(again.meta.typeCode, 'B738');
assert.equal(again.meta.registration, 'N123');
assert.equal(again.meta.photo.src, 'photo.jpg');
assert.equal(again.meta.newestFixMs, T - 600_000, 'an earlier fix applies');
store.forget('abc123');
assert.equal(store.airframes.size, 0);
});
function ingestion(source, { follows = false } = {}) {
const feed = {
_source: source,
_activeUpdateControllers: new Set(),
_trackingRefreshEpoch: 0,
_lastSource: 'Fixture',
_retryAt: 0,
};
const applied = [];
const state = { follows };
const { methods } = createIngestion({
feed,
getQuery: () => ({ latitude: 39 }),
applySnapshot: (snapshot, _viewer, options) => {
applied.push({ snapshot, options });
return { count: 1, ids: new Set() };
},
setSourceLabel: () => {},
applyPendingTrackingRestore: () => {},
followsClock: () => state.follows,
});
return { feed, applied, state, methods };
}
test('civil polls carry no time; a snapshot at a time carries it and replaces', async () => {
const queries = [];
const probe = ingestion({
async getSnapshot(query) {
queries.push(query);
return { records: [], observedAtMs: T - 60_000, freshness: 'current' };
},
});
await probe.methods.update(null);
assert.deepEqual(queries[0], { latitude: 39 });
assert.equal(probe.applied[0].options, undefined);
assert.equal(await probe.methods.loadAt(T - 60_000, { replace: true }), true);
assert.deepEqual(queries[1], { latitude: 39, atMs: T - 60_000 });
assert.deepEqual(probe.applied[1].options, { replace: true });
assert.equal(probe.feed._lastUpdate, T - 60_000);
});
test('following the clock, civil polls stop and a poll already out is dropped', async () => {
let release;
const probe = ingestion({
getSnapshot: () =>
new Promise((resolve) => {
release = resolve;
}),
});
const out = probe.methods.update(null);
probe.state.follows = true;
release({ records: [], observedAtMs: T, freshness: 'current' });
await out;
assert.equal(probe.applied.length, 0);
await probe.methods.update(null);
assert.equal(probe.applied.length, 0, 'no poll while following');
});
test('a failed snapshot at a time reports it without holding the live backoff', async () => {
const probe = ingestion({
async getSnapshot() {
throw Object.assign(new Error('nope'), { retryAfterMs: 60_000 });
},
});
assert.equal(await probe.methods.loadAt(T), false);
assert.equal(probe.feed._retryAt, 0);
assert.equal(probe.feed._lastError, 'Live data unavailable');
});
test('a jump forgets how contacts moved; clearing drops every contact', () => {
const calls = [];
const flightState = {
records: records(),
_positionHistory: new Map([['abc123', []]]),
_displayCourse: new Map([['abc123', 90]]),
_groundSnap: { clear: () => calls.push('ground') },
_displayFloorState: new Map([['abc123', {}]]),
_drReconcileValid: true,
_cachedDRFrame: 7,
_billboards: new Map([['abc123', { id: 'abc123' }]]),
_billboardCollection: { remove: () => calls.push('remove') },
_cullPositions: new Map([['abc123', null]]),
_detectionObjects: new Map([['abc123', {}]]),
_trackedIcao: 'abc123',
feed: { _count: 1, _lastUpdate: T },
};
const parts = {
tracking: {
_clearTrail: () => calls.push('trail'),
_clearTracking: (_skip, { evicted }) => {
calls.push(`untrack:${evicted}`);
flightState._trackedIcao = null;
},
},
rendering: { _releaseModel: (id) => calls.push(`model:${id}`) },
};
const follow = createClockFollow({ flightState, parts });
flightState.records.receive(observation(), view());
follow.resetMotion();
assert.equal(flightState.records.data.size, 0);
assert.equal(flightState._positionHistory.size, 0);
assert.equal(flightState._displayCourse.size, 0);
assert.equal(flightState._displayFloorState.size, 0);
assert.equal(flightState._drReconcileValid, false);
assert.equal(flightState._billboards.size, 1, 'contacts stay until replaced');
follow.clear();
assert.equal(flightState._billboards.size, 0);
assert.equal(flightState._detectionObjects.size, 0);
assert.equal(flightState.feed._count, 0);
assert.equal(flightState.feed._lastUpdate, null);
assert.ok(calls.includes('untrack:true'));
assert.ok(calls.includes('model:abc123'));
});
test('leaving the live edge forgets every contact a host placement was told of', () => {
const forgotten = [];
const flightState = {
records: records(),
_positionHistory: new Map([
['abc123', []],
['def456', []],
]),
_displayCourse: new Map(),
_groundSnap: { clear() {} },
_displayFloorState: new Map(),
};
const parts = { tracking: { _clearTrail() {} } };
const services = { placement: { forget: (id) => forgotten.push(id) } };
createClockFollow({ flightState, services, parts }).resetMotion();
assert.deepEqual(forgotten, ['abc123', 'def456']);
assert.equal(flightState._positionHistory.size, 0);
});
test('a snapshot at a time the source drops for a newer one sets no error', async () => {
const probe = ingestion({
async getSnapshot() {
throw new DOMException('Superseded', 'AbortError');
},
});
await assert.rejects(probe.methods.loadAt(T), { name: 'AbortError' });
assert.equal(probe.feed._lastError, undefined);
assert.equal(probe.feed._backoff, undefined);
assert.equal(probe.feed._retryAt, 0);
});