mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Cover an unplugged local receiver and local IDs in share links with edge-case tests
This commit is contained in:
1 parent
4a9b95e31b
commit
63b2590b9f
1 file changed
+279
@@ -0,0 +1,279 @@
|
|||||||
|
// Edge cases for the local-adsb layer from docs/migration/planet-separation/layers.md:
|
||||||
|
// a scene switch does not reopen the USB device, decoder stream or tuner
|
||||||
|
// permission; an unplugged device ends exactly one session; and no local ID
|
||||||
|
// reaches a public share link.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
import { createLocalAdsbLayer } from './index.js';
|
||||||
|
import { createLocalReceiverFeeds } from './feeds.js';
|
||||||
|
import { createLocalAdsbCesiumRenderer } from './render/cesium.js';
|
||||||
|
import * as selection from '../../data/selectionDispatcher.js';
|
||||||
|
import { readShareFollowSubject } from '../../ui/shareFollow.js';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
function fakeReceiver(initial = {}) {
|
||||||
|
const state = {
|
||||||
|
webUsbSupported: true,
|
||||||
|
connected: true,
|
||||||
|
mode: 'fm',
|
||||||
|
status: 'streaming',
|
||||||
|
message: 'Streaming',
|
||||||
|
aircraft: [],
|
||||||
|
messagesPerSecond: 40,
|
||||||
|
...initial,
|
||||||
|
};
|
||||||
|
const subscribers = new Set();
|
||||||
|
const snapshot = () => ({ ...state, aircraft: [...state.aircraft] });
|
||||||
|
const receiver = {
|
||||||
|
modeCalls: [],
|
||||||
|
connects: 0,
|
||||||
|
closes: 0,
|
||||||
|
getState: snapshot,
|
||||||
|
subscribe(listener) {
|
||||||
|
subscribers.add(listener);
|
||||||
|
return () => subscribers.delete(listener);
|
||||||
|
},
|
||||||
|
set(patch) {
|
||||||
|
Object.assign(state, patch);
|
||||||
|
for (const listener of subscribers) listener(snapshot());
|
||||||
|
},
|
||||||
|
async setMode(mode) {
|
||||||
|
receiver.modeCalls.push(mode);
|
||||||
|
state.mode = mode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async connect() {
|
||||||
|
receiver.connects += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
receiver.closes += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return receiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeFeeds() {
|
||||||
|
let state = { configured: true, polling: false, feeds: [], records: [] };
|
||||||
|
const listeners = new Set();
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
getState: () => state,
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
start() {
|
||||||
|
calls.push('start');
|
||||||
|
state = { ...state, polling: true };
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
calls.push('stop');
|
||||||
|
state = { ...state, polling: false };
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
calls.push('destroy');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeServices() {
|
||||||
|
let selected = null;
|
||||||
|
return {
|
||||||
|
render: { governorRequestRender() {} },
|
||||||
|
context: {
|
||||||
|
registerEntityContext(entity, metadata) {
|
||||||
|
entity.__gevContextId = metadata.id;
|
||||||
|
},
|
||||||
|
selectEntityContext(entity) {
|
||||||
|
selected = { id: entity.__gevContextId, entity };
|
||||||
|
return selected;
|
||||||
|
},
|
||||||
|
clearSelectedEntityContextForLayer() {
|
||||||
|
selected = null;
|
||||||
|
},
|
||||||
|
getSelectedEntityContext: () => selected,
|
||||||
|
removeEntityContextsForLayer() {},
|
||||||
|
},
|
||||||
|
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||||
|
selection,
|
||||||
|
renderer: {
|
||||||
|
create: ({ engine = null, ...options }) =>
|
||||||
|
createLocalAdsbCesiumRenderer(engine, options),
|
||||||
|
},
|
||||||
|
detection: { markSourcesChanged() {} },
|
||||||
|
overlays: { refreshReadout() {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeViewer() {
|
||||||
|
const sources = [];
|
||||||
|
const camera = {
|
||||||
|
rightWC: new Cesium.Cartesian3(0, 1, 0),
|
||||||
|
upWC: new Cesium.Cartesian3(0, 0, 1),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
sources,
|
||||||
|
viewer: {
|
||||||
|
camera,
|
||||||
|
scene: { camera },
|
||||||
|
entities: {
|
||||||
|
add: (entity) => entity,
|
||||||
|
remove() {},
|
||||||
|
},
|
||||||
|
dataSources: {
|
||||||
|
add(source) {
|
||||||
|
sources.push(source);
|
||||||
|
},
|
||||||
|
remove() {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(overrides = {}) {
|
||||||
|
return {
|
||||||
|
icao: 'abc123',
|
||||||
|
callsign: 'LOCAL1',
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
altitudeFt: 10_000,
|
||||||
|
groundSpeedKt: 180,
|
||||||
|
trackDeg: 0,
|
||||||
|
verticalRateFpm: 0,
|
||||||
|
lastPositionAt: 100_000,
|
||||||
|
lastMessageAt: 100_000,
|
||||||
|
messageCount: 12,
|
||||||
|
rssiDbfs: null,
|
||||||
|
band: '1090',
|
||||||
|
source: 'webusb',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('edge:local-adsb: an unplugged receiver ends one session and a scene switch reopens nothing', async (t) => {
|
||||||
|
const receiver = fakeReceiver({ aircraft: [record()] });
|
||||||
|
const feeds = fakeFeeds();
|
||||||
|
const clock = { now: 100_000 };
|
||||||
|
const layer = createLocalAdsbLayer({
|
||||||
|
receiver,
|
||||||
|
feeds,
|
||||||
|
services: fakeServices(),
|
||||||
|
now: () => clock.now,
|
||||||
|
});
|
||||||
|
layer.init(fakeViewer().viewer);
|
||||||
|
await layer.enable();
|
||||||
|
assert.ok(layer.selectAircraft('abc123'));
|
||||||
|
|
||||||
|
// The device is unplugged: the receiver reports it closed its session.
|
||||||
|
receiver.set({
|
||||||
|
connected: false,
|
||||||
|
status: 'error',
|
||||||
|
message: 'Device disconnected',
|
||||||
|
aircraft: [],
|
||||||
|
messagesPerSecond: null,
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
|
||||||
|
// The scene is swapped while the device is gone.
|
||||||
|
layer.detachRenderer();
|
||||||
|
clock.now += 1_000;
|
||||||
|
await layer.update();
|
||||||
|
layer.attachRenderer(fakeViewer().viewer);
|
||||||
|
await layer.update();
|
||||||
|
|
||||||
|
assert.deepEqual(receiver.modeCalls, ['adsb'], 'the tuner is asked once');
|
||||||
|
assert.equal(receiver.connects, 0, 'the USB device is not reopened');
|
||||||
|
assert.deepEqual(
|
||||||
|
feeds.calls,
|
||||||
|
['start'],
|
||||||
|
'the decoder stream is not restarted',
|
||||||
|
);
|
||||||
|
assert.equal(receiver.closes, 0, 'the layer leaves the session to its owner');
|
||||||
|
|
||||||
|
layer.destroy();
|
||||||
|
assert.equal(receiver.closes, 1, 'teardown closes exactly one session');
|
||||||
|
assert.deepEqual(feeds.calls, ['start', 'destroy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edge:local-adsb: a decoder feed that disconnects keeps one polling loop across re-attaches', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const calls = [];
|
||||||
|
let reachable = true;
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
if (!reachable) throw new TypeError('receiver unplugged');
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
configured: true,
|
||||||
|
generatedAt: 1_000_000,
|
||||||
|
feeds: [{ band: '1090', label: '1090 MHz', status: 'live' }],
|
||||||
|
records: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
|
||||||
|
feeds.start();
|
||||||
|
await flush();
|
||||||
|
reachable = false;
|
||||||
|
for (let tick = 0; tick < 3; tick += 1) {
|
||||||
|
// A scene re-attach asks the session to start again while it polls.
|
||||||
|
feeds.start();
|
||||||
|
t.mock.timers.tick(1_000);
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
assert.equal(calls.length, 4, 'one request per interval, not one per start');
|
||||||
|
assert.equal(feeds.getState().feeds[0].status, 'unreachable');
|
||||||
|
assert.equal(feeds.getState().polling, true);
|
||||||
|
feeds.stop();
|
||||||
|
t.mock.timers.tick(5_000);
|
||||||
|
await flush();
|
||||||
|
assert.equal(calls.length, 4, 'stop ends the only loop');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edge:local-adsb: a selected local aircraft never reaches a share link', async (t) => {
|
||||||
|
const receiver = fakeReceiver({
|
||||||
|
mode: 'adsb',
|
||||||
|
aircraft: [record({ lat: 10, lon: 20 })],
|
||||||
|
});
|
||||||
|
const { viewer, sources } = fakeViewer();
|
||||||
|
const layer = createLocalAdsbLayer({
|
||||||
|
receiver,
|
||||||
|
feeds: fakeFeeds(),
|
||||||
|
services: fakeServices(),
|
||||||
|
now: () => 100_000,
|
||||||
|
});
|
||||||
|
t.after(() => layer.destroy());
|
||||||
|
layer.init(viewer);
|
||||||
|
await layer.enable();
|
||||||
|
assert.ok(layer.selectAircraft('abc123'));
|
||||||
|
const marker = sources
|
||||||
|
.flatMap((source) => source.entities.values)
|
||||||
|
.find((entity) =>
|
||||||
|
String(entity.gevTrackedId || '').startsWith('local-adsb:'),
|
||||||
|
);
|
||||||
|
assert.ok(marker, 'the local marker carries its tracked id');
|
||||||
|
|
||||||
|
const shareViewer = {
|
||||||
|
camera: {
|
||||||
|
positionWC: Cesium.Cartesian3.fromDegrees(20, 9.9, 20_000),
|
||||||
|
},
|
||||||
|
clock: { currentTime: Cesium.JulianDate.now() },
|
||||||
|
trackedEntity: marker,
|
||||||
|
};
|
||||||
|
assert.equal(readShareFollowSubject({ viewer: shareViewer }), null);
|
||||||
|
// Control: the same entity under a shareable layer id would be named.
|
||||||
|
const control = {
|
||||||
|
...shareViewer,
|
||||||
|
trackedEntity: {
|
||||||
|
gevTrackedId: 'flights:abc123',
|
||||||
|
gevDisplayPosition: marker.gevDisplayPosition,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(readShareFollowSubject({ viewer: control })?.id, 'abc123');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user