mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Cover a vessel crossing an area cell during an AIS gap with an edge-case test
This commit is contained in:
1 parent
1910a3b2d5
commit
f7cd6797ec
1 file changed
+241
@@ -0,0 +1,241 @@
|
|||||||
|
// Edge case for ais-live-vessels from docs/migration/planet-separation/layers.md:
|
||||||
|
// a vessel crosses into the next area cell while AIS is interrupted. There is
|
||||||
|
// never a second request in flight and the card comes back by MMSI.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||||
|
import * as selection from '../../data/selectionDispatcher.js';
|
||||||
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
|
import { createVesselLayer } from './index.js';
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
const RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
function recordingRenderer(engine) {
|
||||||
|
const sprites = new Set();
|
||||||
|
const moveEnd = new Set();
|
||||||
|
const camera = {
|
||||||
|
positionWC: { x: 0, y: 0, z: 6_500_000 },
|
||||||
|
// 60 km up: the layer asks for a 60 km area around the camera.
|
||||||
|
positionCartographic: {
|
||||||
|
latitude: 40.6 * RAD,
|
||||||
|
longitude: -73.9 * RAD,
|
||||||
|
height: 60_000,
|
||||||
|
},
|
||||||
|
rightWC: { x: 1, y: 0, z: 0 },
|
||||||
|
upWC: { x: 0, y: 1, z: 0 },
|
||||||
|
heading: 0,
|
||||||
|
pitch: 0,
|
||||||
|
roll: 0,
|
||||||
|
};
|
||||||
|
let attached = false;
|
||||||
|
return {
|
||||||
|
sprites,
|
||||||
|
camera,
|
||||||
|
moveTo(lat, lon) {
|
||||||
|
camera.positionCartographic = {
|
||||||
|
latitude: lat * RAD,
|
||||||
|
longitude: lon * RAD,
|
||||||
|
height: 60_000,
|
||||||
|
};
|
||||||
|
for (const listener of moveEnd) listener();
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
kind: 'plain',
|
||||||
|
engine,
|
||||||
|
view: {
|
||||||
|
camera,
|
||||||
|
scene: { canvas: { clientWidth: 0, clientHeight: 0 } },
|
||||||
|
horizon: () => ({ isPointVisible: () => true }),
|
||||||
|
worldToWindow: () => undefined,
|
||||||
|
canvasSize: () => ({ width: 1280, height: 800 }),
|
||||||
|
requestRender: noop,
|
||||||
|
on(event, listener) {
|
||||||
|
if (event !== 'moveEnd') return noop;
|
||||||
|
moveEnd.add(listener);
|
||||||
|
return () => moveEnd.delete(listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fleet: {
|
||||||
|
collection: null,
|
||||||
|
attached: () => attached,
|
||||||
|
attach() {
|
||||||
|
attached = true;
|
||||||
|
},
|
||||||
|
setVisible: noop,
|
||||||
|
add(spec) {
|
||||||
|
const sprite = { ...spec };
|
||||||
|
sprites.add(sprite);
|
||||||
|
return sprite;
|
||||||
|
},
|
||||||
|
remove: (sprite) => sprites.delete(sprite),
|
||||||
|
detach() {
|
||||||
|
attached = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pickedRecord: (picked) =>
|
||||||
|
picked?.id && typeof picked.id === 'object' ? picked.id : null,
|
||||||
|
follow: { onChanged: () => noop, active: () => false },
|
||||||
|
trails: { create: () => null },
|
||||||
|
destroy: noop,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function services(renderer) {
|
||||||
|
return {
|
||||||
|
context: {
|
||||||
|
clearSelectedEntityContextForLayer: noop,
|
||||||
|
registerEntityContext: () => null,
|
||||||
|
selectEntityContext: noop,
|
||||||
|
},
|
||||||
|
trails: { createTrail: null },
|
||||||
|
labels: {},
|
||||||
|
picking,
|
||||||
|
overlay: {
|
||||||
|
setOverlayEntries: noop,
|
||||||
|
setOverlaySourceVisible: noop,
|
||||||
|
clearOverlaySource: noop,
|
||||||
|
hitTestWorldOverlay: () => null,
|
||||||
|
},
|
||||||
|
geoid: { ensureGeoidReady: () => new Promise(noop), geoidHeight: () => 0 },
|
||||||
|
sprites: {
|
||||||
|
registerSpriteCollection: noop,
|
||||||
|
restoreSpriteOrder: noop,
|
||||||
|
restoreSpriteOrderOnEnable: noop,
|
||||||
|
},
|
||||||
|
focus: {
|
||||||
|
focusNowMs: (now) => now,
|
||||||
|
getFocusTarget: () => null,
|
||||||
|
focusPassIsNeeded: () => false,
|
||||||
|
forgetSpriteFocus: noop,
|
||||||
|
},
|
||||||
|
worldFocus: { requestWorldFocus: noop },
|
||||||
|
render: {
|
||||||
|
holdContinuousRender: noop,
|
||||||
|
releaseContinuousRender: noop,
|
||||||
|
governorRequestRender: noop,
|
||||||
|
},
|
||||||
|
selection,
|
||||||
|
renderer: { create: () => renderer },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = (mmsi, latitude, longitude, observedAtMs) => ({
|
||||||
|
id: mmsi,
|
||||||
|
reference: mmsi,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
name: `SHIP ${mmsi}`,
|
||||||
|
type: 'Cargo',
|
||||||
|
observedAtMs,
|
||||||
|
speedMps: 6,
|
||||||
|
headingDeg: 90,
|
||||||
|
courseDeg: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edge:ais-live-vessels: a vessel crossing into the next cell during an AIS gap keeps one request and its card by MMSI', async (t) => {
|
||||||
|
const engine = createFakeEngine();
|
||||||
|
const recorder = recordingRenderer(engine);
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
globalThis.document = Object.assign(new EventTarget(), {
|
||||||
|
getElementById: () => null,
|
||||||
|
});
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.document = previousDocument;
|
||||||
|
});
|
||||||
|
const requests = [];
|
||||||
|
let inFlight = 0;
|
||||||
|
let maxInFlight = 0;
|
||||||
|
const responses = [
|
||||||
|
// Cell A: the vessel is in view.
|
||||||
|
{
|
||||||
|
records: [row('111', 40.6, -73.9, 1_000)],
|
||||||
|
complete: true,
|
||||||
|
transportStatus: 'live',
|
||||||
|
rawRowCount: 1,
|
||||||
|
observedAtMs: 1_000,
|
||||||
|
freshness: 'current',
|
||||||
|
},
|
||||||
|
// AIS gap: the upstream socket is reconnecting and nothing arrives.
|
||||||
|
{
|
||||||
|
records: [],
|
||||||
|
complete: false,
|
||||||
|
transportStatus: 'reconnecting',
|
||||||
|
rawRowCount: 0,
|
||||||
|
observedAtMs: null,
|
||||||
|
freshness: 'stale',
|
||||||
|
stale: true,
|
||||||
|
reconnectAttempt: 1,
|
||||||
|
},
|
||||||
|
// Cell B, after the gap: the same MMSI, now across the boundary.
|
||||||
|
{
|
||||||
|
records: [row('111', 41.2, -73.0, 90_000)],
|
||||||
|
complete: true,
|
||||||
|
transportStatus: 'live',
|
||||||
|
rawRowCount: 1,
|
||||||
|
observedAtMs: 90_000,
|
||||||
|
freshness: 'current',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let release = null;
|
||||||
|
const layer = createVesselLayer({
|
||||||
|
services: services(recorder.renderer),
|
||||||
|
source: {
|
||||||
|
async getSnapshot(query) {
|
||||||
|
requests.push(query.area ?? null);
|
||||||
|
inFlight += 1;
|
||||||
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||||
|
try {
|
||||||
|
// The gap response is held until the camera has moved on.
|
||||||
|
if (requests.length === 2)
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
source: 'AIS fixture',
|
||||||
|
...responses[Math.min(requests.length, responses.length) - 1],
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
inFlight -= 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getTrack: async () => ({ records: [] }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.after(() => layer.destroy());
|
||||||
|
layer.init(null, { engine });
|
||||||
|
await layer.enable();
|
||||||
|
assert.ok(layer.selectById('111'));
|
||||||
|
const record = [...recorder.sprites].find(({ id }) => id?.mmsi === '111')?.id;
|
||||||
|
assert.ok(record, 'the vessel is drawn in cell A');
|
||||||
|
|
||||||
|
const gap = layer.update();
|
||||||
|
// The followed vessel leaves cell A while the gap request is still open:
|
||||||
|
// the move must not open a second request beside it.
|
||||||
|
recorder.moveTo(41.2, -73.0);
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
release();
|
||||||
|
await gap;
|
||||||
|
assert.equal(
|
||||||
|
layer.getSelectedInfo()?.mmsi,
|
||||||
|
'111',
|
||||||
|
'the card survives the gap',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The next poll asks for cell B and finds the vessel there.
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(requests.length, 3);
|
||||||
|
assert.equal(maxInFlight, 1, 'never two requests at once');
|
||||||
|
assert.ok(requests.at(-1), 'the request names an area');
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(requests.at(-1).lat - 41.2) < 1e-6,
|
||||||
|
'the request asks for the new cell',
|
||||||
|
);
|
||||||
|
const info = layer.getSelectedInfo();
|
||||||
|
assert.equal(info?.mmsi, '111', 'the card is restored by MMSI');
|
||||||
|
assert.equal(info.latitude, 41.2);
|
||||||
|
const drawn = [...recorder.sprites].filter(({ id }) => id?.mmsi === '111');
|
||||||
|
assert.equal(drawn.length, 1, 'one chevron for the vessel');
|
||||||
|
assert.equal(drawn[0].id, record, 'the same record, not a new contact');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user