Files
meshcore-analyzer/test-issue-1470-node-tile-helper.js
T
efitenandClaude Opus 5 b38536911a fix(#1858): unstick npm test — three stale assertions and a Windows-only allowlist bug (#1895)
Partial fix for #1858. Clears four blockers; does **not** close the
issue.

## Why partial matters here

`test-all.sh` aborts at the first failing script. So the 9 red tests the
issue counts are not 9 problems a contributor can work through — the run
stops at `test-frontend-helpers.js` and everything after it is
invisible. You have to fix them in order just to see the next one. That
is what this does, as far as I could go without guessing.

## Three assertions that outlived their features

Each was left behind by a merged PR that changed the thing being
asserted. None could pass again without reverting that work.

| Test | Asserted | Reality |
|---|---|---|
| `test-frontend-helpers.js` | `★` / `☆` glyphs in `favStar` | #1648
replaced them with `#ph-star-fill` / `#ph-star` sprite refs |
| `test-channel-psk-ux.js` | literal `🔓` | same migration →
`#ph-lock-open` |
| `test-analytics-channels-integration.js` | a `#/analytics` chip in
`channels.js` | #1367 / PR #1376 deliberately dropped it |

The `favStar` rewrite also tightens two assertions that were not doing
their job: `html.includes('on')` matched the word "favorites", and the
negative case checked `!html.includes(' on')` rather than the class.
Both now assert `aria-pressed` and the class explicitly.

For the analytics chip I removed the assertion rather than weakening it,
and left the reason in place so it does not get "restored" by someone
reading a bare deletion:

```js
// The "Channels page links to Analytics" assertion that used to live here was
// removed: #1367 / PR #1376 ... deliberately deleted the `#/analytics` chip.
```

## A Windows-only bug in the emoji sweep

`test-issue-1648-m6-final-sweep.js` reports **64 violations on Windows
and 1 on Linux**.

`walkFiles` builds each path with `path.relative`, which yields
`public\cb-presets.js` on Windows. Every entry in
`tests/emoji-allowlist.txt` — and the ignore list in the same function —
is written with `/`. So `matchesGlob` matched nothing, and every
deliberately-annotated line (WCAG contrast `✓`/`✗` notes, `⌈⌉` ceiling
brackets in audio-lab) was reported as a violation.

One line, normalising to forward slashes. 64 → 1.

The remaining 1 is the `🗺️` in `public/rx-coverage.js` that **PR #1860
already fixes**, and `test-issue-1648-m6-lint-self.js` cascades from it
(`repo MUST be clean before anti-tautology probe`). Both go green when
#1860 merges. Not duplicated here.

## Still red, deliberately untouched

Four tests fail for reasons that need a behavioural decision rather than
a test edit:

- `test-issue-1438-customizer-mcrole.js` — `--mc-role-companion` empty
- `test-issue-1446-cb-preset-cascade.js` — see below
- `test-issue-1470-node-tile-helper.js` — `getActiveTileProvider`
returns `undefined` for voyager; `getTileUrl` yields the `dark_all` URL
instead
- `test-issue-1485-live-anim-z.js` — animLayer hosts 2 animation shapes,
≥3 expected

### #1446 may not be a stale test

Worth a second look before anyone edits it:

- **Scenario 6** (roles.js + cb-presets.js, stored preset `deut`)
**passes** — `--mc-role-repeater` = `#fe6100`.
- **Scenario 5** is the same setup plus `customize-v2.js` and
`_customizerV2.init({nodeColors:{repeater:'#aaaaaa'}})`, and the var
comes back **empty** — not the preset colour, not the server colour.

If that is the real behaviour, a colourblind user with a saved preset
loses their role colours the moment the customizer initialises against a
server config carrying `nodeColors`. I left it alone: the three-way
cascade (preset vs server config vs user override) is exactly what #1446
defined, and picking a side changes colours for the users the feature
exists for. That is your call, not mine.

## Not addressed: the structural half

Three hand-maintained test lists (267 files, 159 in `deploy.yml`, 53 in
`test-all.sh`, no CI invocation of the latter) is the root cause the
issue identifies, and it is a workflow decision rather than a bug fix.
Happy to do it in whatever shape you want — the obvious one being CI
calling `test-all.sh` and the two lists collapsing into one — but that
changes what gates merges, so it should be your choice.

## Verification

```
node test-frontend-helpers.js               → 627 passed, 0 failed
node test-channel-psk-ux.js                 → 20 passed, 0 failed
node test-analytics-channels-integration.js → 23 passed
node test-issue-1648-m6-final-sweep.js      → 64 violations → 1
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 09:10:52 +02:00

230 lines
9.3 KiB
JavaScript

/* 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 carto-voyager-dark 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('carto-voyager-dark');
const p = ctx.window.getActiveTileProvider();
assert.ok(p, 'provider returned in dark mode');
// #1533 made provider urls lazy functions so the Carto base can be resolved
// at call time (_getCartoBase); they were plain strings under #1430.
assert.ok(typeof p.url === 'function', 'provider url is a resolver function — got ' + typeof p.url);
assert.ok(/voyager/.test(p.url()),
'provider url resolves to voyager — got ' + JSON.stringify(p.url()));
assert.ok(typeof p.invertFilter === 'string' && /invert\(/.test(p.invertFilter),
'carto-voyager-dark invertFilter present — got ' + JSON.stringify(p.invertFilter));
});
test('roles.js: getTileUrl returns voyager URL in dark mode when carto-voyager-dark selected', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadInto(ctx, 'public/map-tile-providers.js');
loadInto(ctx, 'public/roles.js');
ctx.window.MC_setDarkTileProvider('carto-voyager-dark');
const url = ctx.window.getTileUrl();
assert.ok(/voyager/.test(url), 'getTileUrl returns voyager URL — got ' + url);
});
test('_applyTilesToNodeMap: dark + carto-voyager-dark → 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('carto-voyager-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 — 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);