diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a869424..9477ea2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Catalog source availability no longer modifies layer objects; unavailable + tooltips use layer display names. + - Street Level with no providers uses the shared unavailable state and refuses activation before initialization, while keeping its layer identity visible. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc460fb3d..4d02c2224 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3,7 +3,9 @@ Catalog source contracts and their consuming layers are declared in `src/app/sourceComposition.js`. Omitted or null acquisition sources preserve layer registration and serialization, report unavailable, and are refused by the existing -visibility lifecycle before initialization. Invalid supplied methods still fail +visibility lifecycle before initialization. Availability is held in an explicit +catalog lookup supplied to the lifecycle, without modifying layer instances; +unavailable reasons use the layer display name. Invalid supplied methods still fail construction. `createStandaloneApplication` and `createStandaloneCatalog` accept `sources` overrides; unspecified entries retain standalone defaults, and null entries explicitly remove a source. Street Level diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index fc51c7fcd..ff6d73db5 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains }, }, }, - catalog: createLayerCatalog(layers, metadata(layers)), + catalog: { + ...createLayerCatalog(layers, metadata(layers)), + getSourceAvailability: (id) => + id === 'one' + ? { + available: false, + reason: 'One: data source not configured', + } + : undefined, + }, defer: (release) => releases.push(release), onData: (manager) => { observed = manager; }, }); assert.equal(observed, dataManager); + assert.equal(await dataManager.setEnabled('one', true), false); + assert.equal(dataManager.layers.get('one').initialized, false); + assert.equal( + dataManager.getAll().find(({ id }) => id === 'one').stats.error, + 'One: data source not configured', + ); assert.deepEqual(attached, [['one', 'two']]); assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']); assert.throws(() => dataManager.register({ id: 'late' }), /finalized/); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 74cbcf47f..e78a9b249 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -173,9 +173,11 @@ export function createApplicationCatalog({ ], metadata, ); - sourceComposition.bind(catalog.layers); return Object.freeze({ ...catalog, + getSourceAvailability: sourceComposition.createAvailabilityLookup( + catalog.layers, + ), militaryRegistry, surface, weatherClock, diff --git a/src/app/data.js b/src/app/data.js index 85a3183fc..ce3ad0731 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -13,6 +13,7 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, + getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { await dataManager.destroyAll(); diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 77b3ebe10..b813a83bc 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -80,22 +80,29 @@ export function resolveCatalogSources(supplied = {}) { return { sources, isConfigured: (name) => !missing.has(name), - bind(layers) { + createAvailabilityLookup(layers) { + const byId = new Map(layers.map((layer) => [layer.id, layer])); + const availability = new Map(); for (const [name, { layers: ids }] of Object.entries( CATALOG_SOURCE_CONTRACTS, )) { for (const id of ids) { - const layer = layers.find((entry) => entry.id === id); + const layer = byId.get(id); if (!layer) throw new Error(`Missing source consumer: ${id}`); - layer.getSourceAvailability = () => ({ - available: !missing.has(name), - source: name, - reason: missing.has(name) - ? `Data source not configured: ${name}` - : null, - }); + availability.set( + id, + Object.freeze({ + available: !missing.has(name), + source: name, + reason: missing.has(name) + ? `${layer.name || id}: data source not configured` + : null, + }), + ); } } + return (id) => + availability.get(id) ?? byId.get(id)?.getSourceAvailability?.(); }, }; } diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index beafa5c51..44a7c8fa0 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -30,7 +30,7 @@ test('every acquisition source can be omitted without losing catalog membership' const actual = catalog(sources, t); assert.deepEqual(actual.metadata, baseline.metadata, name); for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) { - assert.equal(actual.get(id).getSourceAvailability().available, false, id); + assert.equal(actual.getSourceAvailability(id).available, false, id); } } }); @@ -38,7 +38,10 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); assert.equal(actual.layers.length, 30); - const manager = new LayerLifecycle({}); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: actual.getSourceAvailability }, + ); for (const layer of actual.layers) manager.register(layer); for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) { for (const id of ids) { @@ -47,7 +50,7 @@ test('all acquisition sources may be absent, and enable fails before initializin const row = manager.getAll().find((entry) => entry.id === id); assert.equal(row.enabled, false); assert.equal(row.stats.status, 'unavailable'); - assert.match(row.stats.error, /not configured/); + assert.match(row.stats.error, /data source not configured/); } } }); @@ -85,7 +88,10 @@ test('standalone defaults allow exact replacements and explicit removal, and rej test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); - const manager = new LayerLifecycle({}); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: actual.getSourceAvailability }, + ); manager.register(actual.get('street-level')); assert.equal(await manager.setEnabled('street-level', true), false); assert.equal(manager.layers.get('street-level').initialized, false); @@ -99,3 +105,52 @@ test('empty Street Level uses the shared unavailable lifecycle without initializ true, ); }); + +test('availability lookup accepts frozen layers and uses their display names', () => { + const composition = resolveCatalogSources({}); + const layers = Object.values(CATALOG_SOURCE_CONTRACTS) + .flatMap(({ layers }) => layers) + .map((id) => + Object.freeze({ + id, + name: id === 'fire-perimeters' ? 'Fire perimeters' : id, + }), + ); + const lookup = composition.createAvailabilityLookup(layers); + assert.equal( + lookup('fire-perimeters').reason, + 'Fire perimeters: data source not configured', + ); + assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false); + const manager = new LayerLifecycle({}, { getSourceAvailability: lookup }); + manager.register(layers.find(({ id }) => id === 'fire-perimeters')); + assert.equal( + manager.getAll()[0].stats.error, + 'Fire perimeters: data source not configured', + ); +}); + +test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => { + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches++; + throw new Error('Unexpected fallback acquisition'); + }); + const actual = catalog(createStandaloneLayerSources({ military: null }), t); + const registry = actual.militaryRegistry; + assert.equal(actual.getSourceAvailability('flights').available, true); + assert.equal(registry.isMilitaryLayerActive(), false); + assert.equal(registry.isMilitaryIcao('abc123'), false); + registry.registerMilitaryIcaos([' ABC123 ']); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(registry.isMilitaryIcao('abc123'), true); + assert.equal(registry.isMilitaryIcao('def456'), false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: actual.getSourceAvailability }, + ); + manager.register(actual.get('military')); + assert.equal(await manager.setEnabled('military', true), false); + assert.equal(registry.isMilitaryLayerActive(), false); + assert.equal(fetches, 0); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 6c40b82be..0df5e87a4 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) { * for real-time data overlays on the CesiumJS globe. */ export class LayerLifecycle { - constructor(viewer, { allowQaRegistration = false } = {}) { + constructor( + viewer, + { allowQaRegistration = false, getSourceAvailability } = {}, + ) { + this._catalogSourceAvailability = getSourceAvailability; this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } @@ -228,7 +232,7 @@ export class LayerLifecycle { _normalizedStats(entry) { const moduleStats = this._moduleStats(entry); - const availability = entry.module.getSourceAvailability?.(); + const availability = this._sourceAvailability(entry.module.id); const lifecycleLoading = entry.lifecycleState === 'enabling' || entry.lifecycleState === 'disabling'; @@ -2290,10 +2294,15 @@ export class LayerLifecycle { } } + _sourceAvailability(layerId) { + return ( + this._catalogSourceAvailability?.(layerId) ?? + this.layers.get(layerId)?.module.getSourceAvailability?.() + ); + } + async _visibilityBlockReason(change) { - const availability = this.layers - .get(change.layerId) - ?.module.getSourceAvailability?.(); + const availability = this._sourceAvailability(change.layerId); if (change.enabled && availability?.available === false) return availability.reason || 'Data source unavailable'; for (const callback of this._visibilityGuards) {