fix(cyclones): distinguish deliberate clears from feed eviction

This commit is contained in:
Said Turksever committed 2026-10-20 14:17:39 +01:00
1 parent 1330605c18
commit b358b5dba1
4 files changed
+74 -11

No files matched your search

+3 -3
View File
@@ -1,4 +1,4 @@
- Explicit cyclone selections publish only the currently selected NHC/CPHC - Explicit cyclone selections publish only the currently selected NHC/CPHC
storm into shared context. Switching storms evicts the previous identity storm into shared context. Switching storms removes the previous identity
instead of leaving stale coordinates active (kvnloo, #847; reviewed by without a false eviction event. Deliberate clears remain distinct from feed
quanticsoul4772). disappearance (kvnloo, #847; reviewed by quanticsoul4772).
+2
View File
@@ -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 the prior record; automatic row selection never claims global context, and
refreshes do not re-announce selection. Clearing or losing an explicitly selected refreshes do not re-announce selection. Clearing or losing an explicitly selected
storm removes shared context rather than assigning a fallback. 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. 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. 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.
+10 -8
View File
@@ -89,7 +89,9 @@ export function createCyclonesLayer({
const notify = () => listener?.(); const notify = () => listener?.();
const selected = () => const selected = () =>
snapshot?.storms.find((storm) => storm.id === selectedId) || null; snapshot?.storms.find((storm) => storm.id === selectedId) || null;
function clearSelectedContext() { function clearSelectedContext({ evicted = false } = {}) {
if (!evicted)
context?.clearSelectedEntityContextForLayer?.('weather-cyclones');
context?.removeEntityContextsForLayer?.('weather-cyclones'); context?.removeEntityContextsForLayer?.('weather-cyclones');
selectedContextStormId = null; selectedContextStormId = null;
selectedContextEntity = null; selectedContextEntity = null;
@@ -98,14 +100,11 @@ export function createCyclonesLayer({
function publishSelectedContext({ announce = false } = {}) { function publishSelectedContext({ announce = false } = {}) {
const storm = selected(); const storm = selected();
if (!storm || !context) { if (!storm || !context) {
if (!storm) clearSelectedContext(); if (!storm) clearSelectedContext({ evicted: true });
return null; return null;
} }
selectedContextEntity ||= { show: true }; selectedContextEntity ||= { show: true };
const contextId = `cyclone:${storm.id}`; const contextId = `cyclone:${storm.id}`;
context.removeEntityContextsForLayer?.('weather-cyclones', {
retainIds: new Set([contextId]),
});
const record = context.registerEntityContext?.(selectedContextEntity, { const record = context.registerEntityContext?.(selectedContextEntity, {
id: contextId, id: contextId,
layerId: 'weather-cyclones', layerId: 'weather-cyclones',
@@ -131,6 +130,9 @@ export function createCyclonesLayer({
if (!record) return null; if (!record) return null;
selectedContextStormId = storm.id; selectedContextStormId = storm.id;
if (announce) context.selectEntityContext?.(selectedContextEntity); if (announce) context.selectEntityContext?.(selectedContextEntity);
context.removeEntityContextsForLayer?.('weather-cyclones', {
retainIds: new Set([contextId]),
});
return record; return record;
} }
@@ -302,7 +304,7 @@ export function createCyclonesLayer({
snapshot = next; snapshot = next;
selectedId = null; selectedId = null;
++navigationGeneration; ++navigationGeneration;
clearSelectedContext(); clearSelectedContext({ evicted: true });
error = next.reason || ADVISORIES_UNAVAILABLE; error = next.reason || ADVISORIES_UNAVAILABLE;
return true; return true;
} }
@@ -327,7 +329,7 @@ export function createCyclonesLayer({
else rendering.setSelection(selectedId); else rendering.setSelection(selectedId);
if (selectedContextStormId) { if (selectedContextStormId) {
if (selectedId === selectedContextStormId) publishSelectedContext(); if (selectedId === selectedContextStormId) publishSelectedContext();
else clearSelectedContext(); else clearSelectedContext({ evicted: true });
} }
return true; return true;
} catch (cause) { } catch (cause) {
@@ -338,7 +340,7 @@ export function createCyclonesLayer({
snapshot = null; snapshot = null;
selectedId = null; selectedId = null;
++navigationGeneration; ++navigationGeneration;
clearSelectedContext(); clearSelectedContext({ evicted: true });
return true; return true;
} finally { } finally {
signal?.removeEventListener('abort', abort); signal?.removeEventListener('abort', abort);
+59
View File
@@ -7,6 +7,7 @@ import {
isPointerFree, isPointerFree,
} from '../../data/inputOwnership.js'; } from '../../data/inputOwnership.js';
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js'; import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
import * as contextStore from '../../data/contextStore.js';
const time = '2026-09-16T03:00:00.000Z'; const time = '2026-09-16T03:00:00.000Z';
const storm = (id = 'ep152026') => ({ const storm = (id = 'ep152026') => ({
@@ -481,6 +482,64 @@ test('explicit cyclone selection publishes shared context without auto-claiming
h.layer.destroy(); 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 () => { test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
let next = snapshot([storm(), storm('ep162026')]); let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next }); const h = harness({ getSnapshot: async () => next });