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
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).
+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
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.
+10 -8
View File
@@ -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);
+59
View File
@@ -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 });