Compare commits

...

8 Commits

Author SHA1 Message Date
you b3c0da8a94 docs: restructure spec into implementable milestones 2026-04-03 03:52:33 +00:00
you 3778ba9c95 spec: flesh out node detail page neighbors UI section
Detail the neighbors section placement (between Heard By and Paths),
table columns (Neighbor, Role, Score, Observations, Last Seen, Confidence),
confidence indicators (HIGH/MEDIUM/LOW/AMBIGUOUS), interaction patterns
(click-to-navigate, Show on Map, distance badges), condensed panel view
(top 5 with View All link), deep linking (?section=node-neighbors),
and data fetching/caching strategy.
2026-04-03 03:51:12 +00:00
you 2fc68c4452 docs: add observability and debugging section to neighbor affinity spec 2026-04-03 03:48:36 +00:00
you 2fc5da33d3 docs: add existing disambiguation integration and Playwright E2E tests to neighbor affinity spec 2026-04-03 03:46:38 +00:00
you 5d8c52d2e5 docs: add Jaccard normalization, confidence threshold, and edge cases to neighbor affinity spec 2026-04-03 03:34:40 +00:00
you 016c820207 docs: update neighbor affinity spec with firmware-verified protocol details 2026-04-03 03:22:57 +00:00
you 93f437f937 docs: add neighbor affinity graph spec (#482) 2026-04-03 03:05:39 +00:00
Kpa-clawbot ad97c0fdd1 fix: clear stale parsed cache on observation packets (#505)
## Summary

Fixes #504 — Expanding a packet in the packets UI showed the same path
on every observation instead of each observation's unique path.

## Root Cause

PR #400 (fixing #387) added caching of `JSON.parse` results as
`_parsedPath` and `_parsedDecoded` properties on packet objects. When
observation packets are created via object spread (`{...parentPacket,
...obs}`), these cache properties are copied from the parent. Subsequent
calls to `getParsedPath(obsPacket)` hit the stale cache and return the
parent's path, ignoring the observation's own `path_json`.

## Fix

After every object spread that creates an observation packet from a
parent packet, delete the cache properties so they get re-parsed from
the observation's own data:

```js
delete obsPacket._parsedPath;
delete obsPacket._parsedDecoded;
```

Applied to all 5 spread sites in `public/packets.js`:
- Line 271: detail pane observation selection
- Line 504: flat view observation expansion
- Line 840: grouped view observation expansion
- Line 1012: child observation selection in grouped view
- Line 1982: WebSocket live update observation expansion

## Tests

Added 2 new tests in `test-frontend-helpers.js`:
1. Verifies observation packets get their own path after cache
invalidation (not the parent's)
2. Verifies observation path differs from parent path after cache
invalidation

All 431 frontend helper tests pass. All 62 packet filter tests pass.

---------

Co-authored-by: you <you@example.com>
2026-04-02 19:47:17 -07:00
4 changed files with 1310 additions and 3 deletions
File diff suppressed because it is too large Load Diff
+11
View File
@@ -20,6 +20,17 @@ window.getParsedPath = function getParsedPath(p) {
return p._parsedPath;
};
/**
* Clear cached _parsedPath/_parsedDecoded from a packet object.
* Must be called after spreading a parent packet into an observation/child,
* otherwise the child inherits stale cached values from the parent (issue #504).
*/
window.clearParsedCache = function clearParsedCache(p) {
delete p._parsedPath;
delete p._parsedDecoded;
return p;
};
window.getParsedDecoded = function getParsedDecoded(p) {
if (p._parsedDecoded !== undefined) return p._parsedDecoded;
var raw = p.decoded_json;
+5 -3
View File
@@ -269,6 +269,7 @@
if (obs) {
expandedHashes.add(h);
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp};
clearParsedCache(obsPacket);
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
} else {
selectPacket(data.packet.id, h, data);
@@ -501,7 +502,7 @@
await Promise.all(multiObs.map(async (p) => {
try {
const d = await api(`/packets/${p.hash}`);
if (d?.observations) p._children = d.observations.map(o => ({...d.packet, ...o, _isObservation: true}));
if (d?.observations) p._children = d.observations.map(o => clearParsedCache({...d.packet, ...o, _isObservation: true}));
} catch {}
}));
// Flatten: replace grouped packets with individual observations
@@ -837,7 +838,7 @@
try {
const data = await api(`/packets/${p.hash}`);
if (data?.packet && data.observations) {
p._children = data.observations.map(o => ({...data.packet, ...o, _isObservation: true}));
p._children = data.observations.map(o => clearParsedCache({...data.packet, ...o, _isObservation: true}));
p._fetchedData = data;
}
} catch {}
@@ -1010,6 +1011,7 @@
if (child) {
const parentData = group._fetchedData;
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child;
if (parentData) { clearParsedCache(obsPacket); }
selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id);
}
}
@@ -1979,7 +1981,7 @@
if (!pkt) return;
const group = packets.find(p => p.hash === hash);
if (group && data.observations) {
group._children = data.observations.map(o => ({...pkt, ...o, _isObservation: true}));
group._children = data.observations.map(o => clearParsedCache({...pkt, ...o, _isObservation: true}));
group._fetchedData = data;
// Sort children based on current sort mode
sortGroupChildren(group);
+55
View File
@@ -4411,6 +4411,61 @@ console.log('\n=== app.js: routeTypeName/payloadTypeName edge cases ===');
});
}
// ===== observation packet cache invalidation (issue #504) =====
{
console.log('\n=== Issue #504: observation packets must not inherit parent cache ===');
const helperSource = fs.readFileSync('public/packet-helpers.js', 'utf8');
const ctx = vm.createContext({ window: {}, console, JSON, Array, Object });
vm.runInContext(helperSource, ctx);
const getParsedPath = ctx.window.getParsedPath;
const getParsedDecoded = ctx.window.getParsedDecoded;
const clearParsedCache = ctx.window.clearParsedCache;
test('clearParsedCache removes cached properties and returns the object', () => {
const p = { path_json: '["A"]', decoded_json: '{"t":1}' };
getParsedPath(p);
getParsedDecoded(p);
assert.ok(p._parsedPath !== undefined);
assert.ok(p._parsedDecoded !== undefined);
const ret = clearParsedCache(p);
assert.strictEqual(ret, p, 'returns same object');
assert.strictEqual(p._parsedPath, undefined);
assert.strictEqual(p._parsedDecoded, undefined);
});
test('observation packet gets its own path after cache invalidation', () => {
const parent = { path_json: '["A","B"]', decoded_json: '{"type":"GRP_TXT"}' };
// Prime the cache on parent
getParsedPath(parent);
getParsedDecoded(parent);
// Simulate spread + fix (like packets.js does after issue #504)
const obs = { ...parent, path_json: '["X","Y","Z"]', decoded_json: '{"type":"TXT_MSG"}' };
clearParsedCache(obs);
// getParsedPath re-parses from obs's own path_json
const obsPath = getParsedPath(obs);
assert.deepStrictEqual(obsPath, ['X', 'Y', 'Z'], 'obs gets its own path, not parent\'s');
const obsDecoded = getParsedDecoded(obs);
assert.deepStrictEqual(obsDecoded, { type: 'TXT_MSG' }, 'obs gets its own decoded, not parent\'s');
});
test('observation packet path differs from parent after cache invalidation', () => {
const parent = { path_json: '["hop1"]', decoded_json: '{"type":"REQ"}' };
getParsedPath(parent);
getParsedDecoded(parent);
const obs = { ...parent, path_json: '["hop2","hop3"]', decoded_json: '{"type":"GRP_TXT","text":"hi"}' };
clearParsedCache(obs);
assert.notDeepStrictEqual(getParsedPath(obs), getParsedPath(parent),
'observation must have different path from parent');
assert.notDeepStrictEqual(getParsedDecoded(obs), getParsedDecoded(parent),
'observation must have different decoded from parent');
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);