Files
meshcore-analyzer/test-issue-1420-tile-providers.js
T
Kpa-clawbot 777f77a451 feat(#1420): dark-tile provider picker in customizer (4 variants) (#1430)
# feat(#1420): dark-tile provider picker in customizer (4 variants)

Closes #1420.

## What

Operator pick: don't force a single dark-tile choice on everyone. Wire 4
candidates into the customizer + server config so users can choose which
dark basemap they want, with per-browser persistence.

## Providers shipped

| ID | Source | Filter |
|---|---|---|
| `carto-dark` (default) |
`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | none |
| `esri-darkgray-labels` | Esri Dark Gray Base + Reference (two stacked
layers) | none |
| `voyager-inverted` | Carto Voyager + CSS `invert(1) hue-rotate(180deg)
brightness(0.9) contrast(1.05)` on `.leaflet-tile-pane` | applied in
dark, cleared in light |
| `positron-inverted` | Carto Positron + same CSS invert | applied in
dark, cleared in light |

No new dependencies — all providers are URL-only.

## Architecture

- **`public/map-tile-providers.js`** — registry + 5 public helpers
(`MC_TILE_PROVIDERS`, `MC_setDarkTileProvider`,
`MC_getDarkTileProvider`, `MC_setServerDefaultTileProvider`,
`MC_applyTileFilter`). Persists to
`localStorage['mc-dark-tile-provider']`. Dispatches
`mc-tile-provider-changed` on user pick.
- **`public/map.js` / `public/live.js`** — resolve the active dark
provider via the registry, manage the Esri labels overlay lifecycle (add
when needed, remove cleanly so we don't leak layers on repeated theme
toggles), and apply/clear the CSS filter on `.leaflet-tile-pane`. Listen
for both `data-theme` mutations AND `mc-tile-provider-changed`.
- **`public/customize-v2.js`** — new "Dark Map Tiles" dropdown in the
Display tab. On change, calls `MC_setDarkTileProvider(id)`; the maps
re-render live without reload.
- **`public/roles.js`** — hydrates the server default via
`MC_setServerDefaultTileProvider` from `/api/config/client`.
- **Server (`cmd/server/`)** — new `mapDarkTileProvider` string on
`Config` + surfaced in `ClientConfigResponse`. Default empty → client
uses `carto-dark`.
- **`config.example.json`** — documents the new field with all allowed
values.

## Behavior guarantees (from the acceptance criteria)

-  Light mode is **completely unchanged** — `_resolveTileUrl(false)`
short-circuits to `TILE_LIGHT` with no filter and no overlay logic.
-  Switching dark→light always clears the CSS filter, even if an
inverted provider remains selected (`MC_applyTileFilter` is called on
every theme change and early-returns to `style.filter = ''` when not
dark).
-  Switching light→dark with an inverted provider re-applies the
filter.
-  Attribution is updated per provider (Esri credit for Esri, CartoDB
credit for the others); the Leaflet attribution control is refreshed.
-  Esri uses two stacked layers (base + reference labels). The
reference layer is added/removed cleanly so repeat toggles do not leak.
-  Customizer change → immediate re-render, no reload. Uses the same
"live setting + persist + dispatch event" pattern as cb-presets (#1361).

## TDD

- Red commit: `148b71c3` — `test(#1420): add failing tests for dark-tile
provider registry (red)` — 6/7 assertions fail (stub only returns
nulls).
- Green commit: `49ffb230` — `feat(#1420): dark-tile provider picker — 4
variants wired into customizer` — 7/7 pass.

## Tests

`test-issue-1420-tile-providers.js` (wired into `test-all.sh` and
`.github/workflows/deploy.yml` JS-unit step):

```
── #1420 Dark-tile provider registry ──
   MC_TILE_PROVIDERS has all 4 IDs with url + attribution
   Inverted providers have non-null invertFilter; non-inverted have null
   MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed
   MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch)
   MC_getDarkTileProvider falls back to server default, then carto-dark
   Apply filter for inverted provider in dark mode; clear when switching to non-inverted
   Light mode always clears the CSS filter even if inverted provider is selected
  7 passed, 0 failed
```

`cd cmd/server && go build ./... && go vet ./...` — clean.

## CDP verification

Not run in this PR — the sandbox does not have a Chrome CDP endpoint
reachable, and staging cannot exercise this code path until this branch
is deployed. The issue body's "CDP-verified candidate set" table covers
prior provider-URL validation; the new code path (registry lookup +
filter swap + Esri overlay lifecycle) is covered by the unit tests
above. **Recommend operator run a quick manual verification on staging
post-deploy:** dark mode → open customizer → cycle through all 4
providers, confirm tiles render and the CSS filter is applied for
`voyager-inverted` / `positron-inverted` (verify via
`getComputedStyle(document.querySelector('.leaflet-tile-pane')).filter`).

## Files touched

- `public/map-tile-providers.js` (new)
- `public/map.js`, `public/live.js`, `public/customize-v2.js`,
`public/roles.js`, `public/index.html`
- `cmd/server/config.go`, `cmd/server/routes.go`, `cmd/server/types.go`
- `config.example.json`
- `test-issue-1420-tile-providers.js` (new), `test-all.sh`,
`.github/workflows/deploy.yml`
- `.eslintrc.json` (register new `MC_*` globals)

---------

Co-authored-by: openclaw <bot@openclaw.local>
2026-05-27 14:37:51 +00:00

182 lines
8.0 KiB
JavaScript

/* test-issue-1420-tile-providers.js — Dark-tile provider registry tests.
*
* Asserts the MC_TILE_PROVIDERS registry shape, persistence helpers,
* and CSS-filter swap behavior for inverted variants. Tests via VM
* sandbox (no jsdom dep). Follows the pattern from cb-presets tests.
*/
'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]; },
_raw: store
};
}
function makeSandbox(opts) {
opts = opts || {};
const events = [];
const listeners = {};
const tilePane = { style: { filter: '' } };
const ctx = {
console,
setTimeout, clearTimeout,
JSON, Date, Math, Object, Array, String, Number, Boolean,
localStorage: makeStorage(),
document: {
documentElement: { getAttribute: () => opts.theme || 'dark' },
querySelector: (sel) => sel === '.leaflet-tile-pane' ? tilePane : null,
querySelectorAll: () => [],
addEventListener: () => {},
},
window: {
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
dispatchEvent: (ev) => { events.push(ev); return true; },
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
},
CustomEvent: function (type, init) { this.type = type; this.detail = (init && init.detail) || null; }
};
ctx.window.localStorage = ctx.localStorage;
ctx.globalThis = ctx;
vm.createContext(ctx);
// Make window mirror globals (the module uses window.X assignment)
ctx.window.document = ctx.document;
ctx.events = events;
ctx.listeners = listeners;
ctx.tilePane = tilePane;
return ctx;
}
function loadProviders(ctx) {
const src = fs.readFileSync(path.join(__dirname, 'public', 'map-tile-providers.js'), 'utf8');
vm.runInContext(src, ctx);
}
console.log('── #1420 Dark-tile provider registry ──');
test('MC_TILE_PROVIDERS has all 4 IDs with url + attribution', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const reg = ctx.window.MC_TILE_PROVIDERS;
assert.ok(reg, 'registry must exist on window');
const ids = ['carto-dark', 'esri-darkgray-labels', 'voyager-inverted', 'positron-inverted'];
for (const id of ids) {
assert.ok(reg[id], 'missing provider: ' + id);
assert.ok(typeof reg[id].attribution === 'string' && reg[id].attribution.length > 0, id + ' attribution');
// Esri uses baseUrl + refUrl; others use url
if (id === 'esri-darkgray-labels') {
assert.ok(typeof reg[id].baseUrl === 'string' && reg[id].baseUrl.indexOf('{z}') >= 0, 'esri baseUrl');
assert.ok(typeof reg[id].refUrl === 'string' && reg[id].refUrl.indexOf('{z}') >= 0, 'esri refUrl');
} else {
assert.ok(typeof reg[id].url === 'string' && reg[id].url.indexOf('{z}') >= 0, id + ' url has {z}');
}
}
});
test('Inverted providers have non-null invertFilter; non-inverted have null', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const reg = ctx.window.MC_TILE_PROVIDERS;
assert.strictEqual(reg['carto-dark'].invertFilter, null);
assert.strictEqual(reg['esri-darkgray-labels'].invertFilter, null);
assert.ok(typeof reg['voyager-inverted'].invertFilter === 'string' && reg['voyager-inverted'].invertFilter.indexOf('invert(') >= 0);
assert.ok(typeof reg['positron-inverted'].invertFilter === 'string' && reg['positron-inverted'].invertFilter.indexOf('invert(') >= 0);
});
test('MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed', () => {
const ctx = makeSandbox();
loadProviders(ctx);
ctx.window.MC_setDarkTileProvider('voyager-inverted');
assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), 'voyager-inverted');
assert.ok(ctx.events.length >= 1, 'event dispatched');
const ev = ctx.events[ctx.events.length - 1];
assert.strictEqual(ev.type, 'mc-tile-provider-changed');
assert.ok(ev.detail && ev.detail.id === 'voyager-inverted');
});
test('MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch)', () => {
const ctx = makeSandbox();
loadProviders(ctx);
const ok = ctx.window.MC_setDarkTileProvider('not-a-real-provider');
assert.strictEqual(ok, false);
assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), null);
assert.strictEqual(ctx.events.length, 0);
});
test('MC_getDarkTileProvider falls back to server default, then carto-dark', () => {
const ctx = makeSandbox();
loadProviders(ctx);
// No localStorage, no server hint → default carto-dark
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'carto-dark');
// Server-provided default surfaces through
ctx.window.MC_setServerDefaultTileProvider('esri-darkgray-labels');
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'esri-darkgray-labels');
// localStorage wins over server
ctx.window.MC_setDarkTileProvider('voyager-inverted');
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'voyager-inverted');
});
test('Apply filter for inverted provider in dark mode; clear when switching to non-inverted', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadProviders(ctx);
ctx.window.MC_setDarkTileProvider('voyager-inverted');
ctx.window.MC_applyTileFilter(); // dark theme + inverted → filter set
assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'filter applied: ' + ctx.tilePane.style.filter);
ctx.window.MC_setDarkTileProvider('carto-dark');
ctx.window.MC_applyTileFilter();
assert.strictEqual(ctx.tilePane.style.filter, '', 'filter cleared after switching to carto-dark');
});
test('Light mode always clears the CSS filter even if inverted provider is selected', () => {
const ctx = makeSandbox({ theme: 'light' });
loadProviders(ctx);
ctx.tilePane.style.filter = 'invert(1)'; // pre-set from a prior dark session
ctx.window.MC_setDarkTileProvider('voyager-inverted');
ctx.window.MC_applyTileFilter(); // light theme → must clear regardless of provider
assert.strictEqual(ctx.tilePane.style.filter, '');
});
test('Cross-tab storage event re-dispatches mc-tile-provider-changed and re-applies filter', () => {
const ctx = makeSandbox({ theme: 'dark' });
loadProviders(ctx);
// Sanity: module registered a storage listener.
assert.ok(ctx.listeners.storage && ctx.listeners.storage.length >= 1, 'storage listener registered');
// Simulate localStorage change from another tab (do NOT call setActive — that's same-tab).
ctx.localStorage.setItem('mc-dark-tile-provider', 'voyager-inverted');
const before = ctx.events.length;
ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'voyager-inverted', oldValue: null });
assert.ok(ctx.events.length > before, 'storage event re-dispatched mc-tile-provider-changed');
const ev = ctx.events[ctx.events.length - 1];
assert.strictEqual(ev.type, 'mc-tile-provider-changed');
assert.strictEqual(ev.detail.id, 'voyager-inverted');
assert.strictEqual(ev.detail.crossTab, true);
assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'filter re-applied after cross-tab change');
// Unknown values from other tabs must be ignored.
const beforeIgnored = ctx.events.length;
ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'bogus', oldValue: 'voyager-inverted' });
assert.strictEqual(ctx.events.length, beforeIgnored, 'unknown ids do not re-dispatch');
// Unrelated keys must be ignored.
ctx.listeners.storage[0]({ key: 'other-key', newValue: 'carto-dark', oldValue: null });
assert.strictEqual(ctx.events.length, beforeIgnored, 'unrelated key ignored');
});
process.on('beforeExit', () => {
console.log('');
console.log(' ' + passed + ' passed, ' + failed + ' failed');
if (failed) process.exit(1);
});