diff --git a/cmd/ingestor/config.go b/cmd/ingestor/config.go
index 3c4082416..493905dd6 100644
--- a/cmd/ingestor/config.go
+++ b/cmd/ingestor/config.go
@@ -73,7 +73,8 @@ type Config struct {
// IATAWarnIntervalSec is how often a region dropped by
// ObserverIATAWhitelist is re-logged while it keeps arriving (#110).
- // 0 or less means the default, 6 hours. See iata_drop_warn.go.
+ // 0 or less means the default, 6 hours; larger than 86400 (24 hours)
+ // is capped at 86400. See iata_drop_warn.go.
IATAWarnIntervalSec int `json:"iataWarnIntervalSec,omitempty"`
// iataDropWarn is the bounded per-region throttle for that warning.
diff --git a/cmd/ingestor/iata_drop_warn.go b/cmd/ingestor/iata_drop_warn.go
index 6451da0d7..22cf41af7 100644
--- a/cmd/ingestor/iata_drop_warn.go
+++ b/cmd/ingestor/iata_drop_warn.go
@@ -33,6 +33,10 @@ const (
iataWarnMaxKeyLen = 32
// defaultIATAWarnIntervalSec is the default re-log interval (6h).
defaultIATAWarnIntervalSec = 6 * 60 * 60
+ // maxIATAWarnIntervalSec caps the configured interval (24h). A larger
+ // value buys nothing for a warning meant to stay visible, and a huge one
+ // overflows time.Duration into a negative interval that logs every drop.
+ maxIATAWarnIntervalSec = 24 * 60 * 60
)
// iataDropThrottle is the per-Config throttle state. The zero value is ready.
@@ -47,11 +51,15 @@ type iataDropThrottle struct {
sweeps int // full-table sweeps, for tests
}
-// IATAWarnInterval returns how often a dropped region is re-logged.
+// IATAWarnInterval returns how often a dropped region is re-logged:
+// iataWarnIntervalSec capped at 24 hours, or the default for 0 or less.
func (c *Config) IATAWarnInterval() time.Duration {
if c == nil || c.IATAWarnIntervalSec <= 0 {
return defaultIATAWarnIntervalSec * time.Second
}
+ if c.IATAWarnIntervalSec > maxIATAWarnIntervalSec {
+ return maxIATAWarnIntervalSec * time.Second
+ }
return time.Duration(c.IATAWarnIntervalSec) * time.Second
}
diff --git a/cmd/ingestor/iata_drop_warn_test.go b/cmd/ingestor/iata_drop_warn_test.go
index 9ea129312..8d4b1617c 100644
--- a/cmd/ingestor/iata_drop_warn_test.go
+++ b/cmd/ingestor/iata_drop_warn_test.go
@@ -2,6 +2,7 @@ package main
import (
"fmt"
+ "math"
"os"
"path/filepath"
"strings"
@@ -32,6 +33,43 @@ func TestIATAWarnIntervalDefaultAndConfigured(t *testing.T) {
}
}
+// A huge configured interval must not overflow time.Duration (int64
+// nanoseconds) into a negative interval, which would log every drop: the
+// flood the throttle exists to prevent (#150). It is capped at 24 hours.
+func TestIATAWarnIntervalClampsHugeValues(t *testing.T) {
+ var huge int64 = 1e10 // seconds; 1e19 ns overflows int64
+ for _, sec := range []int{int(huge), math.MaxInt, 24*60*60 + 1, 365 * 24 * 60 * 60} {
+ if got := (&Config{IATAWarnIntervalSec: sec}).IATAWarnInterval(); got != 24*time.Hour {
+ t.Errorf("iataWarnIntervalSec=%d: interval %v, want the 24h cap", sec, got)
+ }
+ }
+ if got := (&Config{IATAWarnIntervalSec: 24 * 60 * 60}).IATAWarnInterval(); got != 24*time.Hour {
+ t.Errorf("exactly 24h: interval %v", got)
+ }
+ if got := (&Config{IATAWarnIntervalSec: 24*60*60 - 1}).IATAWarnInterval(); got != 24*time.Hour-time.Second {
+ t.Errorf("just under the cap: interval %v", got)
+ }
+
+ path := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(path, []byte(`{"observerIATAWhitelist":["ARN"],"iataWarnIntervalSec":10000000000}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := LoadConfig(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := cfg.IATAWarnInterval(); got != 24*time.Hour {
+ t.Fatalf("iataWarnIntervalSec=10000000000 from JSON: interval %v, want 24h", got)
+ }
+ // and the throttle built on it still suppresses a repeat
+ if w, _ := cfg.iataDropWarn.shouldWarn("GOT", t0IATA, cfg.IATAWarnInterval()); !w {
+ t.Fatal("first drop not logged")
+ }
+ if w, _ := cfg.iataDropWarn.shouldWarn("GOT", t0IATA.Add(time.Minute), cfg.IATAWarnInterval()); w {
+ t.Error("a repeat a minute later was logged again")
+ }
+}
+
func TestIATAWarnIntervalFromJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"observerIATAWhitelist":["ARN"],"iataWarnIntervalSec":600}`), 0o600); err != nil {
@@ -159,6 +197,85 @@ func TestIATADropThrottleSweepsAreAmortized(t *testing.T) {
}
}
+// fillIATA tracks n new codes with the given prefix at ts; each must get its
+// own slot.
+func fillIATA(t *testing.T, th *iataDropThrottle, prefix string, n int, ts time.Time, iv time.Duration) {
+ t.Helper()
+ for i := 0; i < n; i++ {
+ if w, o := th.shouldWarn(fmt.Sprintf("%s%04d", prefix, i), ts, iv); !w || o {
+ t.Fatalf("%s%04d at %v: warn=%v overflow=%v, want an own slot", prefix, i, ts.Sub(t0IATA), w, o)
+ }
+ }
+}
+
+// oldest is a lower bound on the oldest tracked time (#150). With entries
+// of different ages it must follow the oldest one still tracked: too high
+// and an expired slot is not reclaimed (a new region falls into the shared
+// overflow throttle), too low and a full table of fresh entries is swept
+// on every drop.
+func TestIATADropThrottleOldestTracksStaggeredEntries(t *testing.T) {
+ var th iataDropThrottle
+ iv := time.Hour
+ half := iataWarnMaxTracked / 2
+ fillIATA(t, &th, "A", half, t0IATA, iv)
+ fillIATA(t, &th, "B", iataWarnMaxTracked-half, t0IATA.Add(30*time.Minute), iv)
+
+ // full of fresh entries: overflow, and no sweep (nothing can be expired)
+ if w, o := th.shouldWarn("EARLY", t0IATA.Add(40*time.Minute), iv); !w || !o {
+ t.Fatalf("full table at +40m: warn=%v overflow=%v, want the overflow warning", w, o)
+ }
+ if th.sweeps != 0 {
+ t.Fatalf("%d sweeps before any entry could expire", th.sweeps)
+ }
+
+ // +1h: the A half has expired; a new region reclaims a slot
+ if w, o := th.shouldWarn("N1", t0IATA.Add(time.Hour), iv); !w || o {
+ t.Fatalf("+1h: warn=%v overflow=%v, want an own slot (A entries expired)", w, o)
+ }
+ if len(th.last) != iataWarnMaxTracked-half+1 || th.sweeps != 1 {
+ t.Fatalf("+1h: %d entries after %d sweeps, want %d after 1", len(th.last), th.sweeps, iataWarnMaxTracked-half+1)
+ }
+ // refill to the cap with entries at +1h
+ fillIATA(t, &th, "C", half-1, t0IATA.Add(time.Hour), iv)
+
+ // +1h30m: the B half (+30m) has expired now and must be reclaimed,
+ // although every entry swept at +1h is gone and the newest are fresh
+ if w, o := th.shouldWarn("N2", t0IATA.Add(90*time.Minute), iv); !w || o {
+ t.Fatalf("+1h30m: warn=%v overflow=%v, want an own slot (B entries expired)", w, o)
+ }
+ if _, ok := th.last["B0000"]; ok {
+ t.Error("expired B entries were not reclaimed at +1h30m")
+ }
+ if len(th.last) != half+1 || th.sweeps != 2 {
+ t.Fatalf("+1h30m: %d entries after %d sweeps, want %d after 2", len(th.last), th.sweeps, half+1)
+ }
+}
+
+// A drop can carry an earlier time than the current oldest entry (the
+// caller takes the time before the lock). oldest must drop to it, or that
+// entry outlives its interval in a full table.
+func TestIATADropThrottleOldestFollowsAnEarlierDrop(t *testing.T) {
+ var th iataDropThrottle
+ iv := time.Hour
+ fillIATA(t, &th, "A", iataWarnMaxTracked, t0IATA, iv)
+ // +1h: everything expired; sweep, keep only LATE (taken at +1h)
+ if w, o := th.shouldWarn("LATE", t0IATA.Add(time.Hour), iv); !w || o {
+ t.Fatalf("+1h: warn=%v overflow=%v", w, o)
+ }
+ // a drop stamped +50m arrives after it
+ if w, o := th.shouldWarn("EARLIER", t0IATA.Add(50*time.Minute), iv); !w || o {
+ t.Fatalf("out-of-order drop: warn=%v overflow=%v", w, o)
+ }
+ fillIATA(t, &th, "F", iataWarnMaxTracked-2, t0IATA.Add(time.Hour), iv)
+ // +1h50m: EARLIER has expired and must free its slot
+ if w, o := th.shouldWarn("NEW", t0IATA.Add(110*time.Minute), iv); !w || o {
+ t.Fatalf("+1h50m: warn=%v overflow=%v, want EARLIER's expired slot", w, o)
+ }
+ if _, ok := th.last["EARLIER"]; ok {
+ t.Error("EARLIER outlived its interval in a full table")
+ }
+}
+
func TestIATADropThrottleConcurrent(t *testing.T) {
var th iataDropThrottle
iv := time.Hour
diff --git a/config.example.json b/config.example.json
index ce4bd1f92..a5446416c 100644
--- a/config.example.json
+++ b/config.example.json
@@ -8,7 +8,7 @@
"observerIATAWhitelist": [],
"_comment_observerIATAWhitelist": "Global IATA region whitelist. When non-empty, only observers whose IATA code (from MQTT topic) matches are processed. Case-insensitive. Empty = allow all. Unlike per-source iataFilter, this applies across all MQTT sources. A dropped region is logged by the ingestor as one '[region-filter] dropping region \"XYZ\"' line, repeated at most every iataWarnIntervalSec while it keeps arriving.",
"iataWarnIntervalSec": 21600,
- "_comment_iataWarnIntervalSec": "Optional (ingestor). Seconds between repeated '[region-filter]' warnings for a region dropped by observerIATAWhitelist. Default 21600 (6h); 0 or omitted = default. At most 512 regions are tracked individually (region codes come from the MQTT topic, so this is bounded); beyond that, drops share one overflow warning with the same interval.",
+ "_comment_iataWarnIntervalSec": "Optional (ingestor). Seconds between repeated '[region-filter]' warnings for a region dropped by observerIATAWhitelist. Default 21600 (6h); 0 or omitted = default; maximum 86400 (24h), larger values are capped. At most 512 regions are tracked individually (region codes come from the MQTT topic, so this is bounded); beyond that, drops share one overflow warning with the same interval.",
"retention": {
"nodeDays": 7,
"observerDays": 14,
diff --git a/public/live.js b/public/live.js
index b7d38aa89..382638050 100644
--- a/public/live.js
+++ b/public/live.js
@@ -1182,6 +1182,9 @@
localStorage.setItem('live-matrix-mode', matrixMode);
applyMatrixTheme(matrixMode);
syncHeatToggleToMatrix(matrixMode);
+ // Matrix ON hid the heat layer; OFF brings it back as Heat is set.
+ // During init applyLiveControlEffects() (re)builds it after loadNodes().
+ if (!matrixMode && heatEnabled) showHeatMap();
} },
{ id: 'liveMatrixRainToggle', restore: () => matrixRain, onChange: (v) => {
matrixRain = v;
@@ -1202,7 +1205,8 @@
}
// Matrix mode owns the heat map: while it is on, the heat layer is hidden
- // and its toggle unchecked and disabled. Safe before the map exists.
+ // and its toggle unchecked and disabled. When it is off, the toggle shows
+ // the Heat setting again (#150). Safe before the map exists.
function syncHeatToggleToMatrix(on) {
const ht = document.getElementById('liveHeatToggle');
if (on) {
@@ -1210,6 +1214,7 @@
if (ht) { ht.checked = false; ht.disabled = true; }
} else if (ht) {
ht.disabled = false; // recover from stale state
+ ht.checked = heatEnabled;
}
}
diff --git a/public/rx-coverage.js b/public/rx-coverage.js
index 8db95f527..da1ba9df2 100644
--- a/public/rx-coverage.js
+++ b/public/rx-coverage.js
@@ -11,6 +11,11 @@
// coverage and leaderboard responses, the settle timer, move handlers)
// captures it and does nothing once a newer mount or a destroy happened.
var generation = 0;
+ // Request sequence per data stream (#150): a coverage or leaderboard
+ // response renders only if no newer request of its kind has started since,
+ // so a slow response for an earlier days (or rx, or viewport) cannot
+ // overwrite newer data.
+ var coverageSeq = 0, boardSeq = 0;
// Initial viewport (#124), first valid one wins: explicit URL lat/lon/zoom,
// this page's own saved view, /api/config/map, then this offline fallback
@@ -22,6 +27,8 @@
var MIN_ZOOM = 1, MAX_ZOOM = 19;
function isLive(gen) { return !destroyed && gen === generation; }
+ function isLatestCoverage(gen, seq) { return isLive(gen) && seq === coverageSeq; }
+ function isLatestBoard(gen, seq) { return isLive(gen) && seq === boardSeq; }
// validView returns {lat, lon, zoom} when all three are present, numeric and
// in range; anything invalid, partial or out of range gives null.
@@ -124,12 +131,12 @@
function drawCoverage() {
if (!map || destroyed) return;
- var gen = generation;
+ var gen = generation, seq = ++coverageSeq;
var b = map.getBounds();
var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(',');
var url = '/api/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : '');
fetch(url).then(function (r) { return r.json(); }).then(function (fc) {
- if (!isLive(gen) || !covLayer) return;
+ if (!isLatestCoverage(gen, seq) || !covLayer) return;
covLayer.clearLayers();
(fc.features || []).forEach(function (f) {
var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; });
@@ -251,11 +258,11 @@
}
function loadBoard() {
- var gen = generation;
+ var gen = generation, seq = ++boardSeq;
fetch('/api/rx-leaderboard?days=' + days + '&limit=25').then(function (r) { return r.json(); })
- .then(function (d) { if (!isLive(gen)) return; boardCache = d.observers || []; renderBoard(); })
+ .then(function (d) { if (!isLatestBoard(gen, seq)) return; boardCache = d.observers || []; renderBoard(); })
.catch(function (e) {
- if (!isLive(gen)) return;
+ if (!isLatestBoard(gen, seq)) return;
console.warn('rx-coverage: leaderboard fetch failed', e);
var el = document.getElementById('rxBoard');
if (el) el.innerHTML = '
Could not load mobile observers.
';
diff --git a/public/style.css b/public/style.css
index 60b3a97d5..5b02ae965 100644
--- a/public/style.css
+++ b/public/style.css
@@ -5101,7 +5101,9 @@ td[data-filter-field] { cursor: context-menu; }
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
- background: var(--surface-2, var(--surface));
+ /* nav-drawer.css paints the title and close button with --nav-text(-muted),
+ so the header sits on the nav colours in every theme (#150). */
+ background: var(--nav-bg2);
}
.nav-drawer-title {
font-weight: 700;
diff --git a/test-issue-124-rx-coverage-viewport.js b/test-issue-124-rx-coverage-viewport.js
index bf077c58f..147b26d73 100644
--- a/test-issue-124-rx-coverage-viewport.js
+++ b/test-issue-124-rx-coverage-viewport.js
@@ -281,6 +281,60 @@ function assertView(env, want, tag) {
assert.strictEqual(newLayer.cleared + newLayer.added, 0, 'the old coverage response was drawn on the new layer (cleared ' + newLayer.cleared + ', added ' + newLayer.added + ')');
});
+ await test('13. #150: switching days quickly, the older days response cannot overwrite the newer data', async () => {
+ const env = makeEnv({ storage: { 'rx-coverage-view': JSON.stringify({ lat: 56.1, lng: 9.9, zoom: 10 }) } });
+ const layers = [];
+ env.sandbox.L.layerGroup = () => {
+ const l = { cleared: 0, polys: [], addTo() { return l; }, clearLayers() { l.cleared++; l.polys = []; } };
+ layers.push(l);
+ return l;
+ };
+ env.sandbox.L.polygon = (ring) => {
+ const pg = { addTo(l) { l.polys.push(ring); return pg; }, bindTooltip() { return pg; } };
+ return pg;
+ };
+ // the days bar: record its click handler
+ const bar = env.sandbox.document.getElementById('rxDays');
+ bar.addEventListener = (ev, fn) => { if (ev === 'click') bar.onclick = fn; };
+ const pickDays = (d) => bar.onclick({ target: { closest: () => ({ dataset: { days: String(d) } }) } });
+ const feature = (lat) => ({ features: [{ properties: {}, geometry: { coordinates: [[[10, lat], [11, lat], [10, lat + 1]]] } }] });
+ const observers = (name) => ({ observers: [{ pubkey: name.toLowerCase(), name, score: 1, cells: 1, nodes: 1, receptions: 1 }] });
+
+ await mount(env, null);
+ env.runTimers(); // first coverage request (days=7)
+ assert(env.pending.some((p) => /rx-leaderboard\?days=7&/.test(p.url)), 'no days=7 leaderboard request');
+ assert(env.pending.some((p) => /rx-coverage\?bbox=.*&days=7$/.test(p.url)), 'no days=7 coverage request');
+
+ pickDays(30);
+ assert(env.pending.some((p) => /rx-leaderboard\?days=30&/.test(p.url)), 'no days=30 leaderboard request');
+ assert(env.pending.some((p) => /rx-coverage\?bbox=.*&days=30$/.test(p.url)), 'no days=30 coverage request');
+
+ // the newer (days=30) responses arrive first
+ const board = env.sandbox.document.getElementById('rxBoard');
+ assert(env.respond(/rx-leaderboard\?days=30&/, observers('NEWER')));
+ assert(env.respond(/rx-coverage\?bbox=.*&days=30$/, feature(60)));
+ await flush();
+ const layer = layers[0];
+ assert(/NEWER/.test(board.innerHTML), 'the days=30 leaderboard did not render');
+ assert(layer.polys.length === 1 && layer.polys[0][0][0] === 60, 'the days=30 coverage did not render: ' + JSON.stringify(layer.polys));
+
+ // then the slow days=7 responses are released
+ assert(env.respond(/rx-leaderboard\?days=7&/, observers('OLDER')));
+ assert(env.respond(/rx-coverage\?bbox=.*&days=7$/, feature(50)));
+ await flush();
+ assert(/NEWER/.test(board.innerHTML) && !/OLDER/.test(board.innerHTML), 'the older days=7 leaderboard replaced the days=30 one');
+ assert(layer.polys.length === 1 && layer.polys[0][0][0] === 60, 'the older days=7 coverage replaced the days=30 one: ' + JSON.stringify(layer.polys));
+
+ // a late failure of an older leaderboard request does not replace it either
+ pickDays(14);
+ pickDays(1);
+ assert(env.respond(/rx-leaderboard\?days=1&/, observers('NEWEST')));
+ await flush();
+ assert(env.failFetch(/rx-leaderboard\?days=14&/));
+ await flush();
+ assert(/NEWEST/.test(board.innerHTML), 'a failed older leaderboard request replaced the newest one: ' + board.innerHTML);
+ });
+
await test('11. coverage filtering and leaderboard requests are unchanged', async () => {
const env = makeEnv({ hash: '#/rx-coverage?days=14' });
await mount(env, { center: [55.68, 12.57], zoom: 9 });
diff --git a/test-issue-125-live-toggles-early-e2e.js b/test-issue-125-live-toggles-early-e2e.js
index 6e59d6236..efa164bf7 100644
--- a/test-issue-125-live-toggles-early-e2e.js
+++ b/test-issue-125-live-toggles-early-e2e.js
@@ -21,6 +21,14 @@
* shows its canvas. Finally three SPA round trips leave one listener per
* toggle (one click writes once).
*
+ * #150 follow-ups: with Matrix and Heat saved ON, Matrix switched OFF during
+ * init leaves the Heat checkbox, the heat layer and the saved setting in
+ * agreement (also for Matrix OFF after init, and for Heat saved OFF). And
+ * leaving Live while init is still awaiting loadNodes(), then coming back,
+ * leaves one listener per toggle on the new mount: before #135 the old
+ * init's continuation wired the new mount's toggles a second time (2
+ * listeners on 8 of 9 toggles, one click wrote localStorage twice).
+ *
* Usage: BASE_URL=http://localhost:13581 node test-issue-125-live-toggles-early-e2e.js
*/
'use strict';
@@ -46,7 +54,9 @@ const TOGGLES = [
{ id: 'liveMatrixToggle', key: 'live-matrix-mode', dflt: false },
{ id: 'liveMatrixRainToggle', key: 'live-matrix-rain', dflt: false },
];
-const KEYS = TOGGLES.map((t) => t.key);
+// All nine wired toggles (#150): Multibyte (#88) has its own E2E above.
+const ALL_TOGGLES = TOGGLES.concat([{ id: 'liveMultibyteToggle', key: 'live-multibyte-only', dflt: false }]);
+const KEYS = ALL_TOGGLES.map((t) => t.key);
// A context whose first page load starts from `seed` ({key: 'true'|'false'};
// other toggle keys removed), counting writes per key and recording errors.
@@ -237,6 +247,100 @@ const loadLive = (page) => () => page.goto(BASE + '/#/live', { waitUntil: 'domco
} finally { await ctx.close(); }
});
+ await step('#150: Matrix and Heat saved ON, Matrix clicked OFF during init: Heat checked, enabled and its layer shown', async () => {
+ const { ctx, page } = await newLivePage(browser, errors, { 'live-matrix-mode': 'true', 'meshcore-live-heatmap': 'true' });
+ try {
+ await withInitHeld(page, loadLive(page), async (p) => {
+ await clickDuringInit(p, 'liveMatrixToggle');
+ const st = await readState(p);
+ assert(st.t.liveHeatToggle.checked === true && st.t.liveHeatToggle.disabled === false,
+ 'Matrix OFF during init: Heat must be checked and enabled (checked=' + st.t.liveHeatToggle.checked + ', disabled=' + st.t.liveHeatToggle.disabled + ')');
+ });
+ let st = await readState(page);
+ assert(st.heatLayer, 'heat layer missing after init although Heat is saved ON and Matrix is OFF');
+ assert(st.t.liveHeatToggle.checked === true, 'Heat checkbox unchecked while the heat layer is shown');
+ assert(st.t.liveHeatToggle.stored === 'true', 'Heat setting changed (stored=' + st.t.liveHeatToggle.stored + ')');
+ // after init: Matrix ON hides the layer; Matrix OFF brings Heat back as saved
+ await page.evaluate(() => document.getElementById('liveMatrixToggle').click());
+ st = await readState(page);
+ assert(!st.heatLayer && !st.t.liveHeatToggle.checked && st.t.liveHeatToggle.disabled, 'Matrix ON after init: Heat must be hidden, unchecked and disabled');
+ await page.evaluate(() => document.getElementById('liveMatrixToggle').click());
+ st = await readState(page);
+ assert(st.t.liveHeatToggle.checked === true && !st.t.liveHeatToggle.disabled, 'Matrix OFF after init: Heat must be checked and enabled again (checked=' + st.t.liveHeatToggle.checked + ')');
+ assert(st.heatLayer, 'Matrix OFF after init: Heat is checked but its layer is not shown');
+ assert(st.t.liveHeatToggle.stored === 'true', 'Heat setting changed by Matrix (stored=' + st.t.liveHeatToggle.stored + ')');
+ } finally { await ctx.close(); }
+ });
+
+ await step('#150: Matrix ON and Heat OFF saved, Matrix clicked OFF during init: Heat stays unchecked with no layer', async () => {
+ const { ctx, page } = await newLivePage(browser, errors, { 'live-matrix-mode': 'true', 'meshcore-live-heatmap': 'false' });
+ try {
+ await withInitHeld(page, loadLive(page), async (p) => {
+ await clickDuringInit(p, 'liveMatrixToggle');
+ const st = await readState(p);
+ assert(st.t.liveHeatToggle.checked === false && st.t.liveHeatToggle.disabled === false,
+ 'Matrix OFF during init with Heat saved OFF: Heat must be unchecked and enabled (checked=' + st.t.liveHeatToggle.checked + ', disabled=' + st.t.liveHeatToggle.disabled + ')');
+ });
+ const st = await readState(page);
+ assert(!st.heatLayer, 'heat layer shown although Heat is saved OFF');
+ assert(st.t.liveHeatToggle.checked === false, 'Heat checkbox checked although Heat is saved OFF');
+ } finally { await ctx.close(); }
+ });
+
+ await step('#150 (#135 regression): leaving Live while init awaits loadNodes() and coming back keeps one listener per toggle', async () => {
+ const { ctx, page } = await newLivePage(browser, errors, {});
+ // this scenario's own errors: the abandoned init's continuation is not
+ // what this step checks (listed, not asserted)
+ const ownErrors = [];
+ page.on('pageerror', (e) => ownErrors.push(e.message));
+ try {
+ await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#pktTable', { state: 'attached' });
+ await page.waitForLoadState('networkidle');
+ // hold the first Live loadNodes() request: init is then past its first
+ // await and stuck in its second one
+ let armed = true, release, hit;
+ const released = new Promise((r) => { release = r; });
+ const intercepted = new Promise((r) => { hit = r; });
+ await page.route(/\/api\/nodes\?limit=/, async (route) => {
+ if (!armed) return route.continue();
+ armed = false;
+ hit();
+ await released;
+ await route.continue().catch((e) => { if (!page.isClosed()) throw e; });
+ });
+ try {
+ await page.evaluate(() => { location.hash = '#/live'; });
+ let timer;
+ await Promise.race([intercepted, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(
+ 'Live init never requested /api/nodes within 15s; the hold point this test relies on has moved')), 15000); })]);
+ clearTimeout(timer);
+ assert(!(await page.evaluate(() => !!window._liveWSHandler())), 'Live finished init while held');
+ await page.evaluate(() => { location.hash = '#/packets'; });
+ await page.waitForSelector('#pktTable', { state: 'attached' });
+ await page.evaluate(() => { location.hash = '#/live'; });
+ await page.locator('#liveHeatToggle').waitFor({ state: 'attached' });
+ } finally {
+ // api() shares the in-flight request, so the old and the new init
+ // both continue when it is answered (the old one first)
+ release();
+ }
+ await page.waitForFunction(() => !!window._liveWSHandler(), null, { timeout: 15000 });
+ await page.waitForLoadState('networkidle');
+ await page.waitForTimeout(500);
+ const doubled = [];
+ for (const t of ALL_TOGGLES) {
+ const before = (await readState(page)).writes[t.key] || 0;
+ await page.evaluate((id) => document.getElementById(id).click(), t.id);
+ const after = (await page.evaluate(() => Object.assign({}, window.__t125Writes)))[t.key] || 0;
+ if (after - before !== 1) doubled.push(t.id + '=' + (after - before));
+ }
+ assert(doubled.length === 0, 'one click wrote localStorage more or less than once on ' + doubled.length + ' of ' +
+ ALL_TOGGLES.length + ' toggles: ' + doubled.join(', ') + (ownErrors.length ? ' (page errors: ' + ownErrors.slice(0, 3).join(' | ') + ')' : ''));
+ if (ownErrors.length) console.log(' note: page errors from the abandoned init: ' + ownErrors.slice(0, 3).join(' | '));
+ } finally { await ctx.close(); }
+ });
+
await step('three SPA round trips keep one listener per toggle', async () => {
const { ctx, page } = await newLivePage(browser, errors, {});
try {
diff --git a/test-nav-drawer-1064-e2e.js b/test-nav-drawer-1064-e2e.js
index 9369d3b13..414985b9e 100644
--- a/test-nav-drawer-1064-e2e.js
+++ b/test-nav-drawer-1064-e2e.js
@@ -273,6 +273,70 @@ async function edgeSwipe(page, x0, y0, x1, y1, steps) {
await wideCtx.close();
+ // (k) #150: the header title (and close button) must be readable on the
+ // header background in both themes. The light theme used to paint the
+ // header with --surface-2 (white) under the white --nav-text title.
+ for (const theme of ['light', 'dark']) {
+ await step(`(k) ${theme} theme: header title ≥ 4.5:1 and close button ≥ 3:1 on the header background`, async () => {
+ const ctx = await browser.newContext({ viewport: { width: 1024, height: 800 }, colorScheme: theme });
+ const p = await ctx.newPage();
+ p.setDefaultTimeout(10000);
+ await p.addInitScript((t) => { try { localStorage.setItem('meshcore-theme', t); } catch (_) {} }, theme);
+ try {
+ await p.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
+ await p.waitForFunction(() => !!(window.__navDrawer && window.__navDrawer.open));
+ await p.evaluate(() => window.__navDrawer.open());
+ await p.waitForSelector('[data-nav-drawer] .nav-drawer-title', { state: 'visible' });
+ const r = await p.evaluate(() => {
+ function rgba(s) {
+ const m = /rgba?\(([^)]+)\)/.exec(s);
+ if (!m) throw new Error('unparsed colour ' + s);
+ const v = m[1].split(/[\s,/]+/).filter(Boolean).map(Number);
+ return { r: v[0], g: v[1], b: v[2], a: v.length > 3 ? v[3] : 1 };
+ }
+ // opaque colour under el: its own background composited over its
+ // ancestors' (stopping at the first opaque one)
+ function backdrop(el) {
+ const layers = [];
+ for (let n = el; n; n = n.parentElement) {
+ const c = rgba(getComputedStyle(n).backgroundColor);
+ if (c.a > 0) layers.push(c);
+ if (c.a >= 1) break;
+ }
+ let out = { r: 255, g: 255, b: 255 };
+ for (let i = layers.length - 1; i >= 0; i--) {
+ const c = layers[i];
+ out = { r: c.r * c.a + out.r * (1 - c.a), g: c.g * c.a + out.g * (1 - c.a), b: c.b * c.a + out.b * (1 - c.a) };
+ }
+ return out;
+ }
+ function lum(c) {
+ const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
+ return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
+ }
+ function ratio(fg, bg) {
+ const a = lum(fg), b = lum(bg);
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
+ }
+ const header = document.querySelector('[data-nav-drawer] .nav-drawer-header');
+ const title = header.querySelector('.nav-drawer-title');
+ const close = header.querySelector('.nav-drawer-close');
+ const bg = backdrop(header);
+ return {
+ theme: document.documentElement.getAttribute('data-theme'),
+ bg: getComputedStyle(header).backgroundColor,
+ title: getComputedStyle(title).color,
+ titleRatio: ratio(rgba(getComputedStyle(title).color), bg),
+ closeRatio: ratio(rgba(getComputedStyle(close).color), backdrop(close)),
+ };
+ });
+ assert(r.theme === theme, 'data-theme is ' + r.theme + ', want ' + theme);
+ assert(r.titleRatio >= 4.5, `title ${r.title} on header ${r.bg}: ${r.titleRatio.toFixed(2)}:1 < 4.5:1`);
+ assert(r.closeRatio >= 3, `close button on header ${r.bg}: ${r.closeRatio.toFixed(2)}:1 < 3:1`);
+ } finally { await ctx.close(); }
+ });
+ }
+
// ── Narrow viewport (Option A): drawer disabled ──
const narrowCtx = await browser.newContext({ viewport: { width: 360, height: 800 } });
const narrow = await narrowCtx.newPage();