mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-11 19:11:38 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2b5913add | |||
| e610434434 | |||
| 122307fa15 | |||
| 98dcf4c1e3 | |||
| 534227ab89 | |||
| adcca3a8fc | |||
| 67ea45aa31 | |||
| 8e86ba57ed | |||
| c266921805 | |||
| eeddf46bc9 | |||
| 0f7c03ccaf | |||
| adcf29dd6b | |||
| 92df28a569 |
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"659 passed","color":"brightgreen"}
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"664 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"38.88%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"37.72%","color":"red"}
|
||||
|
||||
@@ -105,6 +105,7 @@ jobs:
|
||||
node test-channel-fluid-layout.js
|
||||
node test-issue-1279-p2-code-filter.js
|
||||
node test-area-filter.js
|
||||
node test-issue-1293-marker-shapes.js
|
||||
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
@@ -283,6 +284,7 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
@@ -16,6 +16,20 @@ import (
|
||||
// pulse here is sufficient to keep the snapshot fresh.
|
||||
const NeighborEdgesBuilderInterval = 60 * time.Second
|
||||
|
||||
// neighborBuilderMaxBatch caps how many observation rows a single
|
||||
// delta tick may process (#1339). With max_open_conns=1, an unbounded
|
||||
// scan on a multi-million-row table holds the SQLite write lock for
|
||||
// minutes and starves MQTT ingest. The cap keeps each tick bounded;
|
||||
// if a backlog accumulates, successive ticks drain it 50k rows at a
|
||||
// time without ever blocking ingest for long.
|
||||
const neighborBuilderMaxBatch = 50000
|
||||
|
||||
// neighborBuilderSlowTickThreshold is the per-tick wallclock budget
|
||||
// for the builder. Exceeding it is logged loudly so operators can
|
||||
// catch a regression of #1339 quickly. The full instrumentation
|
||||
// framework is tracked in #1340.
|
||||
const neighborBuilderSlowTickThreshold = 5 * time.Second
|
||||
|
||||
// payloadADVERT mirrors the constant in cmd/server/decoder.go.
|
||||
// Duplicated rather than imported so the ingestor binary stays
|
||||
// independent of the server package.
|
||||
@@ -42,13 +56,25 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
|
||||
// Synchronous warm-up: a single pass so the first server load
|
||||
// after process start sees a populated table.
|
||||
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
|
||||
log.Printf("[neighbor-build] initial build error: %v", err)
|
||||
} else {
|
||||
log.Printf("[neighbor-build] initial build: %d edges upserted", n)
|
||||
// Synchronous warm-up: on a fresh DB this is a full scan; on a DB
|
||||
// with persisted neighbor_edges (most restarts), the watermark
|
||||
// short-circuits it into a delta scan. Loop until the per-tick
|
||||
// batch cap stops triggering so we drain any backlog before
|
||||
// returning — first server load needs a fully-populated table.
|
||||
wuStart := time.Now()
|
||||
var wuTotal int
|
||||
for {
|
||||
n, err := s.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
log.Printf("[neighbor-build] initial build error: %v", err)
|
||||
break
|
||||
}
|
||||
wuTotal += n
|
||||
if n < neighborBuilderMaxBatch {
|
||||
break
|
||||
}
|
||||
}
|
||||
log.Printf("[neighbor-build] initial build: %d edges upserted in %s", wuTotal, time.Since(wuStart))
|
||||
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
@@ -58,10 +84,16 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
|
||||
log.Printf("[neighbor-build] tick error: %v", err)
|
||||
start := time.Now()
|
||||
n, err := s.buildAndPersistNeighborEdges()
|
||||
dur := time.Since(start)
|
||||
if err != nil {
|
||||
log.Printf("[neighbor-build] tick error after %s: %v", dur, err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[neighbor-build] %d edges upserted", n)
|
||||
log.Printf("[neighbor-build] tick: %d edges in %s (delta from watermark)", n, dur)
|
||||
}
|
||||
if dur > neighborBuilderSlowTickThreshold {
|
||||
log.Printf("[neighbor-build] SLOW tick: %s — possible regression of #1339", dur)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
@@ -83,6 +115,21 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
|
||||
// observer↔last-hop on all packet types) and upserts them into
|
||||
// neighbor_edges. Returns count of attempted upserts.
|
||||
//
|
||||
// Watermark / delta semantics (#1339): the builder derives a watermark
|
||||
// from MAX(neighbor_edges.last_seen). On an empty edges table (fresh
|
||||
// DB), watermark is 0 and the builder does a full warm-up scan. On
|
||||
// every subsequent call, the SELECT is restricted to observations
|
||||
// whose timestamp is strictly greater than the watermark, bounded by
|
||||
// neighborBuilderMaxBatch. neighbor_edges itself is the persistence —
|
||||
// no metadata table or in-memory state is required, and restarts
|
||||
// resume cleanly from whatever the table reflects.
|
||||
//
|
||||
// Trade-off (documented for #1340 follow-up): an anomalously-old
|
||||
// observation that arrives AFTER its timestamp has already been
|
||||
// crossed by the watermark will be skipped. Acceptable for an
|
||||
// approximate neighbor graph; a periodic full-rebuild can be added
|
||||
// later if needed.
|
||||
//
|
||||
// Resolution of hop-prefix → full pubkey is done via a one-shot
|
||||
// SELECT of (lowered) pubkey prefixes from nodes. Prefixes with
|
||||
// multiple candidates are skipped (matches the conservative
|
||||
@@ -93,6 +140,21 @@ func (s *Store) buildAndPersistNeighborEdges() (int, error) {
|
||||
return 0, fmt.Errorf("build prefix index: %w", err)
|
||||
}
|
||||
|
||||
// Derive the watermark from the existing edges table. RFC3339
|
||||
// → epoch seconds so it can be compared against observations.timestamp
|
||||
// (stored as INTEGER unix epoch). On an empty edges table both the
|
||||
// query and the parse return zero → full warm-up scan.
|
||||
var watermarkRFC sql.NullString
|
||||
if err := s.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&watermarkRFC); err != nil {
|
||||
return 0, fmt.Errorf("read watermark: %w", err)
|
||||
}
|
||||
var watermarkEpoch int64
|
||||
if watermarkRFC.Valid && watermarkRFC.String != "" {
|
||||
if t, parseErr := time.Parse(time.RFC3339, watermarkRFC.String); parseErr == nil {
|
||||
watermarkEpoch = t.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`SELECT
|
||||
t.payload_type,
|
||||
t.decoded_json,
|
||||
@@ -102,7 +164,10 @@ func (s *Store) buildAndPersistNeighborEdges() (int, error) {
|
||||
o.timestamp
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx`)
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
WHERE o.timestamp > ?
|
||||
ORDER BY o.timestamp
|
||||
LIMIT ?`, watermarkEpoch, neighborBuilderMaxBatch)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scan observations: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestNeighborEdgesBuilderDeltaScan enforces issue #1339:
|
||||
// after the initial (warm-up) full build, subsequent ticks of
|
||||
// buildAndPersistNeighborEdges MUST scan only observations newer
|
||||
// than the most recent edge already persisted. The watermark is
|
||||
// derived from MAX(neighbor_edges.last_seen) — neighbor_edges itself
|
||||
// is the persistence, no separate metadata table.
|
||||
//
|
||||
// RED expectations:
|
||||
// 1. After warm-up that produces edges, a second build with NO new
|
||||
// observations is a fast no-op (<1s) and writes nothing.
|
||||
// 2. After inserting K observations with timestamps strictly newer
|
||||
// than the prior MAX(last_seen), the next build upserts exactly
|
||||
// K edges in <1s.
|
||||
// 3. Initial build (empty neighbor_edges) still does a full scan
|
||||
// (warm-up preserved).
|
||||
func TestNeighborEdgesBuilderDeltaScan(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("synthetic 100k-row benchmark; skipped in -short")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "delta.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO nodes (public_key, name) VALUES (?, ?), (?, ?)`,
|
||||
"aaaaaaaaaa", "from-node",
|
||||
"bbbbbbbbbb", "first-hop",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO observers (id, name) VALUES (?, ?)`,
|
||||
"obs-1", "observer-1",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obsRowid int64
|
||||
if err := store.db.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, "obs-1").Scan(&obsRowid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Baseline timestamps: a contiguous block ending at baselineMaxTs.
|
||||
const baseline = 100_000
|
||||
const baselineStartTs int64 = 1735689600 // 2025-01-01 UTC
|
||||
baselineMaxTs := baselineStartTs + int64(baseline) - 1
|
||||
|
||||
tx, err := store.db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txStmt, err := tx.Prepare(`INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
|
||||
VALUES ('', ?, ?, 0, ?, 0, '{}', 'aaaaaaaaaa')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
obsStmt, err := tx.Prepare(`INSERT INTO observations
|
||||
(transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, '["bb"]', ?)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < baseline; i++ {
|
||||
res, err := txStmt.Exec(fmt.Sprintf("h%d", i), baselineStartTs+int64(i), payloadADVERT)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
if _, err := obsStmt.Exec(txID, obsRowid, baselineStartTs+int64(i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Initial warm-up: drain to completion (StartNeighborEdgesBuilder
|
||||
// does the same — call directly so the test doesn't depend on the
|
||||
// goroutine harness). Full scan allowed because neighbor_edges
|
||||
// starts empty.
|
||||
for {
|
||||
n, err := store.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
t.Fatalf("warm-up build: %v", err)
|
||||
}
|
||||
if n == 0 || n < 50000 {
|
||||
break
|
||||
}
|
||||
}
|
||||
var edgesAfterWarmup int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM neighbor_edges`).Scan(&edgesAfterWarmup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if edgesAfterWarmup == 0 {
|
||||
t.Fatal("warm-up produced 0 edges; can't establish a watermark")
|
||||
}
|
||||
// Sanity: MAX(last_seen) should reflect the baseline tail timestamp.
|
||||
var maxLastSeen string
|
||||
if err := store.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&maxLastSeen); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantMax := time.Unix(baselineMaxTs, 0).UTC().Format(time.RFC3339)
|
||||
if maxLastSeen != wantMax {
|
||||
t.Fatalf("MAX(last_seen) after warm-up: want %s, got %s", wantMax, maxLastSeen)
|
||||
}
|
||||
|
||||
// Tick #2: NO new observations. Expect no-op + fast.
|
||||
noopStart := time.Now()
|
||||
n2, err := store.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
t.Fatalf("noop build: %v", err)
|
||||
}
|
||||
noopDur := time.Since(noopStart)
|
||||
if n2 != 0 {
|
||||
t.Fatalf("expected 0 edges on empty-delta tick; got %d (#1339)", n2)
|
||||
}
|
||||
if noopDur > time.Second {
|
||||
t.Fatalf("empty-delta build took %v; expected <1s — builder is "+
|
||||
"still doing a full table scan. (#1339)", noopDur)
|
||||
}
|
||||
|
||||
// Tick #3: insert K observations with timestamps strictly newer
|
||||
// than baselineMaxTs.
|
||||
const delta = 100
|
||||
deltaStartTs := baselineMaxTs + 1
|
||||
tx2, err := store.db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txStmt2, err := tx2.Prepare(`INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
|
||||
VALUES ('', ?, ?, 0, ?, 0, '{}', 'aaaaaaaaaa')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
obsStmt2, err := tx2.Prepare(`INSERT INTO observations
|
||||
(transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, '["bb"]', ?)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < delta; i++ {
|
||||
res, err := txStmt2.Exec(fmt.Sprintf("d%d", i), deltaStartTs+int64(i), payloadADVERT)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
if _, err := obsStmt2.Exec(txID, obsRowid, deltaStartTs+int64(i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx2.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deltaStart := time.Now()
|
||||
n3, err := store.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
t.Fatalf("delta build: %v", err)
|
||||
}
|
||||
deltaDur := time.Since(deltaStart)
|
||||
// Each ADVERT observation with a non-empty path produces 2 edge
|
||||
// candidates (from↔hop[0] and observer↔hop[-1]). The watermark
|
||||
// must clamp the scan to the delta rows ONLY — anything more
|
||||
// proves the WHERE clause was bypassed.
|
||||
if n3 != delta*2 {
|
||||
t.Fatalf("expected %d edges upserted (delta only, 2 per advert obs); got %d. "+
|
||||
"Builder must only scan observations with timestamp > MAX(neighbor_edges.last_seen). (#1339)",
|
||||
delta*2, n3)
|
||||
}
|
||||
if deltaDur > 500*time.Millisecond {
|
||||
t.Fatalf("delta build of %d rows took %v; expected <500ms. (#1339)", delta, deltaDur)
|
||||
}
|
||||
|
||||
// Sanity: MAX(last_seen) advanced.
|
||||
var maxLastSeen2 string
|
||||
if err := store.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&maxLastSeen2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if maxLastSeen2 <= maxLastSeen {
|
||||
t.Fatalf("MAX(last_seen) did not advance: was %s, now %s", maxLastSeen, maxLastSeen2)
|
||||
}
|
||||
}
|
||||
@@ -351,6 +351,18 @@
|
||||
box-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
|
||||
/* #1293 — SVG shape-aware legend swatch (replaces the flat colour dot).
|
||||
* Inline-block wrapper keeps SVG aligned with adjacent text labels. */
|
||||
.live-shape-swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
line-height: 0;
|
||||
}
|
||||
.live-shape-swatch svg { display: block; }
|
||||
|
||||
/* #1274: marker-style swatches — mirror the live map circleMarker ring
|
||||
* convention (bright white ring = repeater, faded ring = other roles).
|
||||
* Background uses --role-repeater / --text-muted via CSS variables so
|
||||
|
||||
+118
-39
@@ -727,7 +727,10 @@
|
||||
if (pauseBtn) { pauseBtn.textContent = '⏸'; pauseBtn.setAttribute('aria-label', 'Pause'); }
|
||||
if (missedEl) missedEl.classList.add('hidden');
|
||||
}
|
||||
if (speedBtn) { speedBtn.textContent = speedLabel(VCR.speed); speedBtn.setAttribute('aria-label', 'Speed ' + speedLabel(VCR.speed)); }
|
||||
if (speedBtn) {
|
||||
speedBtn.textContent = speedLabel(VCR.speed);
|
||||
speedBtn.setAttribute('aria-label', 'Speed ' + speedLabel(VCR.speed));
|
||||
}
|
||||
updateVCRLcd();
|
||||
}
|
||||
|
||||
@@ -1712,7 +1715,13 @@
|
||||
if (roleLegendList) {
|
||||
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="live-dot" style="background:${ROLE_COLORS[role] || '#6b7280'}" aria-hidden="true"></span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
|
||||
// #1293 — SVG swatch shows SHAPE + colour so colourblind ops can
|
||||
// distinguish roles without relying on hue alone (WCAG 1.4.1).
|
||||
const color = ROLE_COLORS[role] || '#6b7280';
|
||||
const swatch = window.makeRoleMarkerSVG
|
||||
? window.makeRoleMarkerSVG(role, color, 14)
|
||||
: `<span class="live-dot" style="background:${color}" aria-hidden="true"></span>`;
|
||||
li.innerHTML = `<span class="live-shape-swatch" aria-hidden="true">${swatch}</span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
|
||||
roleLegendList.appendChild(li);
|
||||
}
|
||||
}
|
||||
@@ -2358,49 +2367,115 @@
|
||||
const isRepeater = n.role === 'repeater';
|
||||
const zoom = map ? map.getZoom() : 11;
|
||||
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
|
||||
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
|
||||
// Shape-aware sizing: keep prior visual weight (~6/4 base) but
|
||||
// route through divIcon so colourblind ops get distinct silhouettes
|
||||
// (#1293). Size is the SVG box; circleMarker radius ~= size/3.
|
||||
const sizePx = Math.max(10, Math.round((isRepeater ? 18 : 14) * zoomScale));
|
||||
|
||||
const glow = L.circleMarker([n.lat, n.lon], {
|
||||
radius: size + 4, fillColor: color, fillOpacity: 0.12, stroke: false, interactive: false
|
||||
}).addTo(nodesLayer);
|
||||
const svgHtml = (window.makeRoleMarkerSVG
|
||||
? window.makeRoleMarkerSVG(n.role, color, sizePx)
|
||||
: '<svg width="' + sizePx + '" height="' + sizePx + '" viewBox="0 0 ' + sizePx + ' ' + sizePx +
|
||||
'"><circle cx="' + (sizePx/2) + '" cy="' + (sizePx/2) + '" r="' + (sizePx/2 - 2) +
|
||||
'" fill="' + color + '" stroke="#fff" stroke-width="2"/></svg>');
|
||||
|
||||
const marker = L.circleMarker([n.lat, n.lon], {
|
||||
radius: size, fillColor: color, fillOpacity: 0.85,
|
||||
color: '#fff', weight: isRepeater ? 1.5 : 0.5, opacity: isRepeater ? 0.6 : 0.3
|
||||
const icon = L.divIcon({
|
||||
html: svgHtml,
|
||||
className: 'live-node-marker live-node-' + (n.role || 'unknown'),
|
||||
iconSize: [sizePx, sizePx],
|
||||
iconAnchor: [sizePx / 2, sizePx / 2],
|
||||
popupAnchor: [0, -sizePx / 2]
|
||||
});
|
||||
const marker = L.marker([n.lat, n.lon], { icon: icon, interactive: true }).addTo(nodesLayer);
|
||||
|
||||
// Highlight ring (#1293): a separate stroke-only circleMarker layered
|
||||
// BENEATH the shape. Hidden by default; pulseNodeMarker grows/fades
|
||||
// its radius + opacity — never fills, so same-hue concentric stacking
|
||||
// (issue's "blue-on-blue") is impossible.
|
||||
const ringPos = [n.lat, n.lon];
|
||||
const ring = L.circleMarker(ringPos, {
|
||||
radius: sizePx / 2 + 4,
|
||||
fillOpacity: 0,
|
||||
fill: false,
|
||||
color: color,
|
||||
weight: 0,
|
||||
opacity: 0,
|
||||
interactive: false
|
||||
}).addTo(nodesLayer);
|
||||
|
||||
marker.bindTooltip(n.name || n.public_key.slice(0, 8), {
|
||||
permanent: false, direction: 'top', offset: [0, -10], className: 'live-tooltip'
|
||||
permanent: false, direction: 'top', offset: [0, -sizePx / 2], className: 'live-tooltip'
|
||||
});
|
||||
|
||||
marker.on('click', () => showNodeDetail(n.public_key));
|
||||
|
||||
marker._glowMarker = glow;
|
||||
marker._highlightRing = ring;
|
||||
marker._baseColor = color;
|
||||
marker._baseSize = size;
|
||||
marker._baseSize = sizePx;
|
||||
marker._role = n.role || 'unknown';
|
||||
nodeMarkers[n.public_key] = marker;
|
||||
|
||||
// Apply matrix tint if active
|
||||
// Apply matrix tint if active — re-render the SVG with matrix colour
|
||||
if (matrixMode) {
|
||||
marker._matrixPrevColor = color;
|
||||
marker._baseColor = '#008a22';
|
||||
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
|
||||
glow.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
|
||||
const mxHtml = window.makeRoleMarkerSVG
|
||||
? window.makeRoleMarkerSVG(marker._role, '#008a22', sizePx)
|
||||
: svgHtml;
|
||||
const el = marker.getElement();
|
||||
if (el) el.innerHTML = mxHtml;
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
// #1293 — divIcon helpers. The live-map node marker is now an
|
||||
// L.marker (divIcon SVG), not an L.circleMarker, so setStyle /
|
||||
// setRadius are no-ops. These helpers update the DOM element
|
||||
// directly so existing call-sites (rescale, stale-dim, matrix mode,
|
||||
// highlight pulse) keep working without same-colour fill stacking.
|
||||
function _liveMarkerEl(marker) {
|
||||
if (!marker || typeof marker.getElement !== 'function') return null;
|
||||
return marker.getElement();
|
||||
}
|
||||
function _liveSetMarkerOpacity(marker, opacity) {
|
||||
var el = _liveMarkerEl(marker);
|
||||
if (el) el.style.opacity = String(opacity);
|
||||
}
|
||||
function _liveSetMarkerSize(marker, sizePx) {
|
||||
var el = _liveMarkerEl(marker);
|
||||
if (!el) return;
|
||||
var svg = el.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.setAttribute('width', sizePx);
|
||||
svg.setAttribute('height', sizePx);
|
||||
}
|
||||
marker._baseSize = sizePx;
|
||||
if (marker._highlightRing && typeof marker._highlightRing.setRadius === 'function') {
|
||||
marker._highlightRing.setRadius(sizePx / 2 + 4);
|
||||
}
|
||||
}
|
||||
function _liveSetMarkerColor(marker, color) {
|
||||
var el = _liveMarkerEl(marker);
|
||||
if (!el) return;
|
||||
if (window.makeRoleMarkerSVG) {
|
||||
el.innerHTML = window.makeRoleMarkerSVG(marker._role || 'unknown', color, marker._baseSize || 14);
|
||||
} else {
|
||||
// Fallback: tweak fill on first shape
|
||||
var shape = el.querySelector('svg > *');
|
||||
if (shape) shape.setAttribute('fill', color);
|
||||
}
|
||||
}
|
||||
window._liveSetMarkerSize = _liveSetMarkerSize;
|
||||
window._liveSetMarkerColor = _liveSetMarkerColor;
|
||||
|
||||
function rescaleMarkers() {
|
||||
const zoom = map.getZoom();
|
||||
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
|
||||
for (const [key, marker] of Object.entries(nodeMarkers)) {
|
||||
const n = nodeData[key];
|
||||
const isRepeater = n && n.role === 'repeater';
|
||||
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
|
||||
marker.setRadius(size);
|
||||
marker._baseSize = size;
|
||||
if (marker._glowMarker) marker._glowMarker.setRadius(size + 4);
|
||||
const sizePx = Math.max(10, Math.round((isRepeater ? 18 : 14) * zoomScale));
|
||||
_liveSetMarkerSize(marker, sizePx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2422,15 +2497,14 @@
|
||||
// API-loaded nodes: dim instead of removing (consistent with static map)
|
||||
if (marker && !marker._staleDimmed) {
|
||||
marker._staleDimmed = true;
|
||||
marker.setStyle({ fillOpacity: 0.25, opacity: 0.15 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.04 });
|
||||
_liveSetMarkerOpacity(marker, 0.35);
|
||||
}
|
||||
} else {
|
||||
// WS-only nodes: remove to prevent unbounded memory growth
|
||||
if (marker) {
|
||||
if (nodesLayer) {
|
||||
try { nodesLayer.removeLayer(marker); } catch (e) {}
|
||||
if (marker._glowMarker) try { nodesLayer.removeLayer(marker._glowMarker); } catch (e) {}
|
||||
if (marker._highlightRing) try { nodesLayer.removeLayer(marker._highlightRing); } catch (e) {}
|
||||
}
|
||||
}
|
||||
delete nodeMarkers[key];
|
||||
@@ -2441,9 +2515,7 @@
|
||||
} else if (marker && marker._staleDimmed) {
|
||||
// Node became active again — restore full opacity
|
||||
marker._staleDimmed = false;
|
||||
var isRepeater = n.role === 'repeater';
|
||||
marker.setStyle({ fillOpacity: 0.85, opacity: isRepeater ? 0.6 : 0.3 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.12 });
|
||||
_liveSetMarkerOpacity(marker, 1);
|
||||
}
|
||||
}
|
||||
if (pruned) {
|
||||
@@ -2948,17 +3020,26 @@
|
||||
requestAnimationFrame(animatePulse);
|
||||
|
||||
const baseColor = marker._baseColor || '#6b7280';
|
||||
const baseSize = marker._baseSize || 6;
|
||||
marker.setStyle({ fillColor: '#fff', fillOpacity: 1, radius: baseSize + 2, color: color, weight: 2 });
|
||||
const baseSize = marker._baseSize || 14;
|
||||
|
||||
if (marker._glowMarker) {
|
||||
marker._glowMarker.setStyle({ fillColor: color, fillOpacity: 0.2, radius: baseSize + 6 });
|
||||
setTimeout(() => marker._glowMarker.setStyle({ fillColor: baseColor, fillOpacity: 0.08, radius: baseSize + 3 }), 500);
|
||||
// #1293 — highlight via OUTLINE ring (no same-colour concentric
|
||||
// fill). Use the marker's pre-allocated _highlightRing; grow + fade
|
||||
// it. Marker shape/colour is left untouched so colourblind silhouette
|
||||
// stays distinguishable during the pulse.
|
||||
const ringHl = marker._highlightRing;
|
||||
if (ringHl && typeof ringHl.setStyle === 'function') {
|
||||
try {
|
||||
ringHl.setStyle({ color: color, weight: 3, opacity: 0.95, fillOpacity: 0, fill: false });
|
||||
ringHl.setRadius(baseSize / 2 + 4);
|
||||
setTimeout(() => {
|
||||
try { ringHl.setStyle({ opacity: 0.4, weight: 2 }); ringHl.setRadius(baseSize / 2 + 8); } catch (e) {}
|
||||
}, 200);
|
||||
setTimeout(() => {
|
||||
try { ringHl.setStyle({ opacity: 0, weight: 0 }); } catch (e) {}
|
||||
}, 700);
|
||||
} catch (e) { /* circleMarker absent — ignore */ }
|
||||
}
|
||||
|
||||
setTimeout(() => marker.setStyle({ fillColor: color, fillOpacity: 0.95, radius: baseSize + 1, weight: 1.5 }), 150);
|
||||
setTimeout(() => marker.setStyle({ fillColor: baseColor, fillOpacity: 0.85, radius: baseSize, color: '#fff', weight: marker._baseSize > 6 ? 1.5 : 0.5 }), 700);
|
||||
|
||||
nodeActivity[key] = (nodeActivity[key] || 0) + 1;
|
||||
}
|
||||
|
||||
@@ -3112,8 +3193,7 @@
|
||||
for (const [key, marker] of Object.entries(nodeMarkers)) {
|
||||
marker._matrixPrevColor = marker._baseColor;
|
||||
marker._baseColor = '#008a22';
|
||||
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
|
||||
_liveSetMarkerColor(marker, '#008a22');
|
||||
}
|
||||
} else {
|
||||
container.classList.remove('matrix-theme');
|
||||
@@ -3134,8 +3214,7 @@
|
||||
for (const [key, marker] of Object.entries(nodeMarkers)) {
|
||||
if (marker._matrixPrevColor) {
|
||||
marker._baseColor = marker._matrixPrevColor;
|
||||
marker.setStyle({ fillColor: marker._matrixPrevColor, color: '#fff', fillOpacity: 0.85, opacity: 1 });
|
||||
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: marker._matrixPrevColor });
|
||||
_liveSetMarkerColor(marker, marker._matrixPrevColor);
|
||||
delete marker._matrixPrevColor;
|
||||
}
|
||||
}
|
||||
@@ -3155,7 +3234,7 @@
|
||||
|
||||
const matrixGreen = '#00ff41';
|
||||
const TRAIL_LEN = Math.min(6, bytes.length);
|
||||
const DURATION_MS = 1100 / VCR.speed;
|
||||
const DURATION_MS = 1100 / VCR.speed; // #922: animation honors VCR.speed
|
||||
const CHAR_INTERVAL = 0.06; // spawn a char every 6% of progress
|
||||
const charMarkers = [];
|
||||
let nextCharAt = CHAR_INTERVAL;
|
||||
@@ -3287,7 +3366,7 @@
|
||||
return;
|
||||
}
|
||||
const elapsed = now - lastStep;
|
||||
const stepMs = 33 / VCR.speed;
|
||||
const stepMs = 33 / VCR.speed; // #922: animation honors VCR.speed
|
||||
if (elapsed >= stepMs) {
|
||||
const ticks = Math.min(Math.floor(elapsed / stepMs), 4);
|
||||
lastStep = now;
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
case 'triangle':
|
||||
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
|
||||
break;
|
||||
case 'hexagon': {
|
||||
// #1293 — pointy-top hexagon for room servers
|
||||
const hr = c - 1.5;
|
||||
let hpts = '';
|
||||
for (let hi = 0; hi < 6; hi++) {
|
||||
const ha = (hi * 60 - 90) * Math.PI / 180;
|
||||
hpts += (c + hr * Math.cos(ha)).toFixed(2) + ',' +
|
||||
(c + hr * Math.sin(ha)).toFixed(2) + ' ';
|
||||
}
|
||||
path = `<polygon points="${hpts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
|
||||
break;
|
||||
}
|
||||
case 'star': {
|
||||
// 5-pointed star
|
||||
const cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
|
||||
@@ -262,6 +274,48 @@
|
||||
toggleBtn.setAttribute('aria-expanded', String(!controlsCollapsed));
|
||||
});
|
||||
|
||||
// #1329: Map controls accordion. Make each section's legend a button-
|
||||
// style toggle with aria-expanded. On mobile (≤640px) only one section
|
||||
// is open at a time so the panel never needs internal scrolling. On
|
||||
// desktop the .mc-collapsed class has no visual effect (CSS only hides
|
||||
// section bodies inside the mobile media query) so all controls stay
|
||||
// visible — but single-open behaviour is still tracked for state
|
||||
// consistency. See test-issue-1329-map-controls-accordion-e2e.js.
|
||||
(function initMapControlsAccordion() {
|
||||
const isMobile = window.innerWidth <= 640;
|
||||
const sections = Array.from(controlsPanel.querySelectorAll('fieldset.mc-section'));
|
||||
sections.forEach((fs, idx) => {
|
||||
const legend = fs.querySelector('legend.mc-label');
|
||||
if (!legend) return;
|
||||
// Initial state: on mobile only the first section is open; on
|
||||
// desktop all sections are open.
|
||||
const open = !isMobile || idx === 0;
|
||||
legend.setAttribute('role', 'button');
|
||||
legend.setAttribute('tabindex', '0');
|
||||
legend.setAttribute('aria-expanded', String(open));
|
||||
fs.classList.toggle('mc-collapsed', !open);
|
||||
const setOpen = (target, openNow) => {
|
||||
target.setAttribute('aria-expanded', String(openNow));
|
||||
const parent = target.closest('fieldset.mc-section');
|
||||
if (parent) parent.classList.toggle('mc-collapsed', !openNow);
|
||||
};
|
||||
const onActivate = (e) => {
|
||||
e.preventDefault();
|
||||
const currentlyOpen = legend.getAttribute('aria-expanded') === 'true';
|
||||
// Single-open: close every other section first.
|
||||
sections.forEach(other => {
|
||||
const otherLegend = other.querySelector('legend.mc-label');
|
||||
if (otherLegend && otherLegend !== legend) setOpen(otherLegend, false);
|
||||
});
|
||||
setOpen(legend, !currentlyOpen);
|
||||
};
|
||||
legend.addEventListener('click', onActivate);
|
||||
legend.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onActivate(e);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Bind controls
|
||||
var clustersEl = document.getElementById('mcClusters');
|
||||
if (clustersEl) {
|
||||
|
||||
+86
-7
@@ -55,16 +55,95 @@
|
||||
sensor: 'Sensors', observer: 'Observers'
|
||||
};
|
||||
|
||||
window.ROLE_STYLE = {
|
||||
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 },
|
||||
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 },
|
||||
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 },
|
||||
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
|
||||
observer: { color: '#8b5cf6', shape: 'star', radius: 11, weight: 2 }
|
||||
// #1293 — Marker shape per role (WCAG 1.4.1 — shape, not only colour).
|
||||
// Single source of truth; ROLE_STYLE.shape is derived from this map.
|
||||
window.ROLE_SHAPES = {
|
||||
repeater: 'circle',
|
||||
companion: 'square',
|
||||
room: 'hexagon',
|
||||
sensor: 'triangle',
|
||||
observer: 'diamond'
|
||||
};
|
||||
|
||||
window.ROLE_STYLE = {
|
||||
repeater: { color: '#dc2626', shape: 'circle', radius: 8, weight: 2 },
|
||||
companion: { color: '#2563eb', shape: 'square', radius: 8, weight: 2 },
|
||||
room: { color: '#16a34a', shape: 'hexagon', radius: 9, weight: 2 },
|
||||
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
|
||||
observer: { color: '#8b5cf6', shape: 'diamond', radius: 9, weight: 2 }
|
||||
};
|
||||
|
||||
// Glyphs mirror the ROLE_SHAPES (used in tooltips, legends, lists).
|
||||
window.ROLE_EMOJI = {
|
||||
repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★'
|
||||
repeater: '●', companion: '■', room: '⬢', sensor: '▲', observer: '◆'
|
||||
};
|
||||
|
||||
/**
|
||||
* #1293 — Shared SVG marker generator. Returns a self-contained
|
||||
* <svg>...</svg> string for the given role/colour/size, with white
|
||||
* stroke for contrast (works on both dark + light tiles). Used by:
|
||||
* - public/live.js → addNodeMarker (L.divIcon)
|
||||
* - public/live.js → role legend swatches
|
||||
* - public/map.js → makeMarkerIcon (legacy switch retained for
|
||||
* per-role overrides + observer star overlay)
|
||||
*
|
||||
* Reads ROLE_SHAPES for the role's geometry; falls back to circle.
|
||||
* Caller controls colour to allow theming overrides (matrix mode,
|
||||
* stale dim, etc.) without rebuilding the marker.
|
||||
*/
|
||||
window.makeRoleMarkerSVG = function (role, color, size) {
|
||||
var shape = (window.ROLE_SHAPES && window.ROLE_SHAPES[role]) || 'circle';
|
||||
size = size || 16;
|
||||
var c = size / 2;
|
||||
var fill = color || (window.ROLE_COLORS && window.ROLE_COLORS[role]) || '#6b7280';
|
||||
var path;
|
||||
switch (shape) {
|
||||
case 'square':
|
||||
path = '<rect x="3" y="3" width="' + (size - 6) + '" height="' + (size - 6) +
|
||||
'" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
|
||||
break;
|
||||
case 'triangle':
|
||||
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + (size - 2) +
|
||||
' 2,' + (size - 2) + '" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
|
||||
break;
|
||||
case 'diamond':
|
||||
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + c + ' ' +
|
||||
c + ',' + (size - 2) + ' 2,' + c +
|
||||
'" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
|
||||
break;
|
||||
case 'hexagon': {
|
||||
// Pointy-top hexagon centred at (c,c), inscribed radius ≈ c-1.5
|
||||
var r = c - 1.5;
|
||||
var pts = '';
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var a = (i * 60 - 90) * Math.PI / 180;
|
||||
pts += (c + r * Math.cos(a)).toFixed(2) + ',' +
|
||||
(c + r * Math.sin(a)).toFixed(2) + ' ';
|
||||
}
|
||||
path = '<polygon points="' + pts.trim() + '" fill="' + fill +
|
||||
'" stroke="#fff" stroke-width="2"/>';
|
||||
break;
|
||||
}
|
||||
case 'star': {
|
||||
var cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
|
||||
var spts = '';
|
||||
for (var j = 0; j < 5; j++) {
|
||||
var aO = (j * 72 - 90) * Math.PI / 180;
|
||||
var aI = ((j * 72) + 36 - 90) * Math.PI / 180;
|
||||
spts += (cx + outer * Math.cos(aO)) + ',' + (cy + outer * Math.sin(aO)) + ' ';
|
||||
spts += (cx + inner * Math.cos(aI)) + ',' + (cy + inner * Math.sin(aI)) + ' ';
|
||||
}
|
||||
path = '<polygon points="' + spts.trim() + '" fill="' + fill +
|
||||
'" stroke="#fff" stroke-width="1.5"/>';
|
||||
break;
|
||||
}
|
||||
default: // circle
|
||||
path = '<circle cx="' + c + '" cy="' + c + '" r="' + (c - 2) +
|
||||
'" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
|
||||
}
|
||||
return '<svg width="' + size + '" height="' + size +
|
||||
'" viewBox="0 0 ' + size + ' ' + size +
|
||||
'" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' + path + '</svg>';
|
||||
};
|
||||
|
||||
window.ROLE_SORT = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
|
||||
+28
-2
@@ -1833,8 +1833,34 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
.search-box { width: 95vw; }
|
||||
.search-overlay { padding-top: 60px; }
|
||||
|
||||
/* Map controls */
|
||||
.map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: 200px; font-size: 12px; padding: 10px 12px; }
|
||||
/* Map controls — #1329: drop fixed 200px cap, use accordion sections
|
||||
instead so visible content fits without internal scrolling. Panel can
|
||||
grow to fill available height; max-height bound by viewport so it
|
||||
never escapes the screen. */
|
||||
.map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: calc(100vh - 80px); font-size: 12px; padding: 10px 12px; }
|
||||
/* On mobile, hide collapsed section bodies (everything inside the
|
||||
fieldset except the legend). The legend remains tappable to expand. */
|
||||
.map-controls fieldset.mc-section.mc-collapsed > *:not(legend) { display: none; }
|
||||
.map-controls fieldset.mc-section > legend.mc-label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
}
|
||||
/* ▸ / ▾ indicator via ::after so we don't touch markup */
|
||||
.map-controls fieldset.mc-section > legend.mc-label::after {
|
||||
content: '▾';
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.map-controls fieldset.mc-section.mc-collapsed > legend.mc-label::after {
|
||||
content: '▸';
|
||||
}
|
||||
#leaflet-map { z-index: 0; }
|
||||
#map-wrap { z-index: 0; }
|
||||
|
||||
|
||||
@@ -157,11 +157,11 @@
|
||||
o.setAttribute('role', 'group');
|
||||
o.setAttribute('aria-label', 'Row actions');
|
||||
var hash = row.getAttribute('data-hash') || row.getAttribute('data-id') || '';
|
||||
var hashAttr = ' data-hash="' + String(hash).replace(/"/g, '"') + '"';
|
||||
o.innerHTML =
|
||||
'<button type="button" class="row-action-btn" data-row-action="trace">Trace</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="filter">Filter</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="copy" data-hash="' +
|
||||
String(hash).replace(/"/g, '"') + '">Copy hash</button>';
|
||||
'<button type="button" class="row-action-btn" data-row-action="trace"' + hashAttr + '>Trace</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="filter"' + hashAttr + '>Filter</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="copy"' + hashAttr + '>Copy hash</button>';
|
||||
document.body.appendChild(o);
|
||||
rowOverlay = o;
|
||||
return o;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* #1293 — Marker shape variation per role + colorblind-safe palette.
|
||||
*
|
||||
* Acceptance:
|
||||
* - ROLE_SHAPES map exposed by roles.js, with repeater=circle,
|
||||
* companion=square, room=hexagon, sensor=triangle, observer=diamond.
|
||||
* - ROLE_STYLE.shape values match ROLE_SHAPES (single source of truth).
|
||||
* - A shared helper `window.makeRoleMarkerSVG(role, color, size)` exists
|
||||
* and can produce a hexagon path for the room role (covers the
|
||||
* previously-missing shape in map.js's switch).
|
||||
* - public/live.js uses `L.divIcon` (shape-aware) for node markers,
|
||||
* NOT the legacy `L.circleMarker` in `addNodeMarker`.
|
||||
* - public/live.js legend renders SVG marker swatches (not flat dots) so
|
||||
* colorblind users can distinguish shape, not only colour.
|
||||
* - public/map.js switch handles `case 'hexagon'`.
|
||||
* - Selected/highlighted state uses an outline RING (no same-colour
|
||||
* filled overlay) — i.e. the highlight path sets fillOpacity:0
|
||||
* (or 'transparent') and uses a stroke-based ring helper.
|
||||
*
|
||||
* Pure-string assertions; no DOM/browser required so this can land
|
||||
* in the JS-unit-tests step of the CI workflow (fast red).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const liveSrc = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1293: ROLE_SHAPES single source of truth ===');
|
||||
|
||||
// ROLE_SHAPES map declared on window
|
||||
assert(/window\.ROLE_SHAPES\s*=\s*\{/.test(rolesSrc),
|
||||
'roles.js declares window.ROLE_SHAPES map');
|
||||
|
||||
// Required role → shape pairings (line-order independent)
|
||||
const shapeBlockMatch = rolesSrc.match(/window\.ROLE_SHAPES\s*=\s*\{([\s\S]*?)\};/);
|
||||
const shapeBlock = shapeBlockMatch ? shapeBlockMatch[1] : '';
|
||||
const expectedShapes = {
|
||||
repeater: 'circle',
|
||||
companion: 'square',
|
||||
room: 'hexagon',
|
||||
sensor: 'triangle',
|
||||
observer: 'diamond',
|
||||
};
|
||||
for (const role of Object.keys(expectedShapes)) {
|
||||
const re = new RegExp(role + '\\s*:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
|
||||
assert(re.test(shapeBlock), `ROLE_SHAPES.${role} === '${expectedShapes[role]}'`);
|
||||
}
|
||||
|
||||
// ROLE_STYLE shape values match the new map
|
||||
const styleBlockMatch = rolesSrc.match(/window\.ROLE_STYLE\s*=\s*\{([\s\S]*?)\};/);
|
||||
const styleBlock = styleBlockMatch ? styleBlockMatch[1] : '';
|
||||
for (const role of Object.keys(expectedShapes)) {
|
||||
// crude per-line check
|
||||
const lineRe = new RegExp(role + '\\s*:[^}]*shape:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
|
||||
assert(lineRe.test(styleBlock),
|
||||
`ROLE_STYLE.${role}.shape === '${expectedShapes[role]}' (matches ROLE_SHAPES)`);
|
||||
}
|
||||
|
||||
console.log('\n=== #1293: shared SVG helper covers hexagon ===');
|
||||
|
||||
assert(/window\.makeRoleMarkerSVG\s*=\s*function/.test(rolesSrc),
|
||||
'roles.js exposes window.makeRoleMarkerSVG(role, color, size)');
|
||||
|
||||
// Helper string must include a hexagon branch (matches map.js switch)
|
||||
const helperMatch = rolesSrc.match(/window\.makeRoleMarkerSVG[\s\S]*?\n\s*\};/);
|
||||
const helperBlock = helperMatch ? helperMatch[0] : '';
|
||||
assert(/case\s+['\"]hexagon['\"]/.test(helperBlock),
|
||||
'helper handles case "hexagon" (room role)');
|
||||
assert(/case\s+['\"]square['\"]/.test(helperBlock),
|
||||
'helper handles case "square"');
|
||||
assert(/case\s+['\"]triangle['\"]/.test(helperBlock),
|
||||
'helper handles case "triangle"');
|
||||
assert(/case\s+['\"]diamond['\"]/.test(helperBlock),
|
||||
'helper handles case "diamond"');
|
||||
|
||||
console.log('\n=== #1293: map.js switch handles hexagon ===');
|
||||
|
||||
assert(/case\s+['\"]hexagon['\"]/.test(mapSrc),
|
||||
'map.js makeMarkerIcon switch has a "hexagon" branch');
|
||||
|
||||
console.log('\n=== #1293: live.js node markers use shape-aware divIcons ===');
|
||||
|
||||
// Carve out addNodeMarker body (best-effort) and assert it uses divIcon.
|
||||
const addNodeIdx = liveSrc.indexOf('function addNodeMarker');
|
||||
assert(addNodeIdx > 0, 'live.js addNodeMarker function present');
|
||||
const addNodeBody = liveSrc.slice(addNodeIdx, addNodeIdx + 2500);
|
||||
assert(/L\.divIcon|window\.makeRoleMarkerSVG|makeRoleMarkerSVG\s*\(/.test(addNodeBody),
|
||||
'addNodeMarker uses L.divIcon / makeRoleMarkerSVG (not legacy circleMarker)');
|
||||
assert(!/L\.circleMarker\(\s*\[\s*n\.lat/.test(addNodeBody),
|
||||
'addNodeMarker no longer creates L.circleMarker for the node itself');
|
||||
|
||||
console.log('\n=== #1293: live.js legend renders shape swatches ===');
|
||||
|
||||
// The role legend block (id="roleLegendList") must inject SVG, not a
|
||||
// flat live-dot span only.
|
||||
const legendIdx = liveSrc.indexOf("getElementById('roleLegendList')");
|
||||
assert(legendIdx > 0, 'live.js renders roleLegendList');
|
||||
const legendBody = liveSrc.slice(legendIdx, legendIdx + 1500);
|
||||
assert(/<svg|makeRoleMarkerSVG/.test(legendBody),
|
||||
'roleLegendList swatches include SVG shape (not bare colour dot)');
|
||||
|
||||
console.log('\n=== #1293: selected/highlight uses outline ring (no same-colour fill overlay) ===');
|
||||
|
||||
// New behaviour: marker highlight pulse must NOT recolor marker fill to
|
||||
// the same packet colour stacked over a same-coloured base. The fix
|
||||
// uses a stroke ring (fillOpacity 0 / 'transparent') for the overlay.
|
||||
assert(/highlightNodeRing|RingHighlight|highlightRing/.test(liveSrc) ||
|
||||
/fillOpacity:\s*0[,\s}]/.test(liveSrc.slice(liveSrc.indexOf('animatePulse') || 0,
|
||||
(liveSrc.indexOf('animatePulse') || 0) + 1500)),
|
||||
'highlight path uses a transparent-fill ring (no same-colour concentric fill)');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(` Passed: ${passed}`);
|
||||
console.log(` Failed: ${failed}`);
|
||||
if (failed > 0) { console.error('\n#1293 FAIL'); process.exit(1); }
|
||||
console.log('\n#1293 PASS');
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* E2E (#1329): Map controls panel on mobile must NOT be capped at 200px
|
||||
* with internal scroll. Use accordion sections — one expanded at a time —
|
||||
* so the visible content always fits without scrolling.
|
||||
*
|
||||
* Mobile (375x812):
|
||||
* - Open Map controls.
|
||||
* - Panel must have accordion sections (legend acts as toggle, with
|
||||
* aria-expanded attribute).
|
||||
* - Default state: at most one section expanded.
|
||||
* - Panel contents must NOT require internal scroll
|
||||
* (scrollHeight <= clientHeight + 1).
|
||||
* - Clicking a different section's legend collapses the previously-open
|
||||
* section (single-open behavior).
|
||||
*
|
||||
* Desktop (1280x800):
|
||||
* - Existing layout unchanged: all sections visible by default,
|
||||
* panel position:absolute, modest width.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-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'); }
|
||||
|
||||
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);
|
||||
|
||||
// === Mobile: 375x812 ===
|
||||
const ctx = await browser.newContext({ viewport: { width: 375, height: 812 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
|
||||
await page.waitForSelector('#leaflet-map', { timeout: 10000 });
|
||||
await page.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Ensure controls panel is expanded (default is collapsed on mobile).
|
||||
await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
const btn = document.getElementById('mapControlsToggle');
|
||||
if (panel && panel.classList.contains('collapsed')) btn && btn.click();
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await step('mobile: at least one accordion section present with aria-expanded', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
// Accordion section markers: legend (or button) carrying aria-expanded
|
||||
// inside a .mc-section.mc-accordion (or equivalent) descendant.
|
||||
const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]');
|
||||
const sections = panel.querySelectorAll('.mc-section');
|
||||
return {
|
||||
toggles: toggles.length,
|
||||
sections: sections.length,
|
||||
expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length,
|
||||
};
|
||||
});
|
||||
assert(data.toggles >= 1,
|
||||
'expected ≥1 accordion toggle (aria-expanded), got ' + data.toggles +
|
||||
' (sections=' + data.sections + ')');
|
||||
});
|
||||
|
||||
await step('mobile: at most one section expanded by default', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]');
|
||||
return {
|
||||
expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length,
|
||||
total: toggles.length,
|
||||
};
|
||||
});
|
||||
assert(data.expandedCount <= 1,
|
||||
'expected ≤1 section expanded by default, got ' + data.expandedCount + '/' + data.total);
|
||||
});
|
||||
|
||||
await step('mobile: panel content does NOT require internal scroll', async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
return {
|
||||
scrollH: panel.scrollHeight,
|
||||
clientH: panel.clientHeight,
|
||||
overflowY: getComputedStyle(panel).overflowY,
|
||||
};
|
||||
});
|
||||
// The accordion sections should keep content within viewport — when only
|
||||
// one section is expanded, panel must not need to scroll internally.
|
||||
assert(data.scrollH <= data.clientH + 1,
|
||||
'panel must not require internal scroll (scrollH=' + data.scrollH +
|
||||
' clientH=' + data.clientH + ')');
|
||||
});
|
||||
|
||||
await step('mobile: clicking a 2nd toggle collapses the first (single-open)', async () => {
|
||||
const result = await page.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
const toggles = Array.from(panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]'));
|
||||
if (toggles.length < 2) return { skip: true, n: toggles.length };
|
||||
// Find one currently closed and one open; if all closed, open first then click second.
|
||||
let openIdx = toggles.findIndex(t => t.getAttribute('aria-expanded') === 'true');
|
||||
if (openIdx === -1) {
|
||||
toggles[0].click();
|
||||
openIdx = 0;
|
||||
}
|
||||
const otherIdx = openIdx === 0 ? 1 : 0;
|
||||
toggles[otherIdx].click();
|
||||
return {
|
||||
skip: false,
|
||||
firstNow: toggles[openIdx].getAttribute('aria-expanded'),
|
||||
otherNow: toggles[otherIdx].getAttribute('aria-expanded'),
|
||||
};
|
||||
});
|
||||
if (result.skip) {
|
||||
throw new Error('need at least 2 accordion toggles to test single-open (got ' + result.n + ')');
|
||||
}
|
||||
assert(result.otherNow === 'true',
|
||||
'second toggle should be open after click, got ' + result.otherNow);
|
||||
assert(result.firstNow === 'false',
|
||||
'first toggle should auto-close (single-open), got ' + result.firstNow);
|
||||
});
|
||||
|
||||
await ctx.close();
|
||||
|
||||
// === Desktop: 1280x800 ===
|
||||
const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const p2 = await ctx2.newPage();
|
||||
await p2.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
|
||||
await p2.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 });
|
||||
await p2.waitForTimeout(300);
|
||||
|
||||
await step('desktop (1280px): panel position:absolute, all section contents visible', async () => {
|
||||
const data = await p2.evaluate(() => {
|
||||
const panel = document.getElementById('mapControls');
|
||||
const cs = getComputedStyle(panel);
|
||||
const rect = panel.getBoundingClientRect();
|
||||
// Check that section content (e.g., labels) is visible on desktop.
|
||||
const allInputs = panel.querySelectorAll('input[type=checkbox], select, button');
|
||||
let visible = 0;
|
||||
allInputs.forEach(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) visible++;
|
||||
});
|
||||
return {
|
||||
position: cs.position,
|
||||
width: Math.round(rect.width),
|
||||
vw: window.innerWidth,
|
||||
visibleControls: visible,
|
||||
totalControls: allInputs.length,
|
||||
};
|
||||
});
|
||||
assert(data.position === 'absolute',
|
||||
'desktop panel must be position:absolute, got ' + data.position);
|
||||
assert(data.width < data.vw * 0.5,
|
||||
'desktop panel must be <50% viewport width, got ' + data.width + '/' + data.vw);
|
||||
// All (or nearly all) controls should be visible on desktop — accordion
|
||||
// collapse must NOT apply at desktop sizes.
|
||||
assert(data.visibleControls >= data.totalControls - 2,
|
||||
'desktop must show all controls (got ' + data.visibleControls + '/' + data.totalControls + ')');
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' +
|
||||
(failed ? ', ' + failed + ' failed' : ''));
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch(err => { console.error('Fatal:', err); process.exit(1); });
|
||||
@@ -0,0 +1,106 @@
|
||||
/* Tests for #1346 — per-packet animation honors VCR.speed in BOTH modes.
|
||||
*
|
||||
* Bug history: PR #922 introduced `stepMs = 33 / VCR.speed` / `DURATION_MS = 1100 / VCR.speed`
|
||||
* so slow-mo (0.25×) and fast-fwd (4×/8×) work for the per-packet animation. An interim fix
|
||||
* mode-gated that to REPLAY only, which removed the ability to slow down / speed up LIVE
|
||||
* animation. Operator wants the original #922 behavior restored: animation ALWAYS follows
|
||||
* VCR.speed regardless of LIVE/REPLAY.
|
||||
*
|
||||
* Behavior:
|
||||
* - LIVE & REPLAY both → animation scaled by VCR.speed
|
||||
* - Inter-packet replay delay `realGap / VCR.speed` unchanged
|
||||
* - UI: speed button visible in BOTH modes (operator can adjust live-anim speed)
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
const src = fs.readFileSync('public/live.js', 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
console.log('\n=== #1346 — per-packet animation honors VCR.speed in BOTH modes ===');
|
||||
|
||||
function extractFn(name) {
|
||||
const start = src.indexOf('function ' + name + '(');
|
||||
assert.ok(start !== -1, `function ${name} not found`);
|
||||
const next = src.indexOf('\n function ', start + 1);
|
||||
return src.substring(start, next === -1 ? start + 4000 : next);
|
||||
}
|
||||
|
||||
function evalWithVCR(expr, VCR) {
|
||||
return new Function('VCR', `return (${expr});`)(VCR);
|
||||
}
|
||||
|
||||
// --- drawAnimatedLine.stepMs ---
|
||||
const stepExpr = extractFn('drawAnimatedLine').match(/const\s+stepMs\s*=\s*([^;]+);/)[1];
|
||||
|
||||
test('LIVE @ speed=0.25 → stepMs = 132 (slow-mo works in LIVE too)', () => {
|
||||
const v = evalWithVCR(stepExpr, { mode: 'LIVE', speed: 0.25 });
|
||||
assert.strictEqual(v, 132, `got ${v}`);
|
||||
});
|
||||
test('LIVE @ speed=4 → stepMs = 8.25 (fast-anim works in LIVE too)', () => {
|
||||
const v = evalWithVCR(stepExpr, { mode: 'LIVE', speed: 4 });
|
||||
assert.strictEqual(v, 8.25, `got ${v}`);
|
||||
});
|
||||
test('LIVE @ speed=1 → stepMs = 33 (baseline)', () => {
|
||||
const v = evalWithVCR(stepExpr, { mode: 'LIVE', speed: 1 });
|
||||
assert.strictEqual(v, 33, `got ${v}`);
|
||||
});
|
||||
test('REPLAY @ speed=4 → stepMs = 8.25 (fast-forward animation)', () => {
|
||||
const v = evalWithVCR(stepExpr, { mode: 'REPLAY', speed: 4 });
|
||||
assert.strictEqual(v, 8.25, `got ${v}`);
|
||||
});
|
||||
test('REPLAY @ speed=0.25 → stepMs = 132 (#922 slow-mo preserved)', () => {
|
||||
const v = evalWithVCR(stepExpr, { mode: 'REPLAY', speed: 0.25 });
|
||||
assert.strictEqual(v, 132, `got ${v}`);
|
||||
});
|
||||
test('REPLAY @ speed=1 → stepMs = 33 (baseline)', () => {
|
||||
const v = evalWithVCR(stepExpr, { mode: 'REPLAY', speed: 1 });
|
||||
assert.strictEqual(v, 33, `got ${v}`);
|
||||
});
|
||||
|
||||
// --- drawMatrixLine.DURATION_MS ---
|
||||
const durExpr = extractFn('drawMatrixLine').match(/const\s+DURATION_MS\s*=\s*([^;]+);/)[1];
|
||||
|
||||
test('LIVE @ speed=4 → DURATION_MS = 275 (fast-fwd in LIVE)', () => {
|
||||
const v = evalWithVCR(durExpr, { mode: 'LIVE', speed: 4 });
|
||||
assert.strictEqual(v, 275, `got ${v}`);
|
||||
});
|
||||
test('LIVE @ speed=0.25 → DURATION_MS = 4400 (slow-mo in LIVE)', () => {
|
||||
const v = evalWithVCR(durExpr, { mode: 'LIVE', speed: 0.25 });
|
||||
assert.strictEqual(v, 4400, `got ${v}`);
|
||||
});
|
||||
test('REPLAY @ speed=4 → DURATION_MS = 275 (fast-forward)', () => {
|
||||
const v = evalWithVCR(durExpr, { mode: 'REPLAY', speed: 4 });
|
||||
assert.strictEqual(v, 275, `got ${v}`);
|
||||
});
|
||||
test('REPLAY @ speed=0.25 → DURATION_MS = 4400 (#922 slow-mo)', () => {
|
||||
const v = evalWithVCR(durExpr, { mode: 'REPLAY', speed: 0.25 });
|
||||
assert.strictEqual(v, 4400, `got ${v}`);
|
||||
});
|
||||
|
||||
// --- inter-packet replay delay regression guard ---
|
||||
test('Inter-packet replay delay still divides realGap by VCR.speed', () => {
|
||||
assert.ok(/delay\s*=\s*Math\.min\([^;]+?\/\s*VCR\.speed/.test(src),
|
||||
'inter-packet replay delay must still divide realGap by VCR.speed');
|
||||
});
|
||||
|
||||
// --- UI: speed button visible in BOTH modes ---
|
||||
test('updateVCRUI does NOT hide speed button in LIVE', () => {
|
||||
const start = src.indexOf('function updateVCRUI(');
|
||||
assert.ok(start !== -1, 'updateVCRUI not found');
|
||||
const end = src.indexOf('\n function ', start + 1);
|
||||
const body = src.substring(start, end === -1 ? start + 4000 : end);
|
||||
// No branch that adds 'hidden' class to speedBtn based on LIVE mode
|
||||
assert.ok(!/speedBtn[\s\S]{0,200}VCR\.mode\s*===\s*['"]LIVE['"][\s\S]{0,200}classList\.add\(['"]hidden['"]\)/.test(body)
|
||||
&& !/VCR\.mode\s*===\s*['"]LIVE['"][\s\S]{0,200}speedBtn[\s\S]{0,200}classList\.add\(['"]hidden['"]\)/.test(body),
|
||||
'speedBtn must NOT be hidden when VCR.mode === LIVE — operator needs it to adjust live-anim speed');
|
||||
});
|
||||
|
||||
console.log(`\n=== ${passed} passed, ${failed} failed ===`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -208,14 +208,12 @@ async function main() {
|
||||
if (!overlayPresent) {
|
||||
fail('(cov1) precondition — overlay did not appear after left swipe');
|
||||
} else {
|
||||
await page.evaluate((h) => {
|
||||
await page.evaluate(() => {
|
||||
// Production stamps data-hash on trace/filter/copy buttons natively
|
||||
// (issue #1305). Just click — no test-side workaround needed.
|
||||
const btn = document.querySelector('.row-action-overlay [data-row-action="trace"]');
|
||||
// Production only sets data-hash on the copy button; for the
|
||||
// trace/filter branch in onClickAction to navigate, the button
|
||||
// must carry data-hash. Stamp it here from the row's hash so
|
||||
// the coverage test exercises the real navigation path.
|
||||
if (btn) { btn.setAttribute('data-hash', h); btn.click(); }
|
||||
}, r.hash);
|
||||
if (btn) { btn.click(); }
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
const state = await page.evaluate(() => ({
|
||||
hash: location.hash,
|
||||
@@ -241,13 +239,11 @@ async function main() {
|
||||
if (!ok) {
|
||||
fail('(cov2) precondition — filter button not in overlay');
|
||||
} else {
|
||||
await page.evaluate((h) => {
|
||||
await page.evaluate(() => {
|
||||
// Production stamps data-hash on filter button natively (#1305).
|
||||
const btn = document.querySelector('.row-action-overlay [data-row-action="filter"]');
|
||||
// Same as cov1: production stamps data-hash only on the copy
|
||||
// button. Stamp it on filter here so onClickAction's hash
|
||||
// guard passes and we exercise the real navigation branch.
|
||||
if (btn) { btn.setAttribute('data-hash', h); btn.click(); }
|
||||
}, r2.hash);
|
||||
if (btn) { btn.click(); }
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
const state = await page.evaluate(() => ({
|
||||
hash: location.hash,
|
||||
|
||||
Reference in New Issue
Block a user