mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 03:44:00 +00:00
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:
1 parent
aca5317a2a
commit
4e518821b5
3 files changed
+247
-147
No files matched your search
@@ -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.
|
||||
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
|
||||
Explicit cyclone selections publish one shared `weather-cyclones` record
|
||||
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.
|
||||
with official advisory provenance, source position, the English classification
|
||||
name and, while the proxy serves its cache, the cache fetch time. Switching
|
||||
storms selects the replacement before pruning the old record; automatic row
|
||||
selection never claims global context, and refreshes do not re-announce
|
||||
selection. Deliberate clear/disable reports `deliberate`; losing the selected
|
||||
storm from the feed reports `evicted` and assigns no fallback.
|
||||
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.
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user