refactor: supply application catalogs explicitly

This commit is contained in:
Sameh Khamis committed 2026-09-14 13:56:51 -07:00
1 parent fad93f22f8
commit c3de679c61
16 files changed
+288 -50

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
const CONTROL_LAYER_IDS = Object.freeze({
trafficLayer: 'traffic',
flightsLayer: 'flights',
militaryFlightsLayer: 'military',
satellitesLayer: 'satellites',
cctvLayer: 'cctv',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
aisLiveVesselsLayer: 'ais-live-vessels',
militaryAwarenessLayer: 'military-awareness',
militaryInstallationsLayer: 'military-installations',
rocketLaunchesLayer: 'rocket-launches',
});
/** Capture the ordered application instances and their serialization metadata. */
export function createLayerCatalog(layers, metadata) {
if (!Array.isArray(layers) || !Array.isArray(metadata))
throw new TypeError(
'Layer instances and serialization metadata are required',
);
const byId = new Map();
for (const layer of layers) {
if (typeof layer?.id !== 'string' || !layer.id || byId.has(layer.id))
throw new TypeError(`Invalid or duplicate catalog layer: ${layer?.id}`);
byId.set(layer.id, layer);
}
const ids = new Set();
for (const entry of metadata) {
if (!byId.has(entry?.id) || ids.has(entry.id))
throw new TypeError(
`Unmatched or duplicate catalog metadata: ${entry?.id}`,
);
ids.add(entry.id);
}
if (ids.size !== byId.size)
throw new TypeError('Catalog metadata is incomplete');
return Object.freeze({
layers: Object.freeze([...layers]),
metadata: Object.freeze(
metadata.map((entry) => Object.freeze({ ...entry })),
),
get: (id) => byId.get(id),
});
}
/** Bind the current control surface to the exact instances registered by the app. */
export function catalogControlServices(catalog) {
if (!catalog?.get)
throw new TypeError('An application layer catalog is required');
return Object.fromEntries(
Object.entries(CONTROL_LAYER_IDS).map(([role, id]) => {
const layer = catalog.get(id);
if (!layer)
throw new TypeError(`Control layer missing from catalog: ${id}`);
return [role, layer];
}),
);
}
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLayerCatalog, catalogControlServices } from './catalog.js';
import { createApplicationData } from './data.js';
const metadata = (layers) =>
layers.map(({ id }) => ({ id, disposition: 'enabled-only' }));
test('catalog captures membership and metadata while preserving the supplied instances', () => {
const first = { id: 'first' };
const layers = [first];
const schema = metadata(layers);
const catalog = createLayerCatalog(layers, schema);
layers.push({ id: 'later' });
schema[0].disposition = 'invalid';
assert.deepEqual(catalog.layers, [first]);
assert.equal(catalog.get('first'), first);
assert.equal(catalog.get('later'), undefined);
assert.equal(catalog.metadata[0].disposition, 'enabled-only');
assert.throws(() => catalog.layers.push({ id: 'later' }), TypeError);
});
test('duplicate, missing and extra metadata fail before application registration', () => {
const layers = [{ id: 'first' }];
assert.throws(
() => createLayerCatalog([...layers, ...layers], metadata(layers)),
/duplicate/,
);
assert.throws(() => createLayerCatalog(layers, []), /incomplete/);
assert.throws(
() => createLayerCatalog(layers, metadata([{ id: 'other' }])),
/Unmatched/,
);
assert.throws(
() =>
createLayerCatalog(layers, [...metadata(layers), ...metadata(layers)]),
/duplicate/,
);
});
test('data setup seals the caller catalog before controls can restore and drains its exact instances', async (t) => {
const previous = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
});
const attached = [];
const layers = [
{
id: 'one',
attachDataManager: (manager) => attached.push([...manager.layers.keys()]),
},
{ id: 'two' },
];
const releases = [];
let observed;
const { dataManager } = createApplicationData({
scene: { viewer: {} },
controls: {
styleManager: {
attachDataManager(manager) {
assert.equal(manager.registrationsFinalized, true);
assert.equal(manager.layers.get('one').module, layers[0]);
},
},
},
catalog: createLayerCatalog(layers, metadata(layers)),
defer: (release) => releases.push(release),
onData: (manager) => {
observed = manager;
},
});
assert.equal(observed, dataManager);
assert.deepEqual(attached, [['one', 'two']]);
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
for (const release of releases.reverse()) await release();
assert.equal(dataManager.layers.size, 0);
});
test('controls bind catalog instances rather than similarly named defaults', () => {
const ids = [
'traffic',
'flights',
'military',
'satellites',
'cctv',
'radio',
'bikeshare',
'ais-live-vessels',
'military-awareness',
'military-installations',
'rocket-launches',
];
const layers = ids.map((id) => ({ id }));
const catalog = createLayerCatalog(layers, metadata(layers));
const services = catalogControlServices(catalog);
assert.equal(services.flightsLayer, layers[1]);
assert.equal(services.aisLiveVesselsLayer, layers[7]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
/Control layer missing/,
);
});
+3 -1
View File
@@ -1,3 +1,4 @@
import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
@@ -8,12 +9,13 @@ export function createApplicationControls({
loaderStatus,
Controls = StyleManager,
services,
catalog,
placeSearch,
defer,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
services,
services: { ...services, ...catalogControlServices(catalog) },
mapStackController,
placeSearch,
});
+7 -36
View File
@@ -1,24 +1,9 @@
import { DataLayerManager } from '../data/manager.js';
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import alprCamerasLayer from '../data/alprCameras.js';
import earthquakesLayer from '../data/earthquakes.js';
import satellitesLayer from '../data/satellites.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
import trafficLayer from '../data/traffic.js';
import cctvLayer from '../data/cctv.js';
import radioLayer from '../data/radio.js';
import bikeshareLayer from '../data/bikeshare.js';
import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
import militaryInstallationsLayer from '../data/militaryInstallations.js';
import militaryAwarenessLayer from '../data/militaryAwareness.js';
import localDataLayers from '../data/localLayers.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer },
controls: { styleManager },
catalog,
allowQaRegistration,
onData,
defer,
@@ -35,26 +20,12 @@ export function createApplicationData({
);
});
onData?.(dataManager);
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
dataManager.register(alprCamerasLayer);
dataManager.register(satellitesLayer);
dataManager.register(rocketLaunchesLayer);
rocketLaunchesLayer.attachDataManager(dataManager);
dataManager.register(trafficLayer);
dataManager.register(cctvLayer);
dataManager.register(radioLayer);
dataManager.register(bikeshareLayer);
dataManager.register(aisLiveVesselsLayer);
dataManager.register(militaryInstallationsLayer);
dataManager.register(militaryAwarenessLayer);
militaryAwarenessLayer.attachDataManager(dataManager);
for (const layer of localDataLayers) {
dataManager.register(layer);
}
// Restoration starts only after the complete production registry is sealed.
dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY);
if (!catalog?.layers || !catalog?.metadata)
throw new TypeError('An application layer catalog is required');
for (const layer of catalog.layers) dataManager.register(layer);
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
// Restoration starts only after the caller's complete registry is sealed.
dataManager.finalizeRegistrations(catalog.metadata);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager)
+11 -3
View File
@@ -10,7 +10,7 @@ import { promisify } from 'node:util';
const run = promisify(execFile);
const bashTest = process.platform === 'win32' ? test.skip : test;
async function launch(overrides = {}, dotenv = '') {
async function launch(overrides = {}, dotenv = '', omitCctv = false) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gev-cctv-launch-'));
try {
await fs.mkdir(path.join(root, 'scripts'));
@@ -20,8 +20,9 @@ async function launch(overrides = {}, dotenv = '') {
for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) {
await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name));
}
await fs.mkdir(path.join(root, 'src', 'app'), { recursive: true });
await fs.copyFile(new URL('./app/data.js', import.meta.url), path.join(root, 'src', 'app', 'data.js'));
await fs.mkdir(path.join(root, 'src', 'standalone'), { recursive: true });
const catalog = await fs.readFile(new URL('./standalone/catalog.js', import.meta.url), 'utf8');
await fs.writeFile(path.join(root, 'src', 'standalone', 'catalog.js'), omitCctv ? catalog.replace(/^\s*cctvLayer,\s*$/m, '') : catalog);
await fs.mkdir(path.join(root, 'node_modules'));
await fs.symlink(fileURLToPath(new URL('.', import.meta.resolve('vite/package.json'))), path.join(root, 'node_modules', 'vite'), 'dir');
await fs.writeFile(path.join(root, '.env'), dotenv);
@@ -79,3 +80,10 @@ bashTest('CCTV preset shares dotenv precedence and names-only credential provena
assert.equal(result.env.GEV_KEY_SETUP_EXTERNAL_KEYS, 'GOOGLE_MAPS_API_KEY');
assert.doesNotMatch(result.output, /fixture-shell-maps|fixture-file-maps|fixture-file-voice/);
});
bashTest('CCTV preset refuses a catalog that no longer registers its layer', async () => {
await assert.rejects(launch({}, '', true), (error) => {
assert.match(error.stdout, /CCTV layer not wired in src\/standalone\/catalog.js/);
return true;
});
});
+9 -2
View File
@@ -1,3 +1,4 @@
import { createStandaloneCatalog } from './catalog.js';
import { createStandalonePlaceSearch } from './placeSearch.js';
import { createApplication } from '../app/application.js';
import { createStandaloneScene } from './scene.js';
@@ -22,6 +23,7 @@ export function createStandaloneApplication({
const loadingScreen = document.getElementById('loading-screen');
const loaderStatus = loadingScreen.querySelector('.loader-status');
let placeSearch;
const catalog = createStandaloneCatalog();
return createApplication({
createScene: (context) => {
placeSearch = createStandalonePlaceSearch({
@@ -37,9 +39,14 @@ export function createStandaloneApplication({
});
},
createControls: (context) =>
createStandaloneControls({ ...context, loaderStatus, placeSearch }),
createStandaloneControls({
...context,
loaderStatus,
placeSearch,
catalog,
}),
createData: (context) =>
createStandaloneData({ ...context, allowQaRegistration }),
createStandaloneData({ ...context, allowQaRegistration, catalog }),
createTools: (context) =>
createStandaloneTools({ ...context, loadingScreen, placeSearch, voice }),
});
+40
View File
@@ -0,0 +1,40 @@
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import alprCamerasLayer from '../data/alprCameras.js';
import earthquakesLayer from '../data/earthquakes.js';
import satellitesLayer from '../data/satellites.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
import trafficLayer from '../data/traffic.js';
import cctvLayer from '../data/cctv.js';
import radioLayer from '../data/radio.js';
import bikeshareLayer from '../data/bikeshare.js';
import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
import militaryInstallationsLayer from '../data/militaryInstallations.js';
import militaryAwarenessLayer from '../data/militaryAwareness.js';
import localDataLayers from '../data/localLayers.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createLayerCatalog } from '../app/catalog.js';
/** Select the existing standalone instances and current persistence schema. */
export function createStandaloneCatalog() {
return createLayerCatalog(
[
flightsLayer,
militaryFlightsLayer,
earthquakesLayer,
alprCamerasLayer,
satellitesLayer,
rocketLaunchesLayer,
trafficLayer,
cctvLayer,
radioLayer,
bikeshareLayer,
aisLiveVesselsLayer,
militaryInstallationsLayer,
militaryAwarenessLayer,
...localDataLayers,
],
LAYER_STATE_REGISTRY,
);
}
+8 -1
View File
@@ -1 +1,8 @@
export { createApplicationControls as createStandaloneControls } from '../app/controls.js';
import { createApplicationControls } from '../app/controls.js';
import { createStandaloneCatalog } from './catalog.js';
export function createStandaloneControls(options) {
return createApplicationControls({
catalog: createStandaloneCatalog(),
...options,
});
}
+8 -1
View File
@@ -1 +1,8 @@
export { createApplicationData as createStandaloneData } from '../app/data.js';
import { createApplicationData } from '../app/data.js';
import { createStandaloneCatalog } from './catalog.js';
export function createStandaloneData(options) {
return createApplicationData({
catalog: createStandaloneCatalog(),
...options,
});
}