Files
worldmonitor/tests/map-layer-executable.test.mts
Elie Habib 3d2dce3be1 feat(energy-atlas): promote Atlas map layers to FULL variant (§R #3 = B) (#3366)
* feat(energy-atlas): promote Atlas map layers to FULL variant (§R #3 = B)

Per plan §R/#3 decision B: the Redis-backed evidence registries
(75 gas + 75 oil pipelines, 200 storage facilities, 29 fuel shortages)
are now toggleable on the main worldmonitor.app map. Previously they
were hardcoded energy-variant-only, and FULL users who toggled
`pipelines: true` got the ~20-entry legacy static PIPELINES list.

Changes:
- `src/components/DeckGLMap.ts`: drop the `SITE_VARIANT === 'energy'`
  gates at :1511-1541. The pipelines layer now always uses
  `createEnergyPipelinesLayer()` (Redis-backed evidence registry);
  `createPipelinesLayer` (legacy static) is left in the file as dead
  code pending a separate cleanup PR that also retires
  `src/config/pipelines.ts`. Storage and fuel-shortage layers are
  now gated only on the variant's `mapLayers.storageFacilities` /
  `mapLayers.fuelShortages` booleans.
- `src/config/panels.ts`: add `storageFacilities: false` +
  `fuelShortages: false` to FULL_MAP_LAYERS (desktop + mobile) so
  the keys exist for toggle dispatch; default off so users opt in.
- `src/config/map-layer-definitions.ts`: extend the `full` variant's
  VARIANT_LAYER_ORDER to include `storageFacilities` and
  `fuelShortages`, so `getAllowedLayerKeys('full')` admits them and
  the layer picker surfaces them.
- `src/config/commands.ts`: add CMD+K toggles
  `layer:storageFacilities` and `layer:fuelShortages` next to the
  existing `layer:pipelines`.

Finance + commodity variants already had `pipelines: true`; they
now render the more comprehensive Redis-backed 150-entry dataset
instead of the ~20-entry legacy list. If a variant doesn't want
this, they set `pipelines: false` in their MAP_LAYERS config.

Part of docs/internal/energy-atlas-registry-expansion.md §R.

* fix(energy-atlas): restrict storageFacilities + fuelShortages to flat renderer

Reviewer (Codex) found two gaps in PR #3366:

1. GlobeMap 3D toggles did nothing. LAYER_REGISTRY declared both new
   layers with the default ['flat', 'globe'] renderers, so the toggle
   showed up in globe mode. But GlobeMap.ts has no rendering support:
   ensureStaticDataForLayer (:2160) only handles cables/pipelines/etc.,
   and the layer-channel map (:2484) has no entries for either. Users
   in globe mode saw the toggle and got silent no-ops.

2. SVG/mobile fallback (Map.ts fullLayers at :381) also has no render
   path for these data types. The existing cyberThreats precedent at
   :387 documents this as an intentional DeckGL-only pattern.

Fix:
- Restrict both LAYER_REGISTRY entries to ['flat'] explicitly. The
  layer picker hides the toggle in globe mode instead of exposing a
  no-op. Comment points to the GlobeMap gap so a future globe-rendering
  PR knows what to undo.
- Extend the existing cyberThreats note in Map.ts:387 to cover
  storageFacilities + fuelShortages too, noting they're already
  hidden from globe mode via the LAYER_REGISTRY restriction.

This is the smallest possible fix consistent with the pre-existing
pattern. Full globe-mode rendering for these layers is out of scope —
tracked separately as a follow-up.

* fix(energy-atlas): gate layer:* CMD+K by current renderer + DeckGL state

Reviewer follow-up on PR #3366: the previous fix restricted
LAYER_REGISTRY renderers to ['flat'] so the globe-mode layer picker
hides storageFacilities / fuelShortages toggles. But CMD+K was still
callable — SearchModal.matchCommands didn't filter `layer:*` commands
by renderer, so a user could CMD+K "storage layer" in globe or SVG
mode and trigger a silent no-op.

Fix — centralize "can this layer render right now?" in one helper:

- Add `deckGLOnly?: boolean` to LayerDefinition. `renderers: ['flat']`
  is not enough because `'flat'` covers both DeckGL-flat and SVG-flat,
  and the SVG/mobile fallback has no render path for either layer.
  Mark both as `deckGLOnly: true`.
- New `isLayerExecutable(key, renderer, isDeckGLActive)` helper in
  map-layer-definitions.ts. Returns true iff renderers include the
  current renderer AND (if deckGLOnly) DeckGL is active.
- `SearchModal.setLayerExecutableFn(fn)`: caller-supplied predicate
  used in both `matchCommands` (search results) and
  `renderAllCommandsList` (full picker).
- `search-manager` wires the predicate using `ctx.map.isGlobeMode()`
  + `ctx.map.isDeckGLActive()`, and also adds a symmetric guard in
  the `layer:` dispatch case so direct activations (keyboard
  accelerator, programmatic invocation) bail the same way.

Pre-existing resilienceScore DeckGL gate at search-manager:494 kept as
a belt-and-suspenders — the new isLayerExecutable check already
covers it since resilienceScore has `renderers: ['flat']` (though it
lacks deckGLOnly). Left the specific check in place to avoid scope
creep on a working guard.

Typecheck clean, 6694/6694 tests pass.

* fix(energy-atlas): filter CMD+K layer commands by variant too

Greptile P2 on commit 3f7a40036: `layer:storageFacilities` and
`layer:fuelShortages` still surface in CMD+K on tech / finance /
commodity / happy variants (where they're not in VARIANT_LAYER_ORDER).
Renderer + DeckGL filter was passing because those variants run flat
DeckGL. Dispatch silently failed at the `variantAllowed` guard in
handleCommand (:491), producing an invisible no-op from the user's
POV.

Fix: extend `setLayerExecutableFn` predicate to also check
`getAllowedLayerKeys(SITE_VARIANT).has(key)` before the renderer
checks. SearchModal now hides these commands on non-full/non-energy
variants where they can't execute.

This also cleans up the pre-existing pattern for other
variant-specific layer commands flagged by Greptile as "consistent
with how other variant-specific layer commands (e.g. layer:nuclear
on tech variant) already behave today" — they now all route through
the same predicate.

* fix(energy-atlas): gate layers:* presets + add isLayerExecutable tests (review P2)

Two Codex P2 findings on this PR:

1. `layers:*` presets bypassed the renderer/DeckGL gate.
   `search-manager.ts:481` checked only `allowed.has(layer)` before
   flipping a preset layer on. A user in globe mode or on SVG
   fallback who ran `layers:all` or `layers:infra` would silently
   set `deckGLOnly` layers (storageFacilities, fuelShortages) to
   true — toggles with no rendered output, and since the picker
   hides those layers under the current renderer the user had no
   way to toggle them back off without switching modes.

   Fix: funnel presets through the same `isLayerExecutable`
   predicate per-layer CMD+K already uses. `executable(k)` combines
   the existing `allowed.has` variant check with the renderer + DeckGL
   gate, so presets now match the per-layer dispatch behavior exactly.

2. No regression tests for the `deckGLOnly` / `isLayerExecutable`
   contract, despite it being behavior-critical renderer gating.

   Fix: added `tests/map-layer-executable.test.mts` — 16 cases:
   - Flag assertions: storageFacilities + fuelShortages carry
     `deckGLOnly: true` and renderers: ['flat']. Layers without the
     flag (pipelines, conflicts, cables) have it `undefined`, not
     accidentally `false`.
   - Renderer-gate cases: deckGLOnly layers pass only on flat + DeckGL
     active, not on SVG fallback, not on globe. Flat-only non-deckGLOnly
     layers (ciiChoropleth) pass on flat regardless of DeckGL status.
     Dual-renderer layers (pipelines) pass on both flat and globe.
     Unknown layer keys return false.
   - Exhaustive 2×2×2 matrix across (renderer, isDeckGL, deckGLOnly)
     using representative layer keys for each shape.

All 16 new tests pass. Full test:data suite still green. Typecheck clean.

* fix(energy-atlas): add pipeline-status to finance + commodity panel sets (review P1)

Codex P1: FINANCE_MAP_LAYERS and COMMODITY_MAP_LAYERS both carry
`pipelines: true`, and PR #3366 unified all variants on
`createEnergyPipelinesLayer` which dispatches
`energy:open-pipeline-detail` on row click. The listener for that
event lives in PipelineStatusPanel.

`PanelLayoutManager.createPanel()` only instantiates panels whose keys
are present in `panelSettings`, which derives from FULL_PANELS /
FINANCE_PANELS / etc. — so on finance and commodity variants the
listener never existed, and pipeline clicks were a silent no-op.

Fix: add `pipeline-status` to both FINANCE_PANELS and COMMODITY_PANELS
with `enabled: false` (panel slot not auto-opened; users invoke it by
clicking a pipeline on the map or via CMD+K). The panel now
instantiates on both variants and the click-through works end to end.

FULL_PANELS + ENERGY_PANELS already had the key from earlier PRs;
no change there.

Typecheck clean, test:data 6696/6696 pass.
2026-04-24 19:09:21 +04:00

140 lines
7.3 KiB
TypeScript

// Regression guards for src/config/map-layer-definitions.ts:
//
// - `deckGLOnly` flag on LayerDefinition
// - `isLayerExecutable(key, renderer, isDeckGLActive)` predicate
//
// Both gate whether a `layer:*` toggle (per-layer CMD+K, `layers:*`
// preset, or programmatic dispatch) is allowed to flip a layer on
// under the active renderer + DeckGL state. Getting them wrong means
// toggles can set `mapLayers[key] = true` for layers that can't
// render — silent no-op state the user can't toggle back off if the
// picker hides the command under the current renderer.
//
// Closes the PR #3366 Codex P2 about missing regression tests for
// the `deckGLOnly` / `isLayerExecutable` contract.
import { strict as assert } from 'node:assert';
import { test, describe } from 'node:test';
import {
LAYER_REGISTRY,
isLayerExecutable,
} from '../src/config/map-layer-definitions';
describe('LAYER_REGISTRY — deckGLOnly flag', () => {
test('storageFacilities and fuelShortages are marked deckGLOnly', () => {
// These two layers ship in PR #3366 with DeckGL-only render paths.
// GlobeMap has no branch for them in ensureStaticDataForLayer, and
// Map.ts SVG fallback has no render code. The `deckGLOnly: true`
// flag is the signal that non-DeckGL contexts must not flip them on.
assert.equal(LAYER_REGISTRY.storageFacilities.deckGLOnly, true,
'storageFacilities must be marked deckGLOnly');
assert.equal(LAYER_REGISTRY.fuelShortages.deckGLOnly, true,
'fuelShortages must be marked deckGLOnly');
});
test('storageFacilities and fuelShortages are flat-only (no globe)', () => {
// Renderer restriction is belt to the deckGLOnly suspenders — it
// hides the toggle from the globe picker, while deckGLOnly also
// blocks dispatch on the SVG fallback even though SVG is "flat".
assert.deepEqual(LAYER_REGISTRY.storageFacilities.renderers, ['flat']);
assert.deepEqual(LAYER_REGISTRY.fuelShortages.renderers, ['flat']);
});
test('layers without deckGLOnly do not accidentally set the flag to false', () => {
// Spot-check: layers that existed before PR #3366 should have
// deckGLOnly unset (undefined), not explicitly `false`. An
// accidentally-introduced `deckGLOnly: false` would technically
// type-check but signals confusion about the contract (absence
// means "no opinion", not "forbids DeckGL").
assert.equal(LAYER_REGISTRY.pipelines.deckGLOnly, undefined,
'pipelines is not deckGLOnly — renders on flat + globe');
assert.equal(LAYER_REGISTRY.conflicts.deckGLOnly, undefined);
assert.equal(LAYER_REGISTRY.cables.deckGLOnly, undefined);
});
});
describe('isLayerExecutable — renderer gate', () => {
test('deckGLOnly layer returns true only on flat + DeckGL active', () => {
// The intended ship state: DeckGL desktop can render, nothing else.
assert.equal(isLayerExecutable('storageFacilities', 'flat', true), true,
'flat + DeckGL should execute');
assert.equal(isLayerExecutable('storageFacilities', 'flat', false), false,
'flat + SVG-fallback (no DeckGL) must NOT execute');
assert.equal(isLayerExecutable('storageFacilities', 'globe', true), false,
'globe mode must NOT execute (no GlobeMap render path)');
assert.equal(isLayerExecutable('storageFacilities', 'globe', false), false,
'globe + SVG is impossible in practice but must also not execute');
});
test('flat-only non-deckGLOnly layer returns true on flat regardless of DeckGL', () => {
// `ciiChoropleth` is renderers:['flat'] but NOT deckGLOnly — it
// renders via a different flat path (choropleth). The gate should
// admit it on flat regardless of DeckGL status.
assert.equal(isLayerExecutable('ciiChoropleth', 'flat', true), true);
// SVG fallback with ciiChoropleth: the renderer gate admits it
// because 'flat' is in its renderers list. CII-specific rendering
// is handled by whatever renders flat-mode layers — that's outside
// isLayerExecutable's scope. deckGLOnly is the only "needs DeckGL
// even on flat" signal.
assert.equal(isLayerExecutable('ciiChoropleth', 'flat', false), true);
assert.equal(isLayerExecutable('ciiChoropleth', 'globe', true), false,
'ciiChoropleth has no globe renderer');
});
test('dual-renderer layer admits both flat and globe', () => {
// `pipelines` has renderers:['flat', 'globe'] (default) — it
// renders on both flat DeckGL/SVG and globe mode.
assert.equal(isLayerExecutable('pipelines', 'flat', true), true);
assert.equal(isLayerExecutable('pipelines', 'flat', false), true);
assert.equal(isLayerExecutable('pipelines', 'globe', true), true);
assert.equal(isLayerExecutable('pipelines', 'globe', false), true);
});
test('unknown layer key returns false', () => {
// Typo or stale key -> must not accidentally pass the gate.
// @ts-expect-error — intentionally passing a key outside the union
assert.equal(isLayerExecutable('nonexistentLayer', 'flat', true), false);
});
});
describe('isLayerExecutable — matrix of renderer x DeckGL x deckGLOnly', () => {
// Exhaustive 2x2x2 matrix to lock down the truth table. Future edits
// to the predicate that accidentally widen the allowed set get
// caught here rather than in production.
const cases: Array<{
renderers: Array<'flat' | 'globe'>;
deckGLOnly: boolean;
renderer: 'flat' | 'globe';
isDeckGL: boolean;
expect: boolean;
why: string;
}> = [
// deckGLOnly:true — only flat + DeckGL active passes
{ renderers: ['flat'], deckGLOnly: true, renderer: 'flat', isDeckGL: true, expect: true, why: 'flat + DeckGL passes deckGLOnly' },
{ renderers: ['flat'], deckGLOnly: true, renderer: 'flat', isDeckGL: false, expect: false, why: 'flat + SVG fails deckGLOnly' },
{ renderers: ['flat'], deckGLOnly: true, renderer: 'globe', isDeckGL: true, expect: false, why: 'globe not in renderers list' },
{ renderers: ['flat'], deckGLOnly: true, renderer: 'globe', isDeckGL: false, expect: false, why: 'globe not in renderers list' },
// deckGLOnly:false/undefined — renderer list is the only gate
{ renderers: ['flat'], deckGLOnly: false, renderer: 'flat', isDeckGL: true, expect: true, why: 'flat-only layer on flat' },
{ renderers: ['flat'], deckGLOnly: false, renderer: 'flat', isDeckGL: false, expect: true, why: 'flat-only layer on SVG (no deckGLOnly requirement)' },
{ renderers: ['flat'], deckGLOnly: false, renderer: 'globe', isDeckGL: true, expect: false, why: 'flat-only layer rejects globe' },
// dual-renderer layers
{ renderers: ['flat', 'globe'], deckGLOnly: false, renderer: 'flat', isDeckGL: true, expect: true, why: 'dual-renderer on flat' },
{ renderers: ['flat', 'globe'], deckGLOnly: false, renderer: 'globe', isDeckGL: true, expect: true, why: 'dual-renderer on globe' },
];
for (const c of cases) {
test(`${c.why}`, () => {
// Pick a representative key matching the (renderers, deckGLOnly) shape.
// storageFacilities = ['flat'] + deckGLOnly:true
// ciiChoropleth = ['flat'] + deckGLOnly:undefined
// pipelines = ['flat','globe'] + deckGLOnly:undefined
let key: keyof typeof LAYER_REGISTRY;
if (c.deckGLOnly) key = 'storageFacilities';
else if (c.renderers.length === 1) key = 'ciiChoropleth';
else key = 'pipelines';
assert.equal(isLayerExecutable(key, c.renderer, c.isDeckGL), c.expect, c.why);
});
}
});