diff --git a/public/live.js b/public/live.js index 266cf7342..4b9e4aed8 100644 --- a/public/live.js +++ b/public/live.js @@ -856,7 +856,12 @@ resolved_path: pkt.resolved_path, _ts: new Date(pkt.timestamp || pkt.created_at).getTime(), decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } }, - snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name + snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name, + // #1898: the region filter matches on observer_id (packetMatchesRegion). Without it every replayed packet has observer_id undefined, + // so the filter skips them all and drops the whole group. observer_iata + // is carried too so obsIataBadgeHtml does not have to fall back to the + // roster map for replayed packets. + observer_id: pkt.observer_id, observer_iata: pkt.observer_iata }; } diff --git a/public/packets.js b/public/packets.js index 426425459..5ad83cee5 100644 --- a/public/packets.js +++ b/public/packets.js @@ -2833,6 +2833,44 @@ }); } + // buildReplayPackets shapes the objects the VCR replay reads back out of + // sessionStorage. Extracted from the replay button's click handler — the + // same reason applyObserverFilter above was extracted (#1748 review): a + // test that re-implements this mapping cannot fail when the real one + // regresses, and this mapping has a history of dropping fields. + // + // #1900: observer_id must be carried, not just the resolved observer NAME. + // The Live region filter (packetMatchesRegion in live.js) matches on + // observer_id and skips any packet where it is null, returning false when + // none match — so omitting it made the replay render nothing at all + // whenever a region was selected. observer_iata rides along so the IATA + // badge does not have to fall back to the roster map. + function buildReplayPackets(pkt, data, ctx) { + const { typeName, decoded, pathHops, obsName } = ctx; + const obs = (data && data.observations) || []; + const replayPackets = []; + if (obs.length > 1) { + for (const o of obs) { + replayPackets.push({ + id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex, + _ts: new Date(o.timestamp).getTime(), + decoded: { header: { payloadTypeName: typeName }, payload: getParsedDecoded(o), path: { hops: getParsedPath(o) } }, + snr: o.snr, rssi: o.rssi, observer: obsName(o.observer_id), + observer_id: o.observer_id, observer_iata: o.observer_iata + }); + } + } else { + replayPackets.push({ + id: pkt.id, hash: pkt.hash, raw: pkt.raw_hex, + _ts: new Date(pkt.timestamp).getTime(), + decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } }, + snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id), + observer_id: pkt.observer_id, observer_iata: pkt.observer_iata + }); + } + return replayPackets; + } + async function renderTableRows() { const tbody = document.getElementById('pktBody'); if (!tbody) return; @@ -3543,29 +3581,8 @@ const replayBtn = panel.querySelector('.replay-live-btn'); if (replayBtn) { replayBtn.addEventListener('click', () => { - // Build replay packets for ALL observations of this transmission - const obs = data.observations || []; - const replayPackets = []; - if (obs.length > 1) { - for (const o of obs) { - const oPath = getParsedPath(o); - const oDec = getParsedDecoded(o); - replayPackets.push({ - id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex, - _ts: new Date(o.timestamp).getTime(), - decoded: { header: { payloadTypeName: typeName }, payload: oDec, path: { hops: oPath } }, - snr: o.snr, rssi: o.rssi, observer: obsName(o.observer_id) - }); - } - } else { - replayPackets.push({ - id: pkt.id, hash: pkt.hash, raw: pkt.raw_hex, - _ts: new Date(pkt.timestamp).getTime(), - decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } }, - snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id) - }); - } - sessionStorage.setItem('replay-packet', JSON.stringify(replayPackets)); + sessionStorage.setItem('replay-packet', JSON.stringify( + buildReplayPackets(pkt, data, { typeName, decoded, pathHops, obsName }))); window.location.hash = '#/live'; }); } @@ -4126,6 +4143,7 @@ _calcVisibleRange, buildPacketsParams, applyObserverFilter, + buildReplayPackets, renderTableRows, _setPackets: function(p) { packets = p; }, _setFilter: function(k, v) { filters[k] = v; }, diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index a276ca443..5658dc517 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -3652,6 +3652,44 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ==='); _children: [{ observer_id: 'A' }, { observer_id: 'B' }], }; + // --- #1900: the VCR replay mapping must carry observer_id --- + // Same rationale as applyObserverFilter above: exercise the real + // production mapping. Dropping observer_id here previously passed every + // test in the suite, because the only coverage lived in + // test-live-region-filter.js and only reached live.js's half of the fix. + const buildReplayPackets = obsFilterCtx.window._packetsTestAPI.buildReplayPackets; + const replayCtx = { + typeName: 'ADVERT', decoded: { t: 1 }, pathHops: ['AA'], + obsName: (id) => 'Name of ' + id, + }; + + test('#1900 replay carries observer_id for a single-observation packet', () => { + const out = buildReplayPackets( + { id: 5, hash: 'h5', raw_hex: 'CAFE', timestamp: '2026-01-01T00:00:00Z', observer_id: 'obs1', observer_iata: 'BRU' }, + { observations: [{ id: 9 }] }, replayCtx); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0].observer_id, 'obs1', 'the Live region filter matches on observer_id'); + assert.strictEqual(out[0].observer_iata, 'BRU'); + assert.strictEqual(out[0].observer, 'Name of obs1', 'the resolved name is still carried too'); + }); + + test('#1900 replay carries observer_id for every observation of a multi-observer packet', () => { + const out = buildReplayPackets( + { id: 5, hash: 'h5', raw_hex: 'CAFE', timestamp: '2026-01-01T00:00:00Z', observer_id: 'obs1' }, + { observations: [ + { id: 1, observer_id: 'obsA', observer_iata: 'BRU', timestamp: '2026-01-01T00:00:00Z' }, + { id: 2, observer_id: 'obsB', observer_iata: 'LAX', timestamp: '2026-01-01T00:00:01Z' }, + ] }, replayCtx); + assert.strictEqual(out.length, 2, 'one replay packet per observation'); + // deepEqual, not deepStrictEqual: the array comes from the vm sandbox's + // realm, so its prototype differs from this one and a STRICT deep compare + // fails on identical contents ("same structure but not reference-equal"). + // The loose form compares across realms and is stronger than joining. + assert.deepEqual(out.map((p) => p.observer_id), ['obsA', 'obsB'], + 'each observation must carry its OWN observer_id, not the transmission representative'); + assert.deepEqual(out.map((p) => p.observer_iata), ['BRU', 'LAX']); + }); + test('grouped mode: keeps a multi-observer row whose representative is not the filtered observer (#1748 core bug)', () => { const result = applyObserverFilter([groupedRowRepresentativeNotFiltered], { observer: 'B' }, true, false); assert.strictEqual(result.length, 1, 'server already guaranteed observer B saw this transmission'); diff --git a/test-live-region-filter.js b/test-live-region-filter.js index cf914727e..403e4bbb2 100644 --- a/test-live-region-filter.js +++ b/test-live-region-filter.js @@ -14,7 +14,13 @@ function test(name, fn) { function makeSandbox() { const ctx = { - window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 }, + window: { + addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1, + // live.js:14 captures these at load time from packet-helpers.js, which + // this sandbox does not load. They must exist before live.js is evaluated. + getParsedDecoded: (p) => { try { return p && p.decoded_json ? JSON.parse(p.decoded_json) : {}; } catch (e) { return {}; } }, + getParsedPath: (p) => { try { return p && p.path_json ? JSON.parse(p.path_json) : []; } catch (e) { return []; } }, + }, document: { readyState: 'complete', createElement: () => ({ style:{}, classList:{add(){},remove(){},contains(){return false;}}, setAttribute(){}, addEventListener(){}, getContext: () => ({clearRect(){},fillRect(){},beginPath(){},arc(){},fill(){},scale(){},fillText(){}}) }), @@ -135,7 +141,49 @@ test('observer iata map can be updated and used by filter', () => { assert.strictEqual(fn([{observer_id:'newobs'}], { 'newobs': 'LAX' }, ['LAX']), true); }); +// --- #1898 / #1900: replayed packets must carry observer_id --- +// dbPacketToLive() used to return only observer (the resolved name), so every +// VCR-replayed packet had observer_id undefined. packetMatchesRegion skips a +// packet whose observer_id is null and returns false when none match, so with a +// region selected the replay rendered nothing at all, and the Replay button on +// packet detail silently no-opped. Both issues are the same omission. +const toLive = ctx.window._liveDbPacketToLive; +assert.ok(toLive, '_liveDbPacketToLive must be exposed'); + +test('#1898: dbPacketToLive carries observer_id through', () => { + const live = toLive({ id: 1, hash: 'h', timestamp: '2026-01-01T00:00:00Z', + observer_id: 'obs1', observer_name: 'Obs One' }); + assert.strictEqual(live.observer_id, 'obs1', + 'observer_id must survive; the region filter matches on it'); +}); + +test('#1898: a replayed packet survives an active region filter', () => { + const live = toLive({ id: 1, hash: 'h', timestamp: '2026-01-01T00:00:00Z', + observer_id: 'obs1', observer_name: 'Obs One' }); + assert.strictEqual(fn([live], { obs1: 'BRU' }, ['BRU']), true, + 'with observer_id carried the group matches and is rendered'); +}); + +test('#1898: a source row without observer_id still yields an unmatched packet', () => { + // Drive the real mapping rather than hand-building the object: asserting on + // packetMatchesRegion alone only re-tests the filter (already covered by + // "does not match when packet has no observer_id" above) and cannot fail if + // dbPacketToLive stops carrying the field — which is the regression this is + // named for. + const live = toLive({ id: 1, hash: 'h', timestamp: '2026-01-01T00:00:00Z', observer_name: 'Obs One' }); + assert.strictEqual(live.observer_id, undefined, 'nothing to carry when the source row has no id'); + assert.strictEqual(fn([live], { obs1: 'BRU' }, ['BRU']), false, + 'a packet with no observer_id is skipped, so the group is dropped'); +}); + +test('#1898: observer_iata is carried so the badge needs no roster lookup', () => { + const live = toLive({ id: 1, hash: 'h', timestamp: '2026-01-01T00:00:00Z', + observer_id: 'obs1', observer_iata: 'BRU' }); + assert.strictEqual(live.observer_iata, 'BRU'); +}); + console.log(`\n${'═'.repeat(40)}`); console.log(` live region filter tests: ${passed} passed, ${failed} failed`); + console.log(`${'═'.repeat(40)}\n`); if (failed > 0) process.exit(1);