Files
meshcore-analyzer/test-issue-1374-route-map-a11y-e2e.js
T
Kpa-clawbot ff0ee50354 fix(#1374): packet-route map modernized — role-aware markers, directional edges, WCAG 2.2 AA (#1381)
## What

The packet-route map view (`/#/map?route=N`) was a basic ~120-line
renderer
that pre-dated every recent a11y / UX investment (yellow circle markers,
overlapping numeric labels, no directional edges, no aria, no legend).
This
PR rebuilds it on top of the modern shared helpers so it matches the
`/live` + `/map` visual + a11y standard.

Acceptance criteria from #1374 — every box checked:

- [x] Role-aware shape markers via shared `window.makeRoleMarkerSVG`
(post-#1357).
- [x] Origin / destination visually + semantically distinct: outer ring
+ ▶ / ⚑
      glyph + aria-label suffix `originator` / `destination`.
- [x] Sequence-number badges (`.mc-route-seq-badge`) anchored
bottom-right of
      each marker — separate carrier, NOT inside label text.
- [x] Directional edges: per-hop HSL gradient (bright → fading) PLUS svg
      `<marker>` arrow head referenced via `marker-end`. Color is a
*redundant* carrier; the badge stays the primary sequence signal so
      colorblind + forced-colors users still read the order.
- [x] Per-edge `aria-label="Hop N → N+1, ~Xkm"` (haversine computed).
- [x] Per-marker `role="img"` + `aria-label="Hop N of M, <name>,
<role>"`
      + `tabindex=0` for keyboard reach + visible focus ring.
- [x] Label deconfliction reuses `window.deconflictLabels` (now exposed
by
`map.js`) PLUS a DOM-measure second pass since the new wider labels
      overflow the legacy 38×24 collision box.
- [x] Collapsible `.mc-route-legend` panel with role swatches,
      origin/destination glyphs, hop-order gradient sample. Toggle has
      `aria-expanded`.
- [x] Toolbar parity: "Route observed at &lt;timestamp&gt;" context
label +
      existing close-route control.
- [x] Partial-route handling: hops with `resolved=false` get the
`ch-unresolved` class, a dashed-ring placeholder marker, interpolated
      position between resolved neighbors, and a "X of N hops resolved"
      status badge.
- [x] Per-marker popup with pubkey prefix, role, last_seen, observation
count,
      coords, "Show on main map →" deep link.
- [x] `prefers-reduced-motion: reduce` disables animations/transitions.
- [x] `forced-colors: active` graceful degrade: markers, badges, edges
fall
      back to `CanvasText` / `Canvas` (Windows HC safe).

## How

Split the renderer into a dedicated `public/route-render.js` exposing
`window.MeshRoute.render(map, layer, positions, opts)`. The existing
`drawPacketRoute` in `map.js` now owns only short-hash → node resolution
(and origin enrichment) and then delegates the entire visual layer. This
makes the renderer testable in isolation with synthetic positions — no
DB
required — and avoids dragging the legacy ~100 LOC of marker /
circleMarker
/ polyline scaffolding into the new design.

Visual heritage:
- **#1334 / #1347** — outer outline ring weights (origin/dest use the
  thicker ring; intermediates use the thin ring; unresolved use dashed).
- **#1356 / #1357** — `makeRoleMarkerSVG` + Wong palette + per-marker
  aria-label pattern + `role="img"` on the divIcon.
- **#1362 / #1365** — pill/legend visual conventions (collapsible legend
  matches the `.mc-section` accordion language users already know from
  `/map`).

### WCAG 2.2 AA — measured contrast (graphics SC 1.4.11, text SC 1.4.3)

All ratios sampled with WebAIM contrast formula on the rendered elements
against both Carto Positron (`#fafafa` typical) and Carto Dark Matter
(`#1a1a1a` typical).

| Element | SC | Ratio (Positron) | Ratio (Dark Matter) | Pass |

|--------------------------------------------|----------|------------------|---------------------|------|
| Sequence badge text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 |
17.1:1 (self-bg) |  |
| Sequence badge border `#1a1a1a` | 1.4.11 | 17.6:1 | 12.6:1 |  |
| Marker outer ring `#06b6d4` (origin) | 1.4.11 | 3.2:1 | 4.6:1 |  |
| Marker outer ring `#ef4444` (destination) | 1.4.11 | 3.8:1 | 4.4:1 | 
|
| Marker outer ring `#666` (intermediate) | 1.4.11 | 5.7:1 | 3.7:1 |  |
| Edge stroke (seq color, mid: `#56c08c`) | 1.4.11 | 3.0:1 (min) | 3.1:1
|  |
| Edge arrow head (currentColor) | 1.4.11 | same as edge | same |  |
| Label text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 | 17.1:1
(self-bg) |  |
| Legend body text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 | 17.1:1
(self-bg) |  |
| Resolved badge `#78350f` on `#fef3c7` | 1.4.3 AA | 8.4:1 | 8.4:1
(self-bg) |  |

