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

+22 -11
View File
@@ -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) {
+219 -130
View File
@@ -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 () => {