mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
1769 lines
62 KiB
JavaScript
1769 lines
62 KiB
JavaScript
// src/data/aisLiveVessels.test.mjs
|
||
// Focused tests for the AIS feed-status derivation helper (Batch 10, finding H3/AIS)
|
||
// and the vessel vertical-datum seam (2026-07-27 datum pass — see
|
||
// docs/superpowers/specs/2026-07-27-vessel-datum-design.md).
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import * as Cesium from 'cesium';
|
||
import {
|
||
AIS_FIRST_CONNECT_GRACE_MS,
|
||
deriveAisFeedError,
|
||
classifyAisFeedSnapshot,
|
||
buildVesselCard,
|
||
buildSelectedVesselCard,
|
||
cardScreenSeparated,
|
||
reduceVesselSelection,
|
||
vesselDatumHeightM,
|
||
_bindVesselInteractionForTest,
|
||
_setVesselStateForTest,
|
||
_reconcileVesselsForTest,
|
||
_applyAisFeedSnapshotForTest,
|
||
_loadLivePositionsForTest,
|
||
_beginAisSessionForTest,
|
||
_setAisRuntimeForTest,
|
||
_getVesselStateForTest,
|
||
_getVesselFeedStateForTest,
|
||
_setVesselOverlayHostForTest,
|
||
_updateVesselCardsForTest,
|
||
applyVesselFocusDeemphasis,
|
||
mapAnalystRecord,
|
||
} from './aisLiveVessels.js';
|
||
import aisLiveVesselsLayer from './aisLiveVessels.js';
|
||
import { registerEntityContext, selectEntityContext } from './contextStore.js';
|
||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||
import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||
import { layerFeedState } from './manager.js';
|
||
|
||
test('open feed with vessels is healthy (null)', () => {
|
||
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
|
||
});
|
||
|
||
test('open feed without a received message is not a fresh healthy update', () => {
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'open', lastMessageAt: null, error: null }, 0),
|
||
'awaiting first AIS message…',
|
||
);
|
||
});
|
||
|
||
test('open feed with a message but zero accepted positions stays non-fresh', () => {
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'open', lastMessageAt: 123, error: null }, 0),
|
||
'awaiting usable AIS positions…',
|
||
);
|
||
});
|
||
|
||
test('snapshot classification distinguishes raw rows from accepted vessel positions', () => {
|
||
const payload = {
|
||
status: 'open',
|
||
lastMessageAt: 123,
|
||
rows: [
|
||
{ mmsi: 'bad-lat', lat: 'not-a-number', lon: 4 },
|
||
{ mmsi: 'bad-lon', lat: 4, lon: undefined },
|
||
{ mmsi: 'valid', lat: 29.7, lon: -95.1 },
|
||
],
|
||
};
|
||
const snapshot = classifyAisFeedSnapshot(payload);
|
||
assert.equal(snapshot.transportStatus, 'open');
|
||
assert.equal(snapshot.lastMessageAt, 123);
|
||
assert.equal(snapshot.rawRowCount, 3);
|
||
assert.equal(snapshot.acceptedRowCount, 1);
|
||
assert.deepEqual(snapshot.acceptedRows.map((row) => row.mmsi), ['valid']);
|
||
assert.equal(snapshot.error, null);
|
||
});
|
||
|
||
test('raw rows that all fail normalization do not satisfy AIS product health', () => {
|
||
const snapshot = classifyAisFeedSnapshot({
|
||
status: 'open',
|
||
lastMessageAt: 123,
|
||
rows: [{ mmsi: 'invalid', lat: 'bad', lon: -95.1 }],
|
||
});
|
||
assert.equal(snapshot.rawRowCount, 1);
|
||
assert.equal(snapshot.acceptedRowCount, 0);
|
||
assert.equal(snapshot.error, 'awaiting usable AIS positions…');
|
||
});
|
||
|
||
test('accepted cached rows remain usable while transport reconnects', () => {
|
||
const snapshot = classifyAisFeedSnapshot({
|
||
status: 'connecting',
|
||
refreshing: true,
|
||
rows: [{ mmsi: 'cached', lat: 29.7, lon: -95.1 }],
|
||
});
|
||
assert.equal(snapshot.acceptedRowCount, 1);
|
||
assert.equal(snapshot.error, null);
|
||
});
|
||
|
||
test('missing key with no rows surfaces a clean reason', () => {
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'missing-key', error: 'AISSTREAM_API_KEY is not set' }, 0),
|
||
'AISSTREAM_API_KEY not set',
|
||
);
|
||
});
|
||
|
||
test('socket error with no rows surfaces "feed down"', () => {
|
||
assert.equal(deriveAisFeedError({ status: 'error', error: 'AISStream websocket error' }, 0), 'feed down');
|
||
});
|
||
|
||
test('closed feed with no rows surfaces "feed disconnected"', () => {
|
||
assert.equal(deriveAisFeedError({ status: 'closed', error: null }, 0), 'feed disconnected');
|
||
});
|
||
|
||
test('non-open status but rows still flowing is treated as stale, not down (null)', () => {
|
||
// e.g. reconnecting/refreshing while a cached buffer still serves rows.
|
||
assert.equal(deriveAisFeedError({ status: 'connecting', error: null }, 30), null);
|
||
});
|
||
|
||
test('unknown status falls back to a generic reason and appends server detail', () => {
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'weird-state', error: 'something specific' }, 0),
|
||
'feed unavailable (something specific)',
|
||
);
|
||
});
|
||
|
||
test('missing status treated as healthy (older/other payload shapes) (null)', () => {
|
||
assert.equal(deriveAisFeedError({ error: 'ignored' }, 0), null);
|
||
assert.equal(deriveAisFeedError(null, 0), null);
|
||
});
|
||
|
||
// --- watchdog statuses -----------------------------------------------------
|
||
// The server reports feed health as live | stale | reconnecting | down. A
|
||
// degraded feed must stay visible even while cached vessels are still drawn.
|
||
|
||
test("'live' is the healthy status and reads exactly like the older 'open'", () => {
|
||
assert.equal(deriveAisFeedError({ status: 'live', lastMessageAt: 1, error: null }, 42), null);
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'live', lastMessageAt: null, error: null }, 0),
|
||
'awaiting first AIS message…',
|
||
);
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'live', lastMessageAt: 123, error: null }, 0),
|
||
'awaiting usable AIS positions…',
|
||
);
|
||
});
|
||
|
||
test('a stale feed is surfaced even though cached vessels are still on screen', () => {
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'stale', silentForMs: 184_000, lastMessageAt: 5 }, 4_812),
|
||
'feed silent 184s — no AIS data',
|
||
);
|
||
});
|
||
|
||
test('a stale feed without a silence figure still says the feed is silent', () => {
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'stale', lastMessageAt: 5 }, 0),
|
||
'feed silent — no AIS data',
|
||
);
|
||
});
|
||
|
||
test('reconnecting reports which attempt is in flight, rows or no rows', () => {
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'reconnecting', reconnectAttempt: 2 }, 900),
|
||
'reconnecting to feed… (attempt 2)',
|
||
);
|
||
assert.equal(deriveAisFeedError({ status: 'reconnecting' }, 0), 'reconnecting to feed…');
|
||
});
|
||
|
||
test('DOWN is a visible terminal state, not a silent retry', () => {
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'down', reconnectAttempt: 5 }, 1_200),
|
||
'feed down — retrying slowly (attempt 5)',
|
||
);
|
||
});
|
||
|
||
test('a rejected API key reads as actionable, not as a countdown', () => {
|
||
assert.equal(
|
||
deriveAisFeedError({ status: 'auth-failed', reconnectAttempt: 3 }, 4_000),
|
||
'API key rejected — check AISSTREAM_API_KEY',
|
||
);
|
||
|
||
const runtime = makeFakeAisRuntime(1_000_000);
|
||
_setAisRuntimeForTest(runtime.runtime);
|
||
try {
|
||
_applyAisFeedSnapshotForTest({}, {
|
||
status: 'auth-failed',
|
||
reconnectAttempt: 3,
|
||
nextAttemptAt: 1_000_000 + 3_600_000,
|
||
refreshing: true,
|
||
rows: [],
|
||
});
|
||
assert.equal(
|
||
aisLiveVesselsLayer.getStats().retryInSec, 0,
|
||
'an hour-long countdown would imply waiting is the fix',
|
||
);
|
||
} finally {
|
||
_setAisRuntimeForTest(null);
|
||
}
|
||
});
|
||
|
||
test('a degraded feed reports its retry countdown to the chip', () => {
|
||
const runtime = makeFakeAisRuntime(1_000_000);
|
||
_setAisRuntimeForTest(runtime.runtime);
|
||
try {
|
||
_applyAisFeedSnapshotForTest({}, {
|
||
status: 'reconnecting',
|
||
reconnectAttempt: 3,
|
||
nextAttemptAt: 1_060_000, // 60s past the fake clock
|
||
refreshing: true,
|
||
rows: [],
|
||
});
|
||
assert.equal(aisLiveVesselsLayer.getStats().retryInSec, 60);
|
||
|
||
// A feed with nothing scheduled must not invent a countdown.
|
||
_applyAisFeedSnapshotForTest({}, { status: 'live', lastMessageAt: 5, rows: [] });
|
||
assert.equal(aisLiveVesselsLayer.getStats().retryInSec, 0);
|
||
} finally {
|
||
_setAisRuntimeForTest(null);
|
||
}
|
||
});
|
||
|
||
test('a degraded feed keeps its reason through snapshot classification', () => {
|
||
const snapshot = classifyAisFeedSnapshot({
|
||
status: 'down',
|
||
reconnectAttempt: 5,
|
||
refreshing: true,
|
||
rows: [{ mmsi: 'cached', lat: 29.7, lon: -95.1 }],
|
||
});
|
||
assert.equal(snapshot.acceptedRowCount, 1, 'the cached vessel is still drawable');
|
||
assert.equal(snapshot.error, 'feed down — retrying slowly (attempt 5)');
|
||
});
|
||
|
||
function makeFakeAisRuntime(startMs = 1000) {
|
||
let nowMs = startMs;
|
||
let nextId = 0;
|
||
const scheduled = [];
|
||
const runtime = {
|
||
now: () => nowMs,
|
||
setTimeout(callback, delayMs) {
|
||
const timer = {
|
||
id: ++nextId,
|
||
callback,
|
||
dueAt: nowMs + delayMs,
|
||
cleared: false,
|
||
fired: false,
|
||
};
|
||
scheduled.push(timer);
|
||
return timer;
|
||
},
|
||
clearTimeout(timer) {
|
||
if (timer) timer.cleared = true;
|
||
},
|
||
};
|
||
return {
|
||
runtime,
|
||
scheduled,
|
||
get nowMs() { return nowMs; },
|
||
advance(deltaMs) {
|
||
nowMs += deltaMs;
|
||
let fired;
|
||
do {
|
||
fired = false;
|
||
for (const timer of scheduled) {
|
||
if (timer.cleared || timer.fired || timer.dueAt > nowMs) continue;
|
||
timer.fired = true;
|
||
timer.callback();
|
||
fired = true;
|
||
}
|
||
} while (fired);
|
||
},
|
||
fireIgnoringClear(timer) {
|
||
timer.callback();
|
||
},
|
||
activeCount() {
|
||
return scheduled.filter((timer) => !timer.cleared && !timer.fired).length;
|
||
},
|
||
};
|
||
}
|
||
|
||
test('zero accepted rows preserve warm vessel selection, trail, and freshness timestamp', () => {
|
||
const record = makeRecord();
|
||
const trail = makeTrailSpy();
|
||
_setVesselStateForTest({
|
||
viewer: {},
|
||
records: [record],
|
||
selectedRecord: record,
|
||
trail,
|
||
trailMmsi: record.mmsi,
|
||
trailPositions: [POS],
|
||
loaded: true,
|
||
lastUpdate: 456,
|
||
transportStatus: 'open',
|
||
lastMessageAt: 123,
|
||
});
|
||
try {
|
||
const result = _applyAisFeedSnapshotForTest({}, {
|
||
status: 'open',
|
||
lastMessageAt: 789,
|
||
rows: [{ mmsi: 'invalid', lat: 'bad', lon: 4 }],
|
||
});
|
||
assert.equal(result.reconciled, false);
|
||
assert.deepEqual(_getVesselFeedStateForTest(), {
|
||
count: 1,
|
||
loaded: true,
|
||
loading: false,
|
||
loadingLabel: '',
|
||
stale: true,
|
||
error: 'awaiting usable AIS positions…',
|
||
status: undefined,
|
||
lastUpdate: 456,
|
||
transportStatus: 'open',
|
||
lastMessageAt: 789,
|
||
rawRowCount: 1,
|
||
acceptedRowCount: 0,
|
||
selectedMmsi: record.mmsi,
|
||
trailMmsi: record.mmsi,
|
||
trailPositionCount: 1,
|
||
sessionId: _getVesselFeedStateForTest().sessionId,
|
||
firstConnectPhase: 'idle',
|
||
firstConnectStartedAt: null,
|
||
firstConnectDeadline: null,
|
||
});
|
||
assert.equal(trail.clearCalls, 0);
|
||
} finally {
|
||
_setVesselStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('open first load stays LOADING for one bounded first-connect grace period', () => {
|
||
const clock = makeFakeAisRuntime();
|
||
_setAisRuntimeForTest(clock.runtime);
|
||
_setVesselStateForTest({ viewer: {}, records: [] });
|
||
try {
|
||
_beginAisSessionForTest();
|
||
const result = _applyAisFeedSnapshotForTest({}, {
|
||
status: 'open',
|
||
lastMessageAt: null,
|
||
rows: [],
|
||
});
|
||
assert.equal(result.reconciled, false);
|
||
const feed = _getVesselFeedStateForTest();
|
||
assert.equal(feed.loaded, true);
|
||
assert.equal(feed.count, 0);
|
||
assert.equal(feed.lastUpdate, null);
|
||
assert.equal(feed.stale, false);
|
||
assert.equal(feed.loading, true);
|
||
assert.equal(feed.loadingLabel, 'awaiting first AIS position…');
|
||
assert.equal(feed.error, null);
|
||
assert.equal(feed.status, undefined);
|
||
assert.equal(feed.firstConnectPhase, 'loading');
|
||
assert.equal(feed.firstConnectStartedAt, 1000);
|
||
assert.equal(feed.firstConnectDeadline, 1000 + AIS_FIRST_CONNECT_GRACE_MS);
|
||
assert.equal(layerFeedState(aisLiveVesselsLayer.getStats()), 'loading');
|
||
assert.equal(feed.transportStatus, 'open');
|
||
assert.equal(feed.lastMessageAt, null);
|
||
assert.equal(feed.rawRowCount, 0);
|
||
assert.equal(feed.acceptedRowCount, 0);
|
||
|
||
clock.advance(AIS_FIRST_CONNECT_GRACE_MS - 1);
|
||
assert.equal(_getVesselFeedStateForTest().loading, true);
|
||
clock.advance(1);
|
||
const expired = _getVesselFeedStateForTest();
|
||
assert.equal(expired.loading, false);
|
||
assert.equal(expired.error, 'awaiting first AIS message…');
|
||
assert.equal(expired.status, 'unavailable');
|
||
assert.equal(expired.firstConnectPhase, 'unavailable');
|
||
assert.equal(layerFeedState(aisLiveVesselsLayer.getStats()), 'unavailable');
|
||
} finally {
|
||
_setVesselStateForTest({ enabled: false });
|
||
_setAisRuntimeForTest();
|
||
}
|
||
});
|
||
|
||
test('open and connecting polls do not restart the first-connect deadline', () => {
|
||
const clock = makeFakeAisRuntime(5000);
|
||
_setAisRuntimeForTest(clock.runtime);
|
||
_setVesselStateForTest({ viewer: {}, records: [] });
|
||
try {
|
||
_beginAisSessionForTest();
|
||
const initial = _getVesselFeedStateForTest();
|
||
clock.advance(12000);
|
||
_applyAisFeedSnapshotForTest({}, { status: 'connecting', rows: [] });
|
||
_applyAisFeedSnapshotForTest({}, { status: 'open', lastMessageAt: 9, rows: [] });
|
||
const afterPolls = _getVesselFeedStateForTest();
|
||
assert.equal(afterPolls.firstConnectStartedAt, initial.firstConnectStartedAt);
|
||
assert.equal(afterPolls.firstConnectDeadline, initial.firstConnectDeadline);
|
||
assert.equal(clock.activeCount(), 1);
|
||
assert.equal(afterPolls.loading, true);
|
||
assert.equal(afterPolls.error, null);
|
||
} finally {
|
||
_setVesselStateForTest({ enabled: false });
|
||
_setAisRuntimeForTest();
|
||
}
|
||
});
|
||
|
||
test('definitive AIS transport failures end grace immediately', () => {
|
||
const clock = makeFakeAisRuntime();
|
||
_setAisRuntimeForTest(clock.runtime);
|
||
_setVesselStateForTest({ viewer: {}, records: [] });
|
||
try {
|
||
_beginAisSessionForTest();
|
||
_applyAisFeedSnapshotForTest({}, {
|
||
status: 'missing-key',
|
||
error: 'AISSTREAM_API_KEY is not set',
|
||
rows: [],
|
||
});
|
||
const feed = _getVesselFeedStateForTest();
|
||
assert.equal(feed.loading, false);
|
||
assert.equal(feed.status, 'unavailable');
|
||
assert.equal(feed.error, 'AISSTREAM_API_KEY not set');
|
||
assert.equal(feed.firstConnectPhase, 'unavailable');
|
||
assert.equal(clock.activeCount(), 0);
|
||
} finally {
|
||
_setVesselStateForTest({ enabled: false });
|
||
_setAisRuntimeForTest();
|
||
}
|
||
});
|
||
|
||
test('first accepted position ends grace and warm data survives later open silence', () => {
|
||
const clock = makeFakeAisRuntime(7000);
|
||
const record = makeRecord();
|
||
_setAisRuntimeForTest(clock.runtime);
|
||
_setVesselOverlayHostForTest({
|
||
setEntries() {},
|
||
setVisible() {},
|
||
clearSource() {},
|
||
});
|
||
_setVesselStateForTest({ viewer: {}, records: [record] });
|
||
try {
|
||
_beginAisSessionForTest();
|
||
const accepted = _applyAisFeedSnapshotForTest({}, {
|
||
status: 'open',
|
||
lastMessageAt: 12,
|
||
rows: [{
|
||
mmsi: record.mmsi,
|
||
name: record.name,
|
||
lat: 51.93,
|
||
lon: 4.05,
|
||
}],
|
||
});
|
||
assert.equal(accepted.reconciled, true);
|
||
let feed = _getVesselFeedStateForTest();
|
||
assert.equal(feed.loading, false);
|
||
assert.equal(feed.firstConnectPhase, 'ready');
|
||
assert.equal(feed.lastUpdate, 7000);
|
||
assert.equal(feed.error, null);
|
||
assert.equal(clock.activeCount(), 0);
|
||
|
||
clock.advance(5000);
|
||
const silent = _applyAisFeedSnapshotForTest({}, {
|
||
status: 'open',
|
||
lastMessageAt: 13,
|
||
rows: [],
|
||
});
|
||
assert.equal(silent.reconciled, false);
|
||
feed = _getVesselFeedStateForTest();
|
||
assert.equal(feed.count, 1);
|
||
assert.equal(feed.lastUpdate, 7000);
|
||
assert.equal(feed.loading, false);
|
||
assert.equal(feed.firstConnectPhase, 'ready');
|
||
assert.equal(feed.stale, true);
|
||
assert.equal(feed.error, 'awaiting usable AIS positions…');
|
||
} finally {
|
||
_setVesselStateForTest({ enabled: false });
|
||
_setVesselOverlayHostForTest();
|
||
_setAisRuntimeForTest();
|
||
}
|
||
});
|
||
|
||
test('superseded first-connect timer cannot expire its replacement session', () => {
|
||
const clock = makeFakeAisRuntime(11000);
|
||
_setAisRuntimeForTest(clock.runtime);
|
||
_setVesselStateForTest({ viewer: {}, records: [] });
|
||
try {
|
||
_beginAisSessionForTest();
|
||
const oldTimer = clock.scheduled[0];
|
||
const oldSessionId = _getVesselFeedStateForTest().sessionId;
|
||
|
||
_setVesselStateForTest({ viewer: {}, records: [] });
|
||
_beginAisSessionForTest();
|
||
const replacement = _getVesselFeedStateForTest();
|
||
assert.notEqual(replacement.sessionId, oldSessionId);
|
||
assert.equal(replacement.firstConnectPhase, 'loading');
|
||
|
||
clock.fireIgnoringClear(oldTimer);
|
||
const afterStaleTimer = _getVesselFeedStateForTest();
|
||
assert.equal(afterStaleTimer.sessionId, replacement.sessionId);
|
||
assert.equal(afterStaleTimer.firstConnectPhase, 'loading');
|
||
assert.equal(afterStaleTimer.firstConnectDeadline, replacement.firstConnectDeadline);
|
||
assert.equal(afterStaleTimer.error, null);
|
||
} finally {
|
||
_setVesselStateForTest({ enabled: false });
|
||
_setAisRuntimeForTest();
|
||
}
|
||
});
|
||
|
||
function deferredFetchResponse() {
|
||
let resolve;
|
||
const promise = new Promise((next) => { resolve = next; });
|
||
return { promise, resolve };
|
||
}
|
||
|
||
function jsonResponse(payload) {
|
||
return { ok: true, status: 200, json: async () => payload };
|
||
}
|
||
|
||
test('HTTP rejection ends first-connect grace without waiting for its deadline', async () => {
|
||
const hadWindow = Object.hasOwn(globalThis, 'window');
|
||
const priorWindow = globalThis.window;
|
||
const priorFetch = globalThis.fetch;
|
||
const priorWarn = console.warn;
|
||
const clock = makeFakeAisRuntime();
|
||
globalThis.window = { location: { origin: 'http://localhost:4173' } };
|
||
globalThis.fetch = async () => ({
|
||
ok: false,
|
||
status: 503,
|
||
json: async () => ({
|
||
status: 'missing-key',
|
||
error: 'AISSTREAM_API_KEY is not set',
|
||
}),
|
||
});
|
||
console.warn = () => {};
|
||
_setAisRuntimeForTest(clock.runtime);
|
||
_setVesselStateForTest({ viewer: {}, records: [] });
|
||
try {
|
||
_beginAisSessionForTest();
|
||
await _loadLivePositionsForTest({});
|
||
const feed = _getVesselFeedStateForTest();
|
||
assert.equal(feed.loading, false);
|
||
assert.equal(feed.status, 'unavailable');
|
||
assert.equal(feed.error, 'AISSTREAM_API_KEY not set');
|
||
assert.equal(feed.firstConnectPhase, 'unavailable');
|
||
assert.equal(clock.activeCount(), 0);
|
||
} finally {
|
||
globalThis.fetch = priorFetch;
|
||
console.warn = priorWarn;
|
||
if (hadWindow) globalThis.window = priorWindow;
|
||
else delete globalThis.window;
|
||
_setVesselStateForTest({ enabled: false });
|
||
_setAisRuntimeForTest();
|
||
}
|
||
});
|
||
|
||
test('superseded AIS response cannot mutate or finalize a replacement request', async () => {
|
||
const hadWindow = Object.hasOwn(globalThis, 'window');
|
||
const priorWindow = globalThis.window;
|
||
const priorFetch = globalThis.fetch;
|
||
const first = deferredFetchResponse();
|
||
const second = deferredFetchResponse();
|
||
const queue = [first, second];
|
||
globalThis.window = { location: { origin: 'http://localhost:4173' } };
|
||
globalThis.fetch = () => queue.shift().promise;
|
||
const oldRecord = makeRecord({ name: 'OLD' });
|
||
const replacement = makeRecord({ name: 'REPLACEMENT' });
|
||
const replacementTrail = makeTrailSpy();
|
||
try {
|
||
_setVesselStateForTest({ viewer: {}, records: [oldRecord], loaded: true, lastUpdate: 100 });
|
||
const oldLoad = _loadLivePositionsForTest({});
|
||
|
||
// Simulate destroy/re-init replacement. The old fetch intentionally ignores abort.
|
||
_setVesselStateForTest({
|
||
viewer: {},
|
||
records: [replacement],
|
||
selectedRecord: replacement,
|
||
trail: replacementTrail,
|
||
trailMmsi: replacement.mmsi,
|
||
trailPositions: [POS],
|
||
loaded: true,
|
||
lastUpdate: 200,
|
||
});
|
||
const replacementLoad = _loadLivePositionsForTest({});
|
||
|
||
first.resolve(jsonResponse({
|
||
status: 'open',
|
||
lastMessageAt: 1000,
|
||
rows: [{ mmsi: oldRecord.mmsi, name: 'STALE RESPONSE', lat: 1, lon: 2 }],
|
||
}));
|
||
await oldLoad;
|
||
let feed = _getVesselFeedStateForTest();
|
||
assert.equal(feed.loading, true, 'old finally must not clear replacement loading ownership');
|
||
assert.equal(feed.lastUpdate, 200, 'old response must not advance replacement freshness');
|
||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||
|
||
second.resolve(jsonResponse({ status: 'open', lastMessageAt: null, rows: [] }));
|
||
await replacementLoad;
|
||
feed = _getVesselFeedStateForTest();
|
||
assert.equal(feed.loading, false);
|
||
assert.equal(feed.lastUpdate, 200);
|
||
assert.equal(feed.error, 'awaiting first AIS message…');
|
||
assert.equal(feed.stale, true);
|
||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||
assert.equal(feed.trailMmsi, replacement.mmsi);
|
||
assert.equal(replacementTrail.clearCalls, 0);
|
||
} finally {
|
||
globalThis.fetch = priorFetch;
|
||
if (hadWindow) globalThis.window = priorWindow;
|
||
else delete globalThis.window;
|
||
_setVesselStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('disable and destroy make abort-ignoring AIS responses inert', async () => {
|
||
const hadWindow = Object.hasOwn(globalThis, 'window');
|
||
const hadDocument = Object.hasOwn(globalThis, 'document');
|
||
const priorWindow = globalThis.window;
|
||
const priorDocument = globalThis.document;
|
||
const priorFetch = globalThis.fetch;
|
||
const windowTarget = new EventTarget();
|
||
windowTarget.location = { origin: 'http://localhost:4173' };
|
||
globalThis.window = windowTarget;
|
||
globalThis.document = { getElementById: () => null };
|
||
try {
|
||
const disableResponse = deferredFetchResponse();
|
||
globalThis.fetch = () => disableResponse.promise;
|
||
const record = makeRecord();
|
||
_setVesselStateForTest({ viewer: {}, records: [record], loaded: true, lastUpdate: 300 });
|
||
const disabledLoad = _loadLivePositionsForTest({});
|
||
aisLiveVesselsLayer.disable();
|
||
disableResponse.resolve(jsonResponse({
|
||
status: 'open',
|
||
lastMessageAt: 1000,
|
||
rows: [{ mmsi: record.mmsi, lat: 1, lon: 2 }],
|
||
}));
|
||
await disabledLoad;
|
||
let feed = _getVesselFeedStateForTest();
|
||
assert.equal(feed.lastUpdate, 300);
|
||
assert.equal(feed.loading, false);
|
||
|
||
const destroyResponse = deferredFetchResponse();
|
||
globalThis.fetch = () => destroyResponse.promise;
|
||
const viewer = { scene: { primitives: { remove() {} } } };
|
||
_setVesselStateForTest({ viewer, records: [record], loaded: true, lastUpdate: 400 });
|
||
const destroyedLoad = _loadLivePositionsForTest(viewer);
|
||
aisLiveVesselsLayer.destroy(viewer);
|
||
destroyResponse.resolve(jsonResponse({
|
||
status: 'open',
|
||
lastMessageAt: 2000,
|
||
rows: [{ mmsi: record.mmsi, lat: 3, lon: 4 }],
|
||
}));
|
||
await destroyedLoad;
|
||
feed = _getVesselFeedStateForTest();
|
||
assert.equal(feed.count, 0);
|
||
assert.equal(feed.lastUpdate, null);
|
||
assert.equal(feed.transportStatus, null);
|
||
assert.equal(feed.loading, false);
|
||
} finally {
|
||
globalThis.fetch = priorFetch;
|
||
if (hadWindow) globalThis.window = priorWindow;
|
||
else delete globalThis.window;
|
||
if (hadDocument) globalThis.document = priorDocument;
|
||
else delete globalThis.document;
|
||
_setVesselStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('vessel focus wire animates alpha with deadband and restores after tracking ends', () => {
|
||
let writes = 0;
|
||
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
|
||
let color = makeColor(1);
|
||
const billboard = {
|
||
position: { x: 1, y: 2, z: 3 },
|
||
show: true,
|
||
get color() { return color; },
|
||
set color(next) { writes += 1; color = next; },
|
||
};
|
||
const records = [{ position: billboard.position, billboard }];
|
||
const target = {
|
||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||
paddingPx: 0,
|
||
cameraDistance: 1000,
|
||
};
|
||
const params = {
|
||
paddingPx: 0,
|
||
dimFloor: 0.25,
|
||
nearerBehavior: 'allow',
|
||
hysteresisPx: 6,
|
||
distanceHysteresisRatio: 0.08,
|
||
attackMs: 300,
|
||
releaseMs: 600,
|
||
writeEpsilon: 0.005,
|
||
};
|
||
let activeCount = 0;
|
||
const apply = (nowMs, focusTarget) => {
|
||
const result = applyVesselFocusDeemphasis({
|
||
records,
|
||
target: focusTarget,
|
||
previousActiveCount: activeCount,
|
||
nowMs,
|
||
screenPositionFor: () => ({ x: 50, y: 50 }),
|
||
cameraDistanceFor: () => 1200,
|
||
params,
|
||
});
|
||
activeCount = result.activeCount;
|
||
return result;
|
||
};
|
||
|
||
assert.equal(apply(0, target).writes, 0);
|
||
assert.equal(apply(150, target).writes, 1);
|
||
assert.ok(color.alpha > 0.25 && color.alpha < 1);
|
||
assert.equal(apply(150, target).writes, 0, 'same timestamp stays inside deadband');
|
||
assert.equal(apply(300, target).writes, 1);
|
||
assert.equal(color.alpha, 0.25);
|
||
assert.equal(apply(300, null).writes, 0, 'release starts continuously from the floor');
|
||
assert.equal(apply(600, null).writes, 1);
|
||
assert.ok(color.alpha > 0.25 && color.alpha < 1);
|
||
assert.equal(apply(900, null).writes, 1);
|
||
assert.equal(color.alpha, 1);
|
||
assert.equal(apply(901, null).writes, 0, 'settled restoration causes no churn');
|
||
assert.equal(writes, 4);
|
||
});
|
||
|
||
test('vessel focus wire performs no alpha writes when nothing has been tracked', () => {
|
||
let writes = 0;
|
||
const billboard = {
|
||
position: { x: 0, y: 0, z: 0 },
|
||
show: true,
|
||
get color() { return { alpha: 1 }; },
|
||
set color(_next) { writes += 1; },
|
||
};
|
||
const result = applyVesselFocusDeemphasis({
|
||
records: [{ position: billboard.position, billboard }],
|
||
target: null,
|
||
nowMs: 100,
|
||
screenPositionFor: () => ({ x: 50, y: 50 }),
|
||
cameraDistanceFor: () => 1200,
|
||
});
|
||
assert.deepEqual(result, {
|
||
writes: 0,
|
||
transitioning: false,
|
||
activeCount: 0,
|
||
ran: false,
|
||
});
|
||
assert.equal(writes, 0);
|
||
});
|
||
|
||
test('vessel focus wire restores a hidden sprite before releasing the active pass', () => {
|
||
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
|
||
const billboard = {
|
||
position: { x: 1, y: 2, z: 3 },
|
||
show: true,
|
||
color: makeColor(1),
|
||
};
|
||
const records = [{ billboard }];
|
||
const target = {
|
||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||
paddingPx: 0,
|
||
cameraDistance: 1000,
|
||
};
|
||
const params = {
|
||
paddingPx: 0,
|
||
dimFloor: 0.25,
|
||
nearerBehavior: 'allow',
|
||
attackMs: 300,
|
||
releaseMs: 600,
|
||
writeEpsilon: 0.005,
|
||
};
|
||
const pass = (nowMs, focusTarget, previousActiveCount) => applyVesselFocusDeemphasis({
|
||
records,
|
||
target: focusTarget,
|
||
previousActiveCount,
|
||
nowMs,
|
||
screenPositionFor: () => ({ x: 50, y: 50 }),
|
||
cameraDistanceFor: () => 1200,
|
||
params,
|
||
});
|
||
|
||
pass(0, target, 0);
|
||
const dimmed = pass(params.attackMs, target, 0);
|
||
assert.equal(dimmed.activeCount, 1);
|
||
assert.equal(billboard.color.alpha, params.dimFloor);
|
||
|
||
billboard.show = false;
|
||
const releaseStart = pass(params.attackMs, null, dimmed.activeCount);
|
||
const restored = pass(params.attackMs + params.releaseMs, null, releaseStart.activeCount);
|
||
assert.equal(restored.ran, true);
|
||
assert.equal(restored.activeCount, 0);
|
||
assert.equal(restored.writes, 1);
|
||
assert.equal(billboard.color.alpha, 1);
|
||
});
|
||
|
||
// --- Selection gestures (FB-1) ---------------------------------------------
|
||
|
||
test('vessel selection: empty-space click requests deselection', () => {
|
||
assert.deepEqual(
|
||
reduceVesselSelection({ selectedMmsi: '353136000', gesture: 'click' }),
|
||
{ action: 'deselect' },
|
||
);
|
||
});
|
||
|
||
test('vessel selection: no selection plus empty-space click is a no-op', () => {
|
||
assert.deepEqual(
|
||
reduceVesselSelection({ selectedMmsi: null, pickedMmsi: null, gesture: 'click' }),
|
||
{ action: 'none' },
|
||
);
|
||
});
|
||
|
||
test('vessel selection: Escape deselects only when this layer owns a selection', () => {
|
||
assert.deepEqual(
|
||
reduceVesselSelection({ selectedMmsi: 353136000, gesture: 'escape' }),
|
||
{ action: 'deselect' },
|
||
);
|
||
assert.deepEqual(
|
||
reduceVesselSelection({ gesture: 'escape' }),
|
||
{ action: 'none' },
|
||
);
|
||
});
|
||
|
||
test('vessel selection: another vessel replaces selection; same vessel is a no-op', () => {
|
||
assert.deepEqual(
|
||
reduceVesselSelection({
|
||
selectedMmsi: '353136000',
|
||
pickedMmsi: '367123450',
|
||
gesture: 'click',
|
||
}),
|
||
{ action: 'select' },
|
||
);
|
||
assert.deepEqual(
|
||
reduceVesselSelection({
|
||
selectedMmsi: '353136000',
|
||
pickedMmsi: '353136000',
|
||
gesture: 'click',
|
||
}),
|
||
{ action: 'none' },
|
||
);
|
||
});
|
||
|
||
// ─── canvas-card builders (LabelCollection → canvas-card migration) ──────────
|
||
// Card models are pure: they read a vessel record and return a vesselLabels
|
||
// entry ({position, gapPx, accent, title, details, selected}). Positions pass
|
||
// through untouched (height-datum caveat: vessels anchor at their current
|
||
// rendered positions — no datum work here).
|
||
|
||
const POS = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 3);
|
||
|
||
function makeRecord(overrides = {}) {
|
||
return {
|
||
name: 'EVER GIVEN',
|
||
mmsi: '353136000',
|
||
type: 'Container Ship',
|
||
destination: '',
|
||
speed: 14.53,
|
||
course: 230.6,
|
||
heading: 231.2,
|
||
lastPositionUtc: '',
|
||
missedRefreshes: 0,
|
||
position: POS,
|
||
billboard: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function makeTrailSpy() {
|
||
return {
|
||
clearCalls: 0,
|
||
clear() { this.clearCalls += 1; },
|
||
setPositions() {},
|
||
destroy() {},
|
||
};
|
||
}
|
||
|
||
function makeClassList(...initial) {
|
||
const classes = new Set(initial);
|
||
return {
|
||
add(value) { classes.add(value); },
|
||
remove(value) { classes.delete(value); },
|
||
contains(value) { return classes.has(value); },
|
||
};
|
||
}
|
||
|
||
function makeInteractionHandler() {
|
||
return {
|
||
click: null,
|
||
destroyCalls: 0,
|
||
setInputAction(callback) { this.click = callback; },
|
||
destroy() {
|
||
this.destroyCalls += 1;
|
||
this.click = null;
|
||
},
|
||
};
|
||
}
|
||
|
||
function makeCesiumEvent() {
|
||
const listeners = new Set();
|
||
return {
|
||
addCalls: 0,
|
||
removeCalls: 0,
|
||
addEventListener(callback) {
|
||
this.addCalls += 1;
|
||
listeners.add(callback);
|
||
return () => {
|
||
if (listeners.delete(callback)) this.removeCalls += 1;
|
||
};
|
||
},
|
||
raise() {
|
||
for (const callback of [...listeners]) callback();
|
||
},
|
||
get listenerCount() { return listeners.size; },
|
||
};
|
||
}
|
||
|
||
function installWireHarness(picked, stateOverrides = {}) {
|
||
const hadWindow = Object.hasOwn(globalThis, 'window');
|
||
const hadDocument = Object.hasOwn(globalThis, 'document');
|
||
const priorWindow = globalThis.window;
|
||
const priorDocument = globalThis.document;
|
||
const windowTarget = new EventTarget();
|
||
windowTarget.location = { origin: 'http://localhost:4173' };
|
||
const keyTarget = {
|
||
keydown: null,
|
||
added: [],
|
||
removed: [],
|
||
addEventListener(type, callback) {
|
||
if (type !== 'keydown') return;
|
||
this.added.push({ type, callback });
|
||
this.keydown = callback;
|
||
},
|
||
removeEventListener(type, callback) {
|
||
if (type !== 'keydown') return;
|
||
this.removed.push({ type, callback });
|
||
if (this.keydown === callback) this.keydown = null;
|
||
},
|
||
dispatch(event) { this.keydown?.(event); },
|
||
};
|
||
const handler = makeInteractionHandler();
|
||
const reinstalledHandlers = [];
|
||
const interactionHandlerFactory = () => {
|
||
const next = makeInteractionHandler();
|
||
reinstalledHandlers.push(next);
|
||
return next;
|
||
};
|
||
const trackedEntityChanged = makeCesiumEvent();
|
||
const viewer = {
|
||
scene: { pick: () => picked },
|
||
trackedEntity: undefined,
|
||
trackedEntityChanged,
|
||
};
|
||
const record = makeRecord();
|
||
const trail = makeTrailSpy();
|
||
const hud = {
|
||
textContent: `AIS: ${record.name}`,
|
||
classList: makeClassList('active'),
|
||
};
|
||
|
||
globalThis.window = windowTarget;
|
||
globalThis.document = {
|
||
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
|
||
};
|
||
_setVesselStateForTest({
|
||
records: [record],
|
||
selectedRecord: record,
|
||
trail,
|
||
trailMmsi: record.mmsi,
|
||
trailPositions: [POS],
|
||
interactionHandlerFactory,
|
||
interactionKeyTarget: keyTarget,
|
||
...stateOverrides,
|
||
});
|
||
_bindVesselInteractionForTest(viewer, handler, keyTarget);
|
||
|
||
return {
|
||
handler,
|
||
keyTarget,
|
||
record,
|
||
trail,
|
||
hud,
|
||
viewer,
|
||
windowTarget,
|
||
reinstalledHandlers,
|
||
interactionHandlerFactory,
|
||
cleanup() {
|
||
aisLiveVesselsLayer.destroy();
|
||
if (hadWindow) globalThis.window = priorWindow;
|
||
else delete globalThis.window;
|
||
if (hadDocument) globalThis.document = priorDocument;
|
||
else delete globalThis.document;
|
||
},
|
||
};
|
||
}
|
||
|
||
test('vessel interaction wire: trail pick does not deselect', () => {
|
||
const harness = installWireHarness({ id: 'gev-trail:71' });
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(harness.trail.clearCalls, 0);
|
||
assert.equal(_getVesselStateForTest().trailMmsi, harness.record.mmsi);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: only the gev-trail: namespace receives the trail no-op', () => {
|
||
const harness = installWireHarness({ id: 'gev-trailing-contact' });
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||
assert.equal(harness.trail.clearCalls, 1);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: flights-owned pick preserves vessel selection', () => {
|
||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||
const harness = installWireHarness({ id: 'a1b2c3' });
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(harness.trail.clearCalls, 0);
|
||
} finally {
|
||
harness.cleanup();
|
||
unregisterPickOwner('flights');
|
||
}
|
||
});
|
||
|
||
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
|
||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||
Object.defineProperty(globalThis, 'matchMedia', {
|
||
configurable: true,
|
||
value: () => ({ matches: true }),
|
||
});
|
||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||
const tile = { primitive: { isCesium3DTileset: true } };
|
||
const harness = installWireHarness(tile);
|
||
let drills = 0;
|
||
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
|
||
drills++;
|
||
assert.deepEqual([limit, width, height], [8, 14, 14]);
|
||
return [tile, { id: 'a1b2c3' }];
|
||
};
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(drills, 1);
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(harness.trail.clearCalls, 0);
|
||
} finally {
|
||
harness.cleanup();
|
||
unregisterPickOwner('flights');
|
||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||
else delete globalThis.matchMedia;
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
|
||
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
|
||
const harness = installWireHarness({ id: 'atx-cam-3' });
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(harness.trail.clearCalls, 0);
|
||
} finally {
|
||
harness.cleanup();
|
||
unregisterPickOwner('cctv');
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: own-layer unkeyed record pick does not deselect', () => {
|
||
const harness = installWireHarness({ id: makeRecord({ mmsi: undefined }) });
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(harness.trail.clearCalls, 0);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: own-shaped evicted record pick does not deselect', () => {
|
||
const harness = installWireHarness({ id: makeRecord({ mmsi: '999999999' }) });
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(harness.trail.clearCalls, 0);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HUD', () => {
|
||
const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 });
|
||
const cleared = [];
|
||
try {
|
||
registerEntityContext(harness.record, {
|
||
id: `ais-${harness.record.mmsi}`,
|
||
layerId: 'ais-live-vessels',
|
||
label: harness.record.name,
|
||
});
|
||
selectEntityContext(harness.record);
|
||
harness.windowTarget.addEventListener('gev:entity-selection-cleared', (event) => {
|
||
cleared.push(event.detail);
|
||
});
|
||
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||
assert.equal(harness.hud.classList.contains('active'), false);
|
||
// Clicking away is a deliberate deselect, not the vessel aging out of the
|
||
// feed. Consumers that keep a readout on screen (the Cockpit Contact
|
||
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
|
||
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: empty pick deselects and emits gev:entity-selection-cleared', () => {
|
||
const harness = installWireHarness(undefined);
|
||
const cleared = [];
|
||
try {
|
||
registerEntityContext(harness.record, {
|
||
id: `ais-${harness.record.mmsi}`,
|
||
layerId: 'ais-live-vessels',
|
||
label: harness.record.name,
|
||
});
|
||
selectEntityContext(harness.record);
|
||
harness.windowTarget.addEventListener('gev:entity-selection-cleared', (event) => {
|
||
cleared.push(event.detail);
|
||
});
|
||
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||
assert.equal(harness.hud.classList.contains('active'), false);
|
||
// Empty pick is a deliberate deselect — see the note above.
|
||
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: Escape deselects the selected vessel', () => {
|
||
const harness = installWireHarness(undefined);
|
||
try {
|
||
harness.keyTarget.dispatch({ key: 'Escape' });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||
assert.equal(harness.hud.classList.contains('active'), false);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: trackedEntityChanged clears only with tracking and a selection', () => {
|
||
const harness = installWireHarness(undefined);
|
||
try {
|
||
harness.viewer.trackedEntityChanged.raise();
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(harness.trail.clearCalls, 0);
|
||
|
||
harness.viewer.trackedEntity = { id: 'tracked-flight' };
|
||
harness.viewer.trackedEntityChanged.raise();
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||
assert.equal(harness.trail.clearCalls, 1);
|
||
|
||
harness.viewer.trackedEntityChanged.raise();
|
||
assert.equal(harness.trail.clearCalls, 1);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction lifecycle: destroy removes the exact bound keydown listener', () => {
|
||
const harness = installWireHarness(undefined);
|
||
try {
|
||
const added = harness.keyTarget.added[0];
|
||
aisLiveVesselsLayer.destroy();
|
||
|
||
assert.equal(harness.handler.destroyCalls, 1);
|
||
assert.equal(harness.keyTarget.removed.length, 1);
|
||
assert.equal(harness.keyTarget.removed[0].type, 'keydown');
|
||
assert.strictEqual(harness.keyTarget.removed[0].callback, added.callback);
|
||
assert.equal(harness.keyTarget.keydown, null);
|
||
assert.equal(harness.viewer.trackedEntityChanged.removeCalls, 1);
|
||
assert.equal(harness.viewer.trackedEntityChanged.listenerCount, 0);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction lifecycle: disable detaches input and enable reinstalls it', async () => {
|
||
const harness = installWireHarness(undefined);
|
||
const priorFetch = globalThis.fetch;
|
||
try {
|
||
const disabledClick = harness.handler.click;
|
||
const disabledKeydown = harness.keyTarget.keydown;
|
||
aisLiveVesselsLayer.disable();
|
||
|
||
assert.equal(harness.handler.destroyCalls, 1);
|
||
assert.equal(harness.handler.click, null);
|
||
assert.equal(harness.keyTarget.keydown, null);
|
||
assert.equal(harness.keyTarget.removed.length, 1);
|
||
assert.strictEqual(harness.keyTarget.removed[0].callback, disabledKeydown);
|
||
assert.equal(harness.viewer.trackedEntityChanged.removeCalls, 1);
|
||
assert.equal(harness.viewer.trackedEntityChanged.listenerCount, 0);
|
||
|
||
_setVesselStateForTest({
|
||
records: [harness.record],
|
||
selectedRecord: harness.record,
|
||
enabled: false,
|
||
trail: harness.trail,
|
||
trailMmsi: harness.record.mmsi,
|
||
trailPositions: [POS],
|
||
interactionHandlerFactory: harness.interactionHandlerFactory,
|
||
interactionKeyTarget: harness.keyTarget,
|
||
});
|
||
disabledClick({ position: { x: 10, y: 20 } });
|
||
disabledKeydown({ key: 'Escape' });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
|
||
globalThis.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({ status: 'open', rows: [] }),
|
||
});
|
||
await aisLiveVesselsLayer.enable(harness.viewer);
|
||
|
||
assert.equal(harness.reinstalledHandlers.length, 1);
|
||
assert.equal(harness.keyTarget.added.length, 2);
|
||
assert.equal(harness.viewer.trackedEntityChanged.addCalls, 2);
|
||
assert.equal(harness.viewer.trackedEntityChanged.listenerCount, 1);
|
||
const reinstalled = harness.reinstalledHandlers[0];
|
||
reinstalled.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||
|
||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||
harness.keyTarget.dispatch({ key: 'Escape' });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||
} finally {
|
||
globalThis.fetch = priorFetch;
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('enable owns one grace timer and disable/re-enable starts a new session', async () => {
|
||
const harness = installWireHarness(undefined, { enabled: false });
|
||
const priorFetch = globalThis.fetch;
|
||
const clock = makeFakeAisRuntime(20000);
|
||
_setAisRuntimeForTest(clock.runtime);
|
||
globalThis.fetch = async () => jsonResponse({ status: 'open', rows: [] });
|
||
try {
|
||
await aisLiveVesselsLayer.enable(harness.viewer);
|
||
const first = _getVesselFeedStateForTest();
|
||
assert.equal(first.firstConnectPhase, 'loading');
|
||
assert.equal(clock.activeCount(), 1);
|
||
|
||
clock.advance(5000);
|
||
await aisLiveVesselsLayer.enable(harness.viewer);
|
||
const duplicateEnable = _getVesselFeedStateForTest();
|
||
assert.equal(duplicateEnable.sessionId, first.sessionId);
|
||
assert.equal(duplicateEnable.firstConnectStartedAt, first.firstConnectStartedAt);
|
||
assert.equal(duplicateEnable.firstConnectDeadline, first.firstConnectDeadline);
|
||
assert.equal(clock.activeCount(), 1);
|
||
|
||
aisLiveVesselsLayer.disable();
|
||
assert.equal(clock.activeCount(), 0);
|
||
await aisLiveVesselsLayer.enable(harness.viewer);
|
||
const replacement = _getVesselFeedStateForTest();
|
||
assert.notEqual(replacement.sessionId, first.sessionId);
|
||
assert.equal(replacement.firstConnectStartedAt, 25000);
|
||
assert.equal(replacement.firstConnectDeadline, 25000 + AIS_FIRST_CONNECT_GRACE_MS);
|
||
assert.equal(replacement.firstConnectPhase, 'loading');
|
||
assert.equal(clock.activeCount(), 1);
|
||
} finally {
|
||
globalThis.fetch = priorFetch;
|
||
harness.cleanup();
|
||
_setAisRuntimeForTest();
|
||
}
|
||
});
|
||
|
||
test('vessel trail lifecycle: deselect clears the selected-vessel trail', () => {
|
||
const harness = installWireHarness(undefined);
|
||
try {
|
||
aisLiveVesselsLayer.clearSelection();
|
||
assert.equal(harness.trail.clearCalls, 1);
|
||
assert.deepEqual(_getVesselStateForTest(), {
|
||
trailMmsi: null,
|
||
trailPositionCount: 0,
|
||
vesselCount: 1,
|
||
});
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel trail lifecycle: reconciliation eviction clears an orphaned trail', () => {
|
||
const record = makeRecord();
|
||
const trail = makeTrailSpy();
|
||
_setVesselStateForTest({
|
||
records: [record],
|
||
trail,
|
||
trailMmsi: record.mmsi,
|
||
trailPositions: [POS],
|
||
});
|
||
try {
|
||
_reconcileVesselsForTest({}, []);
|
||
assert.equal(trail.clearCalls, 1);
|
||
assert.deepEqual(_getVesselStateForTest(), {
|
||
trailMmsi: null,
|
||
trailPositionCount: 0,
|
||
vesselCount: 0,
|
||
});
|
||
} finally {
|
||
_setVesselStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('buildVesselCard: name title + type/speed/heading detail line', () => {
|
||
const card = buildVesselCard(makeRecord());
|
||
assert.equal(card.title, 'EVER GIVEN');
|
||
assert.deepEqual(card.details, ['CONTAINER SHIP · 14.5KT · 231°']);
|
||
assert.equal(card.accent, '57, 213, 255');
|
||
assert.equal(card.selected, false);
|
||
assert.equal(card.position, POS);
|
||
assert.equal(card.id, 'vessel:353136000');
|
||
assert.ok(card.priority > 0);
|
||
assert.ok(Number.isFinite(card.gapPx) && card.gapPx > 0);
|
||
});
|
||
|
||
test('buildVesselCard: heading falls back to course; missing parts are omitted', () => {
|
||
const card = buildVesselCard(makeRecord({ heading: null, type: '', speed: null }));
|
||
assert.deepEqual(card.details, ['231°']);
|
||
const bare = buildVesselCard(makeRecord({ heading: null, course: null, type: '', speed: null }));
|
||
assert.deepEqual(bare.details, []);
|
||
});
|
||
|
||
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
|
||
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
|
||
assert.equal(unnamed.title, 'MMSI 353136000');
|
||
const long = buildVesselCard(makeRecord({ name: 'A'.repeat(40) }));
|
||
assert.ok(long.title.length <= 26, `title too long: ${long.title.length}`);
|
||
});
|
||
|
||
test('buildVesselCard: anchors to the billboard position when present', () => {
|
||
const rendered = { x: 9, y: 9, z: 9 };
|
||
const card = buildVesselCard(makeRecord({ billboard: { position: rendered } }));
|
||
assert.equal(card.position, rendered);
|
||
});
|
||
|
||
test('buildVesselCard: tanker types carry the amber accent', () => {
|
||
const card = buildVesselCard(makeRecord({ type: 'Crude Oil Tanker' }));
|
||
assert.equal(card.accent, '255, 179, 71');
|
||
});
|
||
|
||
test('buildVesselCard: numeric AIS type codes read as family names, not digits', () => {
|
||
const card = buildVesselCard(makeRecord({ type: '84' }));
|
||
assert.deepEqual(card.details, ['TANKER · 14.5KT · 231°']);
|
||
assert.equal(card.accent, '255, 179, 71');
|
||
});
|
||
|
||
test('cardScreenSeparated: rejects candidates inside the min separation radius', () => {
|
||
const accepted = [{ x: 400, y: 300 }];
|
||
assert.equal(cardScreenSeparated(accepted, { x: 400 + 149, y: 300 }, 150), false);
|
||
assert.equal(cardScreenSeparated(accepted, { x: 400 + 151, y: 300 }, 150), true);
|
||
assert.equal(cardScreenSeparated(accepted, { x: 400, y: 300 + 100 }, 150), false);
|
||
assert.equal(cardScreenSeparated([], { x: 0, y: 0 }, 150), true);
|
||
});
|
||
|
||
test('buildSelectedVesselCard: full detail card with MMSI + position time', () => {
|
||
const card = buildSelectedVesselCard(makeRecord({
|
||
lastPositionUtc: '2026-07-27T11:22:33Z',
|
||
}));
|
||
assert.equal(card.selected, true);
|
||
assert.equal(card.id, 'vessel:353136000');
|
||
assert.equal(card.priority, 100000);
|
||
assert.equal(card.title, 'EVER GIVEN');
|
||
assert.deepEqual(card.details, [
|
||
'CONTAINER SHIP · 14.5KT · 231°',
|
||
'MMSI 353136000 · POS: 11:22:33Z',
|
||
]);
|
||
});
|
||
|
||
test('vessel host publication preserves the shipped grid winner and separation selector', () => {
|
||
const publications = [];
|
||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||
const makeCandidate = (mmsi, name, x, y, speed = 1) => makeRecord({
|
||
mmsi,
|
||
name,
|
||
speed,
|
||
position: { x: 1, y: 2, z: 3, screen: { x, y } },
|
||
});
|
||
const low = makeCandidate('100', 'VESSEL', 20, 20, 1);
|
||
const winner = makeCandidate('200', 'NAMED WINNER', 40, 30, 16);
|
||
const separated = makeCandidate('300', 'SEPARATED', 400, 300, 3);
|
||
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) => position.screen;
|
||
_setVesselOverlayHostForTest({
|
||
setEntries(sourceId, entries, options) { publications.push({ sourceId, entries, options }); },
|
||
setVisible() {},
|
||
clearSource() {},
|
||
});
|
||
_setVesselStateForTest({
|
||
viewer: { scene: { canvas: { clientWidth: 1600, clientHeight: 900 } } },
|
||
records: [low, winner, separated],
|
||
});
|
||
try {
|
||
_updateVesselCardsForTest([low, winner, separated]);
|
||
assert.equal(publications.length, 1);
|
||
assert.deepEqual(
|
||
publications[0].entries.map(({ id }) => id).sort(),
|
||
['vessel:200', 'vessel:300'],
|
||
'one higher-priority winner survives the shared cell and the separated card remains',
|
||
);
|
||
assert.equal(publications[0].options.cohortLimit, 112);
|
||
assert.equal(publications[0].options.collisionCapacity, 112);
|
||
} finally {
|
||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||
_setVesselStateForTest({ enabled: false });
|
||
_setVesselOverlayHostForTest();
|
||
}
|
||
});
|
||
|
||
test('vessel real layer lifecycle publishes protected selection and leaves no stale host cards', () => {
|
||
const calls = [];
|
||
const host = {
|
||
setEntries(sourceId, entries, options) { calls.push({ op: 'set', sourceId, entries, options }); },
|
||
setVisible(sourceId, visible) { calls.push({ op: 'visible', sourceId, visible }); },
|
||
clearSource(sourceId) { calls.push({ op: 'clear', sourceId }); },
|
||
};
|
||
const hadWindow = Object.hasOwn(globalThis, 'window');
|
||
const priorWindow = globalThis.window;
|
||
const priorDocument = globalThis.document;
|
||
const record = makeRecord();
|
||
globalThis.window = new EventTarget();
|
||
globalThis.document = { getElementById: () => null };
|
||
const focusRequests = [];
|
||
globalThis.window.addEventListener('gev:world-request-focus', (event) => {
|
||
focusRequests.push(event.detail);
|
||
});
|
||
_setVesselOverlayHostForTest(host);
|
||
_setVesselStateForTest({
|
||
viewer: { scene: { canvas: { clientWidth: 1600, clientHeight: 900 } } },
|
||
records: [record],
|
||
selectedRecord: record,
|
||
billboardCollection: { show: true, remove() {} },
|
||
});
|
||
try {
|
||
_updateVesselCardsForTest([]);
|
||
const publication = calls.find(({ op }) => op === 'set');
|
||
assert.ok(publication, 'production selector published to the host');
|
||
assert.equal(publication.sourceId, 'ais-live-vessels');
|
||
assert.equal(publication.options.cohortLimit, 112);
|
||
assert.equal(publication.options.collisionCapacity, 112);
|
||
assert.equal(publication.entries.length, 1);
|
||
assert.equal(publication.entries[0].variant, 'selected');
|
||
assert.equal(publication.entries[0].protected, true);
|
||
assert.equal(publication.entries[0].collisionGroup, 'ambient-card');
|
||
assert.match(publication.entries[0].accessibilityLabel, /Focus vessel EVER GIVEN, MMSI 353136000/);
|
||
assert.equal(publication.entries[0].activate(), true);
|
||
assert.equal(focusRequests.length, 1);
|
||
assert.equal(focusRequests[0].id, '353136000');
|
||
|
||
aisLiveVesselsLayer.disable();
|
||
assert.ok(calls.some((call) => call.op === 'clear'), 'disable clears host cards');
|
||
assert.ok(calls.some((call) => call.op === 'visible' && call.visible === false));
|
||
|
||
const clearsBeforeDestroy = calls.filter((call) => call.op === 'clear').length;
|
||
aisLiveVesselsLayer.destroy();
|
||
assert.ok(
|
||
calls.filter((call) => call.op === 'clear').length > clearsBeforeDestroy,
|
||
'destroy clears again so teardown cannot retain late entries',
|
||
);
|
||
} finally {
|
||
_setVesselStateForTest({ enabled: false });
|
||
_setVesselOverlayHostForTest();
|
||
if (hadWindow) globalThis.window = priorWindow;
|
||
else delete globalThis.window;
|
||
globalThis.document = priorDocument;
|
||
}
|
||
});
|
||
|
||
test('buildSelectedVesselCard: destination line + STALE marker; placeholders for missing data', () => {
|
||
const card = buildSelectedVesselCard(makeRecord({
|
||
type: 'Tanker',
|
||
speed: null,
|
||
heading: null,
|
||
course: null,
|
||
destination: 'ROTTERDAM',
|
||
missedRefreshes: 2,
|
||
}));
|
||
assert.deepEqual(card.details, [
|
||
'TANKER · --KT · --°',
|
||
'→ ROTTERDAM',
|
||
'MMSI 353136000 · POS: LIVE · STALE',
|
||
]);
|
||
});
|
||
|
||
// --- Vertical datum (h = N + lift) ------------------------------------------
|
||
|
||
test('vesselDatumHeightM falls back to lift alone while the geoid grid is cold', () => {
|
||
assert.equal(vesselDatumHeightM(null, 3), 3);
|
||
assert.equal(vesselDatumHeightM(undefined, 2), 2);
|
||
assert.equal(vesselDatumHeightM(NaN, 3), 3);
|
||
});
|
||
|
||
test('vesselDatumHeightM ADDS the undulation N (sign convention: h = N + lift)', () => {
|
||
// The classic datum bug is subtracting N. Rotterdam N ≈ +45 must land the
|
||
// anchor ~48 m ABOVE the ellipsoid; Houston N ≈ −27 lands it ~24 m below.
|
||
assert.equal(vesselDatumHeightM(45.2, 3), 48.2);
|
||
assert.equal(vesselDatumHeightM(-27.3, 3), -24.3);
|
||
});
|
||
|
||
test('EGM96 N pins the field finding: Rotterdam sea sits ~45 m ABOVE the ellipsoid, Houston ~27 m below', async () => {
|
||
// Confirmed live 2026-07-28: chevrons at ellipsoid height 0 are occluded by
|
||
// the Rotterdam sea mesh (N positive) but visible at Houston (N negative).
|
||
// These bands also feed scripts/qa-vessel-datum.mjs.
|
||
await ensureGeoidReady();
|
||
const rotterdam = geoidHeight(51.93, 4.05);
|
||
const houston = geoidHeight(29.72, -95.08);
|
||
assert.ok(rotterdam > 40 && rotterdam < 50, `N(rotterdam) = ${rotterdam}, expected ≈ +45`);
|
||
assert.ok(houston > -32 && houston < -22, `N(houston) = ${houston}, expected ≈ −27`);
|
||
});
|
||
|
||
// One-click transfer (pre-launch defect #4): clicking a vessel gives it the
|
||
// camera, whether the camera was free or tracking something else. The layer
|
||
// only announces the click — the UI owns the flight (src/worldFocus.js).
|
||
test('vessel interaction wire: selecting a vessel by click requests a camera transfer', () => {
|
||
const harness = installWireHarness({ id: makeRecord() }, { selectedRecord: null });
|
||
const requests = [];
|
||
harness.windowTarget.addEventListener(
|
||
WORLD_FOCUS_REQUEST_EVENT,
|
||
(event) => requests.push(event.detail),
|
||
);
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(requests.length, 1);
|
||
assert.equal(requests[0].kind, 'vessel');
|
||
assert.equal(requests[0].id, harness.record.mmsi);
|
||
assert.equal(requests[0].position, POS);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Overlay-host recorder whose hitTest answers with one card id. The real host
|
||
* only publishes rects for `interactive` entries, so a stub returning a hit is
|
||
* the same contract a painted, actionable card presents.
|
||
*/
|
||
function hostWithCardHit(entryId) {
|
||
return {
|
||
setEntries() {},
|
||
setVisible() {},
|
||
clearSource() {},
|
||
hitTest: () => (entryId ? { sourceId: 'ais-live-vessels', entryId } : null),
|
||
};
|
||
}
|
||
|
||
// Card clicks (P1-3): cards paint on a pointer-events:none canvas, so a click
|
||
// on one picks the TERRAIN behind it — which read as empty space and cleared
|
||
// the selection. A card hit must behave exactly like a sprite hit.
|
||
test('vessel interaction wire: clicking a card selects and transfers, like the sprite', () => {
|
||
const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 }, { selectedRecord: null });
|
||
const requests = [];
|
||
harness.windowTarget.addEventListener(
|
||
WORLD_FOCUS_REQUEST_EVENT,
|
||
(event) => requests.push(event.detail),
|
||
);
|
||
_setVesselOverlayHostForTest(hostWithCardHit(`vessel:${harness.record.mmsi}`));
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(requests.length, 1);
|
||
assert.equal(requests[0].kind, 'vessel');
|
||
assert.equal(requests[0].id, harness.record.mmsi);
|
||
} finally {
|
||
_setVesselOverlayHostForTest(null);
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: clicking the selected vessel card refocuses exactly once', () => {
|
||
const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 });
|
||
const requests = [];
|
||
harness.windowTarget.addEventListener(
|
||
WORLD_FOCUS_REQUEST_EVENT,
|
||
(event) => requests.push(event.detail),
|
||
);
|
||
_setVesselOverlayHostForTest(hostWithCardHit(`vessel:${harness.record.mmsi}`));
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(requests.length, 1);
|
||
assert.equal(requests[0].id, harness.record.mmsi);
|
||
assert.equal(harness.trail.clearCalls, 0);
|
||
} finally {
|
||
_setVesselOverlayHostForTest(null);
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: an unknown card id preserves selection and never flies', () => {
|
||
const harness = installWireHarness({ primitive: {}, content: {}, featureId: 0 });
|
||
const requests = [];
|
||
harness.windowTarget.addEventListener(
|
||
WORLD_FOCUS_REQUEST_EVENT,
|
||
(event) => requests.push(event.detail),
|
||
);
|
||
// An evicted vessel's card id resolves to no live record.
|
||
_setVesselOverlayHostForTest(hostWithCardHit('vessel:999999999'));
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(requests.length, 0);
|
||
} finally {
|
||
_setVesselOverlayHostForTest(null);
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('vessel interaction wire: a sibling-owned pick wins without selection mutation', () => {
|
||
// Another layer is already acting on this click; two camera commands is the
|
||
// worse failure, so the card is not consulted.
|
||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||
const harness = installWireHarness({ id: 'a1b2c3' });
|
||
const requests = [];
|
||
harness.windowTarget.addEventListener(
|
||
WORLD_FOCUS_REQUEST_EVENT,
|
||
(event) => requests.push(event.detail),
|
||
);
|
||
_setVesselOverlayHostForTest(hostWithCardHit(`vessel:${harness.record.mmsi}`));
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(requests.length, 0);
|
||
} finally {
|
||
_setVesselOverlayHostForTest(null);
|
||
harness.cleanup();
|
||
unregisterPickOwner('flights');
|
||
}
|
||
});
|
||
|
||
for (const card of ['live', 'stale', 'absent', 'covered']) {
|
||
test(`vessel interaction wire: ${card} foreground card over registered cyclone`, () => {
|
||
const id = 'cyclone:fixture:cone:0';
|
||
registerPickOwner('weather-cyclones', (pickedId) => pickedId === id);
|
||
const harness = installWireHarness({ id });
|
||
const requests = [];
|
||
harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail));
|
||
const host = hostWithCardHit(card === 'live' ? `vessel:${harness.record.mmsi}` : card === 'stale' ? 'vessel:999999999' : null);
|
||
if (card === 'covered') host.hitTest = (_x, _y, options) => {
|
||
assert.equal(options?.sourceId, undefined, 'test topmost host hit, not a covered AIS card');
|
||
return { sourceId: 'cctv', entryId: 'vessel:999999999' };
|
||
};
|
||
_setVesselOverlayHostForTest(host);
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||
assert.equal(requests.length, card === 'live' ? 1 : 0);
|
||
} finally {
|
||
_setVesselOverlayHostForTest(null);
|
||
harness.cleanup();
|
||
unregisterPickOwner('weather-cyclones');
|
||
}
|
||
});
|
||
}
|
||
|
||
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
|
||
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
|
||
assert.equal(keyed.interactive, true);
|
||
const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' })));
|
||
assert.equal(unkeyed.interactive, false, 'an unkeyed card has no record to select');
|
||
});
|
||
|
||
test('vessel interaction wire: deselecting never moves the camera', () => {
|
||
const harness = installWireHarness({ id: 'gev-empty-space' });
|
||
const requests = [];
|
||
harness.windowTarget.addEventListener(
|
||
WORLD_FOCUS_REQUEST_EVENT,
|
||
(event) => requests.push(event.detail),
|
||
);
|
||
try {
|
||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||
assert.equal(requests.length, 0);
|
||
harness.keyTarget.dispatch({ key: 'Escape' });
|
||
assert.equal(requests.length, 0);
|
||
} finally {
|
||
harness.cleanup();
|
||
}
|
||
});
|
||
|
||
test('a vessel analyst record carries the MMSI the tracker keys on', () => {
|
||
// `id` is a DISPLAY label — the vessel name when it has one — while
|
||
// selection keys on MMSI. The compact voice payload must carry both, or the
|
||
// analyst → track_entity handoff hands over a name and nothing resolvable.
|
||
const named = mapAnalystRecord({
|
||
mmsi: '366999123', name: 'EVER GIVEN', lat: 37.8, lon: -122.4, speed: 12, type: 'Cargo',
|
||
});
|
||
assert.equal(named.id, 'EVER GIVEN');
|
||
assert.equal(named.mmsi, '366999123', 'the key rides along with the label');
|
||
|
||
// A nameless vessel falls back to its MMSI for display; the key is still
|
||
// present in its own field, so the payload never depends on that collapse.
|
||
const nameless = mapAnalystRecord({ mmsi: '366999124', name: null, lat: 37.9, lon: -122.5 });
|
||
assert.equal(nameless.id, '366999124');
|
||
assert.equal(nameless.mmsi, '366999124');
|
||
});
|
||
|
||
test('vessel selection passes the opaque source reference to optional history', async () => {
|
||
const { createVesselSource } = await import('../sources/live/standalone.js');
|
||
_setVesselStateForTest({ enabled: false });
|
||
const requests = [];
|
||
aisLiveVesselsLayer.setSource({
|
||
label: 'Test vessel source',
|
||
getSnapshot: async () => ({ records: [] }),
|
||
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
|
||
});
|
||
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
|
||
try {
|
||
harness.record.reference = 'opaque:test-reference';
|
||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||
await new Promise(resolve => setImmediate(resolve));
|
||
assert.equal(requests.length, 1);
|
||
assert.equal(requests[0].reference, 'opaque:test-reference');
|
||
assert.ok(requests[0].options.signal instanceof AbortSignal);
|
||
assert.equal(aisLiveVesselsLayer.source, 'Test vessel source');
|
||
} finally {
|
||
harness.cleanup();
|
||
_setVesselStateForTest({ enabled: false });
|
||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||
}
|
||
});
|
||
|
||
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
|
||
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
|
||
const sprite = (show) => ({
|
||
position: { x: 1, y: 2, z: 3 },
|
||
show,
|
||
color: makeColor(1),
|
||
});
|
||
const visible = sprite(true);
|
||
const hidden = Array.from({ length: 50 }, () => sprite(false));
|
||
const records = [visible, ...hidden].map((billboard) => ({
|
||
position: billboard.position,
|
||
billboard,
|
||
}));
|
||
const target = {
|
||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||
paddingPx: 0,
|
||
cameraDistance: 1000,
|
||
};
|
||
let projected = 0;
|
||
let measured = 0;
|
||
const apply = (nowMs) =>
|
||
applyVesselFocusDeemphasis({
|
||
records,
|
||
target,
|
||
nowMs,
|
||
screenPositionFor: () => {
|
||
projected += 1;
|
||
return { x: 50, y: 50 };
|
||
},
|
||
cameraDistanceFor: () => {
|
||
measured += 1;
|
||
return 1200;
|
||
},
|
||
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
|
||
});
|
||
|
||
apply(0);
|
||
const result = apply(300);
|
||
|
||
// Only the on-screen sprite pays for projection and distance.
|
||
assert.equal(projected, 2);
|
||
assert.equal(measured, 2);
|
||
assert.equal(visible.color.alpha, 0.25);
|
||
assert.equal(result.activeCount, 1);
|
||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||
});
|