mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 06:42:23 +00:00
Merge remote-tracking branch 'origin/main' into review/pr-1528
This commit is contained in:
commit
d5d824f705
7 files changed
+345
-3
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
- Let the voice assistant read the storm you select in Cyclone advisories:
|
||||||
|
its official advisory, position, intensity and movement. Clearing the
|
||||||
|
selection and the storm leaving the feed are reported apart (kvnloo, #847).
|
||||||
@@ -741,6 +741,12 @@ geographic seams and releases on disable; forecast animation is not implied.
|
|||||||
NHC movement speed is converted from miles per hour to rounded knots by the
|
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.
|
||||||
|
Explicitly selecting a storm publishes one shared `weather-cyclones` record:
|
||||||
|
advisory provenance, source position, the English classification name and,
|
||||||
|
while the proxy serves its cache, the cache fetch time. The automatic row
|
||||||
|
selection does not claim shared context; refreshes do not re-announce it.
|
||||||
|
Clear or disable reports `deliberate`; a storm leaving the feed reports
|
||||||
|
`evicted`, with 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.
|
||||||
|
|||||||
@@ -2336,6 +2336,7 @@
|
|||||||
"src/app/layers/alprCameras.js",
|
"src/app/layers/alprCameras.js",
|
||||||
"src/app/layers/bikeshare.js",
|
"src/app/layers/bikeshare.js",
|
||||||
"src/app/layers/cctv.js",
|
"src/app/layers/cctv.js",
|
||||||
|
"src/app/layers/cyclones.js",
|
||||||
"src/app/layers/earthquakes.js",
|
"src/app/layers/earthquakes.js",
|
||||||
"src/app/layers/firms.js",
|
"src/app/layers/firms.js",
|
||||||
"src/app/layers/flights.js",
|
"src/app/layers/flights.js",
|
||||||
@@ -3380,6 +3381,7 @@
|
|||||||
"src/app/layers/alprCameras.js",
|
"src/app/layers/alprCameras.js",
|
||||||
"src/app/layers/bikeshare.js",
|
"src/app/layers/bikeshare.js",
|
||||||
"src/app/layers/cctv.js",
|
"src/app/layers/cctv.js",
|
||||||
|
"src/app/layers/cyclones.js",
|
||||||
"src/app/layers/earthquakes.js",
|
"src/app/layers/earthquakes.js",
|
||||||
"src/app/layers/firms.js",
|
"src/app/layers/firms.js",
|
||||||
"src/app/layers/flights.js",
|
"src/app/layers/flights.js",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { resolveCatalogSources } from './sourceComposition.js';
|
import { resolveCatalogSources } from './sourceComposition.js';
|
||||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||||
import { createWindLayer } from '../layers/wind/index.js';
|
import { createWindLayer } from '../layers/wind/index.js';
|
||||||
import { createLayerCatalog } from './catalog.js';
|
import { createLayerCatalog } from './catalog.js';
|
||||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||||
@@ -182,7 +182,7 @@ export function createApplicationCatalog({
|
|||||||
id: 'weather-lightning',
|
id: 'weather-lightning',
|
||||||
clock: weatherClock,
|
clock: weatherClock,
|
||||||
}),
|
}),
|
||||||
createCyclonesLayer({ feed: sources.cyclones }),
|
createApplicationCyclones({ feed: sources.cyclones }),
|
||||||
...createOsmOverlayLayers(),
|
...createOsmOverlayLayers(),
|
||||||
...createInfrastructureLayers(localGeoJsonServices),
|
...createInfrastructureLayers(localGeoJsonServices),
|
||||||
createApplicationCables({ source: sources.cables }),
|
createApplicationCables({ source: sources.cables }),
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
||||||
|
import * as context from '../../data/contextStore.js';
|
||||||
|
|
||||||
|
/** Wire cyclone selection to the application shared context. */
|
||||||
|
export function createApplicationCyclones(options) {
|
||||||
|
return createCyclonesLayer({
|
||||||
|
context: typeof window !== 'undefined' ? context : null,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ export function createCyclonesLayer({
|
|||||||
feed,
|
feed,
|
||||||
hitTestOverlay = hitTestWorldOverlay,
|
hitTestOverlay = hitTestWorldOverlay,
|
||||||
overlayHost,
|
overlayHost,
|
||||||
|
context = null,
|
||||||
cesium = Cesium,
|
cesium = Cesium,
|
||||||
createRendering = createCycloneRendering,
|
createRendering = createCycloneRendering,
|
||||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||||
@@ -82,6 +83,7 @@ export function createCyclonesLayer({
|
|||||||
selectedId = null,
|
selectedId = null,
|
||||||
selectionIntent = 'auto',
|
selectionIntent = 'auto',
|
||||||
navigationGeneration = 0,
|
navigationGeneration = 0,
|
||||||
|
selectedContextStormId = null,
|
||||||
clickHandler = null,
|
clickHandler = null,
|
||||||
removeClickCapture = null;
|
removeClickCapture = null;
|
||||||
let enabled = false,
|
let enabled = false,
|
||||||
@@ -92,6 +94,62 @@ 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({ evicted = false } = {}) {
|
||||||
|
if (selectedContextStormId) {
|
||||||
|
if (!evicted)
|
||||||
|
context.clearSelectedEntityContextForLayer('weather-cyclones');
|
||||||
|
context.removeEntityContextsForLayer('weather-cyclones');
|
||||||
|
}
|
||||||
|
selectedContextStormId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callers publish only while selectedId names a storm in the snapshot.
|
||||||
|
function publishSelectedContext({ announce = false } = {}) {
|
||||||
|
const storm = selected();
|
||||||
|
if (!context) return;
|
||||||
|
const entity = {};
|
||||||
|
const contextId = `cyclone:${storm.id}`;
|
||||||
|
context.registerEntityContext(entity, {
|
||||||
|
id: contextId,
|
||||||
|
layerId: 'weather-cyclones',
|
||||||
|
layerName: 'Cyclone advisories',
|
||||||
|
source: 'NOAA NHC / CPHC',
|
||||||
|
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,
|
||||||
|
classificationName: Object.hasOwn(
|
||||||
|
CLASSIFICATION_NAMES,
|
||||||
|
storm.classification,
|
||||||
|
)
|
||||||
|
? CLASSIFICATION_NAMES[storm.classification]
|
||||||
|
: undefined,
|
||||||
|
basin: storm.basin,
|
||||||
|
advisoryNumber: storm.advisoryNumber,
|
||||||
|
issuedAt: storm.issuedAt,
|
||||||
|
positionAt: storm.positionAt,
|
||||||
|
windKt: storm.windKt,
|
||||||
|
pressureHpa: storm.pressureHpa,
|
||||||
|
// Flat scalars: the voice context summary drops nested objects.
|
||||||
|
movementDirectionDegrees: storm.movement?.directionDegrees,
|
||||||
|
movementSpeedKt: storm.movement?.speedKt,
|
||||||
|
advisoryUrl: storm.advisoryUrl,
|
||||||
|
// Only while the proxy serves its cache because NHC is unreachable.
|
||||||
|
staleCacheFetchedAt:
|
||||||
|
snapshot.stale && Number.isFinite(snapshot.fetchedAt)
|
||||||
|
? new Date(snapshot.fetchedAt).toISOString()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
selectedContextStormId = storm.id;
|
||||||
|
if (announce) context.selectEntityContext(entity);
|
||||||
|
context.removeEntityContextsForLayer('weather-cyclones', {
|
||||||
|
retainIds: new Set([contextId]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function select(id) {
|
function select(id) {
|
||||||
if (selectedId !== id) ++navigationGeneration;
|
if (selectedId !== id) ++navigationGeneration;
|
||||||
selectedId = id;
|
selectedId = id;
|
||||||
@@ -236,6 +294,7 @@ export function createCyclonesLayer({
|
|||||||
selectedId = null;
|
selectedId = null;
|
||||||
selectionIntent = 'auto';
|
selectionIntent = 'auto';
|
||||||
++navigationGeneration;
|
++navigationGeneration;
|
||||||
|
clearSelectedContext();
|
||||||
rendering?.clear();
|
rendering?.clear();
|
||||||
},
|
},
|
||||||
async update(_viewer, { signal } = {}) {
|
async update(_viewer, { signal } = {}) {
|
||||||
@@ -259,6 +318,7 @@ export function createCyclonesLayer({
|
|||||||
snapshot = next;
|
snapshot = next;
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
++navigationGeneration;
|
++navigationGeneration;
|
||||||
|
clearSelectedContext({ evicted: true });
|
||||||
error = next.reason || ADVISORIES_UNAVAILABLE;
|
error = next.reason || ADVISORIES_UNAVAILABLE;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -281,6 +341,10 @@ export function createCyclonesLayer({
|
|||||||
: snapshot.storms[0]?.id || null,
|
: snapshot.storms[0]?.id || null,
|
||||||
);
|
);
|
||||||
else rendering.setSelection(selectedId);
|
else rendering.setSelection(selectedId);
|
||||||
|
if (selectedContextStormId) {
|
||||||
|
if (selectedId === selectedContextStormId) publishSelectedContext();
|
||||||
|
else clearSelectedContext({ evicted: true });
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (controller.signal.aborted || request !== controller) return false;
|
if (controller.signal.aborted || request !== controller) return false;
|
||||||
@@ -290,6 +354,7 @@ export function createCyclonesLayer({
|
|||||||
snapshot = null;
|
snapshot = null;
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
++navigationGeneration;
|
++navigationGeneration;
|
||||||
|
clearSelectedContext({ evicted: true });
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
signal?.removeEventListener('abort', abort);
|
signal?.removeEventListener('abort', abort);
|
||||||
@@ -305,6 +370,7 @@ export function createCyclonesLayer({
|
|||||||
if (params.clear === true || params.stormId === null) {
|
if (params.clear === true || params.stormId === null) {
|
||||||
selectionIntent = 'cleared';
|
selectionIntent = 'cleared';
|
||||||
select(null);
|
select(null);
|
||||||
|
clearSelectedContext();
|
||||||
notify();
|
notify();
|
||||||
} else if (
|
} else if (
|
||||||
typeof params.stormId === 'string' &&
|
typeof params.stormId === 'string' &&
|
||||||
@@ -312,6 +378,7 @@ export function createCyclonesLayer({
|
|||||||
) {
|
) {
|
||||||
selectionIntent = 'user';
|
selectionIntent = 'user';
|
||||||
select(params.stormId);
|
select(params.stormId);
|
||||||
|
publishSelectedContext({ announce: true });
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
const storm = selected();
|
const storm = selected();
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ 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';
|
||||||
|
import { createGevActionRunner } from '../../voice/gevActions.js';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
const time = '2026-09-16T03:00:00.000Z';
|
const time = '2026-09-16T03:00:00.000Z';
|
||||||
const storm = (id = 'ep152026') => ({
|
const storm = (id = 'ep152026') => ({
|
||||||
@@ -338,7 +341,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick
|
|||||||
});
|
});
|
||||||
function harness(
|
function harness(
|
||||||
feed = { getSnapshot: async () => snapshot() },
|
feed = { getSnapshot: async () => snapshot() },
|
||||||
{ reducedMotion = false } = {},
|
{ reducedMotion = false, context = null } = {},
|
||||||
) {
|
) {
|
||||||
const applied = [],
|
const applied = [],
|
||||||
navigation = [],
|
navigation = [],
|
||||||
@@ -368,6 +371,7 @@ function harness(
|
|||||||
};
|
};
|
||||||
const layer = createCyclonesLayer({
|
const layer = createCyclonesLayer({
|
||||||
feed,
|
feed,
|
||||||
|
context,
|
||||||
createRendering: () => rendering,
|
createRendering: () => rendering,
|
||||||
matchMedia: () => ({ matches: reducedMotion }),
|
matchMedia: () => ({ matches: reducedMotion }),
|
||||||
openLink: (url) => opened.push(url),
|
openLink: (url) => opened.push(url),
|
||||||
@@ -401,6 +405,256 @@ function harness(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Real shared-context store on a stand-in window; the voice runner reads it.
|
||||||
|
async function withSharedContext(t, body) {
|
||||||
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||||
|
const host = new EventTarget();
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: host,
|
||||||
|
});
|
||||||
|
const layers = [];
|
||||||
|
t.after(() => {
|
||||||
|
try {
|
||||||
|
for (const layer of layers) 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.layerId, detail.reason]),
|
||||||
|
);
|
||||||
|
// 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({
|
||||||
|
viewer: {
|
||||||
|
clock: { onTick: { addEventListener: () => () => {} } },
|
||||||
|
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||||
|
camera: {
|
||||||
|
moveEnd: { addEventListener() {} },
|
||||||
|
positionWC: position,
|
||||||
|
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||||
|
heading: 0,
|
||||||
|
pitch: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
styleManager: {},
|
||||||
|
dataManager: {
|
||||||
|
layers: new Map(),
|
||||||
|
isEnabled: (id) => id === 'weather-cyclones',
|
||||||
|
getAll: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const selectedProperties = async () => {
|
||||||
|
const result = await runner('get_entity_context', { scope: 'selected' });
|
||||||
|
assert.equal(result.scope, 'selected');
|
||||||
|
return result.selected.properties;
|
||||||
|
};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'ep152026' });
|
||||||
|
assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E');
|
||||||
|
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: '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',
|
||||||
|
);
|
||||||
|
|
||||||
|
// A cyclone clear does not report a dangling foreign selection as evicted.
|
||||||
|
store.entities.delete('flight:abc123');
|
||||||
|
h.layer.disable();
|
||||||
|
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||||
|
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 () => {
|
||||||
let next = snapshot([storm(), storm('ep162026')]);
|
let next = snapshot([storm(), storm('ep162026')]);
|
||||||
const h = harness({ getSnapshot: async () => next });
|
const h = harness({ getSnapshot: async () => next });
|
||||||
|
|||||||
Reference in new issue
Block a user