Files
meshcore-analyzer/tests/unit/test-map-deeplink-path-params.js
CoderNemesis 65990873ad feat(map): deep-link the route path picker by observer or All view (#2376)
## Summary

The map route deep link (`#/map?packet=<hash>[&obs=<id>]`) always opened
in the canonical-path view. Two optional query params now choose the
path picker's starting view, so a shared link shows exactly what the
sender meant.

| Param | Effect |
|---|---|
| `paths=all` | Opens the picker's **All** view (every unique path), the
same as clicking **All** |
| `observer=<pubkey>` | Full public key or a hex prefix of 8+
characters, case-insensitive. Picks that observer's **earliest**
observation (by timestamp, then id), makes it the canonical route and
isolates its path row, the same as clicking the row. "Back to packet"
then links to that observation. |

Examples:

```
#/map?packet=974ad4ecefbe3761&paths=all
#/map?packet=974ad4ecefbe3761&observer=a1b2c3d4
```

When params are combined, `obs=` wins over `observer=`, and `observer=`
wins over `paths=all`. An unknown or malformed observer falls back to
the current default view and logs a console warning.

## Changes

- `public/route-view-utils.js`: two new pure helpers.
- `resolveDeepLinkObservation(observations, params)` picks the
observation and the initial view.
- `pathGroupKey(rawHops, filterHops)` is the path picker's grouping key.
It was written out twice in `route-view.js` and now lives in one place,
which keeps the deep link and the picker rows in sync.
- `public/map.js`: `loadRouteFromDeepLink` uses the helper and passes
`initialPathView` through `drawPacketRouteMulti` to the renderer.
- `public/route-view.js`: the picker count and rows use `pathGroupKey`.
`sidebar._applyPathView` applies the view through the existing All and
row click handlers, in the frame after the route's initial fit.

## Performance

This doesn't touch a hot path. The work is one pass over a single
packet's observations (tens at most) plus a scan of the picker rows, and
it runs once per deep-link load. It adds no API calls.

## Config / customizer

No new configurable values.

## Tests

- New unit suite `tests/unit/test-map-deeplink-path-params.js` (29
checks). It covers prefix vs full key, case, short/non-hex keys,
earliest-observation choice with tie-break, the precedence rules,
`pathGroupKey`, and the source wiring.
- `tests/unit/test-issue-1633-hide-1byte-hops.js` replayed a copy of the
key logic. It now tests the real `pathGroupKey`.
- New Playwright suite `tests/e2e/test-map-deeplink-path-params-e2e.js`
(5 checks) on the seeded #1486 packet. It's registered in
`scripts/non-unit-tests.json` and CI shard 3. With the `route-view.js`
change removed, 3 of its 5 checks fail.
- Local runs against the CI-prepared fixture: `test-all.sh` passes.
Route-map a11y E2E 20/20, map mobile E2E 3/3, `test-e2e-playwright.js`
149/152 (3 skipped by the fixture, 0 failed).

## Browser validation

Checked in a browser against a local server, using a scratch copy of the
fixture that gives the seeded packet multi-hop paths through real GPS
nodes. One observer had two observations, and the API listed the later
one first.
- `observer=<prefix>` picked that observer's earliest observation and
drew only its path, with numbered markers and the map fitted to it.
- `paths=all` drew the same picture as clicking **All** by hand.
2026-11-08 17:13:22 +01:00

113 lines
6.1 KiB
JavaScript

/**
* Map route deep-link path params — #/map?packet=<hash> plus
* &observer=<pubkey or 8+ char prefix> isolate that observer's earliest path
* &paths=all open the path picker's "All" view
*
* Covers the real helpers in public/route-view-utils.js
* (resolveDeepLinkObservation, pathGroupKey) and the source wiring in
* map.js / route-view.js that hands the result to the path picker.
*/
'use strict';
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const read = f => fs.readFileSync(path.join(REPO_ROOT, 'public', f), 'utf8');
const warnings = [];
const ctx = { window: {}, console: { log() {}, warn: m => warnings.push(m), error() {} } };
vm.createContext(ctx);
vm.runInContext(read('route-view-utils.js'), ctx);
const U = ctx.window.MC_ROUTE_UTILS;
const OBS_A = 'a1b2c3d4' + '0'.repeat(56);
const OBS_B = 'b1b2c3d4' + '1'.repeat(56);
const observations = [
{ id: 10, observer_id: OBS_B, timestamp: '2026-11-01T10:00:00Z', path_json: '["aa","bb"]' },
{ id: 11, observer_id: OBS_A, timestamp: '2026-11-01T10:00:05Z', path_json: '["cc","dd","ee"]' },
{ id: 12, observer_id: OBS_A.toUpperCase(), timestamp: '2026-11-01T10:00:02Z', path_json: '["ff"]' },
{ id: 13, observer_id: OBS_A, timestamp: '2026-11-01T10:00:02Z', path_json: '["99"]' },
];
const pick = params => U.resolveDeepLinkObservation(observations, params);
console.log('\n=== observer: pick that observer\'s earliest observation ===');
{
const r = pick({ observer: 'a1b2c3d4' });
assert(r.chosen && r.chosen.id === 12, '8-char prefix picks the earliest, ties to the lower id (got ' + (r.chosen && r.chosen.id) + ')');
assert(r.observationId === '12', 'observationId is the chosen observation (back link lands on it)');
assert(r.pathView && r.pathView.mode === 'path' && r.pathView.path.join(',') === 'ff',
'pathView isolates the chosen observation\'s raw hops');
assert(pick({ observer: OBS_A }).chosen.id === 12, 'full public key matches');
assert(pick({ observer: 'A1B2C3D4' }).chosen.id === 12, 'match is case-insensitive');
assert(pick({ observer: ' a1b2c3d4 ' }).chosen.id === 12, 'surrounding whitespace is ignored');
}
console.log('\n=== observer: invalid or unmatched keys fall back ===');
{
warnings.length = 0;
const short = pick({ observer: 'a1b2c3' });
assert(short.chosen.id === 10 && short.pathView === null, 'prefix shorter than 8 chars falls back to the first observation');
assert(pick({ observer: 'a1b2c3d4zz' }).pathView === null, 'non-hex key is rejected');
const none = pick({ observer: 'ffffffff' });
assert(none.chosen.id === 10 && none.pathView === null && none.observationId === null,
'unmatched observer falls back to the first observation with no path view');
assert(warnings.some(w => /ffffffff/.test(w)), 'unmatched observer logs a console warning');
assert(pick({ observer: 'ffffffff', paths: 'all' }).pathView.mode === 'all',
'unmatched observer still honours paths=all');
}
console.log('\n=== precedence ===');
{
const r = pick({ obsId: '11', observer: 'b1b2c3d4' });
assert(r.chosen.id === 11 && r.pathView === null, 'obs wins over observer');
assert(pick({ obsId: '11', paths: 'all' }).pathView.mode === 'all', 'obs combines with paths=all');
assert(pick({ observer: 'b1b2c3d4', paths: 'all' }).pathView.mode === 'path', 'observer wins over paths=all');
{
const fell = pick({ obsId: '999', observer: 'b1b2c3d4' });
assert(fell.chosen.id === 10 && fell.pathView.mode === 'path', 'an unmatched obs falls through to observer');
assert(fell.observationId === '10', 'observationId is the observer\'s observation, not the stale obs (got ' + fell.observationId + ')');
const stale = pick({ obsId: '999' });
assert(stale.chosen.id === 10 && stale.observationId === null,
'an unmatched obs alone falls back to the first observation with no observationId (got ' + stale.observationId + ')');
assert(pick({ obsId: 11 }).observationId === '11', 'a matched obs is returned as a string id');
}
assert(pick({}).chosen.id === 10 && pick({}).pathView === null, 'no params: first observation, default view');
assert(pick({ paths: 'ALL' }).pathView === null, 'paths only accepts "all"');
assert(U.resolveDeepLinkObservation([], { observer: 'a1b2c3d4' }).chosen === null, 'no observations: nothing chosen');
}
console.log('\n=== pathGroupKey matches the path picker grouping ===');
{
assert(U.pathGroupKey(['aa', 'bb']) === 'aa→bb', 'hops joined with the picker arrow');
const drop1 = hops => hops.filter(h => h.length > 2);
assert(U.pathGroupKey(['aa', 'bbcc'], drop1) === 'bbcc', 'filter applied before joining');
assert(U.pathGroupKey(['aa', 'bb'], drop1) !== U.pathGroupKey(['cc', 'dd'], drop1),
'fully filtered paths stay distinct');
assert(U.pathGroupKey(null) === U.pathGroupKey([]), 'missing hops treated as empty');
}
console.log('\n=== wiring ===');
{
const mapSrc = read('map.js');
const rvSrc = read('route-view.js');
assert(/resolveDeepLinkObservation\(observations, \{\s*obsId: obsId, observer: params\.get\('observer'\), paths: params\.get\('paths'\)/.test(mapSrc),
'deep link resolves the observation from obs / observer / paths');
assert(/initialPathView: pick\.pathView/.test(mapSrc), 'deep link hands the path view to the multi-path draw');
assert(/initialPathView: opts\.initialPathView \|\| null/.test(mapSrc), 'multi-path draw forwards it to the renderer');
assert(/var k = pathGroupKey\(rawHops, filterHops\);/.test(rvSrc), 'picker rows are keyed by pathGroupKey');
assert(/if \(clearBtn\) clearBtn\.click\(\);/.test(rvSrc), 'paths=all goes through the All button handler');
assert(/pathRows\[ri\]\.dataset\.pathKey === key\) \{ pathRows\[ri\]\.click\(\);/.test(rvSrc),
'observer view goes through the path row click handler');
assert(/sidebar\._applyPathView\(opts\.initialPathView\)/.test(rvSrc), 'renderer applies the initial path view');
}
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed) process.exit(1);