mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 16:07:54 +00:00
Closes #2001. Two review rounds plus a re-review; findings and evidence on the PR. Verified on a deployment against live data: the field over 1653 nodes, marker tints per filter state, the marker title and popup Scope row reaching the DOM, and the colorblind-preset cascade. The audit and the map are held to the same classification by an end-to-end test that fails when either side's wildcard handling drifts.
200 lines
10 KiB
JavaScript
200 lines
10 KiB
JavaScript
/* Unit tests for the map's scope-configuration state layer (issue #2001)
|
|
*
|
|
* Verifies the three pure pieces the layer is built from:
|
|
* - scopeFilterAccepts: which nodes survive a given filter selection
|
|
* - scopeTint: which CSS variable a node's marker is filled with
|
|
* - scopeFilterHtml: the button group, including which button reads active
|
|
*
|
|
* Runs in the same jsdom-free vm sandbox as test-map-clustering.js, with a
|
|
* tiny Leaflet shim, so it exercises map.js and not the library.
|
|
*/
|
|
'use strict';
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const assert = require('assert');
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
|
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
|
}
|
|
|
|
function makeSandbox() {
|
|
const L = {};
|
|
L.point = (x, y) => ({ x, y });
|
|
L.latLng = (a, b) => ({ lat: a, lng: b });
|
|
L.divIcon = (opts) => ({ _isDivIcon: true, options: opts, html: opts.html, className: opts.className });
|
|
L.layerGroup = () => ({ addLayer(){ return this; }, removeLayer(){ return this; }, clearLayers(){ return this; }, eachLayer(){}, addTo(){ return this; }, hasLayer(){ return false; } });
|
|
L.marker = (latlng, opts) => ({ _isMarker: true, _latlng: latlng, options: opts || {}, getLatLng(){ return this._latlng; }, bindPopup(){ return this; }, bindTooltip(){ return this; } });
|
|
function MarkerClusterGroup(opts) { this.options = opts || {}; this._layers = []; }
|
|
MarkerClusterGroup.prototype.addLayer = function () { return this; };
|
|
MarkerClusterGroup.prototype.addLayers = function () { return this; };
|
|
MarkerClusterGroup.prototype.clearLayers = function () { return this; };
|
|
MarkerClusterGroup.prototype.eachLayer = function () {};
|
|
MarkerClusterGroup.prototype.addTo = function () { return this; };
|
|
L.MarkerClusterGroup = MarkerClusterGroup;
|
|
L.markerClusterGroup = (opts) => new MarkerClusterGroup(opts);
|
|
|
|
const ctx = {
|
|
window: {},
|
|
document: { addEventListener(){}, getElementById(){ return null; }, querySelector(){ return null; }, querySelectorAll(){ return []; }, createElement(){ return { id:'', textContent:'', innerHTML:'', appendChild(){}, addEventListener(){}, setAttribute(){}, classList:{add(){},remove(){},toggle(){}} }; }, head: { appendChild(){} }, body: { appendChild(){} } },
|
|
console, Date, Math, Array, Object, String, Number, JSON, RegExp, Error,
|
|
parseInt, parseFloat, isFinite, isNaN, Map, Set, Promise,
|
|
setTimeout: ()=>{}, clearTimeout: ()=>{}, setInterval: ()=>{}, clearInterval: ()=>{},
|
|
registerPage: () => {}, esc: (s) => s, onWS: () => {}, offWS: () => {},
|
|
localStorage: (() => { const s={}; return { getItem:k=>s[k]||null, setItem:(k,v)=>{s[k]=String(v);}, removeItem:k=>{delete s[k];} }; })(),
|
|
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
|
addEventListener(){}, dispatchEvent(){},
|
|
L: L,
|
|
};
|
|
ctx.window.L = ctx.L;
|
|
vm.createContext(ctx);
|
|
vm.runInContext(fs.readFileSync('public/roles.js', 'utf8'), ctx);
|
|
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
|
vm.runInContext(fs.readFileSync('public/map.js', 'utf8'), ctx);
|
|
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
|
return ctx;
|
|
}
|
|
|
|
console.log('\n=== map.js: scope-configuration state (#2001) ===');
|
|
{
|
|
const internals = makeSandbox().window.__meshcoreMapInternals;
|
|
|
|
test('exposes the scope-state hooks', () => {
|
|
assert.ok(internals, '__meshcoreMapInternals not exposed');
|
|
assert.strictEqual(typeof internals.scopeFilterAccepts, 'function', 'scopeFilterAccepts not exported');
|
|
assert.strictEqual(typeof internals.scopeTint, 'function', 'scopeTint not exported');
|
|
assert.strictEqual(typeof internals.scopeFilterHtml, 'function', 'scopeFilterHtml not exported');
|
|
});
|
|
|
|
test('"all" accepts every node, classified or not', () => {
|
|
assert.strictEqual(internals.scopeFilterAccepts({ scope_config_state: 'full' }, 'all'), true);
|
|
assert.strictEqual(internals.scopeFilterAccepts({ role: 'companion' }, 'all'), true);
|
|
});
|
|
|
|
test('a selected state accepts only nodes in that state', () => {
|
|
const node = { scope_config_state: 'no-scopes' };
|
|
assert.strictEqual(internals.scopeFilterAccepts(node, 'no-scopes'), true);
|
|
assert.strictEqual(internals.scopeFilterAccepts(node, 'full'), false);
|
|
assert.strictEqual(internals.scopeFilterAccepts(node, 'none'), false);
|
|
});
|
|
|
|
// A node with no scope_config_state is unclassified, not "none". The server
|
|
// omits the field for roles that do not forward, and for every node when the
|
|
// declared-regions lookup failed. Treating that as "no scope data" would put
|
|
// companions and phones in the category operators use to hunt for repeaters
|
|
// with no region config.
|
|
test('an unclassified node is not swept into "none"', () => {
|
|
assert.strictEqual(internals.scopeFilterAccepts({ role: 'companion' }, 'none'), false);
|
|
assert.strictEqual(internals.scopeFilterAccepts({ role: 'repeater' }, 'full'), false);
|
|
});
|
|
|
|
test('each state tints through its own CSS variable', () => {
|
|
const want = {
|
|
'full': 'var(--mc-scope-full)',
|
|
'observed': 'var(--mc-scope-observed)',
|
|
'no-unscoped': 'var(--mc-scope-no-unscoped)',
|
|
'no-scopes': 'var(--mc-scope-no-scopes)',
|
|
'no-flood': 'var(--mc-scope-no-flood)',
|
|
'none': 'var(--mc-scope-none)',
|
|
};
|
|
for (const state of Object.keys(want)) {
|
|
assert.strictEqual(internals.scopeTint({ scope_config_state: state }), want[state], `tint for ${state}`);
|
|
}
|
|
});
|
|
|
|
test('an unclassified node has no tint, so its role colour stands', () => {
|
|
assert.strictEqual(internals.scopeTint({ role: 'companion' }), null);
|
|
assert.strictEqual(internals.scopeTint({}), null);
|
|
});
|
|
|
|
test('the filter group offers All plus every state', () => {
|
|
const html = internals.scopeFilterHtml('all');
|
|
for (const value of ['all', 'full', 'observed', 'no-unscoped', 'no-scopes', 'no-flood', 'none']) {
|
|
assert.ok(html.indexOf('data-scope="' + value + '"') >= 0, `missing button for ${value}: ${html}`);
|
|
}
|
|
});
|
|
|
|
// The map has no separate colour legend, so the filter group is the legend:
|
|
// every state button carries the swatch its markers are painted with. A
|
|
// colour on the map with nothing to read it against is a puzzle, not a
|
|
// signal.
|
|
test('every state button carries its own colour swatch', () => {
|
|
const html = internals.scopeFilterHtml('all');
|
|
for (const state of ['full', 'observed', 'no-unscoped', 'no-scopes', 'no-flood', 'none']) {
|
|
assert.ok(html.indexOf('var(--mc-scope-' + state + ')') >= 0, `no swatch colour for ${state}: ${html}`);
|
|
}
|
|
});
|
|
|
|
// Picking a state in the filter and getting the map's ordinary role colours
|
|
// back is the confusing case: the operator asked about scope config, so the
|
|
// markers answer in scope colours without a second control having to be
|
|
// found first. The overlay checkbox stays, for colouring the whole map while
|
|
// the filter is on All.
|
|
test('the tint follows the filter, not only the overlay checkbox', () => {
|
|
assert.strictEqual(internals.scopeTintEnabled(false, 'all'), false);
|
|
assert.strictEqual(internals.scopeTintEnabled(true, 'all'), true);
|
|
assert.strictEqual(internals.scopeTintEnabled(false, 'none'), true);
|
|
assert.strictEqual(internals.scopeTintEnabled(false, 'full'), true);
|
|
});
|
|
|
|
test('the current selection is the active button, and the only one', () => {
|
|
const html = internals.scopeFilterHtml('none');
|
|
const active = html.match(/class="btn active" data-scope="([^"]+)"/g) || [];
|
|
assert.strictEqual(active.length, 1, `expected exactly one active button, got ${active.length}: ${html}`);
|
|
assert.ok(active[0].indexOf('data-scope="none"') >= 0, `active button is not "none": ${active[0]}`);
|
|
});
|
|
}
|
|
|
|
// --- the label render path -------------------------------------------------
|
|
// makeRepeaterLabelIcon is where the tint actually lands for a repeater with
|
|
// hash labels on, which is the default. The helpers above say what the colour
|
|
// should be; these say the label carries it, in a form that is not colour alone.
|
|
{
|
|
const internals = makeSandbox().window.__meshcoreMapInternals;
|
|
const NODE = { public_key: '3e7a1b9c'.repeat(8), hash_size: 2 };
|
|
|
|
test('the scope state reaches the label as a class', () => {
|
|
const html = internals.makeRepeaterLabelIcon(NODE, false, false, null, 'no-flood').html;
|
|
assert.ok(/class="[^"]*scope-no-flood/.test(html), 'no scope-no-flood class in: ' + html);
|
|
});
|
|
|
|
test('the state is in the aria-label and the hover title, not only the colour', () => {
|
|
const html = internals.makeRepeaterLabelIcon(NODE, false, false, null, 'none').html;
|
|
assert.ok(/aria-label="[^"]*scope config No data/.test(html), 'state missing from aria-label: ' + html);
|
|
assert.ok(/title="No data/.test(html), 'state missing from the hover title: ' + html);
|
|
});
|
|
|
|
// Both controls are off by default, so a default map must render exactly what
|
|
// it rendered before this feature existed.
|
|
test('with no scope state the label is byte-identical to the four-argument call', () => {
|
|
const withArg = internals.makeRepeaterLabelIcon(NODE, false, false, 'confirmed', null).html;
|
|
const without = internals.makeRepeaterLabelIcon(NODE, false, false, 'confirmed').html;
|
|
assert.strictEqual(withArg, without);
|
|
assert.ok(!/scope-/.test(without), 'default label carries a scope class: ' + without);
|
|
assert.ok(!/title=/.test(without), 'default label carries a title: ' + without);
|
|
});
|
|
|
|
test('an unknown state string adds nothing to the label', () => {
|
|
const html = internals.makeRepeaterLabelIcon(NODE, false, false, null, 'not-a-state').html;
|
|
assert.ok(!/scope-/.test(html), 'unknown state produced a class: ' + html);
|
|
});
|
|
|
|
// One marker carries one colour: with the multi-byte overlay on the scope
|
|
// tint stands down rather than the two fighting over the same surface.
|
|
test('a dot marker carries the state in words, not only in its fill', () => {
|
|
assert.strictEqual(internals.scopeStateLabel('no-flood'), 'No flood');
|
|
assert.strictEqual(internals.scopeStateLabel('none'), 'No data');
|
|
assert.strictEqual(internals.scopeStateLabel('not-a-state'), '');
|
|
});
|
|
|
|
test('the multi-byte tint wins when both overlays are on', () => {
|
|
const html = internals.makeRepeaterLabelIcon(NODE, false, false, 'confirmed', null).html;
|
|
assert.ok(/status-confirmed/.test(html), 'multi-byte class missing: ' + html);
|
|
assert.ok(!/scope-/.test(html), 'scope class present alongside the multi-byte tint: ' + html);
|
|
});
|
|
}
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed === 0 ? 0 : 1);
|