Files
meshcore-analyzer/test-issue-1136-observer-iata-map.js
T
Kpa-clawbot eddca7acde fix(live): region filter wipes feed — parse {observers:[...]} response (#1136) (#1140)
## Summary
Fixes #1136. The live page region filter wiped all packets, polylines,
and feed entries the moment any region was selected. Root cause:
`public/live.js` parsed `/api/observers` as a top-level array, but the
endpoint returns `{observers:[...], server_time:"..."}` — so
`observerIataMap` stayed empty and `packetMatchesRegion` rejected every
packet.

This was a regression introduced in #1080 (live region filter) after the
typed-struct refactor wrapped the observer list in
`ObserverListResponse` (cmd/server/types.go).

## Fix
- Extracted the parse into `buildObserverIataMap(data)` — a pure helper
that accepts both the real `{observers:[...]}` shape and a bare array
(defensive). Skips observers with no IATA so the result is a direct
lookup map.
- `initLiveRegionFilter` now uses the helper, so the map is populated on
first paint.
- Exposed `_liveBuildObserverIataMap` and `_liveGetObserverIataMap` on
`window` for tests (read-only — no behavior change).

Backend untouched — the API shape is correct.

## Tests (red → green)
**Red commit** (`test(live): failing tests for #1136 region filter wipes
feed`):
- `test-issue-1136-observer-iata-map.js` — failed at "helper must be
exposed" assertion (parser was inlined, not extracted).
- `test-issue-1136-live-region-e2e.js` — Playwright. Loads `/#/live`,
queries `/api/observers` to discover an SJC observer, asserts the live
module's `observerIataMap` is populated, selects SJC via
`RegionFilter.setSelected`, pushes a fixture packet through
`_liveBufferPacket`, and asserts a `.live-feed-item[data-hash=...]`
renders. Failed at both the "map populated" and "feed renders"
assertions — exactly the user-reported symptom.
- Both wired into `.github/workflows/deploy.yml` (unit step + Playwright
step).

**Green commit** (`fix(live): parse {observers:[...]} ...`): all five
unit assertions + all five E2E assertions pass. Existing
`test-live-region-filter.js` from #1080 still passes (no behavior change
to `packetMatchesRegion`).

## Verification (local)
```
node test-issue-1136-observer-iata-map.js   # 5/5 pass
node test-live-region-filter.js              # 9/9 pass (regression guard)
BASE_URL=http://localhost:13581 \
  CHROMIUM_PATH=/usr/bin/chromium \
  node test-issue-1136-live-region-e2e.js    # 5/5 pass against fixture DB
```

## Scope
- One frontend file changed (`public/live.js`).
- Two new tests + 2 lines of CI wiring.
- No backend changes.
- No refactor of unrelated `live.js` code.
- Out of scope: #1108 (the related "hide nodes not seen by region"
feature request) is intentionally not addressed here.

Fixes #1136

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-06 21:24:32 -07:00

134 lines
6.0 KiB
JavaScript

