Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion public/live.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand Down
64 changes: 41 additions & 23 deletions public/packets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
});
}
Expand Down Expand Up @@ -4126,6 +4143,7 @@
_calcVisibleRange,
buildPacketsParams,
applyObserverFilter,
buildReplayPackets,
renderTableRows,
_setPackets: function(p) { packets = p; },
_setFilter: function(k, v) { filters[k] = v; },
Expand Down
38 changes: 38 additions & 0 deletions test-frontend-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
50 changes: 49 additions & 1 deletion test-live-region-filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(){}}) }),
Expand Down Expand Up @@ -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);
Loading