From b358b5dba176e744fa53de73074a0b242b768a4f Mon Sep 17 00:00:00 2001 From: Said Turksever <28983926+asturksever@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:17:39 +0100 Subject: [PATCH] fix(cyclones): distinguish deliberate clears from feed eviction --- changelog.d/847-cyclone-shared-selection.md | 6 +-- docs/CURRENT-STATE.md | 2 + src/layers/cyclones/index.js | 18 ++++--- src/layers/cyclones/index.test.mjs | 59 +++++++++++++++++++++ 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/changelog.d/847-cyclone-shared-selection.md b/changelog.d/847-cyclone-shared-selection.md index a2f58ca71..24edf2ebb 100644 --- a/changelog.d/847-cyclone-shared-selection.md +++ b/changelog.d/847-cyclone-shared-selection.md @@ -1,4 +1,4 @@ - Explicit cyclone selections publish only the currently selected NHC/CPHC - storm into shared context. Switching storms evicts the previous identity - instead of leaving stale coordinates active (kvnloo, #847; reviewed by - quanticsoul4772). + storm into shared context. Switching storms removes the previous identity + without a false eviction event. Deliberate clears remain distinct from feed + disappearance (kvnloo, #847; reviewed by quanticsoul4772). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 42ebf7555..9d6680086 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -394,6 +394,8 @@ 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. 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 66bf13bbd..1b45265c5 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -89,7 +89,9 @@ export function createCyclonesLayer({ const notify = () => listener?.(); const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; - function clearSelectedContext() { + function clearSelectedContext({ evicted = false } = {}) { + if (!evicted) + context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); context?.removeEntityContextsForLayer?.('weather-cyclones'); selectedContextStormId = null; selectedContextEntity = null; @@ -98,14 +100,11 @@ export function createCyclonesLayer({ function publishSelectedContext({ announce = false } = {}) { const storm = selected(); if (!storm || !context) { - if (!storm) clearSelectedContext(); + if (!storm) clearSelectedContext({ evicted: true }); return null; } selectedContextEntity ||= { show: true }; const contextId = `cyclone:${storm.id}`; - context.removeEntityContextsForLayer?.('weather-cyclones', { - retainIds: new Set([contextId]), - }); const record = context.registerEntityContext?.(selectedContextEntity, { id: contextId, layerId: 'weather-cyclones', @@ -131,6 +130,9 @@ export function createCyclonesLayer({ if (!record) return null; selectedContextStormId = storm.id; if (announce) context.selectEntityContext?.(selectedContextEntity); + context.removeEntityContextsForLayer?.('weather-cyclones', { + retainIds: new Set([contextId]), + }); return record; } @@ -302,7 +304,7 @@ export function createCyclonesLayer({ snapshot = next; selectedId = null; ++navigationGeneration; - clearSelectedContext(); + clearSelectedContext({ evicted: true }); error = next.reason || ADVISORIES_UNAVAILABLE; return true; } @@ -327,7 +329,7 @@ export function createCyclonesLayer({ else rendering.setSelection(selectedId); if (selectedContextStormId) { if (selectedId === selectedContextStormId) publishSelectedContext(); - else clearSelectedContext(); + else clearSelectedContext({ evicted: true }); } return true; } catch (cause) { @@ -338,7 +340,7 @@ export function createCyclonesLayer({ snapshot = null; selectedId = null; ++navigationGeneration; - clearSelectedContext(); + clearSelectedContext({ evicted: true }); return true; } finally { signal?.removeEventListener('abort', abort); diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index f7cbcdc12..f1c7656b1 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -7,6 +7,7 @@ import { isPointerFree, } from '../../data/inputOwnership.js'; import { isOwnedByOtherLayer } from '../../data/pickRegistry.js'; +import * as contextStore from '../../data/contextStore.js'; const time = '2026-09-16T03:00:00.000Z'; const storm = (id = 'ep152026') => ({ @@ -481,6 +482,64 @@ test('explicit cyclone selection publishes shared context without auto-claiming h.layer.destroy(); }); +test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const host = new EventTarget(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: host, + }); + let h; + t.after(() => { + try { + h?.layer.destroy(); + } finally { + if (previousWindow) + Object.defineProperty(globalThis, 'window', previousWindow); + else delete globalThis.window; + } + }); + const events = []; + host.addEventListener('gev:entity-selected', ({ detail }) => + events.push(['selected', detail.id]), + ); + host.addEventListener('gev:entity-selection-cleared', ({ detail }) => + events.push(['cleared', detail.reason]), + ); + let next = snapshot([storm(), storm('ep162026')]); + h = harness({ getSnapshot: async () => next }, { context: contextStore }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + h.layer.setParams({ stormId: 'ep162026' }); + 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'); + + h.layer.setParams({ clear: true }); + assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); + assert.equal(contextStore.getContextStore().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' }); + h.layer.disable(); + assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); + assert.equal(contextStore.getContextStore().entities.size, 0); +}); + test('refresh preserves selection intent, including explicit clears and missing storms', async () => { let next = snapshot([storm(), storm('ep162026')]); const h = harness({ getSnapshot: async () => next });