From 63b2590b9fb2b34bed2c5d7acb5f0315934c8a1a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 19:44:55 +0400 Subject: [PATCH] Cover an unplugged local receiver and local IDs in share links with edge-case tests --- src/layers/localAdsb/edgeCases.test.mjs | 279 ++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 src/layers/localAdsb/edgeCases.test.mjs diff --git a/src/layers/localAdsb/edgeCases.test.mjs b/src/layers/localAdsb/edgeCases.test.mjs new file mode 100644 index 000000000..2f23f19d0 --- /dev/null +++ b/src/layers/localAdsb/edgeCases.test.mjs @@ -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'); +});