diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b60c4c4..9944f0257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,12 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md the bundled place. Anything else, including anything malformed, goes to the existing geocoders unchanged. Degrees/minutes/seconds and grid references are not parsed and fall through the same way (#388 — thanks @KuraPiee). +- A data-layer control a provider key is holding back now names that key. With + no FIRMS key the fire layer's control read KEY REQUIRED without saying which + key or where to put it; it now reads "Needs FIRMS_MAP_KEY — add it in Provider + Settings", on the control and in its accessible name. A layer that needs no + key, or already holds one, carries no such text, and an unrecognised key name + produces none rather than a guess (#296 — thanks @Matthew-Selvam). - Draped annotation geometry — area fills and outlines, routes and arrows — classifies onto terrain as well as 3D tiles. On a keyless boot, where Cesium's diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d2192dd44..fbb8e7a31 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -787,7 +787,13 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > is present — everyone gets the same tile. > > Keyless, the honest surface is the **layer row**, which reads -> `UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED`, and the earthquakes half +> `UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED`. Its control also names the +> key: hovering it, and its accessible name, read +> `Needs FIRMS_MAP_KEY — add it in Provider Settings`. A layer declares which +> key it needs as a key-registry id and reports `stats.keyRequired` while that +> key is absent; the panel builds the text from the pair, and produces none for +> a layer that needs no key, already holds one, or names a key the registry does +> not recognise. The earthquakes half > still delivers in full. The shared loading reducer now treats an explicitly > declared missing optional key as a configured terminal state rather than a > failed multi-layer mission, so the global chip completes without showing diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 86ef14292..9341046c1 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -180,6 +180,7 @@ "scripts/qa-map-source-controls.mjs", "src/ui/layers.js", "src/ui/layerPanel.js", + "src/ui/layerKeyRequirement.test.mjs", "src/ui/clearLayersControl.js", "scripts/qa-layer-panel.mjs", "scripts/qa-directions.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e960da933..ae84f6c61 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -353,7 +353,8 @@ "src/ui/layerPanel.js", "src/ui/clearLayersControl.js", "src/loadingFeedback.js", - "src/data/installationFeedback.js" + "src/data/installationFeedback.js", + "src/keySetupCore.mjs" ], "external": [] }, diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 2f39a1ca6..fed812d47 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -2200,6 +2200,10 @@ export class LayerLifecycle { icon: entry.module.icon, source: entry.module.source, showInTogglePanel: entry.module.showInTogglePanel !== false, + // Registry id of the provider key this layer needs, if any (mirrors + // showInTogglePanel). The layer reports stats.keyRequired while that + // key is absent; the panel builds the guidance text from the pair. + requiresKeyId: entry.module.requiresKeyId || null, enabled: entry.enabled, lifecycleState: entry.lifecycleState, lifecycleUncertain: entry.lifecycleUncertain, diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index d1dcb3f5e..7c7e3642c 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -38,6 +38,12 @@ export function createQueries({ source, + // The one data-layer control a provider key gates: the proxy answers + // 503 {error:'no_key'} without a FIRMS key. Declared as a key-registry id + // rather than an env-var string, so the panel can name the key from the + // one place that owns what each key is called. + requiresKeyId: 'firms', + // Live layer: the manager calls update() every 10 minutes while enabled, // which refetches through the /api/firms proxy (the proxy's 30 min TTL — // not this interval — is what protects the upstream FIRMS quota). @@ -75,6 +81,11 @@ export function createQueries({ lastUpdate: layerState._lastUpdate, loading: layerState._loading, stale: layerState._stale, + // The machine-readable half of the keyless state, ahead of the human + // strings below: without it "no key configured" is indistinguishable + // from a broken feed, and the row reads as a fault instead of a step + // the operator can take. + keyRequired: layerState._keyRequired, error: layerState._keyRequired ? 'KEY REQUIRED' : layerState._stale diff --git a/src/ui/layerKeyRequirement.test.mjs b/src/ui/layerKeyRequirement.test.mjs new file mode 100644 index 000000000..c8ce008b3 --- /dev/null +++ b/src/ui/layerKeyRequirement.test.mjs @@ -0,0 +1,143 @@ +// NAMING THE MISSING KEY — a control a provider key holds back must say which +// key, or the operator is left with a dead row and no next step. The three +// halves are pinned here: the layer declares which key it needs and reports +// that it is missing, the manager publishes that on the row, and the panel +// turns the pair into guidance that names the environment variable. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { layerKeyRequirementTooltip } from './layerPanel.js'; +import { DataLayerManager } from '../data/manager.js'; +import { KEY_SETUP_KEYS } from '../keySetupCore.mjs'; +import { createFirmsState } from '../layers/firms/state.js'; +import { createModel } from '../layers/firms/model.js'; +import { createQueries } from '../layers/firms/queries.js'; + +/** The fire layer's query half, with the scene services it never calls stubbed. */ +function firmsQueries() { + const services = { + render: { governorRequestRender() {} }, + anchors: { fireAnchorHeight: () => 0, FIRE_ANCHOR_LIFT_M: 0 }, + overlays: {}, + sprites: {}, + context: {}, + picking: {}, + focus: {}, + }; + const config = { id: 'local-firms', name: 'FIRMS Active Fires' }; + const layerState = createFirmsState({ services, config }); + const components = {}; + const context = { + layerState, + services, + components, + config, + feed: { getSnapshot: async () => ({}) }, + }; + components.model = createModel(context); + return { layerState, methods: createQueries(context).methods }; +} + +test('the fire layer names the key it needs and reports when it is missing', () => { + const { layerState, methods } = firmsQueries(); + assert.equal(methods.requiresKeyId, 'firms'); + assert.ok( + KEY_SETUP_KEYS.some((entry) => entry.id === 'firms'), + 'the declared id must exist in the key registry', + ); + + assert.equal( + methods.getStats().keyRequired, + false, + 'no key is missing until the feed says so', + ); + + layerState._keyRequired = true; + const stats = methods.getStats(); + assert.equal(stats.keyRequired, true); + assert.equal( + stats.error, + 'KEY REQUIRED', + 'the human string the row already shows is unchanged', + ); +}); + +test('the guidance names the environment variable and where to set it', () => { + const text = layerKeyRequirementTooltip({ + requiresKeyId: 'firms', + stats: { keyRequired: true }, + }); + assert.match(text, /FIRMS_MAP_KEY/); + assert.match(text, /Provider Settings/); +}); + +test('guidance appears only for a key that is actually missing and actually named', () => { + for (const layer of [ + undefined, + {}, + { requiresKeyId: 'firms' }, + { requiresKeyId: 'firms', stats: {} }, + { requiresKeyId: 'firms', stats: { keyRequired: false } }, + // Truthy but not the boolean the contract asks for: a stray string must + // not be read as "the key is missing". + { requiresKeyId: 'firms', stats: { keyRequired: 'yes' } }, + // Missing key, but the layer never said which one. + { stats: { keyRequired: true } }, + { requiresKeyId: '', stats: { keyRequired: true } }, + { requiresKeyId: ' ', stats: { keyRequired: true } }, + // An id the registry does not know must not be guessed at: guidance + // naming the wrong variable sends the operator to the wrong provider. + { requiresKeyId: 'not-a-provider', stats: { keyRequired: true } }, + ]) { + assert.equal( + layerKeyRequirementTooltip(layer), + '', + `${JSON.stringify(layer)} must produce no guidance`, + ); + } +}); + +test('the manager publishes the declared key id on the row', async () => { + const manager = new DataLayerManager(); + const base = { + name: 'Test layer', + icon: '•', + source: 'Test', + init() {}, + enable() {}, + disable() {}, + update() {}, + destroy() {}, + }; + manager.register({ + ...base, + id: 'gated', + requiresKeyId: 'firms', + getStats: () => ({ count: 0, keyRequired: true }), + }); + manager.register({ + ...base, + id: 'ungated', + getStats: () => ({ count: 3, lastUpdate: Date.now() }), + }); + + // The manager consults a layer's getStats() only once it is initialized, so + // the rows are read the way the panel reads them: after the layers are on. + await manager.setEnabled('gated', true); + await manager.setEnabled('ungated', true); + + const rows = manager.getAll(); + const gated = rows.find((row) => row.id === 'gated'); + const ungated = rows.find((row) => row.id === 'ungated'); + assert.equal(gated.requiresKeyId, 'firms'); + assert.equal(gated.stats.keyRequired, true); + assert.match(layerKeyRequirementTooltip(gated), /FIRMS_MAP_KEY/); + + assert.equal( + ungated.requiresKeyId, + null, + 'a layer that needs no key declares none', + ); + assert.equal(layerKeyRequirementTooltip(ungated), ''); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 51142ed19..6a8e16c19 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -1,4 +1,5 @@ import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; +import { keySetupRequirement } from '../keySetupCore.mjs'; const FEED_STATE_LABELS = Object.freeze({ nominal: 'ON', loading: 'LOADING', @@ -8,6 +9,26 @@ const FEED_STATE_LABELS = Object.freeze({ unavailable: 'UNAVAILABLE', }); +/** + * Guidance for a control a missing provider key is holding back. + * + * The key registry already owns what each key is called and which environment + * variables enable it, so a layer only declares WHICH key it needs + * (`requiresKeyId`) and reports `stats.keyRequired` while that key is absent. + * Naming the variable turns an unexplained dead control into a next step. + * + * An unnamed or unknown key returns '' rather than guessing: guidance naming + * the wrong variable sends the operator to the wrong provider. + * + * @param {object} [layer] Row from the layer manager's getAll(). + * @returns {string} Guidance text, or '' when no key guidance applies. + */ +export function layerKeyRequirementTooltip(layer = {}) { + if (layer?.stats?.keyRequired !== true) return ''; + const requiresKeyId = String(layer.requiresKeyId || '').trim(); + return requiresKeyId ? keySetupRequirement(requiresKeyId) : ''; +} + /** * Normalize heterogeneous layer stats into one honest control-chip state. * @param {object|null} stats Layer getStats() result. @@ -511,7 +532,17 @@ export class LayerPanel { : layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'; - button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`); + const keyGuidance = layerKeyRequirementTooltip(layer); + // Name the missing key on the control itself: a row reading KEY REQUIRED + // without saying WHICH key leaves a dead control and no next step. Empty + // when the layer needs no key, or already has one. + button.title = keyGuidance; + button.setAttribute( + 'aria-label', + keyGuidance + ? `${layer.name}: ${button.textContent}. ${keyGuidance}` + : `${layer.name}: ${button.textContent}`, + ); } _formatCount(n) {