From eb44231a556ef0e75c3d1feb5b0233f6c102b15c Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 13 Oct 2026 08:11:45 +0200 Subject: [PATCH] fix(maps): honour the legacy dark tile URL, document raw-URL limits (#390) Follow-ups to #366. #366 restored the legacy LIGHT tile-URL override in front of the registry's implicit fallback; dark kept being masked by the registry default exactly the way light was. - map-tile-providers.js: getSelectedDarkId() mirrors getSelectedLightId() so an implicit fallback stays distinguishable from an explicit choice, and getTileSpec() resolves the legacy branch per theme. Precedence for both themes is now browser choice > configured provider default > legacy URL > built-in fallback. A raw URL keeps id:null, so it borrows no vendor credit, reference overlay or invert filter. - MC_applyTileFilter() resolves through getTileSpec() too: an operator's own dark tileset must not be CSS-inverted on top of already being dark. - roles.js: record MC_DARK_TILE_URL_OVERRIDE from tiles.dark / map.tiles.darkUrl, mirroring the light override. - config.example.json: document the four legacy raw-URL keys, their precedence, the generic credit they get, and their zoom ceiling. Configuring a provider by id (darkDefault/lightDefault) is unchanged and still takes priority, so installs that configure providers explicitly resolve through the registry exactly as before. Relates to #390. --- config.example.json | 1 + public/map-tile-providers.js | 45 ++-- public/roles.js | 6 + test-all.sh | 1 + test-issue-332-no-carto-tiles-e2e.js | 62 +++++ test-issue-390-legacy-tile-followups.js | 307 ++++++++++++++++++++++++ 6 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 test-issue-390-legacy-tile-followups.js diff --git a/config.example.json b/config.example.json index caa64d29f..afad55883 100644 --- a/config.example.json +++ b/config.example.json @@ -99,6 +99,7 @@ "map": { "tiles": { "_comment_defaults": "#332 — the built-in defaults are 'osm-dark' / 'osm-standard' (keyless OpenStreetMap, tiles to zoom 18). Set darkDefault/lightDefault to any enabled style id to override; they take priority over the built-in fallback. Carto is NOT the default any more: since August 2026 keyless Carto raster tiles come back HTTP 200 with 'API KEY REQUIRED' stamped into the PNG, and CoreScope has no Carto basemap-key plumbing.", + "_comment_legacy": "Legacy raw tile-URL overrides — \"tiles.dark\" / \"tiles.light\" at the top level, or \"darkUrl\" / \"lightUrl\" here — are still honoured: they apply when no provider was chosen in the browser and no darkDefault/lightDefault is set, i.e. precedence is browser choice > darkDefault/lightDefault > raw URL > built-in fallback. A raw URL carries no vendor metadata, so such a map shows the generic \"\u00a9 OpenStreetMap contributors\" credit (no vendor-specific or licence attribution), is never CSS-inverted in dark mode, and is capped at zoom 18 like the built-in OpenStreetMap default. Prefer darkDefault/lightDefault with a registered style id when you need the correct credit, licence link or a different zoom ceiling.", "darkDefault": "osm-dark", "lightDefault": "osm-standard", "providers": { diff --git a/public/map-tile-providers.js b/public/map-tile-providers.js index c9a40f7fb..10de639a9 100644 --- a/public/map-tile-providers.js +++ b/public/map-tile-providers.js @@ -34,6 +34,10 @@ var _serverDefaultLight = null; var INVERT_CSS = 'invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.05)'; + /* #364/#390 — a legacy raw tile URL carries no vendor metadata, so it + * keeps the historical generic credit. Documented for operators in + * config.example.json `map.tiles._comment_legacy`. */ + var LEGACY_ATTRIBUTION = '© OpenStreetMap contributors'; var _cfg = null; @@ -157,14 +161,21 @@ } - function getActiveId() { + // #390 — mirror of getSelectedLightId(): a valid explicit choice can + // itself be the built-in fallback id, so an implicit fallback has to stay + // distinguishable from one the operator or user actually picked. + function getSelectedDarkId() { try { var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY); if (_hasId(stored) && REGISTRY[stored].type === 'dark') return stored; } catch (_) {} if (_cfg && _cfg.darkDefault && _hasId(_cfg.darkDefault)) return _cfg.darkDefault; if (_hasId(_serverDefault)) return _serverDefault; - return DEFAULT_ID; + return null; + } + + function getActiveId() { + return getSelectedDarkId() || DEFAULT_ID; } // A valid explicit choice can itself be the built-in fallback id. Keep @@ -221,10 +232,12 @@ return; } + // #390 — resolve through getTileSpec so the legacy-URL branch is + // honoured here too. A legacy dark URL is an operator's own dark + // tileset; inverting it on top of that renders the map light. var isDark = _isDarkEffective(); - var id = isDark ? getActiveId() : getActiveLightId(); - var p = REGISTRY[id]; - pane.style.filter = (isDark && p && p.invertFilter) ? p.invertFilter : ''; + var spec = getTileSpec(isDark ? 'dark' : 'light'); + pane.style.filter = (isDark && spec && spec.invertFilter) ? spec.invertFilter : ''; } @@ -242,17 +255,21 @@ */ function getTileSpec(type) { var isLight = (type === 'light'); - // #364 precedence: browser selection > configured provider default > - // legacy light URL > built-in fallback. roles.js records the actual - // loaded URL, not TILE_LIGHT's always-present built-in value. Unknown - // custom vendors keep the historical generic credit and no inversion; - // id:null means this is not a registry provider with vendor metadata. - var legacyLightUrl = window.MC_LIGHT_TILE_URL_OVERRIDE; - if (isLight && !getSelectedLightId() && typeof legacyLightUrl === 'string' && legacyLightUrl) { - return { id: null, url: legacyLightUrl, attribution: '© OpenStreetMap contributors', refUrl: null, maxZoom: BASE_STYLES[DEFAULT_ID_LIGHT].maxZoom, invertFilter: null }; + var fallback = isLight ? DEFAULT_ID_LIGHT : DEFAULT_ID; + // #364/#390 precedence, applied per theme: browser selection > + // configured provider default > legacy URL > built-in fallback. + // roles.js records the actual loaded URL, not TILE_DARK/TILE_LIGHT's + // always-present built-in value. Unknown custom vendors keep the + // historical generic credit and no inversion; id:null means this is + // not a registry provider with vendor metadata. #390 gives dark the + // branch light got in #366 — `tiles.dark` / `map.tiles.darkUrl` were + // masked by the registry default the same way. + var legacyUrl = isLight ? window.MC_LIGHT_TILE_URL_OVERRIDE : window.MC_DARK_TILE_URL_OVERRIDE; + var selected = isLight ? getSelectedLightId() : getSelectedDarkId(); + if (!selected && typeof legacyUrl === 'string' && legacyUrl) { + return { id: null, url: legacyUrl, attribution: LEGACY_ATTRIBUTION, refUrl: null, maxZoom: BASE_STYLES[fallback].maxZoom, invertFilter: null }; } var id = isLight ? getActiveLightId() : getActiveId(); - var fallback = isLight ? DEFAULT_ID_LIGHT : DEFAULT_ID; var p = REGISTRY[id] || BASE_STYLES[id] || BASE_STYLES[fallback]; var u = p ? (p.url || p.baseUrl) : null; return { diff --git a/public/roles.js b/public/roles.js index 76b13f2dd..1fa21214f 100644 --- a/public/roles.js +++ b/public/roles.js @@ -601,7 +601,11 @@ window.TILE_LIGHT = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; // Runtime metadata for the registry, not a new config setting. An // implicit provider fallback must not mask the operator's legacy URL. + // #390 — dark needs the same treatment as light got in #366: before it, + // `tiles.dark` / `map.tiles.darkUrl` were silently masked by whichever + // dark style the registry defaulted to. window.MC_LIGHT_TILE_URL_OVERRIDE = null; + window.MC_DARK_TILE_URL_OVERRIDE = null; function _tileThemeIsDark() { return document.documentElement.getAttribute('data-theme') === 'dark' || @@ -787,6 +791,8 @@ } window.MC_LIGHT_TILE_URL_OVERRIDE = cfg.tiles ? (cfg.tiles.light || null) : (cfg.map && cfg.map.tiles && cfg.map.tiles.lightUrl) || null; + window.MC_DARK_TILE_URL_OVERRIDE = cfg.tiles ? (cfg.tiles.dark || null) : + (cfg.map && cfg.map.tiles && cfg.map.tiles.darkUrl) || null; if (typeof window.MC_initTileRegistry === 'function') window.MC_initTileRegistry(true); if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds); if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds); diff --git a/test-all.sh b/test-all.sh index 944de32ad..2c76ff975 100755 --- a/test-all.sh +++ b/test-all.sh @@ -115,6 +115,7 @@ run test-issue-1418-polish-review.js run test-issue-1420-tile-providers.js run test-issue-1614-tile-url-function.js run test-issue-332-tile-provider-fallback.js +run test-issue-390-legacy-tile-followups.js run test-issue-1438-marker-css-vars.js run test-issue-1438-customizer-mcrole.js run test-issue-1446-cb-preset-cascade.js diff --git a/test-issue-332-no-carto-tiles-e2e.js b/test-issue-332-no-carto-tiles-e2e.js index 9c3008618..014592f09 100644 --- a/test-issue-332-no-carto-tiles-e2e.js +++ b/test-issue-332-no-carto-tiles-e2e.js @@ -21,6 +21,12 @@ * template plus an invert filter, so a map that attaches tiles but * skips the filter renders a light basemap inside a dark page. * + * #364/#390 extend it: an operator's legacy raw tile URL (`tiles.light` / + * `tiles.dark`, or `map.tiles.lightUrl` / `darkUrl`) must reach the real + * network requests of the surfaces above, per theme, and must not borrow a + * registry provider's credit, reference overlay or invert filter — a raw + * dark tileset inverted on top of being dark renders light. + * * Third-party tile IMAGE requests are stubbed with a 1x1 PNG at the route * level: the point is which URL the page asks for, not what comes back, * and CI has no business reaching a tile CDN. Everything else off-origin @@ -363,6 +369,62 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } }); } + // #390: the same contract for the DARK legacy override, which stayed + // masked by the registry default after #366 fixed light. A raw dark + // tileset must also never be CSS-inverted on top of already being dark — + // checkFilter() reads the resolved spec, so it asserts exactly that. + const legacyDarkUrl = 'https://private.example/dark-tiles/{z}/{x}/{y}.png'; + for (const [label, clientConfig] of [ + ['tiles.dark', { tiles: { dark: legacyDarkUrl } }], + ['map.tiles.darkUrl', { map: { tiles: { darkUrl: legacyDarkUrl } } }], + ]) { + for (const theme of ['dark', 'light']) { + await step(`#390 ${label}: node inset uses ${theme === 'dark' ? 'legacy URL' : 'unchanged light provider'}`, async () => { + await withPage(theme, async (page, tiles) => { + await page.goto(BASE + '/#/nodes/' + NODE_KEY, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#nodeFullMap .leaflet-tile-pane', { state: 'attached' }); + await page.waitForTimeout(600); + check(label, tiles, { expectHost: theme === 'dark' ? 'private.example' : EXPECT.light }); + await checkFilter(page, '#nodeFullMap .leaflet-tile-pane', theme, label); + if (theme === 'light') assert(!tiles.some((u) => u.includes('private.example')), 'the legacy DARK URL leaked into light tiles'); + }, { clientConfig }); + }); + await step(`#390 ${label}: /#/map in ${theme} follows the per-theme resolution`, async () => { + await withPage(theme, async (page, tiles) => { + await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#leaflet-map .leaflet-tile-pane', { state: 'attached', timeout: 15000 }); + await page.waitForTimeout(1500); + check(label + ' map', tiles, { expectHost: theme === 'dark' ? 'private.example' : EXPECT.light }); + await checkFilter(page, '#leaflet-map .leaflet-tile-pane', theme, label + ' map'); + }, { clientConfig }); + }); + } + await step(`#390 ${label}: explicit dark selection recovers from failed legacy tiles`, async () => { + await withPage('dark', async (page, tiles) => { + // An explicit choice of the built-in dark id must not be mistaken + // for an implicit fallback; a subsequent mount stops using the URL. + let failedRequests = 0; + await page.route('https://private.example/**', (route) => { + failedRequests++; + return route.fulfill({ status: 503, body: 'synthetic tile failure' }); + }); + await page.goto(BASE + '/#/nodes/' + NODE_KEY, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#nodeFullMap .leaflet-tile-pane', { state: 'attached' }); + await page.waitForTimeout(600); + assert(failedRequests > 0, 'the legacy dark tile URL never produced a failing network request'); + assert(await page.evaluate(() => window.getTileUrl()) === legacyDarkUrl, 'the failing legacy provider was not active'); + await page.evaluate(() => window.MC_setDarkTileProvider('osm-dark')); + tiles.length = 0; + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#nodeFullMap .leaflet-tile-pane', { state: 'attached' }); + await page.waitForTimeout(600); + check('explicit dark recovery', tiles, { expectHost: OSM_HOST }); + assert(!tiles.some((u) => u.includes('private.example')), 'legacy URL still masked the explicit dark choice'); + await checkFilter(page, '#nodeFullMap .leaflet-tile-pane', 'dark', label); + }, { clientConfig }); + }); + } + // The standalone geo-filter builder has no provider registry at all; it // used to hard-code the CARTO dark template. await step('standalone /geofilter-builder.html requests no CARTO tiles', async () => { diff --git a/test-issue-390-legacy-tile-followups.js b/test-issue-390-legacy-tile-followups.js new file mode 100644 index 000000000..034e8dce6 --- /dev/null +++ b/test-issue-390-legacy-tile-followups.js @@ -0,0 +1,307 @@ +/* test-issue-390-legacy-tile-followups.js — #390, follow-ups to #366/#364. + * + * #366 restored the legacy LIGHT tile-URL override (`tiles.light` / + * `map.tiles.lightUrl`) in front of the registry's implicit fallback. Three + * gaps were left behind: + * + * P1 The DARK legacy override (`tiles.dark` / `map.tiles.darkUrl`) is + * still masked by the registry default exactly the way light was + * before #366 — no legacy branch existed for dark at all. The #366 + * precedence must hold per theme: + * explicit browser choice > configured provider default + * > legacy URL > built-in fallback + * and an operator's own dark tileset must not be CSS-inverted on top + * of already being dark. + * P2 A legacy raw URL carries no vendor metadata, so it keeps the + * generic `© OpenStreetMap contributors` credit. That is by design + * but was undocumented; config.example.json must say so, and the + * documented credit must be the one the resolver really returns. + * P3 A legacy URL's spec borrows the built-in OSM default's zoom + * ceiling, so a custom provider that serves one level deeper is + * clipped. Also by design for now, and likewise documented — with + * the documented ceiling pinned to the real one. + * + * Runs via: node test-issue-390-legacy-tile-followups.js + * Pure vm sandbox — no jsdom, no Playwright. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +let passed = 0, failed = 0; +async function asyncTest(name, fn) { + try { await fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +const GENERIC_CREDIT = '© OpenStreetMap contributors'; + +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]; }, + }; +} + +// Browser-like sandbox: one object is both `window` and the global, so the +// bare-name references inside roles.js / map-tile-providers.js resolve the +// way they do in a page. +function makeSandbox(theme) { + const tilePane = { + style: { filter: '' }, + setAttribute: () => {}, + getAttribute: () => null, + removeAttribute: () => {}, + }; + const ctx = { + console, + setTimeout, clearTimeout, + JSON, Date, Math, Object, Array, String, Number, Boolean, Set, Map, Promise, + localStorage: makeStorage(), + addEventListener: () => {}, + dispatchEvent: () => true, + matchMedia: () => ({ matches: theme === 'dark', addEventListener: () => {} }), + document: { + documentElement: { + getAttribute: () => theme, + setAttribute: () => {}, + style: { getPropertyValue: () => '', setProperty: () => {} }, + }, + querySelector: (sel) => (sel === '.leaflet-tile-pane' ? tilePane : null), + querySelectorAll: () => [], + getElementById: () => null, + createElement: () => ({ style: {}, appendChild: () => {}, setAttribute: () => {}, addEventListener: () => {} }), + addEventListener: () => {}, + body: { appendChild: () => {}, style: {} }, + head: { appendChild: () => {} }, + readyState: 'complete', + }, + CustomEvent: function (type, init) { this.type = type; this.detail = (init && init.detail) || null; }, + }; + ctx.window = ctx; + ctx.globalThis = ctx; + ctx.tilePane = tilePane; + vm.createContext(ctx); + return ctx; +} + +function loadInto(ctx, relPath) { + vm.runInContext(fs.readFileSync(path.join(__dirname, relPath), 'utf8'), ctx, { filename: relPath }); +} + +// Drive the real async client-config loader so the legacy keys travel the +// production path (roles.js applyConfig → MC_initTileRegistry(true)). +async function configuredStack(config, theme, storedDark, storedLight) { + const ctx = makeSandbox(theme || 'dark'); + if (storedDark) ctx.localStorage.setItem('mc-dark-tile-provider', storedDark); + if (storedLight) ctx.localStorage.setItem('mc-light-tile-provider', storedLight); + ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve(config) }); + loadInto(ctx, path.join('public', 'roles.js')); + loadInto(ctx, path.join('public', 'map-tile-providers.js')); + await ctx.MeshConfigReady; + return ctx; +} + +// Brace-matched extraction of `function (...) { … }`, so the test runs +// the production helper body rather than a copy of it. +function extractFn(relPath, name) { + const src = fs.readFileSync(path.join(__dirname, relPath), 'utf8'); + const m = src.match(new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{')); + if (!m) throw new Error(name + ' definition not found in ' + relPath); + let depth = 0, i = m.index; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) { i++; break; } } + } + return src.slice(m.index, i); +} + +function makeTileLayerSpy() { + const calls = []; + return { + calls, + L: { tileLayer(url, opts) { return { addTo(map) { calls.push({ url, opts, map }); return this; } }; } }, + }; +} + +const DARK_URL = 'https://private.example/dark/{z}/{x}/{y}.png'; +const LIGHT_URL = 'https://private.example/light/{z}/{x}/{y}.png'; + +// ═══ P1 — the dark legacy override is honoured, with #366 precedence ════ +async function darkLegacyTests() { + console.log('\n── #390 P1: dark legacy tile URL override ──'); + + for (const [label, config] of [ + ['tiles.dark', { tiles: { dark: DARK_URL } }], + ['map.tiles.darkUrl', { map: { tiles: { darkUrl: DARK_URL } } }], + ]) { + await asyncTest('P1 ' + label + ' overrides the implicit dark fallback', async () => { + const ctx = await configuredStack(config, 'dark'); + assert.strictEqual(ctx.TILE_DARK, DARK_URL, 'the config loader actually ran'); + assert.strictEqual(ctx.getTileUrl(), DARK_URL); + const spec = ctx.MC_getTileSpec('dark'); + assert.strictEqual(spec.url, DARK_URL, 'every resolver sees the same override'); + assert.strictEqual(spec.id, null, 'a raw URL is not a registry provider'); + assert.strictEqual(spec.attribution, GENERIC_CREDIT); + assert.strictEqual(spec.refUrl, null); + assert.strictEqual(spec.invertFilter, null, 'an operator dark tileset must not be inverted'); + assert.strictEqual(ctx.getActiveTileProvider(), null, 'an unknown vendor has no registry metadata'); + }); + + await asyncTest('P1 ' + label + ' leaves the tile pane filter cleared', async () => { + const ctx = await configuredStack(config, 'dark'); + ctx.tilePane.style.filter = 'stale filter'; + ctx.MC_applyTileFilter(); + assert.strictEqual(ctx.tilePane.style.filter, '', + 'inverting an already-dark custom tileset renders it light'); + }); + + await asyncTest('P1 ' + label + ' drives the node-detail inset without borrowing metadata', async () => { + const ctx = await configuredStack(config, 'dark'); + // Synthetic unused metadata makes borrowing the fallback provider's + // credit, reference overlay or inversion observable in the real helper. + Object.assign(ctx.MC_TILE_PROVIDERS['osm-dark'], { + attribution: 'UNUSED VENDOR', refUrl: 'https://unused.example/labels/{z}/{x}/{y}', invertFilter: 'invert(1)', + }); + const spy = makeTileLayerSpy(); + ctx.L = spy.L; + const pane = { style: { filter: 'stale filter' } }; + ctx.__map = { getPane: () => pane }; + vm.runInContext(extractFn('public/nodes.js', '_applyTilesToNodeMap') + '\n_applyTilesToNodeMap(__map);', ctx); + assert.strictEqual(spy.calls.length, 1, 'an unknown vendor must not borrow a reference overlay'); + assert.strictEqual(spy.calls[0].url, DARK_URL); + assert.strictEqual(spy.calls[0].opts.attribution, GENERIC_CREDIT, + 'preserve the historical generic fallback credit'); + assert.strictEqual(pane.style.filter, ''); + }); + + for (const stored of ['carto-dark', 'osm-dark']) { + await asyncTest('P1 browser choice ' + stored + ' wins over ' + label, async () => { + const ctx = await configuredStack(config, 'dark', stored); + assert.strictEqual(ctx.MC_getTileSpec('dark').id, stored, + 'an explicit choice, including the fallback id itself, beats the legacy URL'); + assert.notStrictEqual(ctx.getTileUrl(), DARK_URL); + assert.ok(ctx.getActiveTileProvider()); + }); + } + + await asyncTest('P1 invalid browser choice falls back to ' + label, async () => { + const ctx = await configuredStack(config, 'dark', 'missing-provider'); + assert.strictEqual(ctx.getTileUrl(), DARK_URL); + }); + + await asyncTest('P1 ' + label + ' never replaces light-provider tiles', async () => { + const ctx = await configuredStack(config, 'light'); + const spec = ctx.MC_getTileSpec('light'); + assert.strictEqual(spec.id, 'osm-standard'); + assert.notStrictEqual(ctx.getTileUrl(), DARK_URL); + assert.ok(ctx.getActiveTileProvider(), 'the light provider keeps its registry metadata'); + }); + } + + await asyncTest('P1 configured darkDefault wins over the legacy URL; browser choice wins over both', async () => { + const ctx = await configuredStack({ + map: { tiles: { darkUrl: DARK_URL, darkDefault: 'esri-darkgray-labels' } }, + }, 'dark'); + assert.strictEqual(ctx.MC_getTileSpec('dark').id, 'esri-darkgray-labels'); + assert.ok(ctx.getTileUrl().includes('arcgisonline.com')); + ctx.MC_setDarkTileProvider('osm-dark'); + assert.strictEqual(ctx.MC_getTileSpec('dark').id, 'osm-dark'); + assert.ok(ctx.getTileUrl().includes('tile.openstreetmap.org')); + }); + + await asyncTest('P1 unavailable darkDefault and disabled cached provider recover to the legacy URL', async () => { + const ctx = await configuredStack({ + map: { tiles: { darkUrl: DARK_URL, darkDefault: 'missing-provider', providers: { stamen: { enabled: false } } } }, + }, 'dark', 'stamen-toner-dark'); + assert.strictEqual(ctx.getTileUrl(), DARK_URL); + }); + + await asyncTest('P1 explicit programmatic server default wins over the legacy dark URL', async () => { + const ctx = await configuredStack({ tiles: { dark: DARK_URL } }, 'dark'); + ctx.MC_setServerDefaultTileProvider('carto-dark'); + assert.strictEqual(ctx.MC_getTileSpec('dark').id, 'carto-dark'); + assert.ok(ctx.getTileUrl().includes('cartocdn.com')); + }); + + await asyncTest('P1 a legacy dark URL keeps the generic credit even with an unused OSM vendor configured', async () => { + const ctx = await configuredStack({ + map: { tiles: { darkUrl: DARK_URL, providers: { osm: { enabled: true, provider: 'maptiler', token: 'synthetic-token' } } } }, + }, 'dark'); + assert.strictEqual(ctx.getTileUrl(), DARK_URL); + assert.strictEqual(ctx.MC_getTileSpec('dark').attribution, GENERIC_CREDIT); + assert.strictEqual(ctx.getActiveTileProvider(), null); + }); + + await asyncTest('P1 both legacy URLs at once resolve per theme', async () => { + const ctx = await configuredStack({ tiles: { dark: DARK_URL, light: LIGHT_URL } }, 'dark'); + assert.strictEqual(ctx.MC_getTileSpec('dark').url, DARK_URL); + assert.strictEqual(ctx.MC_getTileSpec('light').url, LIGHT_URL); + assert.strictEqual(ctx.getTileUrl(), DARK_URL, 'dark theme takes the dark override'); + }); + + await asyncTest('P1 MC_getDarkTileProvider() keeps returning a resolvable registry id', async () => { + const ctx = await configuredStack({ tiles: { dark: DARK_URL } }, 'dark'); + const id = ctx.MC_getDarkTileProvider(); + assert.strictEqual(id, ctx.MC_DARK_TILE_DEFAULT, + 'the public accessor must not start returning null for legacy installs'); + assert.ok(ctx.MC_TILE_PROVIDERS[id], 'the layer control still finds the id in the registry'); + }); + + await asyncTest('P1 no legacy config leaves the dark fallback and its invert filter intact', async () => { + const ctx = await configuredStack({ map: { tiles: {} } }, 'dark'); + const spec = ctx.MC_getTileSpec('dark'); + assert.strictEqual(spec.id, 'osm-dark'); + assert.ok(spec.invertFilter, 'the built-in dark default still inverts'); + ctx.MC_applyTileFilter(); + assert.strictEqual(ctx.tilePane.style.filter, spec.invertFilter); + }); +} + +// ═══ P2/P3 — the legacy URL's credit and zoom ceiling are documented ════ +async function legacyDocTests() { + console.log('\n── #390 P2/P3: legacy raw URLs are documented ──'); + + const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8')); + const note = (cfg.map && cfg.map.tiles && cfg.map.tiles._comment_legacy) || ''; + + await asyncTest('P2 config.example.json documents all four legacy tile-URL keys', async () => { + assert.ok(note, 'map.tiles._comment_legacy must document the legacy URL keys'); + for (const key of ['tiles.dark', 'tiles.light', 'darkUrl', 'lightUrl']) { + assert.ok(note.indexOf(key) !== -1, 'the legacy note must name ' + key); + } + assert.ok(/darkDefault|lightDefault/.test(note), + 'the note must point operators at the provider-id form, which keeps priority'); + }); + + await asyncTest('P2 the documented credit is the credit the resolver really returns', async () => { + assert.ok(note.indexOf(GENERIC_CREDIT) !== -1, + 'the note must quote the generic credit a raw URL gets'); + const ctx = await configuredStack({ tiles: { dark: DARK_URL, light: LIGHT_URL } }, 'dark'); + assert.strictEqual(ctx.MC_getTileSpec('dark').attribution, GENERIC_CREDIT); + assert.strictEqual(ctx.MC_getTileSpec('light').attribution, GENERIC_CREDIT); + }); + + await asyncTest('P3 the documented zoom ceiling is the ceiling the resolver really returns', async () => { + const m = note.match(/capped at zoom (\d+)/); + assert.ok(m, 'the legacy note must state the zoom ceiling a raw URL inherits'); + const ctx = await configuredStack({ tiles: { dark: DARK_URL, light: LIGHT_URL } }, 'dark'); + for (const theme of ['dark', 'light']) { + assert.strictEqual(ctx.MC_getTileSpec(theme).maxZoom, Number(m[1]), + 'the note says zoom ' + m[1] + ' but the legacy ' + theme + ' spec declares ' + + ctx.MC_getTileSpec(theme).maxZoom); + } + }); +} + +darkLegacyTests() + .then(legacyDocTests) + .then(() => { + console.log('\n' + (failed === 0 ? '✅' : '❌') + ' #390: ' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed === 0 ? 0 : 1); + }) + .catch((e) => { console.error(e); process.exit(1); });