/* Unit test (#1136): live.js must parse /api/observers correctly.
*
* Regression: PR #1080 wrote `if (Array.isArray(list))` and treated the
* response as a top-level array. The actual /api/observers shape is
* `{ observers: [...], server_time: "..." }` (cmd/server/types.go
* ObserverListResponse). Result: observerIataMap stays empty and ANY
* region selection drops every packet.
*
* This test loads live.js into a vm sandbox and asserts that the exposed
* builder helper produces a populated map from the realistic API shape.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' \u2705 ' + name); }
catch (e) { failed++; console.log(' \u274C ' + name + ': ' + e.message); }
}
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
document: {
readyState: 'complete',
createElement: () => ({ style: {}, classList: { add(){}, remove(){}, contains(){return false;} }, setAttribute(){}, addEventListener(){}, getContext: () => ({clearRect(){},fillRect(){},beginPath(){},arc(){},fill(){},scale(){},fillText(){}}) }),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [], querySelector: () => null,
createElementNS: () => ({ setAttribute(){} }),
documentElement: { getAttribute: () => null, setAttribute: () => {}, dataset: {} },
body: { appendChild: () => {}, removeChild: () => {}, contains: () => false },
hidden: false,
},
console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
Error, TypeError, Map, Set, Promise, URLSearchParams,
parseInt, parseFloat, isNaN, isFinite,
encodeURIComponent, decodeURIComponent,
setTimeout: () => 0, clearTimeout: () => {},
setInterval: () => 0, clearInterval: () => {},
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
requestAnimationFrame: () => 0,
cancelAnimationFrame: () => {},
localStorage: (() => { const s = {}; return { getItem: k => s[k] !== undefined ? s[k] : null, setItem: (k,v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(),
location: { hash: '', protocol: 'https:', host: 'localhost' },
CustomEvent: class CustomEvent {},
addEventListener: () => {}, dispatchEvent: () => {},
getComputedStyle: () => ({ getPropertyValue: () => '' }),
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
navigator: {}, visualViewport: null,
MutationObserver: function() { this.observe=()=>{}; this.disconnect=()=>{}; },
WebSocket: function() { this.close=()=>{}; },
IATA_COORDS_GEO: {},
L: {
circleMarker: () => ({addTo(){return this;},bindTooltip(){return this;},on(){return this;},setRadius(){},setStyle(){},setLatLng(){},getLatLng(){return{lat:0,lng:0};},remove(){}}),
polyline: () => ({addTo(){return this;},setStyle(){},remove(){}}),
polygon: () => ({addTo(){return this;},remove(){}}),
map: () => ({setView(){return this;},addLayer(){return this;},on(){return this;},getZoom(){return 11;},getCenter(){return{lat:0,lng:0};},getBounds(){return{contains:()=>true};},fitBounds(){return this;},invalidateSize(){},remove(){},hasLayer(){return false;},removeLayer(){}}),
layerGroup: () => ({addTo(){return this;},addLayer(){},removeLayer(){},clearLayers(){},hasLayer(){return true;},eachLayer(){}}),
tileLayer: () => ({addTo(){return this;}}),
control: { attribution: () => ({addTo(){}}) },
DomUtil: { addClass(){}, removeClass(){} },
},
registerPage: () => {}, onWS: () => {}, offWS: () => {}, connectWS: () => {},
api: () => Promise.resolve([]), invalidateApiCache: () => {},
favStar: () => '', bindFavStars: () => {},
getFavorites: () => [], isFavorite: () => false,
HopResolver: { init(){}, resolve: () => ({}), ready: () => false },
MeshAudio: null,
RegionFilter: { init(){}, getSelected: () => null, onChange: () => {}, offChange: () => {}, regionQueryString: () => '', getRegionParam: () => '' },
};
vm.createContext(ctx);
return ctx;
}
function load(ctx, file) {
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
console.log('\n=== live.js: /api/observers parse (#1136) ===');
const ctx = makeSandbox();
load(ctx, 'public/roles.js');
load(ctx, 'public/live.js');
const build = ctx.window._liveBuildObserverIataMap;
assert.ok(build, '_liveBuildObserverIataMap must be exposed (regression: missing parser helper)');
const realShape = {
observers: [
{ id: 'OBS1', iata: 'SJC', name: 'A' },
{ id: 'OBS2', iata: 'OAK', name: 'B' },
{ id: 'OBS3', iata: 'SFO', name: 'C' },
{ id: 'OBS4', iata: null, name: 'no-iata' },
],
server_time: '2026-05-07T00:00:00Z',
};
test('parses {observers:[...], server_time} response and populates map', () => {
const m = build(realShape);
assert.strictEqual(m.OBS1, 'SJC');
assert.strictEqual(m.OBS2, 'OAK');
assert.strictEqual(m.OBS3, 'SFO');
});
test('skips observers without iata', () => {
const m = build(realShape);
assert.ok(!('OBS4' in m), 'observers with null iata should not be in map');
});
test('returns empty map for null/undefined input', () => {
assert.strictEqual(Object.keys(build(null)).length, 0);
assert.strictEqual(Object.keys(build(undefined)).length, 0);
});
test('returns empty map when observers field is missing', () => {
assert.strictEqual(Object.keys(build({ server_time: 'x' })).length, 0);
});
test('back-compat: also accepts a top-level array (defensive)', () => {
// If the API shape ever changes back, don\'t silently break.
const m = build([{ id: 'X1', iata: 'LAX' }]);
assert.strictEqual(m.X1, 'LAX');
});
console.log('\n' + '='.repeat(40));
console.log(' observer iata map tests: ' + passed + ' passed, ' + failed + ' failed');
console.log('='.repeat(40) + '\n');
if (failed > 0) process.exit(1);