Cover an unplugged local receiver and local IDs in share links with edge-case tests

This commit is contained in:
German Khokhlov committed 2026-11-03 19:44:55 +04:00
1 parent 4a9b95e31b
commit 63b2590b9f
1 file changed
+279
+279
View File
@@ -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');
});