From 696aecc10d7813339b63fbdbc20d0159cc2604cd Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 18 Jul 2026 18:48:15 +0200 Subject: [PATCH] test: add coverage for scopeCellHtml and the column-prefs backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per bot review on PR #1852 (comment 5011643190): two changes in the recent delta had zero test coverage, which the repo's TDD policy (AGENTS.md § Test-First Development) requires for both net-new UI helpers and bug fixes on existing UI. - scopeCellHtml (public/app.js): unit tests for all three states (non-transport dash, resolved region, unresolved "unknown") plus an XSS-escaping check. - Column-prefs backfill (public/packets.js): extracted the inline reconciliation logic into a standalone reconcileVisibleCols(), exposed via _packetsTestAPI like the rest of packets.js's pure helpers, and unit tested directly instead of only through the DOM. Writing the test surfaced a real bug in the original backfill: it couldn't distinguish "column didn't exist yet" from "user explicitly unchecked this column" — both look identical as a missing key in the saved array, so any existing column a user had hidden (e.g. Observer) would get silently resurrected on next page load. Fixed by persisting a second "known columns" baseline (packets-visible-cols-known) alongside the visibility array: a missing key only gets backfilled as newly-visible if it's also absent from the known-columns baseline: a pre-existing key missing from the saved array is a deliberate user choice and stays hidden. Falls back to the pre-#1852 column list for visitors whose saved prefs predate persisting the baseline at all. Also registers scopeCellHtml in .eslintrc.json's cross-file globals list (same treatment as transportBadge) — packets.js referencing it was tripping no-undef since app.js and packets.js are separate non-module scripts. Co-Authored-By: Claude Sonnet 5 --- .eslintrc.json | 1 + public/packets.js | 42 +++++++++++++++++++++++--------- test-frontend-helpers.js | 21 ++++++++++++++++ test-packets.js | 52 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 11 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index a0c09c18..67087ec0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -266,6 +266,7 @@ "require": "readonly", "routeLayer": "readonly", "routeTypeName": "readonly", + "scopeCellHtml": "readonly", "setupPullToReconnect": "readonly", "syncBadgeColors": "readonly", "timeAgo": "readonly", diff --git a/public/packets.js b/public/packets.js index b5ca9254..90feb101 100644 --- a/public/packets.js +++ b/public/packets.js @@ -729,6 +729,31 @@ let regionMap = {}; const TYPE_NAMES = SHORT_BY_ID; function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; } + + // Column keys that existed before the Scope column (#1852) — the "known" + // baseline for a saved array from before packets-visible-cols-known was + // ever persisted. A key missing from `saved` is only auto-shown if it's + // ALSO missing from `known`; a pre-existing key missing from `saved` + // means the user explicitly hid it, and must stay hidden. + const LEGACY_COL_KEYS = ['region', 'time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details']; + + // Reconcile a saved column-visibility array (#71) against the current + // COL_DEFS, so a since-added column (e.g. "scope") gets shown by default + // for returning visitors instead of staying hidden forever — while a + // column the user explicitly unchecked (present in `known` but absent + // from `saved`) stays hidden. `known` is the column-key set from the last + // time prefs were saved; falls back to LEGACY_COL_KEYS when absent + // (a visitor whose saved prefs predate persisting it at all). + function reconcileVisibleCols(saved, known, colDefs, defaultHidden) { + if (!saved) return colDefs.map(c => c.key).filter(k => !defaultHidden.includes(k)); + const knownSet = new Set(known && known.length ? known : LEGACY_COL_KEYS); + const result = saved.slice(); + for (const c of colDefs) { + if (knownSet.has(c.key)) continue; + if (!result.includes(c.key) && !defaultHidden.includes(c.key)) result.push(c.key); + } + return result; + } const isMobile = window.innerWidth <= 1024; const PACKET_LIMIT = isMobile ? 1000 : 50000; let savedTimeWindowMin = Number(localStorage.getItem('meshcore-time-window')); @@ -2021,19 +2046,12 @@ // badge (#1188) renders on mobile. Without observer in scope the user // can't see who heard the packet at all. const defaultHidden = isNarrow ? ['region', 'hash', 'path', 'rpt', 'size', 'scope'] : ['region']; - let visibleCols; + let savedCols, knownCols; try { - visibleCols = JSON.parse(localStorage.getItem('packets-visible-cols')); + savedCols = JSON.parse(localStorage.getItem('packets-visible-cols')); + knownCols = JSON.parse(localStorage.getItem('packets-visible-cols-known')); } catch {} - if (!visibleCols) { - visibleCols = COL_DEFS.map(c => c.key).filter(k => !defaultHidden.includes(k)); - } else { - // A saved preference predates a since-added column (e.g. "scope") — - // default new columns to visible instead of silently hiding them. - for (const c of COL_DEFS) { - if (!visibleCols.includes(c.key) && !defaultHidden.includes(c.key)) visibleCols.push(c.key); - } - } + let visibleCols = reconcileVisibleCols(savedCols, knownCols, COL_DEFS, defaultHidden); const colMenu = document.getElementById('colToggleMenu'); const pktTable = document.getElementById('pktTable'); function applyColVisibility() { @@ -2041,6 +2059,7 @@ pktTable.classList.toggle('hide-col-' + c.key, !visibleCols.includes(c.key)); }); localStorage.setItem('packets-visible-cols', JSON.stringify(visibleCols)); + localStorage.setItem('packets-visible-cols-known', JSON.stringify(COL_DEFS.map(c => c.key))); } colMenu.innerHTML = COL_DEFS.map(c => `` @@ -3887,6 +3906,7 @@ window._packetsTestAPI = { typeName, obsName, + reconcileVisibleCols, getDetailPreview, sortGroupChildren, getPathHopCount, diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index bd998942..6e2bdbf3 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -1958,6 +1958,27 @@ console.log('\n=== app.js: isTransportRoute + transportBadge ==='); assert.ok(html.includes('TRANSPORT_FLOOD'), 'should contain route type name in title'); }); test('transportBadge(1) returns empty string', () => assert.strictEqual(transportBadge(1), '')); + + const scopeCellHtml = ctx.scopeCellHtml; + test('scopeCellHtml non-transport route returns em-dash', () => { + assert.strictEqual(scopeCellHtml(1, 'dk'), '—'); + assert.strictEqual(scopeCellHtml(2, ''), '—'); + }); + test('scopeCellHtml transport route with resolved scope shows the region name', () => { + const html = scopeCellHtml(0, 'dk-oj'); + assert.ok(html.includes('badge-scope'), 'should contain badge-scope class'); + assert.ok(!html.includes('badge-scope-unknown'), 'resolved scope should not use the unknown modifier'); + assert.ok(html.includes('dk-oj'), 'should contain the region name'); + }); + test('scopeCellHtml transport route with no match shows muted unknown', () => { + const html = scopeCellHtml(3, ''); + assert.ok(html.includes('badge-scope-unknown'), 'should contain the unknown modifier class'); + assert.ok(html.includes('unknown'), 'should contain the word unknown'); + }); + test('scopeCellHtml escapes region names', () => { + const html = scopeCellHtml(0, ''); + assert.ok(!html.includes(' { + assert(typeof api.reconcileVisibleCols === 'function'); + }); + + test('no saved prefs: falls back to all columns minus defaultHidden', () => { + const result = api.reconcileVisibleCols(null, null, COL_DEFS, defaultHidden); + assert.deepStrictEqual(result, COL_DEFS.map(c => c.key).filter(k => k !== 'region')); + }); + + test('legacy saved prefs (no known-cols baseline yet) get the scope column backfilled', () => { + // Reproduces a real saved localStorage value from before the Scope + // column (#1852) existed, and before packets-visible-cols-known was + // ever persisted — must not stay permanently hidden. + const legacy = ['time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details']; + const result = api.reconcileVisibleCols(legacy, null, COL_DEFS, defaultHidden); + assert.ok(result.includes('scope'), 'scope column should be backfilled into a legacy saved array'); + }); + + test('a column the user explicitly hid (present in known, absent from saved) stays hidden', () => { + const known = ['region', 'time', 'hash', 'size', 'type', 'scope', 'observer', 'path', 'rpt', 'details']; + const saved = ['time', 'hash', 'size', 'type', 'scope', 'path', 'rpt', 'details']; // observer unchecked by user + const result = api.reconcileVisibleCols(saved, known, COL_DEFS, defaultHidden); + assert.ok(!result.includes('observer'), 'user-hidden column must not be resurrected'); + }); + + test('a genuinely new column (absent from known) gets backfilled even with a known-cols baseline', () => { + const known = ['region', 'time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details']; // no scope yet + const saved = ['time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details']; + const result = api.reconcileVisibleCols(saved, known, COL_DEFS, defaultHidden); + assert.ok(result.includes('scope'), 'a column absent from the known baseline should be backfilled'); + }); + + test('a column in defaultHidden is not force-added even if missing from saved and known', () => { + const legacy = ['time', 'hash', 'type', 'observer', 'path', 'rpt', 'details']; // no size, no scope + const narrowDefaultHidden = ['region', 'size', 'scope']; + const result = api.reconcileVisibleCols(legacy, null, COL_DEFS, narrowDefaultHidden); + assert.ok(!result.includes('scope'), 'columns in defaultHidden should not be backfilled'); + assert.ok(!result.includes('size'), 'columns in defaultHidden should not be backfilled'); + }); +} + console.log('\n=== packets.js: _invalidateRowCounts / _refreshRowCountsIfDirty (#410) ==='); { const ctx = loadPacketsSandbox();