mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 12:22:42 +00:00
test(#1461/#1470/#1468): replace grep checks with behavioral tests
Operator pushback (correctly): source-grep tests are checkbox theatre.
Replace with real assertions on actual function side effects.
- test-issue-1461-mobile-page-actions.js (vm sandbox + minimal Element
shim): load mobile-page-actions.js, build a mobile DOM, trigger
hashchange, assert the slot under .nav-left contains exactly the ⏸ +
Filters ▾ buttons, click them and assert delegation to the real
#pktPauseBtn / #filterToggleBtn, assert the 3 More-sheet mirrors land
with correct data-mpa-mirror ids, assert click delegation, assert
desktop is a no-op, assert idempotency on re-init, assert detail
sheet closes on route change off /packets. 6 tests, all passing.
- test-issue-1470-node-tile-helper.js (vm sandbox + Leaflet mock):
load map-tile-providers.js + roles.js, extract the
_applyTilesToNodeMap function source from nodes.js by brace-balanced
regex, run it inside the sandbox. Assert getActiveTileProvider
returns null in light mode + the selected provider in dark mode.
Assert the helper passes the voyager URL to L.tileLayer when
voyager-inverted is selected AND sets the tile pane invert filter.
Assert carto-dark (non-inverted) leaves filter empty. Assert light
mode falls back to TILE_LIGHT (carto light_all). 6 tests, all
passing.
- #1468 covered by 2 Playwright E2E tests added inline to
test-e2e-playwright.js (uses the existing
window._channelsProcessWSBatchForTest hook):
* orphan CHAN message with no payload.channel → no "unknown"
bucket created, channel count delta = 0
* control: same message WITH payload.channel → channel IS routed
into the registry
- Delete the 3 source-grep tests committed in 21f92a29.
Wired the 2 vm-sandbox tests into test-all.sh.
This commit is contained in:
@@ -45,6 +45,10 @@ node test-issue-1450-logo-aspect.js
|
||||
node test-issue-1454-channels-toggle.js
|
||||
node test-issue-1456-score-labels.js
|
||||
|
||||
# #1461 mobile UX overhaul + #1470 node-detail tile helper (#1468 covered by E2E)
|
||||
node test-issue-1461-mobile-page-actions.js
|
||||
node test-issue-1470-node-tile-helper.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " All tests passed"
|
||||
|
||||
@@ -2071,6 +2071,87 @@ async function run() {
|
||||
|
||||
// ─── End mobile filter tests ──────────────────────────────────────────────
|
||||
|
||||
// ─── #1468 — drop client-side "unknown" channel synthesis ────────────────
|
||||
|
||||
await test('#1468: live WS CHAN message with no payload.channel is dropped (no "unknown" bucket)', async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
|
||||
// Wait for the channels init() to mount and expose the test hook.
|
||||
await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 });
|
||||
|
||||
// Snapshot starting state so we can compare deltas.
|
||||
const before = await page.evaluate(() => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') };
|
||||
});
|
||||
|
||||
// Feed a CHAN-like message with NO payload.channel field.
|
||||
await page.evaluate(() => {
|
||||
window._channelsProcessWSBatchForTest([
|
||||
{
|
||||
type: 'packet',
|
||||
data: {
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'GRP_TXT' },
|
||||
payload: { /* no `channel` */ text: 'orphan: hello' },
|
||||
},
|
||||
},
|
||||
},
|
||||
], null);
|
||||
});
|
||||
|
||||
const after = await page.evaluate(() => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') };
|
||||
});
|
||||
|
||||
// No "unknown" channel materialized.
|
||||
assert(!after.names.includes('unknown'),
|
||||
'channels list does not contain a synthesized "unknown" entry — got ' + JSON.stringify(after.names));
|
||||
// And the channel-count delta is 0 — the orphan message was dropped, not bucketed.
|
||||
assert(after.count === before.count,
|
||||
`channel count unchanged after orphan WS msg — before=${before.count}, after=${after.count}`);
|
||||
});
|
||||
|
||||
await test('#1468 control: same WS message WITH payload.channel is still routed', async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 });
|
||||
|
||||
const sentinel = '__test_chan_1468_' + Date.now();
|
||||
const before = await page.evaluate((name) => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return { hasSentinel: s.channels.some(c => (c.name || c.channel) === name) };
|
||||
}, sentinel);
|
||||
assert(!before.hasSentinel, 'pre: sentinel channel does not pre-exist');
|
||||
|
||||
await page.evaluate((name) => {
|
||||
window._channelsProcessWSBatchForTest([
|
||||
{
|
||||
type: 'packet',
|
||||
data: {
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'GRP_TXT' },
|
||||
payload: { channel: name, text: 'alice: hi', sender: 'alice' },
|
||||
},
|
||||
},
|
||||
},
|
||||
], null);
|
||||
}, sentinel);
|
||||
|
||||
const after = await page.evaluate((name) => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return {
|
||||
hasSentinel: s.channels.some(c => (c.name || c.channel) === name),
|
||||
names: s.channels.map(c => c.name || c.channel || ''),
|
||||
};
|
||||
}, sentinel);
|
||||
assert(after.hasSentinel,
|
||||
'control: channel WITH payload.channel IS routed into the registry — got ' + JSON.stringify(after.names));
|
||||
});
|
||||
|
||||
// ─── End #1468 tests ──────────────────────────────────────────────────────
|
||||
|
||||
// Extract frontend coverage if instrumented server is running
|
||||
try {
|
||||
const coverage = await page.evaluate(() => window.__coverage__);
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/* test-issue-1461-mobile-page-actions.js — behavioral test for the mobile
|
||||
* page-actions wiring shipped in #1471. Loads mobile-page-actions.js into
|
||||
* a vm sandbox with a minimal DOM mock (no jsdom dep), then exercises the
|
||||
* public observable surfaces:
|
||||
*
|
||||
* 1. On mobile + /#/packets, hashchange synthesizes a "Filters ▾" button
|
||||
* and a "⏸" button under .nav-left, and clicking them delegates to the
|
||||
* real #filterToggleBtn / #pktPauseBtn elements.
|
||||
* 2. The bottom-nav More sheet receives 3 injected mirrors (Favorites /
|
||||
* Search / Customize) when the sheet element is present.
|
||||
* 3. On desktop viewport, no buttons are injected.
|
||||
* 4. Idempotent: re-init does not duplicate mirrors.
|
||||
* 5. Route change off /packets clears the slot AND closes the detail sheet.
|
||||
*
|
||||
* No source-grep. All assertions are on real function side effects.
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
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); }
|
||||
}
|
||||
|
||||
// ── Minimal Element / Document shim — no jsdom dep ────────────────────────
|
||||
function makeElement(tag) {
|
||||
const el = {
|
||||
tagName: String(tag || 'div').toUpperCase(),
|
||||
children: [],
|
||||
parentNode: null,
|
||||
id: '',
|
||||
className: '',
|
||||
type: '',
|
||||
title: '',
|
||||
textContent: '',
|
||||
_attrs: {},
|
||||
_listeners: {},
|
||||
_clickCount: 0,
|
||||
};
|
||||
el.style = { _cssText: '', get cssText() { return this._cssText; }, set cssText(v) { this._cssText = v; } };
|
||||
el.setAttribute = function (k, v) {
|
||||
this._attrs[k] = String(v);
|
||||
if (k === 'class') this.className = String(v);
|
||||
if (k === 'id') this.id = String(v);
|
||||
};
|
||||
el.getAttribute = function (k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; };
|
||||
el.appendChild = function (child) { child.parentNode = this; this.children.push(child); return child; };
|
||||
el.insertBefore = function (child, ref) {
|
||||
child.parentNode = this;
|
||||
const idx = this.children.indexOf(ref);
|
||||
if (idx < 0) this.children.push(child);
|
||||
else this.children.splice(idx, 0, child);
|
||||
return child;
|
||||
};
|
||||
el.removeChild = function (child) {
|
||||
const idx = this.children.indexOf(child);
|
||||
if (idx >= 0) this.children.splice(idx, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
};
|
||||
el.addEventListener = function (type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); };
|
||||
el.dispatchEvent = function (ev) { (this._listeners[ev.type] || []).forEach(fn => fn(ev)); };
|
||||
el.click = function () {
|
||||
this._clickCount++;
|
||||
this.dispatchEvent({ type: 'click', target: this, preventDefault() {}, stopPropagation() {} });
|
||||
};
|
||||
el.closest = function (sel) {
|
||||
let cur = this;
|
||||
while (cur) {
|
||||
if (_elementMatches(cur, sel)) return cur;
|
||||
cur = cur.parentNode;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
el.querySelector = function (sel) { return _walk(this, sel, true)[0] || null; };
|
||||
el.querySelectorAll = function (sel) { return _walk(this, sel, false); };
|
||||
el.classList = {
|
||||
add(c) { const s = new Set(String(el.className).split(/\s+/).filter(Boolean)); s.add(c); el.className = Array.from(s).join(' '); },
|
||||
remove(c) { const s = new Set(String(el.className).split(/\s+/).filter(Boolean)); s.delete(c); el.className = Array.from(s).join(' '); },
|
||||
contains(c) { return new Set(String(el.className).split(/\s+/).filter(Boolean)).has(c); },
|
||||
};
|
||||
Object.defineProperty(el, 'innerHTML', {
|
||||
configurable: true,
|
||||
get() { return ''; },
|
||||
set(v) {
|
||||
if (v === '' || v == null) {
|
||||
el.children.forEach(c => { c.parentNode = null; });
|
||||
el.children = [];
|
||||
}
|
||||
},
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function _elementMatches(el, sel) {
|
||||
if (!el || !sel) return false;
|
||||
sel = String(sel).trim();
|
||||
// Comma list: any selector matches
|
||||
if (sel.indexOf(',') >= 0) {
|
||||
return sel.split(',').some(s => _elementMatches(el, s.trim()));
|
||||
}
|
||||
// Descendant combinator
|
||||
if (sel.indexOf(' ') >= 0) {
|
||||
const parts = sel.split(/\s+/);
|
||||
const last = parts[parts.length - 1];
|
||||
if (!_elementMatches(el, last)) return false;
|
||||
let cur = el.parentNode;
|
||||
for (let i = parts.length - 2; i >= 0; i--) {
|
||||
let found = false;
|
||||
while (cur) {
|
||||
if (_elementMatches(cur, parts[i])) { found = true; cur = cur.parentNode; break; }
|
||||
cur = cur.parentNode;
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// ID
|
||||
if (sel.startsWith('#')) return el.id === sel.slice(1);
|
||||
// Attribute [data-X] or [data-X="v"]
|
||||
const attrMatch = sel.match(/^\[([a-zA-Z0-9-]+)(?:="([^"]+)")?\]$/);
|
||||
if (attrMatch) {
|
||||
const k = attrMatch[1], v = attrMatch[2];
|
||||
if (!Object.prototype.hasOwnProperty.call(el._attrs, k)) return false;
|
||||
return v == null ? true : el._attrs[k] === v;
|
||||
}
|
||||
// .class
|
||||
if (sel.startsWith('.')) {
|
||||
return el.classList && el.classList.contains(sel.slice(1));
|
||||
}
|
||||
// tag.class
|
||||
if (sel.indexOf('.') > 0) {
|
||||
const [tag, ...cls] = sel.split('.');
|
||||
if (el.tagName.toLowerCase() !== tag.toLowerCase()) return false;
|
||||
return cls.every(c => el.classList && el.classList.contains(c));
|
||||
}
|
||||
// Bare tag
|
||||
return el.tagName.toLowerCase() === sel.toLowerCase();
|
||||
}
|
||||
|
||||
function _walk(root, sel, firstOnly) {
|
||||
const out = [];
|
||||
function visit(n) {
|
||||
if (n !== root && _elementMatches(n, sel)) {
|
||||
out.push(n);
|
||||
if (firstOnly) return true;
|
||||
}
|
||||
for (const c of (n.children || [])) {
|
||||
if (visit(c) && firstOnly) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const docElements = {};
|
||||
const docRoot = makeElement('html');
|
||||
const docBody = makeElement('body');
|
||||
docRoot.appendChild(docBody);
|
||||
const documentListeners = {};
|
||||
const windowListeners = {};
|
||||
|
||||
const documentMock = {
|
||||
documentElement: docRoot,
|
||||
body: docBody,
|
||||
createElement(tag) { return makeElement(tag); },
|
||||
getElementById(id) {
|
||||
if (docElements[id]) return docElements[id];
|
||||
function find(n) {
|
||||
if (n.id === id) return n;
|
||||
for (const c of (n.children || [])) { const r = find(c); if (r) return r; }
|
||||
return null;
|
||||
}
|
||||
return find(docRoot);
|
||||
},
|
||||
querySelector(sel) { return docRoot.querySelector(sel) || docBody.querySelector(sel); },
|
||||
querySelectorAll(sel) { return docRoot.querySelectorAll(sel).concat(docBody.querySelectorAll(sel)); },
|
||||
addEventListener(type, fn) { (documentListeners[type] = documentListeners[type] || []).push(fn); },
|
||||
dispatchEvent(ev) { (documentListeners[ev.type] || []).forEach(fn => fn(ev)); return true; },
|
||||
readyState: 'complete',
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
console,
|
||||
setTimeout, clearTimeout,
|
||||
JSON, Date, Math, Object, Array, String, Number, Boolean, Set, Map,
|
||||
document: documentMock,
|
||||
window: {
|
||||
innerWidth: opts.innerWidth || 390,
|
||||
addEventListener(type, fn) { (windowListeners[type] = windowListeners[type] || []).push(fn); },
|
||||
dispatchEvent(ev) { (windowListeners[ev.type] || []).forEach(fn => fn(ev)); return true; },
|
||||
},
|
||||
location: { hash: opts.hash || '#/packets' },
|
||||
};
|
||||
ctx.globalThis = ctx;
|
||||
ctx.window.document = documentMock;
|
||||
ctx.window.location = ctx.location;
|
||||
vm.createContext(ctx);
|
||||
ctx._docRoot = docRoot;
|
||||
ctx._docBody = docBody;
|
||||
ctx._documentListeners = documentListeners;
|
||||
ctx._windowListeners = windowListeners;
|
||||
ctx._registerElement = (el) => { if (el.id) docElements[el.id] = el; };
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadMPA(ctx) {
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'mobile-page-actions.js'), 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
}
|
||||
|
||||
function buildMobileDOM(ctx, opts) {
|
||||
opts = opts || {};
|
||||
const body = ctx._docBody;
|
||||
|
||||
const nav = makeElement('div');
|
||||
nav.className = 'nav-left';
|
||||
body.appendChild(nav);
|
||||
|
||||
const realFilter = makeElement('button');
|
||||
realFilter.id = 'filterToggleBtn';
|
||||
realFilter.setAttribute('class', 'filter-toggle-btn');
|
||||
body.appendChild(realFilter);
|
||||
ctx._registerElement(realFilter);
|
||||
|
||||
const realPause = makeElement('button');
|
||||
realPause.id = 'pktPauseBtn';
|
||||
body.appendChild(realPause);
|
||||
ctx._registerElement(realPause);
|
||||
|
||||
if (opts.bottomNav !== false) {
|
||||
const sheet = makeElement('div');
|
||||
sheet.setAttribute('data-bottom-nav-sheet', '');
|
||||
body.appendChild(sheet);
|
||||
|
||||
['favToggle', 'searchToggle', 'customizeToggle'].forEach(id => {
|
||||
const btn = makeElement('button');
|
||||
btn.id = id;
|
||||
body.appendChild(btn);
|
||||
ctx._registerElement(btn);
|
||||
});
|
||||
const sep = makeElement('div');
|
||||
sep.setAttribute('class', 'bottom-nav-sheet-sep');
|
||||
sheet.appendChild(sep);
|
||||
}
|
||||
|
||||
const detailSheet = makeElement('div');
|
||||
detailSheet.id = 'mobileDetailSheet';
|
||||
detailSheet.classList.add('open');
|
||||
body.appendChild(detailSheet);
|
||||
ctx._registerElement(detailSheet);
|
||||
|
||||
return { nav, realFilter, realPause };
|
||||
}
|
||||
|
||||
console.log('── #1461 mobile-page-actions behavioral tests ──');
|
||||
|
||||
test('mobile + /#/packets: hashchange injects ⏸ + Filters ▾ buttons under .nav-left', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
const { nav, realFilter, realPause } = buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
|
||||
const slot = ctx.document.getElementById('navPageActions');
|
||||
assert.ok(slot, 'navPageActions slot was created');
|
||||
assert.strictEqual(slot.parentNode, nav, 'slot is appended under .nav-left');
|
||||
|
||||
const btnTexts = slot.children.map(c => c.textContent);
|
||||
assert.ok(btnTexts.includes('⏸'), 'pause button (⏸) injected — got: ' + JSON.stringify(btnTexts));
|
||||
assert.ok(btnTexts.some(t => /Filters/.test(t)), 'Filters button injected — got: ' + JSON.stringify(btnTexts));
|
||||
|
||||
const pauseBtn = slot.children.find(c => c.textContent === '⏸');
|
||||
pauseBtn.click();
|
||||
assert.strictEqual(realPause._clickCount, 1, 'pause-mirror delegates click to #pktPauseBtn');
|
||||
|
||||
const filtBtn = slot.children.find(c => /Filters/.test(c.textContent));
|
||||
filtBtn.click();
|
||||
assert.strictEqual(realFilter._clickCount, 1, 'Filters-mirror delegates click to #filterToggleBtn');
|
||||
});
|
||||
|
||||
test('mobile: 3 More-sheet mirrors (Favorites/Search/Customize) injected', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
|
||||
const sheet = ctx.document.querySelector('[data-bottom-nav-sheet]');
|
||||
assert.ok(sheet, 'sheet found');
|
||||
const mirrors = sheet.querySelectorAll('[data-mpa-mirror]');
|
||||
assert.strictEqual(mirrors.length, 3, 'exactly 3 mirrors injected — got ' + mirrors.length);
|
||||
const ids = mirrors.map(m => m.getAttribute('data-mpa-mirror')).sort();
|
||||
assert.deepStrictEqual(ids, ['customizeToggle', 'favToggle', 'searchToggle']);
|
||||
|
||||
const favReal = ctx.document.getElementById('favToggle');
|
||||
const favMirror = mirrors.find(m => m.getAttribute('data-mpa-mirror') === 'favToggle');
|
||||
favMirror.click();
|
||||
assert.strictEqual(favReal._clickCount, 1, 'Favorites mirror delegates click to #favToggle');
|
||||
});
|
||||
|
||||
test('mobile: idempotent — re-init does not duplicate More-sheet mirrors', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
const sheet = ctx.document.querySelector('[data-bottom-nav-sheet]');
|
||||
const mirrors = sheet.querySelectorAll('[data-mpa-mirror]');
|
||||
assert.strictEqual(mirrors.length, 3, 'still 3 mirrors after re-init — got ' + mirrors.length);
|
||||
});
|
||||
|
||||
test('desktop viewport (innerWidth=1280): slot stays empty', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 1280, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
const slot = ctx.document.getElementById('navPageActions');
|
||||
if (slot) {
|
||||
assert.strictEqual(slot.children.length, 0, 'desktop slot empty — got ' + slot.children.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile + non-packets route: hashchange clears the slot', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
ctx.location.hash = '#/map';
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
const slot = ctx.document.getElementById('navPageActions');
|
||||
if (slot) {
|
||||
assert.strictEqual(slot.children.length, 0, 'slot cleared off /packets — got ' + slot.children.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile detail sheet closes on route change away from /packets', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
const sheet = ctx.document.getElementById('mobileDetailSheet');
|
||||
assert.ok(sheet.classList.contains('open'), 'pre: sheet open');
|
||||
loadMPA(ctx);
|
||||
ctx.location.hash = '#/map';
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
assert.ok(!sheet.classList.contains('open'), 'sheet closed after route change off /packets');
|
||||
});
|
||||
|
||||
console.log(`\n#1461 mobile-page-actions: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,226 @@
|
||||
/* test-issue-1470-node-tile-helper.js — behavioral test for the
|
||||
* _applyTilesToNodeMap helper shipped in #1471 and roles.js
|
||||
* getActiveTileProvider() / getTileUrl() integration with the
|
||||
* MC_TILE_PROVIDERS registry.
|
||||
*
|
||||
* Strategy:
|
||||
* - vm-load public/map-tile-providers.js + public/roles.js into a single
|
||||
* sandbox with mocked localStorage + document(theme=dark).
|
||||
* - Extract the _applyTilesToNodeMap function source from public/nodes.js
|
||||
* by regex and run it in the same sandbox.
|
||||
* - Mock L.tileLayer / L.map to capture the URL + options the helper passes
|
||||
* and the .addTo() target. Assert that selecting voyager-inverted in the
|
||||
* customizer ends up calling tileLayer with the voyager URL AND setting
|
||||
* the tile pane filter to the provider.invertFilter string.
|
||||
*
|
||||
* No source-grep on the production fix. The assertions exercise the helper
|
||||
* code path end-to-end. Reverting the fix (re-hardcoding OSM) breaks this.
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
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 makeStorage() {
|
||||
const store = {};
|
||||
return {
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },
|
||||
setItem(k, v) { store[k] = String(v); },
|
||||
removeItem(k) { delete store[k]; },
|
||||
clear() { for (const k of Object.keys(store)) delete store[k]; },
|
||||
};
|
||||
}
|
||||
|
||||
function makeLeafletMock() {
|
||||
const calls = [];
|
||||
const tilePane = { style: { filter: '' } };
|
||||
const fakeMap = {
|
||||
_tileLayers: [],
|
||||
getPane: (name) => name === 'tilePane' ? tilePane : null,
|
||||
};
|
||||
const L = {
|
||||
tileLayer(url, opts) {
|
||||
const layer = {
|
||||
url, opts,
|
||||
addTo(map) {
|
||||
calls.push({ kind: 'tileLayer', url, opts, map });
|
||||
map._tileLayers.push(this);
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
},
|
||||
map() { return fakeMap; },
|
||||
marker() { return { addTo: () => ({ bindPopup: () => {} }) }; },
|
||||
};
|
||||
return { L, calls, fakeMap, tilePane };
|
||||
}
|
||||
|
||||
function makeSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const ctx = {
|
||||
console,
|
||||
setTimeout, clearTimeout,
|
||||
JSON, Date, Math, Object, Array, String, Number, Boolean, Set, Map,
|
||||
fetch: () => Promise.resolve({ ok: false, json: () => Promise.resolve({}) }),
|
||||
localStorage: makeStorage(),
|
||||
document: {
|
||||
documentElement: {
|
||||
getAttribute: () => opts.theme || 'dark',
|
||||
style: { getPropertyValue: () => '' },
|
||||
},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
getElementById: () => null,
|
||||
createElement: () => ({ style: {}, appendChild: () => {}, setAttribute: () => {}, addEventListener: () => {} }),
|
||||
addEventListener: () => {},
|
||||
body: { appendChild: () => {}, style: {} },
|
||||
head: { appendChild: () => {} },
|
||||
readyState: 'complete',
|
||||
},
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
matchMedia: () => ({ matches: opts.prefersDark !== false, addEventListener: () => {} }),
|
||||
},
|
||||
CustomEvent: function (type, init) { this.type = type; this.detail = (init && init.detail) || null; },
|
||||
};
|
||||
ctx.window.localStorage = ctx.localStorage;
|
||||
ctx.window.document = ctx.document;
|
||||
ctx.globalThis = ctx;
|
||||
vm.createContext(ctx);
|
||||
// After context creation, make the sandbox global object also be `window`
|
||||
// so `window.X = ...` in roles.js makes X a bare global the same file
|
||||
// can reference. We do this by copying assignments back — simpler: install
|
||||
// a Proxy. Cheapest practical fix: shadow common globals after load.
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadInto(ctx, relPath) {
|
||||
const src = fs.readFileSync(path.join(__dirname, relPath), 'utf8');
|
||||
vm.runInContext(src, ctx, { filename: relPath });
|
||||
// Mirror window.* back to sandbox globals so code that uses bare names
|
||||
// (which in a browser are window.X) can still resolve them in vm. We do
|
||||
// this AFTER each file load so window.getTileUrl, window.TILE_DARK, etc.
|
||||
// become bare refs `getTileUrl` / `TILE_DARK` for callers in the same
|
||||
// sandbox.
|
||||
for (const k of Object.keys(ctx.window)) {
|
||||
if (!(k in ctx)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
}
|
||||
|
||||
function extractApplyTilesHelper() {
|
||||
const nodesSrc = fs.readFileSync(path.join(__dirname, 'public', 'nodes.js'), 'utf8');
|
||||
// Match the function _applyTilesToNodeMap(map) { ... } block. Brace-count.
|
||||
const startMatch = nodesSrc.match(/function\s+_applyTilesToNodeMap\s*\(map\)\s*\{/);
|
||||
if (!startMatch) throw new Error('_applyTilesToNodeMap definition not found in public/nodes.js');
|
||||
const start = startMatch.index;
|
||||
let depth = 0, i = start;
|
||||
for (; i < nodesSrc.length; i++) {
|
||||
const ch = nodesSrc[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) { i++; break; }
|
||||
}
|
||||
}
|
||||
return nodesSrc.slice(start, i);
|
||||
}
|
||||
|
||||
console.log('── #1470 node-detail inset-map tile-provider routing ──');
|
||||
|
||||
test('roles.js + providers: getActiveTileProvider returns null in light mode', () => {
|
||||
const ctx = makeSandbox({ theme: 'light', prefersDark: false });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
const p = ctx.window.getActiveTileProvider();
|
||||
assert.strictEqual(p, null, 'expected null in light mode, got ' + JSON.stringify(p));
|
||||
});
|
||||
|
||||
test('roles.js + providers: getActiveTileProvider returns selected provider in dark mode', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
const p = ctx.window.getActiveTileProvider();
|
||||
assert.ok(p, 'provider returned in dark mode');
|
||||
assert.ok(typeof p.url === 'string' && /voyager/.test(p.url),
|
||||
'provider url is voyager — got ' + JSON.stringify(p.url));
|
||||
assert.ok(typeof p.invertFilter === 'string' && /invert\(/.test(p.invertFilter),
|
||||
'voyager-inverted invertFilter present — got ' + JSON.stringify(p.invertFilter));
|
||||
});
|
||||
|
||||
test('roles.js: getTileUrl returns voyager URL in dark mode when voyager-inverted selected', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
const url = ctx.window.getTileUrl();
|
||||
assert.ok(/voyager/.test(url), 'getTileUrl returns voyager URL — got ' + url);
|
||||
});
|
||||
|
||||
test('_applyTilesToNodeMap: dark + voyager-inverted → tileLayer(voyagerURL) + invert filter', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
|
||||
const mock = makeLeafletMock();
|
||||
ctx.L = mock.L;
|
||||
ctx._fakeMap = mock.fakeMap;
|
||||
vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' });
|
||||
vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx);
|
||||
|
||||
assert.ok(mock.calls.length >= 1, 'tileLayer was called — got ' + mock.calls.length + ' calls');
|
||||
const firstCall = mock.calls[0];
|
||||
assert.ok(/voyager/.test(firstCall.url),
|
||||
'first tileLayer URL is voyager — got ' + firstCall.url);
|
||||
assert.ok(/invert\(/.test(mock.tilePane.style.filter),
|
||||
'tile pane filter set to invert(...) — got ' + JSON.stringify(mock.tilePane.style.filter));
|
||||
});
|
||||
|
||||
test('_applyTilesToNodeMap: dark + carto-dark (non-inverted) → no invert filter applied', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
ctx.window.MC_setDarkTileProvider('carto-dark');
|
||||
|
||||
const mock = makeLeafletMock();
|
||||
ctx.L = mock.L;
|
||||
ctx._fakeMap = mock.fakeMap;
|
||||
vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' });
|
||||
vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx);
|
||||
|
||||
assert.ok(mock.calls.length >= 1, 'tileLayer was called');
|
||||
assert.ok(/cartocdn|basemaps\.cartocdn|dark_all/.test(mock.calls[0].url),
|
||||
'carto-dark URL — got ' + mock.calls[0].url);
|
||||
assert.strictEqual(mock.tilePane.style.filter, '',
|
||||
'no invert filter for carto-dark — got ' + JSON.stringify(mock.tilePane.style.filter));
|
||||
});
|
||||
|
||||
test('_applyTilesToNodeMap: light mode → uses TILE_LIGHT (carto light_all), no invert', () => {
|
||||
const ctx = makeSandbox({ theme: 'light', prefersDark: false });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
|
||||
const mock = makeLeafletMock();
|
||||
ctx.L = mock.L;
|
||||
ctx._fakeMap = mock.fakeMap;
|
||||
vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' });
|
||||
vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx);
|
||||
|
||||
assert.ok(mock.calls.length >= 1, 'tileLayer called');
|
||||
assert.ok(/light_all|openstreetmap/.test(mock.calls[0].url),
|
||||
'light mode uses light tile URL — got ' + mock.calls[0].url);
|
||||
assert.strictEqual(mock.tilePane.style.filter, '', 'no invert filter in light mode');
|
||||
});
|
||||
|
||||
console.log(`\n#1470 node-detail tile helper: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user