diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa535c295..a1b0874c1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -743,12 +743,12 @@ NHC movement speed is converted from miles per hour to rounded knots by the proxy; advisory wind intensity is already in knots and is passed through. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. Explicit cyclone selections publish one shared `weather-cyclones` record -with official advisory provenance and source position. Switching storms removes -the prior record; automatic row selection never claims global context, and -refreshes do not re-announce selection. Clearing or losing an explicitly selected -storm removes shared context rather than assigning a fallback. -Deliberate clear/disable reports `deliberate`; feed disappearance reports -`evicted`. Switching storms selects the replacement before pruning the old record. +with official advisory provenance, source position, the English classification +name and, while the proxy serves its cache, the cache fetch time. Switching +storms selects the replacement before pruning the old record; automatic row +selection never claims global context, and refreshes do not re-announce +selection. Deliberate clear/disable reports `deliberate`; losing the selected +storm from the feed reports `evicted` and assigns no fallback. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index ef8bbad27..8741ad8a3 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -96,19 +96,19 @@ export function createCyclonesLayer({ const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; function clearSelectedContext({ evicted = false } = {}) { - if (!evicted) - context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); - context?.removeEntityContextsForLayer?.('weather-cyclones'); + if (selectedContextStormId) { + if (!evicted) + context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); + context?.removeEntityContextsForLayer?.('weather-cyclones'); + } selectedContextStormId = null; selectedContextEntity = null; } + // Callers publish only while selectedId names a storm in the snapshot. function publishSelectedContext({ announce = false } = {}) { const storm = selected(); - if (!storm || !context) { - if (!storm) clearSelectedContext({ evicted: true }); - return null; - } + if (!context) return; selectedContextEntity ||= { show: true }; const contextId = `cyclone:${storm.id}`; const record = context.registerEntityContext?.(selectedContextEntity, { @@ -116,13 +116,20 @@ export function createCyclonesLayer({ layerId: 'weather-cyclones', layerName: 'Cyclone advisories', source: 'NOAA NHC / CPHC', - // Layer identity is already carried separately. Keep source names literal - // rather than adding an untranslated user-facing prefix. + // Keep official storm names verbatim; layerName identifies the layer. label: storm.name, latitude: storm.position.latitude, longitude: storm.position.longitude, + // At most 12 properties reach the voice summary; keep the list short. properties: { classification: storm.classification, + // English source meaning; unknown codes stay unexpanded. + classificationName: Object.hasOwn( + CLASSIFICATION_NAMES, + storm.classification, + ) + ? CLASSIFICATION_NAMES[storm.classification] + : undefined, basin: storm.basin, advisoryNumber: storm.advisoryNumber, issuedAt: storm.issuedAt, @@ -133,15 +140,19 @@ export function createCyclonesLayer({ movementDirectionDegrees: storm.movement?.directionDegrees, movementSpeedKt: storm.movement?.speedKt, advisoryUrl: storm.advisoryUrl, + // Present only while the server serves its cache with NHC unreachable. + staleCacheFetchedAt: + snapshot.stale && Number.isFinite(snapshot.fetchedAt) + ? new Date(snapshot.fetchedAt).toISOString() + : undefined, }, }); - if (!record) return null; + if (!record) return; selectedContextStormId = storm.id; if (announce) context.selectEntityContext?.(selectedContextEntity); context.removeEntityContextsForLayer?.('weather-cyclones', { retainIds: new Set([contextId]), }); - return record; } function select(id) { diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index f70f4f6a8..a3e40fc95 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -405,96 +405,19 @@ function harness( }, }; } -test('explicit cyclone selection publishes shared context without auto-claiming it', async () => { - let next = snapshot([storm(), storm('ep162026')]); - const records = new Map(); - let selectedContext = null; - let selectedEvents = 0; - const context = { - registerEntityContext(entity, metadata) { - entity.__gevContextId = metadata.id; - const record = { ...metadata, entity }; - records.set(metadata.id, record); - return record; - }, - selectEntityContext(entity) { - selectedContext = records.get(entity.__gevContextId) || null; - selectedEvents += 1; - return selectedContext; - }, - removeEntityContextsForLayer(layerId, { retainIds } = {}) { - for (const [id, record] of records) { - if (record.layerId === layerId && !retainIds?.has(id)) - records.delete(id); - } - if (selectedContext && !records.has(selectedContext.id)) - selectedContext = null; - }, - }; - const h = harness({ getSnapshot: async () => next }, { context }); - h.layer.enable(); - await h.layer.update(); - - assert.equal( - selectedEvents, - 0, - 'the automatic first-storm row selection must not claim shared context', - ); - assert.equal(records.size, 0); - - h.layer.setParams({ stormId: 'ep162026' }); - assert.equal(selectedEvents, 1); - assert.equal(selectedContext?.layerId, 'weather-cyclones'); - assert.equal(selectedContext?.source, 'NOAA NHC / CPHC'); - assert.equal(selectedContext?.label, 'Another system'); - assert.equal(selectedContext?.latitude, 15.5); - assert.equal(selectedContext?.longitude, -125.8); - assert.equal(selectedContext?.properties.advisoryNumber, '10'); - - h.layer.setParams({ stormId: 'ep152026' }); - assert.deepEqual([...records.keys()], ['cyclone:ep152026']); - assert.equal(selectedContext?.id, 'cyclone:ep152026'); - h.layer.setParams({ stormId: 'ep162026' }); - assert.deepEqual([...records.keys()], ['cyclone:ep162026']); - assert.equal(selectedContext?.id, 'cyclone:ep162026'); - assert.equal(selectedEvents, 3, 'each explicit storm switch announces once'); - - await h.layer.update(); - assert.equal( - selectedEvents, - 3, - 'refresh updates the selected record without re-announcing the click', - ); - - next = snapshot([storm()]); - await h.layer.update(); - assert.equal( - selectedContext, - null, - 'a vanished user-selected storm must clear instead of transferring context to the fallback row', - ); - assert.equal(records.size, 0); - - h.layer.setParams({ stormId: 'ep152026' }); - assert.equal(selectedEvents, 4); - h.layer.setParams({ clear: true }); - assert.equal(selectedContext, null); - assert.equal(records.size, 0); - - h.layer.destroy(); -}); - -test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { +// Runs a test body against the real shared-context store on a stand-in window, +// with the voice runner reading the same store. +async function withSharedContext(t, body) { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const host = new EventTarget(); Object.defineProperty(globalThis, 'window', { configurable: true, value: host, }); - let h; + const layers = []; t.after(() => { try { - h?.layer.destroy(); + for (const layer of layers) layer.destroy(); } finally { if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); @@ -506,9 +429,9 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed events.push(['selected', detail.id]), ); host.addEventListener('gev:entity-selection-cleared', ({ detail }) => - events.push(['cleared', detail.reason]), + events.push(['cleared', detail.layerId, detail.reason]), ); - // The voice runner reads the same store and bounds its basemap wait. + // The voice runner bounds its basemap wait with the window timers. Object.assign(host, { setTimeout, clearTimeout }); const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000); const runner = createGevActionRunner({ @@ -535,55 +458,221 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed assert.equal(result.scope, 'selected'); return result.selected.properties; }; - let next = snapshot([ - { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, - { ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } }, - ]); - h = harness({ getSnapshot: async () => next }, { context: contextStore }); - h.layer.enable(); - await h.layer.update(); - h.layer.setParams({ stormId: 'ep152026' }); - let properties = await selectedProperties(); - assert.equal( - properties.movementDirectionDegrees, - '315', - 'selected storm movement should survive voice summarization', - ); - assert.equal(properties.movementSpeedKt, '12'); - h.layer.setParams({ stormId: 'ep162026' }); - properties = await selectedProperties(); - assert.equal( - properties.movementDirectionDegrees, - '0', - 'a due-north heading is a value, not a missing one', - ); - assert.equal(properties.movementSpeedKt, '0', 'stationary is a value'); - assert.deepEqual(events, [ - ['selected', 'cyclone:ep152026'], - ['selected', 'cyclone:ep162026'], - ]); - assert.deepEqual( - [...contextStore.getContextStore().entities.keys()], - ['cyclone:ep162026'], - ); - await h.layer.update(); - assert.equal(events.length, 2, 'refresh must not announce selection'); + const store = contextStore.getContextStore(); + const open = (feed) => { + const h = harness(feed, { context: contextStore }); + layers.push(h.layer); + return h; + }; + await body({ events, selectedProperties, store, open }); +} - h.layer.setParams({ clear: true }); - assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); - assert.equal(contextStore.getContextStore().entities.size, 0); +test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { + await withSharedContext( + t, + async ({ events, selectedProperties, store, open }) => { + let next = snapshot([ + { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, + { + ...storm('ep162026'), + movement: { directionDegrees: 0, speedKt: 10 }, + }, + ]); + const h = open({ getSnapshot: async () => next }); + h.layer.enable(); + await h.layer.update(); + assert.deepEqual( + events, + [], + 'the automatic first-storm row selection must not claim shared context', + ); + assert.equal(store.entities.size, 0); - h.layer.setParams({ stormId: 'ep162026' }); - next = snapshot([storm()]); - await h.layer.update(); - assert.deepEqual(events.at(-1), ['cleared', 'evicted']); - assert.equal(contextStore.getContextStore().entities.size, 0); - assert.equal(contextStore.getContextStore().selectedEntityId, null); + h.layer.setParams({ stormId: 'ep152026' }); + const record = store.entities.get('cyclone:ep152026'); + assert.equal(record.layerId, 'weather-cyclones'); + assert.equal(record.source, 'NOAA NHC / CPHC'); + assert.equal(record.label, 'Fifteen-E'); + assert.equal(record.latitude, 15.5); + assert.equal(record.longitude, -125.8); + let properties = await selectedProperties(); + assert.equal(properties.classification, 'PTC'); + assert.equal(properties.classificationName, 'Post-tropical cyclone'); + assert.equal(properties.advisoryNumber, '10'); + assert.equal( + properties.movementDirectionDegrees, + '315', + 'selected storm movement should survive voice summarization', + ); + assert.equal(properties.movementSpeedKt, '12'); + assert.equal( + properties.advisoryUrl, + 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml', + ); + assert.equal(properties.staleCacheFetchedAt, undefined); - h.layer.setParams({ stormId: 'ep152026' }); - h.layer.disable(); - assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); - assert.equal(contextStore.getContextStore().entities.size, 0); + h.layer.setParams({ stormId: 'ep162026' }); + properties = await selectedProperties(); + assert.equal( + properties.movementDirectionDegrees, + '0', + 'a due-north heading is a value, not a missing one', + ); + assert.equal(properties.movementSpeedKt, '10'); + assert.deepEqual(events, [ + ['selected', 'cyclone:ep152026'], + ['selected', 'cyclone:ep162026'], + ]); + assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']); + + next = snapshot([ + { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, + { ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } }, + ]); + await h.layer.update(); + assert.equal(events.length, 2, 'refresh must not announce selection'); + properties = await selectedProperties(); + assert.equal( + properties.movementSpeedKt, + '0', + 'stationary is a value, refreshed into the selected record', + ); + + h.layer.setParams({ clear: true }); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'deliberate', + ]); + assert.equal(store.entities.size, 0); + + h.layer.setParams({ stormId: 'ep162026' }); + next = snapshot([storm()]); + await h.layer.update(); + assert.deepEqual( + events.at(-1), + ['cleared', 'weather-cyclones', 'evicted'], + 'a vanished user-selected storm must clear instead of transferring context to the fallback row', + ); + assert.equal(store.entities.size, 0); + assert.equal(store.selectedEntityId, null); + + h.layer.setParams({ stormId: 'ep152026' }); + h.layer.disable(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'deliberate', + ]); + assert.equal(store.entities.size, 0); + }, + ); +}); + +test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => { + await withSharedContext( + t, + async ({ events, selectedProperties, store, open }) => { + let next = snapshot([{ ...storm(), classification: 'XX' }]); + let fail = false; + const h = open({ + getSnapshot: async () => { + if (fail) throw new Error('Network unavailable'); + return next; + }, + }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + let properties = await selectedProperties(); + assert.equal(properties.classification, 'XX'); + assert.equal( + properties.classificationName, + undefined, + 'an unknown code is not given a guessed meaning', + ); + + next = { ...snapshot(), stale: true }; + await h.layer.update(); + properties = await selectedProperties(); + assert.equal(properties.staleCacheFetchedAt, time); + assert.equal(events.length, 1, 'refresh must not announce selection'); + + next = { + ...snapshot([]), + unavailable: true, + reason: 'Cyclone data unavailable', + }; + await h.layer.update(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'evicted', + ]); + assert.equal(store.entities.size, 0); + + next = snapshot(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + assert.equal(store.selectedEntityId, 'cyclone:ep152026'); + fail = true; + await h.layer.update(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'evicted', + ]); + assert.equal(store.entities.size, 0); + assert.equal(store.selectedEntityId, null); + }, + ); +}); + +test('cyclone clears leave another layer selection and its records alone', async (t) => { + await withSharedContext(t, async ({ events, store, open }) => { + const h = open({ getSnapshot: async () => snapshot() }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + const foreign = {}; + contextStore.registerEntityContext(foreign, { + id: 'flight:abc123', + layerId: 'flights', + label: 'Foreign', + }); + contextStore.selectEntityContext(foreign); + const before = events.length; + + h.layer.setParams({ clear: true }); + assert.equal(store.selectedEntityId, 'flight:abc123'); + assert.ok(store.entities.has('flight:abc123')); + assert.equal(store.entities.has('cyclone:ep152026'), false); + assert.equal( + events.length, + before, + 'no clear is reported for a foreign selection', + ); + + h.layer.disable(); + assert.equal(store.selectedEntityId, 'flight:abc123'); + assert.equal(events.length, before); + + // A foreign record removed without clearing its selection: a cyclone + // feed failure must not report that selection as evicted by cyclones. + store.entities.delete('flight:abc123'); + let fail = false; + const other = open({ + getSnapshot: async () => { + if (fail) throw new Error('Network unavailable'); + return snapshot(); + }, + }); + other.layer.enable(); + await other.layer.update(); + fail = true; + await other.layer.update(); + assert.equal(events.length, before); + }); }); test('refresh preserves selection intent, including explicit clears and missing storms', async () => {