test: add coverage for scopeCellHtml and the column-prefs backfill

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 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-18 18:48:15 +02:00
co-authored by Claude Sonnet 5
parent 557d14e50f
commit 696aecc10d
4 changed files with 105 additions and 11 deletions
+1
View File
@@ -266,6 +266,7 @@
"require": "readonly",
"routeLayer": "readonly",
"routeTypeName": "readonly",
"scopeCellHtml": "readonly",
"setupPullToReconnect": "readonly",
"syncBadgeColors": "readonly",
"timeAgo": "readonly",
+31 -11
View File
@@ -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 =>
`<label><input type="checkbox" data-col="${c.key}" ${visibleCols.includes(c.key) ? 'checked' : ''}> ${c.label}</label>`
@@ -3887,6 +3906,7 @@
window._packetsTestAPI = {
typeName,
obsName,
reconcileVisibleCols,
getDetailPreview,
sortGroupChildren,
getPathHopCount,
+21
View File
@@ -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, '<img src=x onerror=alert(1)>');
assert.ok(!html.includes('<img'), 'should not contain raw injected markup');
});
}
// ===== ANALYTICS.JS: Channel Sort =====
+52
View File
@@ -1019,6 +1019,58 @@ console.log('\n=== packets.js: page registration ===');
});
}
console.log('\n=== packets.js: reconcileVisibleCols (column-prefs backfill) ===');
{
const ctx = loadPacketsSandbox();
const api = ctx._packetsTestAPI;
const COL_DEFS = [
{ key: 'region' }, { key: 'time' }, { key: 'hash' }, { key: 'size' },
{ key: 'type' }, { key: 'scope' }, { key: 'observer' }, { key: 'path' },
{ key: 'rpt' }, { key: 'details' },
];
const defaultHidden = ['region'];
test('reconcileVisibleCols is exported', () => {
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();