From 1d1d4b748117f8825e14977017d6cbb7217246a3 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 19:12:39 +0200 Subject: [PATCH 1/4] fix(packet-path-map): use Phosphor icons instead of emoji in View Path tooltips The role prefix (repeater/room/client/sensor) and the "First to hear it" tooltip used emoji, added after the #1648 M6 emoji lint gate. That made test-issue-1648-m6-final-sweep.js report 5 violations and test-issue-1648-m6-lint-self.js fail its clean-repo precondition. Swap them for Phosphor sprite icons: the same glyphs observer-detail.js uses for neighbor roles, plus ph-flag for first-to-hear. test-packet-path-map.js now asserts the sprite ids. Relates to #174 Co-Authored-By: Claude Opus 5.5 --- public/packet-path-map.js | 16 +++++++++++----- test-packet-path-map.js | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 952ed72c5..370039173 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -94,12 +94,18 @@ // markers stay circleMarker dots throughout (a role-specific shape // would clash with the color/dash coding already carrying primary, // approx, and observer meaning). + // Phosphor sprite icons, not emoji (#1648 lint gate); same glyphs as + // observer-detail.js's directNeighborRoleIcon. + function phIcon(name) { + return ' '; + } + function roleIcon(role) { switch (role) { - case 'repeater': return '📡 '; - case 'room': return '🏠 '; - case 'client': return '📱 '; - case 'sensor': return '🌡️ '; + case 'repeater': return phIcon('broadcast'); + case 'room': return phIcon('house-line'); + case 'client': return phIcon('radio'); + case 'sensor': return phIcon('thermometer'); default: return ''; } } @@ -412,7 +418,7 @@ radius: 11, color: cssVar('--status-green'), weight: 3, fillOpacity: 0, opacity: 0.9, }) .addTo(map) - .bindTooltip('🏁 First to hear it: ' + escapeHtml(firstPoint.name) + ' (' + data.first.hops + ' hop' + (data.first.hops === 1 ? '' : 's') + (firstPoint.approx ? ', approx. position' : '') + ')', { className: 'packet-path-tooltip' }); + .bindTooltip(phIcon('flag') + 'First to hear it: ' + escapeHtml(firstPoint.name) + ' (' + data.first.hops + ' hop' + (data.first.hops === 1 ? '' : 's') + (firstPoint.approx ? ', approx. position' : '') + ')', { className: 'packet-path-tooltip' }); } try { map.fitBounds(bounds, { padding: [30, 30] }); } catch (e) { /* single point */ } diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 7920e9cb6..f5643dc54 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -1042,8 +1042,8 @@ function makeSandbox(apiImpl) { }; await ctx.window.PacketPathMap.open('deadbeef'); - assert.ok(tooltips.some((t) => t.includes('📡') && t.includes('RepeaterA')), 'expected a repeater icon on RepeaterA, got: ' + JSON.stringify(tooltips)); - assert.ok(tooltips.some((t) => t.includes('🏠') && t.includes('RoomObserver')), 'expected a room icon on RoomObserver, got: ' + JSON.stringify(tooltips)); + assert.ok(tooltips.some((t) => t.includes('#ph-broadcast') && t.includes('RepeaterA')), 'expected a repeater icon on RepeaterA, got: ' + JSON.stringify(tooltips)); + assert.ok(tooltips.some((t) => t.includes('#ph-house-line') && t.includes('RoomObserver')), 'expected a room icon on RoomObserver, got: ' + JSON.stringify(tooltips)); passed++; console.log(' ✅ nodes with a known role get a role icon in their tooltip'); } catch (e) { failed++; console.log(' ❌ nodes with a known role get a role icon in their tooltip: ' + e.message); } From fa0525a42963a6cb0af3fceb493fed29bcc20218 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 19:12:39 +0200 Subject: [PATCH 2/4] test(live): update anim-layer assertions for the #1521 canvas pulses #1521 moved pulseNode()'s rings and ghost pulsing from per-node L.circleMarker + rAF loops (animatePulse/ghostPulse) into the canvas engine's activePulses/activeGhosts queues. That left animLayer with 2 .addTo() sites, and the two functions the null-guard tests looked for no longer exist. - test-issue-1485-live-anim-z.js: expect >=2 animLayer shapes, and assert the canvas pane (animationsPane) sits above markerPane (600). - test-live-anims.js: guard pulseNode() entry and renderAnimations() (animCtx + ghost-expiry animLayer) instead of the removed functions, and fail if a looked-up function is missing instead of scanning the start of the file. Relates to #174 Co-Authored-By: Claude Opus 5.5 --- test-issue-1485-live-anim-z.js | 17 ++++++++++++++--- test-live-anims.js | 22 +++++++++++++++------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/test-issue-1485-live-anim-z.js b/test-issue-1485-live-anim-z.js index 794a6c495..2d3a496ff 100644 --- a/test-issue-1485-live-anim-z.js +++ b/test-issue-1485-live-anim-z.js @@ -79,15 +79,26 @@ console.log('\n=== #1485 live anim z-order C: per-shape inheritance ==='); // At least one polyline/circleMarker addTo(animLayer) call exists — we're // trusting Leaflet pane inheritance from the LayerGroup parent. Sanity -// check that the four animLayer / pathsLayer addTo sites still exist +// check that the animLayer / pathsLayer addTo sites still exist // (regression detector if someone moves circles to the default pane). +// animLayer hosts 2 since #1521 (ghost markers + matrix char markers): +// the pulseNode() rings moved off animLayer into the canvas engine, +// whose pane is checked below. const animAddTo = (liveSrc.match(/\.addTo\(animLayer\)/g) || []).length; const pathsAddTo = (liveSrc.match(/\.addTo\(pathsLayer\)/g) || []).length; -assert(animAddTo >= 3, - 'animLayer still hosts >=3 .addTo() animation shapes (got ' + animAddTo + ')'); +assert(animAddTo >= 2, + 'animLayer still hosts >=2 .addTo() animation shapes (got ' + animAddTo + ')'); assert(pathsAddTo >= 3, 'pathsLayer still hosts >=3 .addTo() trail shapes (got ' + pathsAddTo + ')'); +// The canvas engine (in-flight packets + pulse rings since #1521) draws +// on 'animationsPane', which must also sit above markerPane (600). +const canvasZ = liveSrc.match( + /getPane\(\s*['"]animationsPane['"]\s*\)\.style\.zIndex\s*=\s*['"]?(\d+)/ +); +assert(!!canvasZ && parseInt(canvasZ[1], 10) > 600, + 'animationsPane (canvas pulses) z-index > markerPane 600 (got ' + (canvasZ ? canvasZ[1] : 'none') + ')'); + console.log('\n=== Summary ==='); console.log(' Passed: ' + passed); console.log(' Failed: ' + failed); diff --git a/test-live-anims.js b/test-live-anims.js index 31b4f4e81..671addb78 100644 --- a/test-live-anims.js +++ b/test-live-anims.js @@ -117,16 +117,24 @@ test2('drawAnimatedLine renderFades() has null guard', () => { assert.ok(fadeBody.includes('!pathsLayer'), 'renderFades() missing pathsLayer null guard'); }); -test2('pulseNode animatePulse() has null guard', () => { - const pulseStart = src2.indexOf('function animatePulse(now)'); +// #1521 replaced the per-node animatePulse()/ghostPulse() rAF loops with +// the activePulses/activeGhosts queues drained by renderAnimations(); the +// guards now live at pulseNode() entry and in renderAnimations(). +test2('pulseNode() has null guard', () => { + const pulseStart = src2.indexOf('function pulseNode('); + assert.ok(pulseStart >= 0, 'pulseNode() not found'); const pulseBody = src2.substring(pulseStart, pulseStart + 200); - assert.ok(pulseBody.includes('!animLayer'), 'animatePulse() missing animLayer null guard'); + assert.ok(pulseBody.includes('!animLayer || !nodesLayer'), 'pulseNode() missing animLayer/nodesLayer null guard'); }); -test2('ghostPulse has null guard', () => { - const ghostStart = src2.indexOf('function ghostPulse(now)'); - const ghostBody = src2.substring(ghostStart, ghostStart + 200); - assert.ok(ghostBody.includes('!animLayer'), 'ghostPulse() missing animLayer null guard'); +test2('renderAnimations() has null guards for canvas and ghost expiry', () => { + const renderStart = src2.indexOf('function renderAnimations(now)'); + assert.ok(renderStart >= 0, 'renderAnimations() not found'); + const renderEnd = src2.indexOf('// Render Pulses', renderStart); + assert.ok(renderEnd > renderStart, 'renderAnimations() pulse section not found'); + const renderBody = src2.substring(renderStart, renderEnd); + assert.ok(renderBody.includes('if (!animCtx) return;'), 'renderAnimations() missing animCtx null guard'); + assert.ok(renderBody.includes('animLayer && animLayer.hasLayer(g.marker)'), 'renderAnimations() ghost expiry missing animLayer null guard'); }); console.log(`\n${p2} passed, ${f2} failed\n`); From a0b165d9c5dca69adfe46816efe7a9e823bca84a Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 19:12:39 +0200 Subject: [PATCH 3/4] test(observer-detail): stub document.addEventListener in naive-banner sandbox observer-detail.js has bound a delegated document click listener at module load since 5063919d (SNR history modal). The vm sandbox document stub had no addEventListener, so loading the module threw before any assertion ran. Relates to #174 Co-Authored-By: Claude Opus 5.5 --- test-naive-banner-tone.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-naive-banner-tone.js b/test-naive-banner-tone.js index 6beb16b9a..e28fd2509 100644 --- a/test-naive-banner-tone.js +++ b/test-naive-banner-tone.js @@ -13,7 +13,7 @@ function test(name, fn) { // Load observer-detail.js in a VM sandbox const src = fs.readFileSync(__dirname + '/public/observer-detail.js', 'utf8'); const window = {}; -const ctx = vm.createContext({ window, document: { getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], createElement: () => ({ style: {}, classList: { add(){}, remove(){} }, appendChild(){}, setAttribute(){} }), createDocumentFragment: () => ({ appendChild(){} }) }, console, setTimeout, setInterval, clearInterval, fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }), location: { hash: '' }, history: { replaceState() {} }, Chart: function() { return { destroy(){}, update(){} }; }, registerPage() {}, L: { map(){ return { setView(){ return this; }, on(){ return this; }, remove(){} }; }, tileLayer(){ return { addTo(){} }; } } }); +const ctx = vm.createContext({ window, document: { addEventListener() {}, getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], createElement: () => ({ style: {}, classList: { add(){}, remove(){} }, appendChild(){}, setAttribute(){} }), createDocumentFragment: () => ({ appendChild(){} }) }, console, setTimeout, setInterval, clearInterval, fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }), location: { hash: '' }, history: { replaceState() {} }, Chart: function() { return { destroy(){}, update(){} }; }, registerPage() {}, L: { map(){ return { setView(){ return this; }, on(){ return this; }, remove(){} }; }, tileLayer(){ return { addTo(){} }; } } }); vm.runInContext(src, ctx); const render = ctx.window.ObserverDetailNaiveBanner.render; From c3a01e85915bf7356b63f061b8e45c249442608d Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 19:12:39 +0200 Subject: [PATCH 4/4] test(observers): expect the Neighbors column in the observers table 0a55abe5 (#1865) added a Neighbors column (th + td). The column-count check still passed, but the ordered heading list was not updated. Relates to #174 Co-Authored-By: Claude Opus 5.5 --- test-observers-headings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-observers-headings.js b/test-observers-headings.js index f980c0025..3d4bf2080 100644 --- a/test-observers-headings.js +++ b/test-observers-headings.js @@ -59,7 +59,7 @@ test('expected headings present and ordered', () => { while ((m = re.exec(thead)) !== null) labels.push(m[1].trim()); const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet', 'Packet Health', 'Total Packets', 'Packets/Hour', 'Clock Offset', 'Uptime', - 'Firmware', 'Client']; + 'Firmware', 'Client', 'Neighbors']; assert.deepStrictEqual(labels, expected, `Headings out of sync.\nGot: ${JSON.stringify(labels)}\nExpected: ${JSON.stringify(expected)}`); });