mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
web: list only protocols in view in the map legend (#983)
This commit is contained in:
6 files changed
+760
-11
No files matched your search
@@ -29,6 +29,7 @@
|
||||
* Web: Reticulum rows match every other row's height; one dash glyph throughout the nodes table (SPEC RA11)
|
||||
* Data/Web: Reticulum nodes are keyed on the announcing identity, not on each destination - one node, several destinations (SPEC RE7/RE10)
|
||||
* Data: the ingestor's own node id is its primary announced identity (SPEC RE8)
|
||||
* Web: the map legend lists only protocols in view: a protocol toggled off, or with no node matching the filter, leaves it; Clear filters also clears the filter text (SPEC LP1)
|
||||
* Data: the host's own transport aspect is ingested when the stack has transport enabled (SPEC RE9)
|
||||
* Data: the Reticulum ingestor re-posts its host's own destinations every hour, so `rns.transport` and the host aspects stay fresh on a connection that never reconnects (SPEC RE8)
|
||||
* Data: `PROTOCOL=reticulum` needs `INGESTOR_NODE_ID` in Docker, when nothing on the ingestor's RNS stack announces, or when two local identities tie; otherwise the node id is derived once a local app announces (SPEC RE5/RE8)
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The map legend follows the search text and Clear filters (SPEC LP1).
|
||||
*
|
||||
* Boots the dashboard over a stub fetch that serves one node per protocol,
|
||||
* each active this week, then drives the real search input and its clear
|
||||
* button. By default the three legend columns are swapped for stubs after
|
||||
* boot, since the legend panel needs Leaflet. With `leaflet: true` a stub
|
||||
* Leaflet mounts the real panel, so its columns, role chips and Clear filters
|
||||
* button are the ones the app built. Everything between the user event and a
|
||||
* column's `display` runs as in the browser (listener, `applyFilter`, the
|
||||
* `/api/stats` callback, `applyProtocolVisibility`).
|
||||
*
|
||||
* @module app/__tests__/main-legend-protocols
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDomEnvironment } from './dom-environment.js';
|
||||
import { makeLeafletStub } from './main-app-leaflet-stub.js';
|
||||
import { MINIMAL_CONFIG, setupAppWithOptions } from './main-app-test-helpers.js';
|
||||
import { initializeApp } from '../main.js';
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
/** Column order of the map legend, left to right. */
|
||||
const LEGEND_ORDER = Object.freeze(['meshcore', 'meshtastic', 'reticulum']);
|
||||
|
||||
/**
|
||||
* One loaded node per protocol, all heard within the past 7 days. The
|
||||
* Meshtastic and MeshCore nodes have a position, so they render a marker;
|
||||
* a Reticulum announce carries none.
|
||||
*/
|
||||
const NODES = Object.freeze([
|
||||
{
|
||||
node_id: '!aa000001', short_name: 'ALFA', long_name: 'Alpha Tower',
|
||||
role: 'ROUTER', protocol: 'meshtastic', last_heard: NOW - 60,
|
||||
latitude: 52.52, longitude: 13.4,
|
||||
},
|
||||
{
|
||||
node_id: '!bb000002', short_name: 'BRAV', long_name: 'Bravo Repeater',
|
||||
role: 'REPEATER', protocol: 'meshcore', last_heard: NOW - 120,
|
||||
latitude: 52.53, longitude: 13.42,
|
||||
},
|
||||
{
|
||||
node_id: '!cc000003', short_name: 'CHRL', long_name: 'Charlie Peer',
|
||||
role: 'PEER', protocol: 'reticulum', last_heard: NOW - 180,
|
||||
},
|
||||
]);
|
||||
|
||||
/**
|
||||
* Build one `/api/stats` window bucket.
|
||||
*
|
||||
* @param {number} week Active nodes in the past 7 days.
|
||||
* @returns {{hour: number, day: number, week: number, month: number}} Bucket.
|
||||
*/
|
||||
const bucket = week => ({ hour: week, day: week, week, month: week });
|
||||
|
||||
/** `/api/stats` payload: every protocol has one node active this week. */
|
||||
const STATS_PAYLOAD = Object.freeze({
|
||||
total: { nodes: bucket(3), packets: { hour: 12 } },
|
||||
meshtastic: { nodes: bucket(1) },
|
||||
meshcore: { nodes: bucket(1) },
|
||||
reticulum: { nodes: bucket(1) },
|
||||
});
|
||||
|
||||
/** Click event stub for the handlers `legendClickHandler` wraps. */
|
||||
const CLICK = Object.freeze({ preventDefault() {}, stopPropagation() {} });
|
||||
|
||||
/**
|
||||
* Let a user event's `applyFilter` and its `/api/stats` callback settle.
|
||||
*
|
||||
* @returns {Promise<void>} Resolves after a short timer.
|
||||
*/
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
/**
|
||||
* Answer the dashboard's GETs: the node list, the stats snapshot, nothing else.
|
||||
*
|
||||
* @param {string} url Requested URL.
|
||||
* @returns {Promise<{ok: boolean, status: number, json: Function}>} Response stub.
|
||||
*/
|
||||
function stubFetch(url) {
|
||||
const path = String(url);
|
||||
let body = [];
|
||||
if (path.startsWith('/api/stats/')) body = [];
|
||||
else if (path.startsWith('/api/stats')) body = STATS_PAYLOAD;
|
||||
else if (path.startsWith('/api/nodes/')) body = null;
|
||||
else if (path.startsWith('/api/nodes')) body = NODES;
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub Leaflet whose controls mount: `addTo` runs the control's `onAdd`, and
|
||||
* `L.DomUtil.create` builds mock elements, so the real legend panel exists.
|
||||
*
|
||||
* @returns {{leaflet: Object, created: Array<Object>}} The stub and every
|
||||
* element `L.DomUtil.create` built, in creation order.
|
||||
*/
|
||||
function makeMountingLeafletStub() {
|
||||
const leaflet = makeLeafletStub();
|
||||
const created = [];
|
||||
leaflet.control = () => ({
|
||||
addTo(map) {
|
||||
this.container = typeof this.onAdd === 'function' ? this.onAdd(map) : null;
|
||||
return this;
|
||||
},
|
||||
});
|
||||
leaflet.DomUtil = {
|
||||
create(tagName, className, parent) {
|
||||
const element = document.createElement(tagName);
|
||||
element.className = className;
|
||||
className.split(' ').forEach(name => element.classList.add(name));
|
||||
if (parent) parent.appendChild(element);
|
||||
created.push(element);
|
||||
return element;
|
||||
},
|
||||
};
|
||||
leaflet.DomEvent = { ...leaflet.DomEvent, disableClickPropagation() {}, disableScrollPropagation() {} };
|
||||
return { leaflet, created };
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the app over the mounting Leaflet stub, with a `#map` container,
|
||||
* the three meta-row protocol toggles and, unless `searchBox` is false, the
|
||||
* search input and its clear button.
|
||||
*
|
||||
* @param {{searchBox: boolean}} options Whether the view has a search box.
|
||||
* @returns {{testUtils: Object, env: Object, leaflet: Object, created: Array<Object>, cleanup: Function}}
|
||||
* App handles; `cleanup` restores the globals.
|
||||
*/
|
||||
function setupAppWithLegendPanel({ searchBox }) {
|
||||
const env = createDomEnvironment({ includeBody: true });
|
||||
env.registerElement('map', env.createElement('div', 'map'));
|
||||
const ids = ['protocolToggleMeshcore', 'protocolToggleMeshtastic', 'protocolToggleReticulum'];
|
||||
if (searchBox) ids.push('filterInput', 'filterClear');
|
||||
for (const id of ids) env.registerElement(id, env.createElement(id === 'filterInput' ? 'input' : 'button', id));
|
||||
// initializeApp watches a media query to collapse the legend; the DOM stub has none.
|
||||
env.window.matchMedia = () => ({ matches: false, media: '', addEventListener() {}, removeEventListener() {} });
|
||||
const { leaflet, created } = makeMountingLeafletStub();
|
||||
const previousL = globalThis.L;
|
||||
globalThis.window.L = leaflet;
|
||||
globalThis.L = leaflet;
|
||||
const cleanup = () => {
|
||||
globalThis.L = previousL;
|
||||
env.cleanup();
|
||||
};
|
||||
try {
|
||||
const { _testUtils } = initializeApp(MINIMAL_CONFIG);
|
||||
return { testUtils: _testUtils, env, leaflet, created, cleanup };
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the legend columns for stubs (the panel is not mounted without Leaflet).
|
||||
*
|
||||
* @param {Object} testUtils App test utilities.
|
||||
* @returns {Object<string, {style: {display: string}}>} Column stubs by protocol.
|
||||
*/
|
||||
function injectColumnStubs(testUtils) {
|
||||
const columns = {
|
||||
meshcore: { style: { display: '' } },
|
||||
meshtastic: { style: { display: '' } },
|
||||
reticulum: { style: { display: '' } },
|
||||
};
|
||||
testUtils._setProtocolColElements(columns.meshcore, columns.meshtastic, columns.reticulum);
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the mounted legend columns to their protocols; the panel builds them
|
||||
* left to right.
|
||||
*
|
||||
* @param {Array<Object>} created Elements `L.DomUtil.create` built.
|
||||
* @returns {Object<string, Object>} Column elements by protocol.
|
||||
*/
|
||||
function mountedColumns(created) {
|
||||
const [meshcore, meshtastic, reticulum] = created.filter(element => element.classList.contains('legend-column'));
|
||||
return { meshcore, meshtastic, reticulum };
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the app with {@link NODES} loaded.
|
||||
*
|
||||
* @param {{leaflet?: boolean, searchBox?: boolean}} [options] `leaflet` mounts
|
||||
* the real legend panel; `searchBox: false` (with `leaflet`) boots a view
|
||||
* without the search input.
|
||||
* @returns {Promise<{
|
||||
* testUtils: Object,
|
||||
* columns: Object<string, Object>,
|
||||
* input: ?Object,
|
||||
* clearButton: ?Object,
|
||||
* typeFilter: function(string): Promise<void>,
|
||||
* clickClear: function(): Promise<void>,
|
||||
* clickClearFilters: function(): Promise<void>,
|
||||
* clickToggle: function(string): Promise<void>,
|
||||
* clickRoleChip: function(string, string): Promise<void>,
|
||||
* markersOnMap: function(): number,
|
||||
* shown: function(): Array<string>,
|
||||
* cleanup: function(): Promise<void>
|
||||
* }>} Harness for one test. The click helpers past `clickClear` and
|
||||
* `markersOnMap` need `leaflet`.
|
||||
*/
|
||||
async function bootLegendApp({ leaflet = false, searchBox = true } = {}) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch;
|
||||
let harness;
|
||||
try {
|
||||
harness = leaflet
|
||||
? setupAppWithLegendPanel({ searchBox })
|
||||
: setupAppWithOptions({ extraElements: ['filterInput', 'filterClear'] });
|
||||
await harness.testUtils.initialLoad;
|
||||
await harness.testUtils.flushCollectionBackfills();
|
||||
await settle();
|
||||
assert.equal(harness.testUtils.getLoadedNodeCount(), NODES.length, 'every stub node is loaded');
|
||||
} catch (error) {
|
||||
if (harness) harness.testUtils.stopAutoRefresh();
|
||||
globalThis.fetch = originalFetch;
|
||||
if (harness) harness.cleanup();
|
||||
throw error;
|
||||
}
|
||||
const { testUtils, env } = harness;
|
||||
const columns = leaflet ? mountedColumns(harness.created) : injectColumnStubs(testUtils);
|
||||
const input = env.document.getElementById('filterInput');
|
||||
// The clear button refocuses the input; the DOM stub has no focus().
|
||||
if (input) input.focus = () => {};
|
||||
const clearButton = env.document.getElementById('filterClear');
|
||||
|
||||
/**
|
||||
* Run an element's click listeners, then let the refilter settle.
|
||||
*
|
||||
* @param {Object} element Mock element.
|
||||
* @param {Object} [event] Event passed to the listeners.
|
||||
* @returns {Promise<void>} Resolves once settled.
|
||||
*/
|
||||
const click = async (element, event) => {
|
||||
element._listeners.get('click').forEach(handler => handler(event));
|
||||
await settle();
|
||||
};
|
||||
|
||||
return {
|
||||
testUtils,
|
||||
columns,
|
||||
input,
|
||||
clearButton,
|
||||
async typeFilter(text) {
|
||||
input.value = text;
|
||||
input._listeners.get('input').forEach(handler => handler());
|
||||
await settle();
|
||||
},
|
||||
clickClear: () => click(clearButton),
|
||||
clickClearFilters: () => click(harness.created.find(element => element.classList.contains('legend-reset')), CLICK),
|
||||
clickToggle: name => click(env.document.getElementById(`protocolToggle${name}`)),
|
||||
clickRoleChip: (protocol, role) => click(columns[protocol].childNodes.find(node => node.dataset?.role === role), CLICK),
|
||||
markersOnMap() {
|
||||
const { circleMarkers, markers, layerGroups } = harness.leaflet._recorded;
|
||||
const nodeMarkers = new Set([...circleMarkers, ...markers]);
|
||||
return layerGroups.reduce((count, group) => count + group._layers.filter(layer => nodeMarkers.has(layer)).length, 0);
|
||||
},
|
||||
shown: () => LEGEND_ORDER.filter(protocol => columns[protocol].style.display !== 'none'),
|
||||
async cleanup() {
|
||||
await settle();
|
||||
testUtils.stopAutoRefresh();
|
||||
globalThis.fetch = originalFetch;
|
||||
harness.cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a protocol whose nodes all fail the text filter leaves the map legend (LP1)', async () => {
|
||||
const app = await bootLegendApp();
|
||||
try {
|
||||
// Only the Meshtastic node matches. MeshCore and Reticulum keep their
|
||||
// 7-day activity and stay toggled on: the filter alone takes them out.
|
||||
await app.typeFilter('alpha');
|
||||
assert.deepEqual(app.shown(), ['meshtastic'], 'only the protocol with a matching node keeps its column');
|
||||
assert.equal(app.testUtils.hiddenProtocols.size, 0, 'the text filter toggles no protocol off');
|
||||
|
||||
// Same normalisation as the table: trimmed and case-insensitive, across
|
||||
// the node id, short name and long name.
|
||||
await app.typeFilter(' BRAVO ');
|
||||
assert.deepEqual(app.shown(), ['meshcore'], 'the column set follows the filter text as it changes');
|
||||
await app.typeFilter('!cc0000');
|
||||
assert.deepEqual(app.shown(), ['reticulum'], 'a node id match keeps its protocol listed');
|
||||
} finally {
|
||||
await app.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('clearing the text filter brings the legend columns back (LP1)', async () => {
|
||||
const app = await bootLegendApp();
|
||||
try {
|
||||
await app.typeFilter('zz-no-such-node-zz');
|
||||
assert.deepEqual(app.shown(), [], 'a filter that matches no node leaves no protocol column');
|
||||
|
||||
await app.typeFilter('');
|
||||
assert.deepEqual(app.shown(), LEGEND_ORDER, 'deleting the text returns every column');
|
||||
|
||||
await app.typeFilter('charlie');
|
||||
assert.deepEqual(app.shown(), ['reticulum']);
|
||||
await app.clickClear();
|
||||
assert.deepEqual(app.shown(), LEGEND_ORDER, 'the clear button returns every column');
|
||||
} finally {
|
||||
await app.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('role filters never take a protocol column out of the map legend (LP1)', async () => {
|
||||
const app = await bootLegendApp();
|
||||
try {
|
||||
const { testUtils } = app;
|
||||
// Switch off the role of the only MeshCore node: it leaves the table and
|
||||
// the map, but its column must stay so its role chips can bring it back.
|
||||
testUtils.activeRoleFilters.add(testUtils.makeRoleFilterKey('REPEATER', 'meshcore'));
|
||||
assert.equal(testUtils.matchesRoleFilter(NODES[1]), false, 'the MeshCore node is filtered out by role');
|
||||
|
||||
await app.typeFilter('');
|
||||
assert.deepEqual(app.shown(), LEGEND_ORDER, 'role filters alone hide no column');
|
||||
|
||||
// The text matches only that role-filtered node: MeshCore keeps its
|
||||
// column, Meshtastic and Reticulum (no matching node) leave.
|
||||
await app.typeFilter('bravo');
|
||||
assert.deepEqual(app.shown(), ['meshcore'], 'the text filter decides, the role filter does not');
|
||||
} finally {
|
||||
await app.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('Clear filters also empties the search box and brings the legend columns back (LP1)', async () => {
|
||||
const app = await bootLegendApp({ leaflet: true });
|
||||
try {
|
||||
const { testUtils } = app;
|
||||
assert.deepEqual(app.shown(), LEGEND_ORDER, 'the mounted legend lists all three protocols');
|
||||
assert.equal(app.markersOnMap(), 2, 'the Meshtastic and MeshCore nodes are on the map');
|
||||
|
||||
// A role chip, a protocol toggle and a search that matches nothing.
|
||||
await app.clickRoleChip('meshcore', 'REPEATER');
|
||||
await app.clickToggle('Reticulum');
|
||||
await app.typeFilter('zz-no-such-node-zz');
|
||||
assert.equal(testUtils.activeRoleFilters.size, 1);
|
||||
assert.ok(testUtils.hiddenProtocols.has('reticulum'));
|
||||
assert.deepEqual(app.shown(), [], 'a search that matches nothing leaves no column');
|
||||
assert.equal(app.markersOnMap(), 0);
|
||||
|
||||
const repaints = testUtils.getRenderCount();
|
||||
await app.clickClearFilters();
|
||||
assert.equal(app.input.value, '', 'Clear filters empties the search box');
|
||||
assert.equal(app.clearButton.hidden, true, "the search box's clear button hides with the text");
|
||||
assert.equal(testUtils.activeRoleFilters.size, 0, 'the role filters are reset');
|
||||
assert.equal(testUtils.hiddenProtocols.size, 0, 'the protocol toggles are reset');
|
||||
assert.deepEqual(app.shown(), LEGEND_ORDER, 'every active column is back');
|
||||
assert.ok(testUtils.getRenderCount() > repaints, 'the table, map and chat refilter');
|
||||
assert.equal(app.markersOnMap(), 2, 'both nodes are back on the map');
|
||||
} finally {
|
||||
await app.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the search box's clear button still clears only the search text (LP1)", async () => {
|
||||
const app = await bootLegendApp({ leaflet: true });
|
||||
try {
|
||||
const { testUtils } = app;
|
||||
await app.clickRoleChip('meshcore', 'REPEATER');
|
||||
await app.clickToggle('Reticulum');
|
||||
await app.typeFilter('zz-no-such-node-zz');
|
||||
assert.deepEqual(app.shown(), []);
|
||||
|
||||
await app.clickClear();
|
||||
assert.equal(app.input.value, '', 'the search box is empty');
|
||||
assert.equal(testUtils.activeRoleFilters.size, 1, 'the role filter stays');
|
||||
assert.ok(testUtils.hiddenProtocols.has('reticulum'), 'Reticulum stays toggled off');
|
||||
assert.deepEqual(app.shown(), ['meshcore', 'meshtastic'], 'the columns the search removed are back');
|
||||
assert.equal(app.markersOnMap(), 1, 'the MeshCore node stays role-filtered off the map');
|
||||
} finally {
|
||||
await app.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('Clear filters still resets the role filters and toggles on a view without a search box (LP1)', async () => {
|
||||
const app = await bootLegendApp({ leaflet: true, searchBox: false });
|
||||
try {
|
||||
const { testUtils } = app;
|
||||
assert.equal(app.input, null, 'this view has no search box');
|
||||
await app.clickRoleChip('meshcore', 'REPEATER');
|
||||
await app.clickToggle('Reticulum');
|
||||
assert.deepEqual(app.shown(), ['meshcore', 'meshtastic']);
|
||||
|
||||
await app.clickClearFilters();
|
||||
assert.equal(testUtils.activeRoleFilters.size, 0);
|
||||
assert.equal(testUtils.hiddenProtocols.size, 0);
|
||||
assert.deepEqual(app.shown(), LEGEND_ORDER);
|
||||
} finally {
|
||||
await app.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -604,6 +604,98 @@ test('applyProtocolVisibility handles missing per-protocol data gracefully', ()
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyProtocolVisibility x meta-row protocol toggles (map-legend regression)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stats with 7-day activity on all three protocols, so every meta-row toggle
|
||||
* stays visible and the un-strand rule never fires.
|
||||
*/
|
||||
const ALL_ACTIVE_STATS = Object.freeze({
|
||||
meshcore: { hour: 1, day: 2, week: 5, month: 10 },
|
||||
meshtastic: { hour: 2, day: 3, week: 8, month: 15 },
|
||||
reticulum: { hour: 1, day: 3, week: 3, month: 3 },
|
||||
});
|
||||
|
||||
/**
|
||||
* Inject three visible legend column stubs.
|
||||
*
|
||||
* @param {Object} testUtils App test utilities.
|
||||
* @returns {{mcCol: Object, mtCol: Object, rtCol: Object}} Column stubs.
|
||||
*/
|
||||
function injectVisibleColumns(testUtils) {
|
||||
const mcCol = { style: { display: '' } };
|
||||
const mtCol = { style: { display: '' } };
|
||||
const rtCol = { style: { display: '' } };
|
||||
testUtils._setProtocolColElements(mcCol, mtCol, rtCol);
|
||||
return { mcCol, mtCol, rtCol };
|
||||
}
|
||||
|
||||
test('applyProtocolVisibility drops the legend column of a protocol hidden with its toggle', () => {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
const { mcCol, mtCol, rtCol } = injectVisibleColumns(testUtils);
|
||||
// The user turned MeshCore off with its meta-row toggle. Every protocol
|
||||
// keeps 7-day activity, so the toggle (the way back) stays on screen.
|
||||
testUtils.hiddenProtocols.add('meshcore');
|
||||
|
||||
testUtils.applyProtocolVisibility(ALL_ACTIVE_STATS);
|
||||
|
||||
assert.equal(mcCol.style.display, 'none', 'a protocol hidden from view leaves the map legend');
|
||||
assert.equal(mtCol.style.display, '', 'a protocol in view keeps its legend column');
|
||||
assert.equal(rtCol.style.display, '', 'a protocol in view keeps its legend column');
|
||||
assert.ok(testUtils.hiddenProtocols.has('meshcore'), 'the explicit hide is preserved');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('applyProtocolVisibility returns a legend column when its protocol is shown again', () => {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
const { mcCol, mtCol, rtCol } = injectVisibleColumns(testUtils);
|
||||
testUtils.hiddenProtocols.add('meshcore');
|
||||
testUtils.hiddenProtocols.add('reticulum');
|
||||
testUtils.applyProtocolVisibility(ALL_ACTIVE_STATS);
|
||||
assert.equal(mcCol.style.display, 'none', 'hidden MeshCore leaves the legend');
|
||||
assert.equal(rtCol.style.display, 'none', 'hidden Reticulum leaves the legend');
|
||||
|
||||
testUtils.hiddenProtocols.delete('reticulum');
|
||||
testUtils.applyProtocolVisibility(ALL_ACTIVE_STATS);
|
||||
|
||||
assert.equal(rtCol.style.display, '', 'Reticulum shown again: its column returns');
|
||||
assert.equal(mcCol.style.display, 'none', 'MeshCore still hidden: its column stays out');
|
||||
assert.equal(mtCol.style.display, '');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('applyProtocolVisibility gives an un-stranded protocol its column back in the same pass', async () => {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
// Let the un-strand's applyFilter() async fallout settle before cleanup.
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
|
||||
try {
|
||||
const { mcCol } = injectVisibleColumns(testUtils);
|
||||
testUtils.hiddenProtocols.add('meshcore');
|
||||
// MeshCore is now the only active protocol: its toggle disappears, the
|
||||
// un-strand rule drops it from hiddenProtocols, and its nodes return.
|
||||
// Its column must return with them, not one stats round-trip later.
|
||||
testUtils.applyProtocolVisibility({
|
||||
meshcore: { hour: 1, day: 2, week: 5, month: 10 },
|
||||
meshtastic: { hour: 0, day: 0, week: 0, month: 0 },
|
||||
reticulum: { hour: 0, day: 0, week: 0, month: 0 },
|
||||
});
|
||||
|
||||
assert.ok(!testUtils.hiddenProtocols.has('meshcore'), 'the chipless protocol is un-stranded');
|
||||
assert.equal(mcCol.style.display, '', 'the un-stranded protocol is back in view, so is its column');
|
||||
await settle();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// restartAutoRefresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unit tests for {@link legendProtocolsInView} (SPEC LP1).
|
||||
*
|
||||
* @module app/__tests__/map-legend-protocols
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { legendProtocolsInView } from '../map-legend-protocols.js';
|
||||
|
||||
/** Stats with 7-day activity on all three protocols. */
|
||||
const ALL_ACTIVE = Object.freeze({
|
||||
meshcore: { week: 4 },
|
||||
meshtastic: { week: 12 },
|
||||
reticulum: { week: 3 },
|
||||
});
|
||||
|
||||
/** One node per protocol, matched by long name. */
|
||||
const NODES = Object.freeze([
|
||||
{ node_id: '!aa000001', long_name: 'Alpha Tower', protocol: 'meshtastic', role: 'ROUTER' },
|
||||
{ node_id: '!bb000002', long_name: 'Bravo Repeater', protocol: 'meshcore', role: 'REPEATER' },
|
||||
{ node_id: '!cc000003', long_name: 'Charlie Peer', protocol: 'reticulum', role: 'PEER' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* Text filter stub: case-insensitive substring on the long name.
|
||||
*
|
||||
* @param {Object} node Node record.
|
||||
* @param {string} query Normalised query.
|
||||
* @returns {boolean} Whether the node matches.
|
||||
*/
|
||||
const matchesText = (node, query) => String(node.long_name || '').toLowerCase().includes(query);
|
||||
|
||||
/**
|
||||
* Protocol filter stub that shows every node.
|
||||
*
|
||||
* @returns {boolean} Always true.
|
||||
*/
|
||||
const showAll = () => true;
|
||||
|
||||
/**
|
||||
* Run {@link legendProtocolsInView} with defaults and return the listed
|
||||
* protocols in column order.
|
||||
*
|
||||
* @param {Object} overrides Parameters to replace.
|
||||
* @returns {Array<string>} Listed protocols.
|
||||
*/
|
||||
function listed(overrides = {}) {
|
||||
return [
|
||||
...legendProtocolsInView({
|
||||
stats: ALL_ACTIVE,
|
||||
hiddenProtocols: new Set(),
|
||||
nodes: NODES,
|
||||
query: '',
|
||||
matchesText,
|
||||
matchesProtocol: showAll,
|
||||
...overrides,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
test('legendProtocolsInView lists every active protocol, in column order, with nothing hidden or filtered (LP1)', () => {
|
||||
assert.deepEqual(listed(), ['meshcore', 'meshtastic', 'reticulum']);
|
||||
// Without a text filter the loaded nodes are not consulted at all.
|
||||
assert.deepEqual(listed({ nodes: [] }), ['meshcore', 'meshtastic', 'reticulum']);
|
||||
assert.deepEqual(listed({ query: undefined, nodes: undefined }), ['meshcore', 'meshtastic', 'reticulum']);
|
||||
});
|
||||
|
||||
test('legendProtocolsInView drops a protocol without 7-day activity (LP1)', () => {
|
||||
assert.deepEqual(listed({ stats: { meshcore: { week: 0 }, meshtastic: { week: 12 } } }), ['meshtastic']);
|
||||
assert.deepEqual(listed({ stats: { week: 5 } }), [], 'no per-protocol data reads as no activity');
|
||||
assert.deepEqual(listed({ stats: null }), []);
|
||||
});
|
||||
|
||||
test('legendProtocolsInView drops a protocol hidden with its meta-row toggle (LP1)', () => {
|
||||
assert.deepEqual(listed({ hiddenProtocols: new Set(['meshcore']) }), ['meshtastic', 'reticulum']);
|
||||
assert.deepEqual(listed({ hiddenProtocols: new Set(['meshcore', 'meshtastic', 'reticulum']) }), []);
|
||||
});
|
||||
|
||||
test('legendProtocolsInView keeps only protocols with a node passing the text filter while one is set (LP1)', () => {
|
||||
const calls = [];
|
||||
const recordingText = (node, query) => {
|
||||
calls.push(query);
|
||||
return matchesText(node, query);
|
||||
};
|
||||
assert.deepEqual(listed({ query: 'alpha', matchesText: recordingText }), ['meshtastic']);
|
||||
assert.ok(calls.length > 0 && calls.every(query => query === 'alpha'), 'the text filter sees the query');
|
||||
assert.deepEqual(listed({ query: 'r' }), ['meshcore', 'meshtastic', 'reticulum'], 'every protocol has a match');
|
||||
assert.deepEqual(listed({ query: 'zz-no-such-node-zz' }), [], 'no match lists no protocol');
|
||||
});
|
||||
|
||||
test('legendProtocolsInView also requires the node to pass the protocol filter (LP1)', () => {
|
||||
const hideMeshcoreNodes = node => node.protocol !== 'meshcore';
|
||||
assert.deepEqual(listed({ query: 'bravo', matchesProtocol: hideMeshcoreNodes }), []);
|
||||
assert.deepEqual(listed({ query: 'e', matchesProtocol: hideMeshcoreNodes }), ['meshtastic', 'reticulum']);
|
||||
});
|
||||
|
||||
test('legendProtocolsInView counts a node without a protocol as Meshtastic (LP1)', () => {
|
||||
const legacy = [{ node_id: '!dd000004', long_name: 'Legacy Box', protocol: null }];
|
||||
assert.deepEqual(listed({ query: 'legacy', nodes: legacy }), ['meshtastic']);
|
||||
});
|
||||
|
||||
test('legendProtocolsInView skips empty node slots and a missing node list (LP1)', () => {
|
||||
assert.deepEqual(listed({ query: 'charlie', nodes: [null, undefined, NODES[2]] }), ['reticulum']);
|
||||
assert.deepEqual(listed({ query: 'charlie', nodes: undefined }), [], 'nothing loaded, nothing matches');
|
||||
});
|
||||
|
||||
test('legendProtocolsInView never lists a match whose protocol is inactive or toggled off (LP1)', () => {
|
||||
assert.deepEqual(listed({ query: 'bravo', stats: { meshcore: { week: 0 }, meshtastic: { week: 12 } } }), []);
|
||||
assert.deepEqual(listed({ query: 'bravo', hiddenProtocols: new Set(['meshcore']) }), []);
|
||||
});
|
||||
@@ -61,6 +61,7 @@ import { createMapAutoFitController } from './map-auto-fit-controller.js';
|
||||
import { resolveAutoFitBoundsConfig } from './map-auto-fit-settings.js';
|
||||
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
|
||||
import { resolveLegendVisibility, legendToggleLabel } from './map-legend-visibility.js';
|
||||
import { legendProtocolsInView } from './map-legend-protocols.js';
|
||||
import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focus.js';
|
||||
import { createMapCenterResetHandler } from './map-center-reset.js';
|
||||
import { enhanceCoordinateCell } from './nodes-coordinate-links.js';
|
||||
@@ -1495,9 +1496,8 @@ export function initializeApp(config) {
|
||||
const activeRoleFilters = new Set();
|
||||
/** @type {Map<string, HTMLElement>} Compound key → legend button element. */
|
||||
const legendRoleButtons = new Map();
|
||||
/** @type {Set<string>} Protocols hidden by the user via legend toggles. */
|
||||
/** @type {Set<string>} Protocols hidden by the user with the meta-row protocol toggles. */
|
||||
const hiddenProtocols = new Set();
|
||||
const legendProtocolButtons = new Map();
|
||||
|
||||
/**
|
||||
* Wrap a legend button click handler so it always calls
|
||||
@@ -2218,6 +2218,10 @@ export function initializeApp(config) {
|
||||
resetButton.addEventListener('click', legendClickHandler(() => {
|
||||
activeRoleFilters.clear();
|
||||
hiddenProtocols.clear();
|
||||
// The search text is a filter too (SPEC LP1): applyFilter then hides
|
||||
// the box's own clear button and refilters the table, map and chat,
|
||||
// as clearing the box does.
|
||||
if (filterInput) filterInput.value = '';
|
||||
updateLegendRoleFiltersUI();
|
||||
applyFilter();
|
||||
}));
|
||||
@@ -5849,10 +5853,14 @@ export function initializeApp(config) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide/show UI elements based on per-protocol activity in the past 7 days.
|
||||
* Hide/show UI elements based on per-protocol activity in the past 7 days
|
||||
* and on which protocols are in view (SPEC LP1).
|
||||
*
|
||||
* Hides the Charts nav link when meshtastic has no active nodes, and hides
|
||||
* legend columns for protocols with zero weekly activity.
|
||||
* Hides the Charts nav link when meshtastic has no active nodes, shows a
|
||||
* protocol's meta-row toggle only while two or more protocols are active,
|
||||
* and lists in the map legend only the protocols in view (SPEC LP1, decided
|
||||
* by {@link legendProtocolsInView}). Runs from {@link applyFilter}'s stats
|
||||
* callback, so the legend follows the toggles and the filter text.
|
||||
*
|
||||
* @param {{meshcore?: {week: number}, meshtastic?: {week: number}, reticulum?: {week: number}}} stats Stats from /api/stats.
|
||||
* @returns {void}
|
||||
@@ -5862,11 +5870,6 @@ export function initializeApp(config) {
|
||||
const meshtasticWeek = stats?.meshtastic?.week ?? 0;
|
||||
const reticulumWeek = stats?.reticulum?.week ?? 0;
|
||||
|
||||
// Hide legend columns for protocols with no activity in the past 7 days.
|
||||
if (meshcoreColEl) meshcoreColEl.style.display = meshcoreWeek === 0 ? 'none' : '';
|
||||
if (meshtasticColEl) meshtasticColEl.style.display = meshtasticWeek === 0 ? 'none' : '';
|
||||
if (reticulumColEl) reticulumColEl.style.display = reticulumWeek === 0 ? 'none' : '';
|
||||
|
||||
// Show a protocol's toggle button only when that protocol has weekly
|
||||
// activity and at least one other protocol does too — filtering is
|
||||
// pointless when only one protocol is present. (Generalises the original
|
||||
@@ -5895,6 +5898,25 @@ export function initializeApp(config) {
|
||||
unstranded = true;
|
||||
}
|
||||
}
|
||||
// List in the legend only the protocols in view (SPEC LP1): 7-day
|
||||
// activity, not toggled off, and, while a text filter is set, a node
|
||||
// passing the text and protocol filters. After the un-strand pass, so a
|
||||
// protocol it brought back regains its column in the same pass.
|
||||
const legendProtocols = legendProtocolsInView({
|
||||
stats,
|
||||
hiddenProtocols,
|
||||
nodes: allNodes,
|
||||
query: normaliseChatFilterQuery(filterInput ? filterInput.value : ''),
|
||||
matchesText: matchesTextFilter,
|
||||
matchesProtocol: matchesProtocolFilter,
|
||||
});
|
||||
for (const [col, protocol] of [
|
||||
[meshcoreColEl, 'meshcore'],
|
||||
[meshtasticColEl, 'meshtastic'],
|
||||
[reticulumColEl, 'reticulum'],
|
||||
]) {
|
||||
if (col) col.style.display = legendProtocols.has(protocol) ? '' : 'none';
|
||||
}
|
||||
if (unstranded) {
|
||||
// Re-run the same sync path a chip click uses so the nodes reappear.
|
||||
// Bounded: the dropped protocols are no longer in the set, so the
|
||||
@@ -5977,7 +5999,6 @@ export function initializeApp(config) {
|
||||
activeRoleFilters,
|
||||
hiddenProtocols,
|
||||
legendRoleButtons,
|
||||
legendProtocolButtons,
|
||||
updateTitleCount,
|
||||
updateLegendProtocolCounts,
|
||||
updateProtocolToggleCounts,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which protocols the map legend lists (SPEC LP1).
|
||||
*
|
||||
* The legend has one column per protocol. A column keys the markers of its
|
||||
* protocol, so it is listed only while that protocol is in view.
|
||||
*
|
||||
* @module app/map-legend-protocols
|
||||
*/
|
||||
|
||||
import { normalizeFilterProtocol } from './main/filter-helpers.js';
|
||||
|
||||
/**
|
||||
* Protocols that own a map-legend column, in column order.
|
||||
*
|
||||
* @type {ReadonlyArray<string>}
|
||||
*/
|
||||
const LEGEND_PROTOCOLS = Object.freeze(['meshcore', 'meshtastic', 'reticulum']);
|
||||
|
||||
/**
|
||||
* Collect the protocols that have at least one node passing a predicate.
|
||||
*
|
||||
* Nodes are bucketed with {@link normalizeFilterProtocol}, the mapping the
|
||||
* role filters use, so a legacy node without a protocol counts as Meshtastic.
|
||||
*
|
||||
* @param {Array<Object>|undefined} nodes Loaded node records.
|
||||
* @param {function(Object): boolean} passes Predicate a node must pass.
|
||||
* @returns {Set<string>} Protocol tokens with at least one passing node.
|
||||
*/
|
||||
function protocolsWithPassingNodes(nodes, passes) {
|
||||
const found = new Set();
|
||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||
if (node && passes(node)) found.add(normalizeFilterProtocol(node.protocol));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which protocols the map legend lists, one column each (SPEC LP1).
|
||||
*
|
||||
* A protocol is listed while all three hold:
|
||||
*
|
||||
* 1. it had activity in the past 7 days (`stats[protocol].week` above 0);
|
||||
* 2. it is not hidden with its meta-row toggle (`hiddenProtocols`);
|
||||
* 3. while a text filter is set, at least one loaded node of that protocol
|
||||
* passes the text and protocol filters.
|
||||
*
|
||||
* Role filters are not part of the third condition: a protocol whose roles
|
||||
* are all switched off keeps its column, so its role chips stay reachable.
|
||||
* With no text filter set, the third condition does not apply.
|
||||
*
|
||||
* @param {Object} params Inputs.
|
||||
* @param {?Object} params.stats Normalised `/api/stats` snapshot; a missing
|
||||
* per-protocol `week` count reads as 0.
|
||||
* @param {Set<string>} params.hiddenProtocols Protocols hidden with the
|
||||
* meta-row toggles.
|
||||
* @param {Array<Object>} [params.nodes] Loaded node records.
|
||||
* @param {string} [params.query] Normalised text filter; empty when none is set.
|
||||
* @param {function(Object, string): boolean} [params.matchesText] Text filter:
|
||||
* whether a node matches the query.
|
||||
* @param {function(Object): boolean} [params.matchesProtocol] Protocol filter:
|
||||
* whether a node's protocol is shown.
|
||||
* @returns {Set<string>} Protocols whose legend column shows.
|
||||
*/
|
||||
export function legendProtocolsInView({ stats, hiddenProtocols, nodes, query, matchesText, matchesProtocol }) {
|
||||
// Only a set text filter can empty a column of nodes; skip the scan otherwise.
|
||||
const matched = query
|
||||
? protocolsWithPassingNodes(nodes, node => matchesText(node, query) && matchesProtocol(node))
|
||||
: null;
|
||||
return new Set(
|
||||
LEGEND_PROTOCOLS.filter(
|
||||
protocol =>
|
||||
(stats?.[protocol]?.week ?? 0) > 0 &&
|
||||
!hiddenProtocols.has(protocol) &&
|
||||
(matched === null || matched.has(protocol)),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user