fix(cyclones): name the classification and mark a stale cache in shared context

- Publish classificationName next to the raw NHC code; unknown codes stay
  unexpanded.
- Publish staleCacheFetchedAt only while the proxy serves its cache.
- Touch the shared store on clear only after this layer published, so a
  feed failure no longer evicts another layer's dangling selection.
- Drop the unreachable no-storm branch and the unused return value.
- Tests: split the heading fixture into due north and stationary, cover
  the unavailable and failure paths with the real store, and check that
  clear/disable leave another layer's selection alone; fold the fake-store
  test into the real-store one.
This commit is contained in:
German Khokhlov committed 2026-11-06 19:43:04 +04:00
1 parent aca5317a2a
commit 4e518821b5
3 files changed
+247 -147

No files matched your search

+6 -6
View File
@@ -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. 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. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
Explicit cyclone selections publish one shared `weather-cyclones` record Explicit cyclone selections publish one shared `weather-cyclones` record
with official advisory provenance and source position. Switching storms removes with official advisory provenance, source position, the English classification
the prior record; automatic row selection never claims global context, and name and, while the proxy serves its cache, the cache fetch time. Switching
refreshes do not re-announce selection. Clearing or losing an explicitly selected storms selects the replacement before pruning the old record; automatic row
storm removes shared context rather than assigning a fallback. selection never claims global context, and refreshes do not re-announce
Deliberate clear/disable reports `deliberate`; feed disappearance reports selection. Deliberate clear/disable reports `deliberate`; losing the selected
`evicted`. Switching storms selects the replacement before pruning the old record. storm from the feed reports `evicted` and assigns no fallback.
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.
+22 -11
View File
@@ -96,19 +96,19 @@ export function createCyclonesLayer({
const selected = () => const selected = () =>
snapshot?.storms.find((storm) => storm.id === selectedId) || null; snapshot?.storms.find((storm) => storm.id === selectedId) || null;
function clearSelectedContext({ evicted = false } = {}) { function clearSelectedContext({ evicted = false } = {}) {
if (!evicted) if (selectedContextStormId) {
context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); if (!evicted)
context?.removeEntityContextsForLayer?.('weather-cyclones'); context?.clearSelectedEntityContextForLayer?.('weather-cyclones');
context?.removeEntityContextsForLayer?.('weather-cyclones');
}
selectedContextStormId = null; selectedContextStormId = null;
selectedContextEntity = null; selectedContextEntity = null;
} }
// Callers publish only while selectedId names a storm in the snapshot.
function publishSelectedContext({ announce = false } = {}) { function publishSelectedContext({ announce = false } = {}) {
const storm = selected(); const storm = selected();
if (!storm || !context) { if (!context) return;
if (!storm) clearSelectedContext({ evicted: true });
return null;
}
selectedContextEntity ||= { show: true }; selectedContextEntity ||= { show: true };
const contextId = `cyclone:${storm.id}`; const contextId = `cyclone:${storm.id}`;
const record = context.registerEntityContext?.(selectedContextEntity, { const record = context.registerEntityContext?.(selectedContextEntity, {
@@ -116,13 +116,20 @@ export function createCyclonesLayer({
layerId: 'weather-cyclones', layerId: 'weather-cyclones',
layerName: 'Cyclone advisories', layerName: 'Cyclone advisories',
source: 'NOAA NHC / CPHC', source: 'NOAA NHC / CPHC',
// Layer identity is already carried separately. Keep source names literal // Keep official storm names verbatim; layerName identifies the layer.
// rather than adding an untranslated user-facing prefix.
label: storm.name, label: storm.name,
latitude: storm.position.latitude, latitude: storm.position.latitude,
longitude: storm.position.longitude, longitude: storm.position.longitude,
// At most 12 properties reach the voice summary; keep the list short.
properties: { properties: {
classification: storm.classification, 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, basin: storm.basin,
advisoryNumber: storm.advisoryNumber, advisoryNumber: storm.advisoryNumber,
issuedAt: storm.issuedAt, issuedAt: storm.issuedAt,
@@ -133,15 +140,19 @@ export function createCyclonesLayer({
movementDirectionDegrees: storm.movement?.directionDegrees, movementDirectionDegrees: storm.movement?.directionDegrees,
movementSpeedKt: storm.movement?.speedKt, movementSpeedKt: storm.movement?.speedKt,
advisoryUrl: storm.advisoryUrl, 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; selectedContextStormId = storm.id;
if (announce) context.selectEntityContext?.(selectedContextEntity); if (announce) context.selectEntityContext?.(selectedContextEntity);
context.removeEntityContextsForLayer?.('weather-cyclones', { context.removeEntityContextsForLayer?.('weather-cyclones', {
retainIds: new Set([contextId]), retainIds: new Set([contextId]),
}); });
return record;
} }
function select(id) { function select(id) {
+219 -130
View File
@@ -405,96 +405,19 @@ function harness(
}, },
}; };
} }
test('explicit cyclone selection publishes shared context without auto-claiming it', async () => { // Runs a test body against the real shared-context store on a stand-in window,
let next = snapshot([storm(), storm('ep162026')]); // with the voice runner reading the same store.
const records = new Map(); async function withSharedContext(t, body) {
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) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const host = new EventTarget(); const host = new EventTarget();
Object.defineProperty(globalThis, 'window', { Object.defineProperty(globalThis, 'window', {
configurable: true, configurable: true,
value: host, value: host,
}); });
let h; const layers = [];
t.after(() => { t.after(() => {
try { try {
h?.layer.destroy(); for (const layer of layers) layer.destroy();
} finally { } finally {
if (previousWindow) if (previousWindow)
Object.defineProperty(globalThis, 'window', 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]), events.push(['selected', detail.id]),
); );
host.addEventListener('gev:entity-selection-cleared', ({ detail }) => 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 }); Object.assign(host, { setTimeout, clearTimeout });
const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000); const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000);
const runner = createGevActionRunner({ const runner = createGevActionRunner({
@@ -535,55 +458,221 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed
assert.equal(result.scope, 'selected'); assert.equal(result.scope, 'selected');
return result.selected.properties; return result.selected.properties;
}; };
let next = snapshot([ const store = contextStore.getContextStore();
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, const open = (feed) => {
{ ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } }, const h = harness(feed, { context: contextStore });
]); layers.push(h.layer);
h = harness({ getSnapshot: async () => next }, { context: contextStore }); return h;
h.layer.enable(); };
await h.layer.update(); await body({ events, selectedProperties, store, open });
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');
h.layer.setParams({ clear: true }); test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => {
assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); await withSharedContext(
assert.equal(contextStore.getContextStore().entities.size, 0); 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' }); h.layer.setParams({ stormId: 'ep152026' });
next = snapshot([storm()]); const record = store.entities.get('cyclone:ep152026');
await h.layer.update(); assert.equal(record.layerId, 'weather-cyclones');
assert.deepEqual(events.at(-1), ['cleared', 'evicted']); assert.equal(record.source, 'NOAA NHC / CPHC');
assert.equal(contextStore.getContextStore().entities.size, 0); assert.equal(record.label, 'Fifteen-E');
assert.equal(contextStore.getContextStore().selectedEntityId, null); 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.setParams({ stormId: 'ep162026' });
h.layer.disable(); properties = await selectedProperties();
assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); assert.equal(
assert.equal(contextStore.getContextStore().entities.size, 0); 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 () => { test('refresh preserves selection intent, including explicit clears and missing storms', async () => {