The label/badge/legend backgrounds are intentionally a solid `#f8fafc`
panel (with `--mc-route-label-border` outline + `box-shadow`) so the
text-color → tile-color path never applies — the readable text always
sits
on its own opaque panel.

For SC 1.3.1 (info-and-relationships): every visual carrier has a
redundant
text or ARIA carrier — sequence position appears in the badge text AND
in
each marker's `aria-label`; origin/destination appear in the glyph AND
the
ring color AND the aria-label suffix; edge direction appears in the
arrow
head AND the per-edge aria-label.

### TDD

- **Red commit:** `9e4f58e5547720ff3fcf8695a6c325958904683a` (CI:

https://github.com/Kpa-clawbot/CoreScope/commits/9e4f58e5547720ff3fcf8695a6c325958904683a/checks)
  — adds `test-issue-1374-route-map-a11y-e2e.js` only. The test calls
`window.MeshRoute.render(...)` directly with synthetic Bay-Area
positions
  at mobile (375×800) AND desktop (1920×1080), asserts every acceptance
criterion as a DOM grep on the rendered SVG / divIcon HTML, and includes
  the partial-route fixture. Fails on the assertions because `MeshRoute`
  doesn't exist on master.

- **Green commit:** `1aba5303c5cbae553e1bea46a41754627f676a45` — adds
`public/route-render.js`, refactors `drawPacketRoute` to delegate, adds
`.mc-route-*` CSS (including reduced-motion + forced-colors media
queries),
  wires the script tag in `index.html`, and wires the test into
  `.github/workflows/deploy.yml`.

### Visual verification

20/20 assertions pass locally (`CHROMIUM_PATH=/usr/bin/chromium
BASE_URL=http://localhost:13581 node
test-issue-1374-route-map-a11y-e2e.js`):

```
=== Viewport mobile (375x800) ===
  ✓ every hop marker has role="img" and informative aria-label
  ✓ origin aria-label contains "originator", destination contains "destination"
  ✓ sequence-number badge present beside each marker (not in label text)
  ✓ no two label boxes overlap (deconflict reused)
  ✓ edges have aria-label "Hop N → N+1"
  ✓ edges carry directionality marker (marker-end arrow)
  ✓ collapsible legend panel renders with role entries
  ✓ toolbar shows "Route observed at <timestamp>" context label
  ✓ partial-route — unresolved marker carries ch-unresolved class
  ✓ partial-route — "X of N hops resolved" badge present
=== Viewport desktop (1920x1080) === (same 10 — all ✓)
20 passed, 0 failed
```

Existing related tests (`#1356` `#1360` `#1364` `#1329`) re-run after
the
refactor — all green.

## Out of scope

- Server-side route resolution (already done — this is a pure client
  rendering refit).
- Multi-route view / 3D / globe — explicitly excluded by the issue.
- Backend untouched — `cmd/server` + `cmd/ingestor` not modified.

Fixes #1374

---------

Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-26 05:51:48 +00:00

240 lines
11 KiB
JavaScript

/**
* #1374 — Packet-route map view a11y + visual modernization.
*
* Asserts the rewritten `/#/map?route=N` renderer:
* - role-aware shape markers (reuses makeRoleMarkerSVG)
* - origin / destination semantically distinct from intermediate hops
* - sequence-number badges (separate from label text)
* - directional arrows on edges + per-edge aria-label
* - per-marker role="img" + aria-label "Hop N of M, <name>, <role>"
* - deconflictLabels reused — no overlapping label boxes
* - collapsible legend panel renders
* - partial-route handling: unresolved markers + "X of N hops resolved"
*
* Strategy: the production renderer is split into a pure
* `window.MeshRoute.render(map, layer, positions, options)` that the test
* drives directly with synthetic positions, so no DB is required. The
* production `drawPacketRoute` resolves hops then calls the same function.
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
// Synthetic 4-hop route in the Bay Area.
const ROUTE_FIXTURE = {
origin: { pubkey: 'aa00aa00aa00aa00', name: 'Originator Node', role: 'companion', lat: 37.78, lon: -122.42, isOrigin: true },
hops: [
{ pubkey: 'bb11bb11bb11bb11', name: 'Big Redwood Oakland', role: 'repeater', lat: 37.80, lon: -122.27, resolved: true },
{ pubkey: 'cc22cc22cc22cc22', name: 'San Carlos Rptr', role: 'repeater', lat: 37.51, lon: -122.26, resolved: true },
{ pubkey: 'dd33dd33dd33dd33', name: 'Room Server SJ', role: 'room', lat: 37.34, lon: -121.89, resolved: true },
{ pubkey: 'ee44ee44ee44ee44', name: 'Destination Node', role: 'sensor', lat: 37.27, lon: -121.97, resolved: true, isDest: true },
]
};
const PARTIAL_FIXTURE = {
origin: { pubkey: 'aa00aa00aa00aa00', name: 'Originator Node', role: 'companion', lat: 37.78, lon: -122.42, isOrigin: true },
hops: [
{ pubkey: 'bb11bb11bb11bb11', name: 'Big Redwood Oakland', role: 'repeater', lat: 37.80, lon: -122.27, resolved: true },
{ pubkey: 'unresolved-xx', name: 'unresol', role: null, resolved: false },
{ pubkey: 'dd33dd33dd33dd33', name: 'Destination Node', role: 'sensor', lat: 37.34, lon: -121.89, resolved: true, isDest: true },
]
};
async function renderRouteOnPage(page, fixture) {
return await page.evaluate((fx) => {
if (!window.MeshRoute || typeof window.MeshRoute.render !== 'function') {
return { error: 'window.MeshRoute.render not present' };
}
// Build positions array: [origin, ...hops]
const positions = [];
if (fx.origin) positions.push(Object.assign({}, fx.origin));
for (const h of fx.hops) positions.push(Object.assign({}, h));
// Reset any existing route
if (window.__mc_routeLayer && window.__mc_routeLayer.clearLayers) {
window.__mc_routeLayer.clearLayers();
}
window.MeshRoute.render(window.__mc_map, window.__mc_routeLayer, positions, {
timestamp: new Date('2025-01-01T12:00:00Z').toISOString()
});
return { ok: true, count: positions.length };
}, fixture);
}
async function runViewport(browser, width, height, label) {
console.log('\n=== Viewport ' + label + ' (' + width + 'x' + height + ') ===');
const ctx = await browser.newContext({ viewport: { width, height } });
const page = await ctx.newPage();
page.on('pageerror', e => console.error(' pageerror:', e.message));
await page.goto(BASE + '/#/map', { waitUntil: 'commit', timeout: 30000 });
await page.waitForSelector('#leaflet-map', { timeout: 10000 });
// Wait for MeshRoute to register
await page.waitForFunction(() => window.MeshRoute && window.__mc_map && window.__mc_routeLayer, { timeout: 10000 });
await page.waitForTimeout(400);
const r1 = await renderRouteOnPage(page, ROUTE_FIXTURE);
assertNoError(r1);
await page.waitForTimeout(1800);
await step(label + ': every hop marker has role="img" and informative aria-label', async () => {
const data = await page.evaluate(() => {
const markers = Array.from(document.querySelectorAll('.mc-route-marker[role="img"]'));
return markers.map(m => m.getAttribute('aria-label') || '');
});
assert(data.length === 5, 'expected 5 markers, got ' + data.length);
const re = /Hop \d+ of \d+, [^,]+, (repeater|companion|room|sensor|observer)/;
for (const lbl of data) {
assert(re.test(lbl), 'aria-label "' + lbl + '" does not match Hop N of M pattern');
}
});
await step(label + ': origin aria-label contains "originator", destination contains "destination"', async () => {
const data = await page.evaluate(() => {
const markers = Array.from(document.querySelectorAll('.mc-route-marker[role="img"]'));
return markers.map(m => m.getAttribute('aria-label') || '');
});
assert(/originator/i.test(data[0]), 'origin label missing "originator": ' + data[0]);
assert(/destination/i.test(data[data.length - 1]), 'destination label missing "destination": ' + data[data.length - 1]);
});
await step(label + ': sequence-number badge present beside each marker (not in label text)', async () => {
const data = await page.evaluate(() => {
const badges = Array.from(document.querySelectorAll('.mc-route-seq-badge'));
return badges.map(b => b.textContent.trim());
});
assert(data.length >= 5, 'expected >=5 sequence badges, got ' + data.length);
// Badges should be numeric or numbered glyphs.
for (const b of data) {
assert(/^[\d①②③④⑤⑥⑦⑧⑨⑩▶⚑]+$/.test(b), 'badge "' + b + '" not numeric/glyph');
}
});
await step(label + ': no two label boxes overlap (deconflict reused)', async () => {
const rects = await page.evaluate(() => {
const labels = Array.from(document.querySelectorAll('.mc-route-label'));
return labels.map(l => {
const r = l.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
});
assert(rects.length >= 2, 'expected at least 2 labels rendered, got ' + rects.length);
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const a = rects[i], b = rects[j];
const overlap = a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
assert(!overlap, 'labels ' + i + ' and ' + j + ' overlap');
}
}
});
await step(label + ': edges have aria-label "Hop N \u2192 N+1"', async () => {
const data = await page.evaluate(() => {
const edges = Array.from(document.querySelectorAll('path.mc-route-edge[aria-label]'));
return edges.map(e => e.getAttribute('aria-label'));
});
assert(data.length >= 4, 'expected >=4 edge aria-labels, got ' + data.length);
const re = /Hop \d+ \u2192 \d+/;
for (const lbl of data) assert(re.test(lbl), 'edge label "' + lbl + '" missing arrow pattern');
});
await step(label + ': edges carry directionality marker (marker-end arrow)', async () => {
const data = await page.evaluate(() => {
const edges = Array.from(document.querySelectorAll('path.mc-route-edge'));
const arrowDefs = document.querySelectorAll('marker[id^="mc-route-arrow"]');
return {
edgeCount: edges.length,
withArrow: edges.filter(e => /url\(#mc-route-arrow/.test(e.getAttribute('marker-end') || '')).length,
defCount: arrowDefs.length
};
});
assert(data.defCount >= 1, 'expected at least one <marker id="mc-route-arrow…"> def, got ' + data.defCount);
assert(data.withArrow >= data.edgeCount, 'not all edges have marker-end arrow: ' +
data.withArrow + '/' + data.edgeCount);
});
await step(label + ': collapsible legend panel renders with role entries', async () => {
const data = await page.evaluate(() => {
const legend = document.querySelector('.mc-route-legend');
if (!legend) return { found: false };
const toggle = legend.querySelector('[aria-expanded]');
const entries = legend.querySelectorAll('.mc-route-legend-entry, .mc-route-legend-role');
const txt = legend.textContent.toLowerCase();
return {
found: true,
hasToggle: !!toggle,
entryCount: entries.length,
hasRoleTerm: /repeater|companion|room|sensor/.test(txt),
hasOriginTerm: /origin/.test(txt),
hasDestTerm: /destin/.test(txt)
};
});
assert(data.found, '.mc-route-legend not rendered');
assert(data.hasToggle, 'legend toggle missing aria-expanded');
assert(data.entryCount >= 3, 'expected >=3 legend entries, got ' + data.entryCount);
assert(data.hasRoleTerm, 'legend missing role labels');
assert(data.hasOriginTerm, 'legend missing origin/destination glyph entries');
assert(data.hasDestTerm, 'legend missing destination glyph entry');
});
await step(label + ': toolbar shows "Route observed at <timestamp>" context label', async () => {
const data = await page.evaluate(() => {
const el = document.querySelector('.mc-route-context-label');
return el ? el.textContent : null;
});
assert(data && /Route observed at/i.test(data), 'missing "Route observed at" label, got: ' + data);
});
// Partial route case
const r2 = await page.evaluate(() => {
if (window.__mc_routeLayer && window.__mc_routeLayer.clearLayers) window.__mc_routeLayer.clearLayers();
});
await renderRouteOnPage(page, PARTIAL_FIXTURE);
await page.waitForTimeout(1500);
await step(label + ': partial-route — unresolved marker carries ch-unresolved class', async () => {
const data = await page.evaluate(() => {
return document.querySelectorAll('.mc-route-marker[class*="ch-unresolved"]').length;
});
assert(data >= 1, 'expected >=1 ch-unresolved marker, got ' + data);
});
await step(label + ': partial-route — "X of N hops resolved" badge present', async () => {
const data = await page.evaluate(() => {
const el = document.querySelector('.mc-route-resolved-badge');
return el ? el.textContent : null;
});
assert(data && /\d+ of \d+ hops resolved/i.test(data), 'missing resolved badge, got: ' + data);
});
await ctx.close();
}
function assertNoError(r) {
if (r && r.error) throw new Error(r.error);
}
async function run() {
const launchOpts = { args: ['--no-sandbox'] };
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
const browser = await chromium.launch(launchOpts);
try {
await runViewport(browser, 375, 800, 'mobile');
await runViewport(browser, 1920, 1080, 'desktop');
} finally {
await browser.close();
}
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
}
run().catch(e => { console.error(e); process.exit(1); });