import test from 'node:test'; import assert from 'node:assert/strict'; import { createCyclonesLayer } from './index.js'; import { claimPointer, releasePointer, isPointerFree, } from '../../data/inputOwnership.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 storm = (id = 'ep152026') => ({ id, name: id === 'ep152026' ? 'Fifteen-E' : 'Another system', classification: 'PTC', basin: 'EP', position: { longitude: -125.8, latitude: 15.5 }, positionAt: time, issuedAt: time, advisoryNumber: '10', windKt: 25, pressureHpa: 1006, geometryStatus: 'pending', geometryAdvisoryNumber: null, forecastPoints: [], track: null, cone: null, advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml', }); const snapshot = (storms = [storm()]) => ({ schemaVersion: 1, storms, unavailable: false, stale: false, fetchedAt: Date.parse(time), coverage: 'Atlantic and eastern/central North Pacific', reason: null, }); const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }; test('map selection yields to pointer owners and owns only its enabled handler', async () => { const handlers = [], pickedEntity = {}, foreignEntity = {}; let picked = { id: pickedEntity }, overlayHit = null, overlayTests = 0, picks = 0, notices = 0; const rendering = { setSnapshot: async () => true, setSelection() {}, clear() {}, destroy() {}, pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null), ownsPickId: (id) => id === 'cyclone:ep162026:center', getDiagnostics: () => ({}), }; const cesium = { ScreenSpaceEventType: { LEFT_CLICK: 'left' }, ScreenSpaceEventHandler: class { constructor(canvas) { assert.ok(canvas); handlers.push(this); this.destroyed = false; } setInputAction(callback, type) { assert.equal(type, 'left'); this.click = callback; } destroy() { this.destroyed = true; } isDestroyed() { return this.destroyed; } }, }; const layer = createCyclonesLayer({ feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) }, cesium, createRendering: () => rendering, hitTestOverlay: () => { overlayTests++; return overlayHit; }, }); const canvas = new EventTarget(); canvas.getBoundingClientRect = () => ({ left: 100, top: 200 }); const viewer = { scene: { canvas, pick() { picks++; return picked; }, }, trackedEntity: null, camera: { flyToBoundingSphere() { assert.fail('Click must not move the camera'); }, }, }; layer.init(viewer); layer.setRowControlsListener(() => notices++); assert.equal(handlers.length, 0, 'disabled module installs no handler'); layer.enable(); layer.enable(); assert.equal( isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true, 'flight tracking recognizes the cyclone pick and leaves the camera alone', ); assert.equal(isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center'), true); assert.equal( isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign'), false, 'registration recognizes exact current IDs, not a prefix', ); assert.equal(handlers.length, 1, 'enable is idempotent'); await layer.update(); const click = { position: { x: 10, y: 20 } }; const owner = claimPointer('director'); assert.ok(owner); try { handlers[0].click(click); assert.equal(picks, 0, 'claimed pointer is checked before picking'); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); } finally { releasePointer(owner); } picked = { id: foreignEntity }; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); picked = { primitive: {} }; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectionIntent, 'auto'); picked = { id: pickedEntity }; overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' }; const beforeCard = picks; handlers[0].click(click); assert.equal( picks, beforeCard, 'foreground AIS card wins before scene picking', ); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); // A native capture runs before an earlier sibling handler clears hit rects. const up = new Event('pointerup'); Object.assign(up, { clientX: 110, clientY: 220 }); canvas.dispatchEvent(up); overlayHit = null; handlers[0].click(click); assert.equal( layer.getDiagnostics().selectedId, 'ep152026', 'captured AIS hit survives synchronous sibling overlay rebuild', ); // A captured background hit remains background even if a sibling adds a card. canvas.dispatchEvent(up); overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' }; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); layer.setParams({ stormId: 'ep152026' }); // A new gesture clears an unconsumed release snapshot. canvas.dispatchEvent(up); canvas.dispatchEvent(new Event('pointerdown')); overlayHit = null; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); layer.setParams({ stormId: 'ep152026' }); overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' }; canvas.dispatchEvent(up); overlayHit = null; handlers[0].click({ position: { x: 15, y: 25 } }); assert.equal( layer.getDiagnostics().selectedId, 'ep162026', 'a different click cannot reuse the captured card', ); layer.setParams({ stormId: 'ep152026' }); overlayHit = { sourceId: 'cctv', entryId: 'camera:1' }; const before = notices; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); assert.match(layer.getRowControls().summary.detail, /Another system/); assert.equal(layer.getRowControls().list.items[1].active, true); assert.ok(notices > before); assert.equal(viewer.trackedEntity, null); picked = undefined; overlayHit = { sourceId: 'ais-live-vessels' }; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); overlayHit = null; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, null); assert.equal(layer.getDiagnostics().selectionIntent, 'cleared'); picked = { id: pickedEntity }; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); picked = { id: 'flight:abc', primitive: {} }; handlers[0].click(click); assert.equal( layer.getDiagnostics().selectedId, 'ep162026', "another layer's pick leaves the selection alone", ); picked = { content: {}, primitive: {} }; handlers[0].click(click); assert.equal( layer.getDiagnostics().selectedId, null, '3D Tiles surface content is empty map', ); assert.equal(layer.getDiagnostics().selectionIntent, 'cleared'); await layer.update(); assert.equal(layer.getDiagnostics().selectedId, null); assert.equal( isPointerFree(), true, 'ambient selection never claims the pointer', ); layer.disable(); assert.equal( isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), false, ); assert.equal(handlers[0].destroyed, true); assert.equal(layer.getDiagnostics().selectionActive, false); const afterDisable = picks; const afterDisableOverlay = overlayTests; canvas.dispatchEvent(up); assert.equal( overlayTests, afterDisableOverlay, 'disable removes native capture listeners', ); handlers[0].click(click); assert.equal(picks, afterDisable, 'queued disabled callback is inert'); layer.enable(); await layer.update(); assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true); handlers[0].click(click); assert.equal( picks, afterDisable, 'superseded handler remains inert after re-enable', ); assert.equal(handlers.length, 2); layer.destroy(); assert.equal( isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), false, ); assert.equal(handlers[1].destroyed, true); }); test('a click on a storm card or lead-hour label selects that storm without picking', async () => { const handlers = []; let picks = 0, overlayHit = null, hostArgument; const layer = createCyclonesLayer({ feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) }, cesium: { ScreenSpaceEventType: { LEFT_CLICK: 'left' }, ScreenSpaceEventHandler: class { constructor() { handlers.push(this); } setInputAction(callback) { this.click = callback; } destroy() {} }, }, overlayHost: 'host', createRendering: ({ overlayHost }) => { hostArgument = overlayHost; return { setSnapshot: async () => true, setSelection() {}, clear() {}, destroy() {}, pickStorm: () => null, ownsPickId: () => false, getDiagnostics: () => ({}), }; }, hitTestOverlay: () => overlayHit, }); const canvas = new EventTarget(); canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }); layer.init({ scene: { canvas, pick() { picks++; return { content: {} }; }, }, }); assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer'); layer.enable(); await layer.update(); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); const click = { position: { x: 10, y: 20 } }; overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' }; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); assert.equal(layer.getDiagnostics().selectionIntent, 'user'); overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' }; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); // A card from a superseded advisory is not empty map. overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' }; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); // The native release snapshot carries the card identity too. const up = new Event('pointerup'); Object.assign(up, { clientX: 10, clientY: 20 }); overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' }; canvas.dispatchEvent(up); overlayHit = null; handlers[0].click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); assert.equal(picks, 0, 'card clicks never fall through to the scene'); handlers[0].click(click); assert.equal(picks, 1); assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears'); layer.destroy(); }); function harness( feed = { getSnapshot: async () => snapshot() }, { reducedMotion = false, context = null } = {}, ) { const applied = [], navigation = [], opened = []; let cleared = 0, destroyed = 0, selection = null; const rendering = { async setSnapshot(value) { applied.push(value); return true; }, setSelection(id) { selection = id; }, getFocusSphere: () => ({ radius: 500000 }), clear() { cleared++; }, destroy() { destroyed++; }, getDiagnostics: () => ({ entities: applied.at(-1)?.storms.length || 0, timerActive: false, }), }; const layer = createCyclonesLayer({ feed, context, createRendering: () => rendering, matchMedia: () => ({ matches: reducedMotion }), openLink: (url) => opened.push(url), }); const viewer = { camera: { flyToBoundingSphere: (sphere, options) => navigation.push({ sphere, options }), }, }; layer.init(viewer); layer.attachShellServices({ runNavigation: (fn) => { navigation.push('claimed'); return fn(); }, }); return { layer, applied, navigation, opened, get cleared() { return cleared; }, get destroyed() { return destroyed; }, get selection() { return selection; }, }; } // 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 () => { let next = snapshot([storm(), storm('ep162026')]); const h = harness({ getSnapshot: async () => next }); h.layer.enable(); await h.layer.update(); assert.equal(h.selection, 'ep152026'); assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto'); next = snapshot([storm('ep162026')]); await h.layer.update(); assert.equal(h.selection, 'ep162026'); assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto'); next = snapshot([storm(), storm('ep162026')]); h.layer.setParams({ stormId: 'ep162026' }); h.layer.setParams({ stormId: 'ep162026' }); await h.layer.update(); assert.equal(h.selection, 'ep162026'); assert.equal(h.layer.getDiagnostics().selectionIntent, 'user'); assert.equal(h.navigation.length, 0); next = snapshot([storm()]); await h.layer.update(); assert.equal(h.selection, 'ep152026'); assert.equal(h.layer.getDiagnostics().selectionIntent, 'user'); for (const clear of [{ stormId: null }, { clear: true }]) { h.layer.setParams({ stormId: 'ep152026' }); h.layer.setParams(clear); assert.equal(h.selection, null); assert.equal(h.layer.getDiagnostics().selectionIntent, 'cleared'); await h.layer.update(); assert.equal(h.selection, null); assert.equal(h.layer.getRowControls().summary.detail, '1 active storm'); } h.layer.disable(); assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto'); h.layer.enable(); await h.layer.update(); assert.equal(h.selection, 'ep152026'); h.layer.destroy(); }); test('clearing while a refreshed data source is staging cannot reselect a storm', async () => { const staged = deferred(); const staging = deferred(); let calls = 0; const layer = createCyclonesLayer({ feed: { getSnapshot: async () => snapshot() }, createRendering: () => ({ setSnapshot() { if (++calls === 1) return true; staging.resolve(); return staged.promise; }, setSelection() {}, getDiagnostics: () => ({}), clear() {}, destroy() {}, }), }); layer.init({}); layer.enable(); await layer.update(); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); const pending = layer.update(); await staging.promise; layer.setParams({ clear: true }); staged.resolve(true); await pending; assert.equal(layer.getDiagnostics().selectedId, null); assert.equal(layer.getDiagnostics().selectionIntent, 'cleared'); layer.destroy(); }); test('queued focus is revoked by selection changes, clears, refresh fallback and teardown', async () => { for (const change of ['select', 'clear', 'fallback', 'disable', 'destroy']) { let next = snapshot([storm(), storm('ep162026')]); const h = harness({ getSnapshot: async () => next }); const callbacks = []; h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) }); h.layer.enable(); await h.layer.update(); h.layer.setParams({ stormId: 'ep152026', focus: true }); if (change === 'select') { h.layer.setParams({ stormId: 'ep162026' }); h.layer.setParams({ stormId: 'ep152026' }); } else if (change === 'clear') h.layer.setParams({ stormId: null }); else if (change === 'fallback') { next = snapshot([storm('ep162026')]); await h.layer.update(); } else h.layer[change](); callbacks.shift()(); assert.equal(h.navigation.length, 0, change); h.layer.destroy(); } }); test('only the latest queued focus runs and an unchanged selection survives refresh', async () => { const h = harness(); const callbacks = []; h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) }); h.layer.enable(); await h.layer.update(); h.layer.setParams({ stormId: 'ep152026', focus: true }); h.layer.setParams({ stormId: 'ep152026', focus: true }); await h.layer.update(); callbacks[0](); assert.equal(h.navigation.length, 0); callbacks[1](); assert.equal(h.navigation.length, 1); assert.equal(h.navigation[0].options.duration, 1.4); h.layer.destroy(); }); test('classification display expands known codes without altering the source or guessing unknown meanings', async () => { for (const [code, label] of [ ['PC', 'Potential tropical cyclone'], ['PTC', 'Post-tropical cyclone'], ['HU', 'Hurricane'], ['TS', 'Tropical storm'], ['TD', 'Tropical depression'], ['STS', 'Subtropical storm'], ['STD', 'Subtropical depression'], ['EX', 'EX'], ['UNKNOWN', 'UNKNOWN'], ]) { const record = { ...storm(), classification: code }; const h = harness({ getSnapshot: async () => snapshot([record]) }); try { h.layer.enable(); await h.layer.update(); const controls = h.layer.getRowControls(); assert.ok(controls.summary.detail.includes(` · ${label} · `)); assert.ok(controls.list.items[0].text.includes(` · ${label} · `)); assert.equal(record.classification, code); assert.equal(h.applied[0].storms[0].classification, code); } finally { h.layer.destroy(); } } }); test('advisory selection uses accessible row descriptors and shared camera handoff', async () => { const h = harness( { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) }, { reducedMotion: true }, ); h.layer.enable(); assert.equal(await h.layer.update(), true); const controls = h.layer.getRowControls(); assert.equal(controls.readout, true); assert.deepEqual(controls.chips, []); assert.equal(controls.summary.coverage, 'Atlantic · E/C Pacific'); assert.equal(controls.summary.actions[0].href, storm().advisoryUrl); assert.equal(controls.summary.lines.length, 3); assert.deepEqual(controls.summary.settings, []); assert.deepEqual(controls.summary.actions, [ { id: 'advisory', label: 'Official advisory ↗', href: storm().advisoryUrl }, ]); assert.match(controls.summary.compact, /2 active storms · .* selected/); assert.equal(controls.summary.sections, undefined); assert.match(controls.list.ariaLabel, /NHC/); assert.equal(controls.list.items.length, 2); assert.equal(controls.list.items[0].active, true); assert.match(controls.info, /awaiting advisory 10/); assert.equal( controls.info.split('Track/cone awaiting advisory 10').length - 1, 1, ); assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10'); // No line break inside the date: a word joiner holds the hyphen. assert.match(controls.info, /issued 09-\u206016\u00a003:00\u00a0UTC/); assert.match(controls.info, /: 25\u00a0kt · .*: 1006\u00a0hPa/); assert.match(controls.infoTitle, /not storm size/); assert.match(controls.infoTitle, /follows the surface/); assert.equal(controls.list.items[1].params.focus, true); assert.equal( controls.chips.some((chip) => chip.id === 'focus'), false, ); h.layer.setParams(controls.list.items[1].params); assert.equal(h.selection, 'ep162026'); assert.equal(h.navigation[0], 'claimed'); assert.equal(h.navigation[1].options.duration, 0); h.layer.setParams(controls.list.items[1].params); assert.equal(h.navigation.length, 4, 'selected row focuses again'); h.layer.setParams({ advisory: true }); assert.match(h.opened[0], /^https:\/\/www\.nhc\.noaa\.gov\/text\//); h.layer.disable(); h.layer.setParams({ focus: true, advisory: true }); assert.equal(h.navigation.length, 4); assert.equal(h.opened.length, 1); assert.equal(h.layer.getStats().count, 0); assert.equal(h.layer.getDiagnostics().timerActive, false); h.layer.destroy(); assert.equal(h.destroyed, 1); }); test('empty successful coverage, unavailable source and stale advisory are different states', async () => { let next = snapshot([]); const h = harness({ getSnapshot: async () => next }); h.layer.enable(); await h.layer.update(); assert.equal(h.layer.getStats().empty, true); assert.match(h.layer.getRowControls().summary.detail, /No active NHC\/CPHC/); next = { ...snapshot(), stale: true }; await h.layer.update(); assert.match(h.layer.getRowControls().summary.status, /stale/); assert.match( h.layer.getRowControls().info, /Track\/cone awaiting advisory 10/, ); assert.match(h.layer.getRowControls().info, /Cached advisory · stale source/); next = { ...snapshot([]), unavailable: true, reason: 'Cyclone data unavailable', }; await h.layer.update(); assert.equal(h.layer.getStats().empty, false); assert.match(h.layer.getRowControls().summary.status, /unavailable/); assert.ok(h.cleared > 0); h.layer.destroy(); }); test('disabled and superseded sources cannot publish late advisories even if they ignore abort', async () => { const first = deferred(), second = deferred(); const signals = []; let calls = 0; const h = harness({ getSnapshot: ({ signal }) => { signals.push(signal); return (++calls === 1 ? first : second).promise; }, }); h.layer.enable(); const old = h.layer.update(), latest = h.layer.update(); assert.equal(signals[0].aborted, true); second.resolve(snapshot()); assert.equal(await latest, true); first.resolve(snapshot([storm('ep162026')])); assert.equal(await old, false); assert.equal(h.applied.length, 1); assert.equal(h.selection, 'ep152026'); h.layer.destroy(); const delayed = deferred(); const disabled = harness({ getSnapshot: () => delayed.promise }); disabled.layer.enable(); const work = disabled.layer.update(); disabled.layer.disable(); delayed.resolve(snapshot()); assert.equal(await work, false); assert.equal(disabled.applied.length, 0); assert.equal(disabled.layer.getDiagnostics().requestPending, false); disabled.layer.destroy(); }); test('external cancellation and acquisition failure cannot install or retain unlabeled advisory geometry', async () => { let fail = false; const h = harness({ getSnapshot: async () => { if (fail) throw new Error('Network unavailable'); return snapshot(); }, }); h.layer.enable(); assert.equal( await h.layer.update(undefined, { signal: AbortSignal.abort() }), false, ); assert.equal(h.applied.length, 0); await h.layer.update(); fail = true; await h.layer.update(); assert.equal(h.layer.getStats().count, 0); assert.match(h.layer.getStats().error, /Network/); assert.ok(h.cleared > 0); h.layer.destroy(); });