diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6a322ea9..167ea65a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,12 @@ jobs: - name: Run setup policy checks run: npm run doctor -- --json + - name: Check adopted formatting + run: npm run format:check + + - name: Check package boundaries + run: npm run check:boundaries + - name: Run unit tests run: npm test @@ -70,6 +76,12 @@ jobs: - name: Run the Pinokio install path run: node scripts/pinokio-install.mjs + - name: Check adopted formatting + run: npm run format:check + + - name: Check package boundaries + run: npm run check:boundaries + - name: Run focused onboarding tests run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..1aaad8a03 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,22 @@ +dist +.gev-cache +.gev-logs +screenshots +qa-shots +output +coverage +3d-models +pinokio/ENVIRONMENT +pinokio/.installed +src/data/local_data/ +*.geojson +*.geojsonl + +# Local configuration, generated assets and browser evidence +.env +.env.* +!.env.example +node_modules +.vite +.vite-* +*.min.js diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..b1e6b2baf --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "semi": true, + "endOfLine": "lf" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3415c2a..00d24586c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +### Development + +- Adopt Prettier tooling contributed by RohanDaCoder (#227), with an explicit + file scope, pinned formatter and Linux/Windows CI checks. Format the reusable + infrastructure modules and their consumer tests. Package boundary checks keep + those exports separate from app startup and local Node services. + ### Fixed - Separate optional Google server credentials for Places and Street View from diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6760dfdf1..62da95f5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,22 @@ The highest-leverage places to jump in: - Match the surrounding code — comment density, naming, and idiom. - Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required. +## Formatting and reusable components + +Run `npm run format` before submitting changes to adopted modules, then +`npm run format:check` and `npm run check:boundaries`. Formatting uses the +explicit file list in `scripts/format-scope.json`; add newly extracted modules +and tests there in a separate mechanical commit. Keep unadopted files consistent +with their surrounding style. CI checks all adopted files and package boundaries +on Linux and Windows. + +Reusable package exports own their state and receive application operations +through explicit callbacks. They must not import the standalone bootstrap or +local Node services. Update `scripts/package-boundaries.json` and consumer tests +when adding an export or expanding a component. See +[formatting and component boundaries](docs/CODE-BOUNDARIES.md) for the current +ownership and adoption process. + ## Pull requests 1. Branch off `main`. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md new file mode 100644 index 000000000..2d4df7ad5 --- /dev/null +++ b/docs/CODE-BOUNDARIES.md @@ -0,0 +1,50 @@ +# Formatting and component boundaries + +Run `npm run format` to format the files in `scripts/format-scope.json` and +`npm run format:check` to check that same list without writing. CI checks the +entire adopted list on Linux and Windows. Prettier is pinned in the development +dependencies; use the installed version so local and CI output agree. The shared +configuration specifies two spaces, single quotes, semicolons and LF endings. + +Add new reusable modules and their tests to the list as they are extracted. +Keep mechanical formatting in its own commit after behavior is stable. Existing +source-text regression assertions still apply; investigate failures and preserve +their behavioral coverage when a move or line wrap changes a tested shape. +Files outside the list retain their surrounding style until deliberately adopted. +Generated output, local configuration, browser evidence and bundled datasets are +excluded. The formatter validates every entry before writing any file. + +## Current component ownership + +| Surface | Owns | Receives from its caller | +| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | +| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations | +| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations | +| `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements | +| `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services | +| `src/main.js` and `vite.config.js` | Standalone startup and local Node services | Local configuration | + +The three scoped package exports are browser source modules. Use their documented +exports instead of importing standalone startup or reaching into internal files. +The application owns the viewer, context store, overlay host and render scheduler; +layers use the supplied callbacks. See [the infrastructure contract](INFRASTRUCTURE-LAYERS.md). + +`npm run check:boundaries` builds every declared package export, with app Vite +configuration disabled. `scripts/package-boundaries.json` lists each export's +component, owned modules and external runtime dependencies. A new export must be +classified. Imports outside the declared modules fail, including unused and +literal dynamic imports. Cesium stays external so the consuming application +supplies the same compatible instance as its viewer. Existing consumer tests +also check import-time inactivity and asset URLs under a non-root base. + +These checks cover the declared exports, not every import in the application. +They check build-time imports, not arbitrary runtime-generated module URLs. +Keep runtime module discovery out of these exports. When extracting another +component, add its ownership and consumer tests together. Node services must use +separate entry points and their own checks when they become reusable; importing +them into a browser component is not supported. No Node service is exported yet. + +This setup establishes boundaries for the existing infrastructure component. +Application construction, UI panels and individual source adapters remain future +extractions; they should become smaller modules with explicit lifecycle owners +as their callers migrate. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4485b1026..cea6f4a53 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,14 @@ # God's Eye View Current State +## Scoped formatting and package checks + +`npm run format` and `npm run format:check` operate on the explicit adopted-file +list. `npm run check:boundaries` checks the browser dependency graph of all +current package exports; infrastructure owns its three implementation modules +and takes Cesium from the consumer. CI runs both checks on Linux and Windows. +The standalone app, layer behavior and public export paths remain unchanged. +See [component ownership and adoption](CODE-BOUNDARIES.md). + ## Google browser and server keys Local Places nearby/text search and the CCTV Street View fallback prefer diff --git a/package-lock.json b/package-lock.json index 6529133eb..c24590e4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "satellite.js": "^6.0.2" }, "devDependencies": { + "prettier": "3.9.6", "puppeteer": "^25.10.0", "sharp": "^0.35.4", "vite": "^6.0.0", @@ -2220,6 +2221,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/protobufjs": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", diff --git a/package.json b/package.json index e2676c976..dcf0b4a77 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gods-eye-view", "private": true, "version": "0.1.1", - "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", + "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", "author": "Bilawal Sidhu", @@ -33,6 +33,8 @@ ], "scripts": { "doctor": "node scripts/setup-doctor.mjs", + "format": "node scripts/format.mjs --write", + "format:check": "node scripts/format.mjs --check", "dev": "vite", "dev:secure": "./scripts/dev-secure.sh", "opensky:import": "./scripts/opensky-import-client.sh", @@ -40,7 +42,8 @@ "preview": "vite preview", "test": "node scripts/run-unit-tests.mjs", "test:track": "node scripts/track-regression.mjs", - "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs" + "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", + "check:boundaries": "node scripts/check-package-boundaries.mjs" }, "dependencies": { "@mapbox/vector-tile": "^3.0.0", @@ -51,6 +54,7 @@ "satellite.js": "^6.0.2" }, "devDependencies": { + "prettier": "3.9.6", "puppeteer": "^25.10.0", "sharp": "^0.35.4", "vite": "^6.0.0", diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs new file mode 100644 index 000000000..50abe15c2 --- /dev/null +++ b/scripts/check-package-boundaries.mjs @@ -0,0 +1,131 @@ +import { readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { build, normalizePath } from 'vite'; + +/** Build each browser export group and reject imports outside its declared ownership. */ +export async function checkPackageBoundaries(root) { + root = await realpath(root); + const readJson = async (name) => + JSON.parse(await readFile(path.join(root, name), 'utf8')); + const pkg = await readJson('package.json'); + const groups = await readJson('scripts/package-boundaries.json'); + const declaredExports = Object.keys(pkg.exports || {}).sort(); + const classifiedExports = Object.values(groups) + .flatMap((group) => group.exports) + .sort(); + if ( + !declaredExports.length || + JSON.stringify(declaredExports) !== JSON.stringify(classifiedExports) + ) { + throw new Error( + 'Every package export must belong to exactly one boundary group', + ); + } + const reports = []; + for (const [name, group] of Object.entries(groups)) { + if ( + !Array.isArray(group.modules) || + !group.modules.length || + !Array.isArray(group.external) + ) { + throw new Error(`Invalid package boundary: ${name}`); + } + const allowed = new Set(); + for (const module of group.modules) { + if ( + typeof module !== 'string' || + path.isAbsolute(module) || + module.includes('\\') || + module.split('/').includes('..') + ) { + throw new Error(`Boundary modules must be repository paths: ${name}`); + } + const resolved = await realpath(path.join(root, module)); + const relative = path.relative(root, resolved); + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`Boundary module escapes repository: ${name}`); + } + allowed.add(normalizePath(resolved)); + } + for (const external of group.external) { + if ( + !Object.hasOwn(pkg.dependencies || {}, external) && + !Object.hasOwn(pkg.peerDependencies || {}, external) + ) { + throw new Error( + `Boundary external must be a declared runtime dependency: ${external}`, + ); + } + } + const input = group.exports.map((key) => { + const target = pkg.exports[key]; + if ( + typeof target !== 'string' || + !target.startsWith('./') || + !allowed.has(normalizePath(path.resolve(root, target))) + ) { + throw new Error(`Export must point to an owned module: ${key}`); + } + return path.resolve(root, target); + }); + const seen = new Set(); + await build({ + root, + configFile: false, + envFile: false, + publicDir: false, + logLevel: 'silent', + plugins: [ + { + name: 'check-package-ownership', + moduleParsed(info) { + if (!allowed.has(info.id)) { + throw new Error( + `Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`, + ); + } + seen.add(info.id); + }, + }, + ], + build: { + lib: { entry: input, formats: ['es'] }, + write: false, + minify: false, + assetsInlineLimit: 0, + rollupOptions: { + input, + external: group.external, + // Unused imports must still obey ownership; tree shaking is not a boundary. + treeshake: false, + preserveEntrySignatures: 'strict', + }, + }, + }); + reports.push({ name, exports: input.length, modules: seen.size }); + } + return reports; +} + +const invoked = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ''; +if (import.meta.url === invoked) { + try { + const reports = await checkPackageBoundaries( + fileURLToPath(new URL('../', import.meta.url)), + ); + for (const report of reports) + console.log( + `Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`, + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json new file mode 100644 index 000000000..647fffacf --- /dev/null +++ b/scripts/format-scope.json @@ -0,0 +1,15 @@ +[ + ".prettierrc.json", + "package.json", + "scripts/format-scope.json", + "scripts/package-boundaries.json", + "scripts/format.mjs", + "scripts/check-package-boundaries.mjs", + "src/tooling/format.test.mjs", + "src/tooling/packageBoundaries.test.mjs", + "src/data/infrastructure.js", + "src/data/infrastructure.test.mjs", + "src/data/localGeojsonCore.js", + "src/data/localGeojsonLod.js", + "docs/CODE-BOUNDARIES.md" +] diff --git a/scripts/format.mjs b/scripts/format.mjs new file mode 100644 index 000000000..b74dfbdcd --- /dev/null +++ b/scripts/format.mjs @@ -0,0 +1,101 @@ +import { readFile, writeFile, realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import * as prettier from 'prettier'; + +/** Check or format the explicitly adopted files, without scanning other source. */ +export async function formatAdoptedFiles(root, mode) { + if (!['--check', '--write'].includes(mode)) { + throw new Error('Usage: node scripts/format.mjs --check|--write'); + } + root = await realpath(root); + const scope = JSON.parse( + await readFile(path.join(root, 'scripts/format-scope.json'), 'utf8'), + ); + if ( + !Array.isArray(scope) || + !scope.length || + new Set(scope).size !== scope.length + ) { + throw new Error( + 'Formatting scope must be a nonempty list of unique file paths', + ); + } + + // Validate the whole scope before any write, including resolved symlink paths. + const files = []; + for (const name of scope) { + if ( + typeof name !== 'string' || + !name || + name.includes('\\') || + path.isAbsolute(name) || + name.split('/').includes('..') + ) { + throw new Error( + 'Formatting scope entries must be paths inside the repository', + ); + } + const file = path.join(root, name); + const relative = path.relative(root, await realpath(file)); + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + !(await stat(file)).isFile() + ) { + throw new Error( + `Formatting scope must contain repository files: ${name}`, + ); + } + const info = await prettier.getFileInfo(file, { + ignorePath: path.join(root, '.prettierignore'), + }); + if (info.ignored || !info.inferredParser) { + throw new Error( + `Formatting scope contains an ignored or unsupported file: ${name}`, + ); + } + const options = { ...(await prettier.resolveConfig(file)), filepath: file }; + const source = await readFile(file, 'utf8'); + files.push({ + name, + file, + source, + formatted: await prettier.format(source, options), + }); + } + + const changed = files.filter(({ source, formatted }) => source !== formatted); + if (mode === '--write') { + for (const { file, formatted } of changed) await writeFile(file, formatted); + } + return { count: files.length, changed: changed.map(({ name }) => name) }; +} + +const invoked = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ''; +if (import.meta.url === invoked) { + try { + if (process.argv.length !== 3) + throw new Error('Usage: node scripts/format.mjs --check|--write'); + const mode = process.argv[2]; + const result = await formatAdoptedFiles( + fileURLToPath(new URL('../', import.meta.url)), + mode, + ); + if (mode === '--check' && result.changed.length) { + for (const name of result.changed) + console.error(`Needs formatting: ${name}`); + process.exitCode = 1; + } else { + console.log( + `${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`, + ); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json new file mode 100644 index 000000000..20fa4547b --- /dev/null +++ b/scripts/package-boundaries.json @@ -0,0 +1,15 @@ +{ + "infrastructure": { + "exports": [ + "./infrastructure", + "./infrastructure/geojson", + "./infrastructure/lod" + ], + "modules": [ + "src/data/infrastructure.js", + "src/data/localGeojsonCore.js", + "src/data/localGeojsonLod.js" + ], + "external": ["cesium"] + } +} diff --git a/src/data/infrastructure.js b/src/data/infrastructure.js index bac9e81d5..511c6e23e 100644 --- a/src/data/infrastructure.js +++ b/src/data/infrastructure.js @@ -1,8 +1,12 @@ import { createLocalGeoJsonLayer } from './localGeojsonCore.js'; // Resolved by Vite in builds and relative to this module in other consumers. -const datacentersUrl = new URL('./local_data/datacenters/datacenters.geojsonl', import.meta.url).href; -const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url).href; +const datacentersUrl = new URL( + './local_data/datacenters/datacenters.geojsonl', + import.meta.url, +).href; +const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url) + .href; /** * Create fresh datacenter and dam layers without starting or loading them. @@ -10,29 +14,35 @@ const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url).href * @returns {object[]} Datacenters then dams, with stable standalone identities. */ export function createInfrastructureLayers(services) { - const datacenters = createLocalGeoJsonLayer({ - id: 'local-datacenters', - url: datacentersUrl, - name: 'Datacenters', - color: '#00ffff', // Cyan - icon: '▣', - source: 'Local', - labels: true, - labelMax: 700, - labelGridPx: 138, - }, services); + const datacenters = createLocalGeoJsonLayer( + { + id: 'local-datacenters', + url: datacentersUrl, + name: 'Datacenters', + color: '#00ffff', // Cyan + icon: '▣', + source: 'Local', + labels: true, + labelMax: 700, + labelGridPx: 138, + }, + services, + ); - const dams = createLocalGeoJsonLayer({ - id: 'local-dams', - url: damsUrl, - name: 'Dams', - color: '#0088ff', // Blue - icon: '▰', - source: 'USACE', - labels: true, - labelMax: 900, - labelGridPx: 132, - }, services); + const dams = createLocalGeoJsonLayer( + { + id: 'local-dams', + url: damsUrl, + name: 'Dams', + color: '#0088ff', // Blue + icon: '▰', + source: 'USACE', + labels: true, + labelMax: 900, + labelGridPx: 132, + }, + services, + ); return [datacenters, dams]; } diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 416423636..d8da4c3d4 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -12,11 +12,18 @@ function services() { return { records, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, - registerEntityContext(entity, metadata) { records.set(metadata.id, { entity, ...metadata }); }, - selectEntityContext(entity) { selection = entity; }, - clearSelectedEntityContextForLayer() { selection = undefined; }, + registerEntityContext(entity, metadata) { + records.set(metadata.id, { entity, ...metadata }); + }, + selectEntityContext(entity) { + selection = entity; + }, + clearSelectedEntityContextForLayer() { + selection = undefined; + }, removeEntityContextsForLayer(id) { - for (const [key, record] of records) if (record.layerId === id) records.delete(key); + for (const [key, record] of records) + if (record.layerId === id) records.delete(key); }, governorRequestRender() {}, selection: () => selection, @@ -24,7 +31,12 @@ function services() { } test('package exports import without an application, DOM, fetch, or timers', () => { - const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` globalThis.fetch = () => { throw new Error('unexpected fetch'); }; globalThis.setTimeout = () => { throw new Error('unexpected timer'); }; for (const key of ['window', 'document']) { @@ -33,51 +45,99 @@ test('package exports import without an application, DOM, fetch, or timers', () await import('gods-eye-view/infrastructure'); await import('gods-eye-view/infrastructure/geojson'); await import('gods-eye-view/infrastructure/lod'); - `], { cwd: new URL('../..', import.meta.url), encoding: 'utf8' }); + `, + ], + { cwd: new URL('../..', import.meta.url), encoding: 'utf8' }, + ); assert.equal(result.status, 0, result.stderr); }); -test('infrastructure factory preserves identity and creates independent state without loading', t => { - t.mock.method(globalThis, 'fetch', () => { throw new Error('factory must not fetch'); }); +test('infrastructure factory preserves identity and creates independent state without loading', (t) => { + t.mock.method(globalThis, 'fetch', () => { + throw new Error('factory must not fetch'); + }); const first = createInfrastructureLayers(services()); const second = createInfrastructureLayers(services()); - assert.deepEqual(first.map(({ id, name, source }) => ({ id, name, source })), [ - { id: 'local-datacenters', name: 'Datacenters', source: 'Local' }, - { id: 'local-dams', name: 'Dams', source: 'USACE' }, - ]); + assert.deepEqual( + first.map(({ id, name, source }) => ({ id, name, source })), + [ + { id: 'local-datacenters', name: 'Datacenters', source: 'Local' }, + { id: 'local-dams', name: 'Dams', source: 'USACE' }, + ], + ); first.forEach((layer, index) => { assert.notEqual(layer, second[index]); layer.destroy(); - assert.deepEqual(second[index].getStats(), { count: 0, lastUpdate: null, error: null }); + assert.deepEqual(second[index].getStats(), { + count: 0, + lastUpdate: null, + error: null, + }); second[index].destroy(); }); }); test('dataset URLs still name the complete bundled sources', () => { - for (const [file, count] of [['datacenters', 4351], ['dams', 704]]) { - const lines = readFileSync(new URL(`./local_data/${file}/${file}.geojsonl`, import.meta.url), 'utf8') - .split('\n').filter(line => line.trim()); + for (const [file, count] of [ + ['datacenters', 4351], + ['dams', 704], + ]) { + const lines = readFileSync( + new URL(`./local_data/${file}/${file}.geojsonl`, import.meta.url), + 'utf8', + ) + .split('\n') + .filter((line) => line.trim()); assert.equal(lines.length, count); } }); -test('two viewers use their supplied contexts and dispose independently', async t => { - t.mock.method(globalThis, 'fetch', async () => ({ ok: true, text: async () => JSON.stringify({ - type: 'Feature', id: 'same-id', properties: { name: 'Dam' }, - geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] }, - }) })); +test('two viewers use their supplied contexts and dispose independently', async (t) => { + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + text: async () => + JSON.stringify({ + type: 'Feature', + id: 'same-id', + properties: { name: 'Dam' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0.01, 0], + [0, 0.01], + [0, 0], + ], + ], + }, + }), + })); const hosts = [services(), services()]; - const instances = hosts.map(host => { + const instances = hosts.map((host) => { const sources = new Cesium.DataSourceCollection(); let click; - const layer = createLocalGeoJsonLayer({ - id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl', - screenSpaceEventHandlerFactory: () => ({ setInputAction(fn) { click = fn; }, destroy() {} }), - }, host); + const layer = createLocalGeoJsonLayer( + { + id: 'local-dams', + name: 'Dams', + color: '#0088ff', + url: '/dams.geojsonl', + screenSpaceEventHandlerFactory: () => ({ + setInputAction(fn) { + click = fn; + }, + destroy() {}, + }), + }, + host, + ); const viewer = { dataSources: sources, scene: { - canvas: {}, preRender: new Cesium.Event(), requestRender() {}, + canvas: {}, + preRender: new Cesium.Event(), + requestRender() {}, screenSpaceCameraController: { enableInputs: true }, pick: () => ({ id: sources.get(0).entities.values[0] }), }, @@ -85,7 +145,9 @@ test('two viewers use their supplied contexts and dispose independently', async }; return { layer, viewer, click: () => click({ position: {} }) }; }); - t.after(() => instances.forEach(({ layer, viewer }) => layer.destroy(viewer))); + t.after(() => + instances.forEach(({ layer, viewer }) => layer.destroy(viewer)), + ); await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer))); assert.equal(hosts[0].records.size, 1); assert.equal(hosts[1].records.size, 1); @@ -109,22 +171,35 @@ test('consumer build includes only infrastructure code and resolves assets under write: false, assetsInlineLimit: 0, rollupOptions: { - input: fileURLToPath(import.meta.resolve('gods-eye-view/infrastructure')), + input: fileURLToPath( + import.meta.resolve('gods-eye-view/infrastructure'), + ), external: ['cesium'], preserveEntrySignatures: 'strict', }, }, }); const output = result.output; - const entry = output.find(item => item.type === 'chunk' && item.isEntry); - const sources = Object.keys(entry.modules).filter(id => id.endsWith('.js')); - assert.deepEqual(sources.map(id => id.split('/').at(-1)).sort(), [ - 'infrastructure.js', 'localGeojsonCore.js', 'localGeojsonLod.js', + const entry = output.find((item) => item.type === 'chunk' && item.isEntry); + const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); + assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ + 'infrastructure.js', + 'localGeojsonCore.js', + 'localGeojsonLod.js', ]); - assert.deepEqual(entry.imports, ['cesium'], 'the viewer supplies the same Cesium dependency'); + assert.deepEqual( + entry.imports, + ['cesium'], + 'the viewer supplies the same Cesium dependency', + ); for (const name of ['datacenters', 'dams']) { - const asset = output.find(item => item.type === 'asset' && item.fileName.includes(name)); + const asset = output.find( + (item) => item.type === 'asset' && item.fileName.includes(name), + ); assert.ok(asset, `${name} must be emitted`); - assert.ok(entry.code.includes(`/example/${asset.fileName}`), `${name} must retain the consumer base path`); + assert.ok( + entry.code.includes(`/example/${asset.fileName}`), + `${name} must retain the consumer base path`, + ); } }); diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 6f6b3a27d..1c4499914 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -494,7 +494,7 @@ test('local infrastructure creates no native labels or per-frame geometry callba assert.doesNotMatch(source, /new Cesium\.CallbackProperty/); assert.match(source, /feature\.position = tip/); assert.match(source, /record\.entity\.position\.setValue\(record\.tip\)/); - assert.match(source, /const stemPositionBuffers = \[\[base, tip\], \[base, tip\]\]/); + assert.match(source, /const stemPositionBuffers = \[\s*\[base, tip\],\s*\[base, tip\],?\s*\]/); assert.match(source, /record\.entity\.polyline\.positions\.setValue\(stemPositions\)/); assert.match(source, /viewer\.camera\.moveEnd\.addEventListener/); assert.match(source, /if \(refreshStemGeometry \|\| terrainFloorChanged\)/); diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index d18ce833f..b90d1b66b 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -15,7 +15,8 @@ const LOCAL_OVERLAY_COLLISION_CAPACITY = 96; const LOCAL_OVERLAY_CELL_SURPLUS = 2; const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000; const LOCAL_OVERLAY_FADE_START_M = 250000; -const LOCAL_OVERLAY_FADE_START_RATIO = LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M; +const LOCAL_OVERLAY_FADE_START_RATIO = + LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M; // Stems are anchored at ellipsoid height 0, but high-elevation features // (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid, // burying the short close-in stem inside the photoreal mesh. Once the @@ -63,8 +64,12 @@ export function localInfrastructureOverlayCopy(properties, layerId) { props.capacity, ]); const line = [operator, capacity] - .filter((value, index, values) => value && values.indexOf(value) === index) - .filter((value) => value.toLocaleLowerCase() !== title.toLocaleLowerCase()) + .filter( + (value, index, values) => value && values.indexOf(value) === index, + ) + .filter( + (value) => value.toLocaleLowerCase() !== title.toLocaleLowerCase(), + ) .join(' · '); if (line) details.push(clampCardLine(line)); } else if (layerId === 'local-dams') { @@ -147,21 +152,29 @@ export function createLocalInfrastructureOverlayEntry({ * @param {number} [options.cohortLimit=Infinity] Host-safe materialization cap. * @returns {object[]} Bounded overlay entries for shared-host arbitration. */ -export function selectLocalInfrastructureOverlayCohort(records, { - maxEntries, - gridPx, - width, - height, - project, - cohortLimit = Number.POSITIVE_INFINITY, -}) { +export function selectLocalInfrastructureOverlayCohort( + records, + { + maxEntries, + gridPx, + width, + height, + project, + cohortLimit = Number.POSITIVE_INFINITY, + }, +) { const sourceCap = Math.max(0, Math.floor(Number(maxEntries) || 0)); const materializationCap = Number.isFinite(Number(cohortLimit)) ? Math.max(0, Math.floor(Number(cohortLimit))) : Number.POSITIVE_INFINITY; const cap = Math.min(sourceCap, materializationCap); const cellSize = Math.max(1, Number(gridPx) || 1); - if (!Array.isArray(records) || records.length === 0 || cap === 0 || typeof project !== 'function') { + if ( + !Array.isArray(records) || + records.length === 0 || + cap === 0 || + typeof project !== 'function' + ) { return []; } @@ -170,8 +183,13 @@ export function selectLocalInfrastructureOverlayCohort(records, { for (const record of records) { const screen = project(record); if (!Number.isFinite(screen?.x) || !Number.isFinite(screen?.y)) continue; - if (screen.x < -padding || screen.x > width + padding - || screen.y < -padding || screen.y > height + padding) continue; + if ( + screen.x < -padding || + screen.x > width + padding || + screen.y < -padding || + screen.y > height + padding + ) + continue; const key = `${Math.floor(screen.x / cellSize)}:${Math.floor(screen.y / cellSize)}`; let contenders = cells.get(key); if (!contenders) { @@ -189,7 +207,8 @@ export function selectLocalInfrastructureOverlayCohort(records, { } primary.sort(compareLocalOverlayRecords); surplus.sort(compareLocalOverlayRecords); - if (primary.length >= cap) return primary.slice(0, cap).map((record) => record.entry); + if (primary.length >= cap) + return primary.slice(0, cap).map((record) => record.entry); const candidates = primary.concat(surplus.slice(0, cap - primary.length)); return candidates.map((record) => record.entry); } @@ -201,10 +220,7 @@ export function selectLocalInfrastructureOverlayCohort(records, { * @param {object} [options.host] Test seam for the three host lifecycle calls. * @returns {{show:function():void,publish:function(object[]):void,hide:function():void,destroy:function():void}} */ -export function createLocalInfrastructureOverlayPublisher({ - sourceId, - host, -}) { +export function createLocalInfrastructureOverlayPublisher({ sourceId, host }) { let visible = false; let published = false; let destroyed = false; @@ -227,17 +243,26 @@ export function createLocalInfrastructureOverlayPublisher({ }, publish(entries) { if (destroyed || !visible) return; - if (lastPublication && entries.length === lastPublication.length - && entries.every((entry, index) => { + if ( + lastPublication && + entries.length === lastPublication.length && + entries.every((entry, index) => { const previous = lastPublication[index]; - return entry === previous.entry - && entry.position?.x === previous.x - && entry.position?.y === previous.y - && entry.position?.z === previous.z; - })) return; + return ( + entry === previous.entry && + entry.position?.x === previous.x && + entry.position?.y === previous.y && + entry.position?.z === previous.z + ); + }) + ) + return; host.setEntries(sourceId, entries, sourceOptions); - lastPublication = entries.map(entry => ({ - entry, x: entry.position?.x, y: entry.position?.y, z: entry.position?.z, + lastPublication = entries.map((entry) => ({ + entry, + x: entry.position?.x, + y: entry.position?.y, + z: entry.position?.z, })); published = entries.length > 0; }, @@ -288,26 +313,31 @@ export function localDatasetError(error) { * One live instance per layer id is allowed in a given viewer/context/overlay host. * Destroy the previous instance before replacing it. Importing creates no layers. */ -export function createLocalGeoJsonLayer({ - id, - url, - name, - color, - icon = '📍', - source = 'Local JSONL', - labels = true, - labelMax = DEFAULT_LABEL_MAX, - labelGridPx = DEFAULT_LABEL_GRID_PX, - screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), - projectToWindow = (scene, position) => Cesium.SceneTransforms.worldToWindowCoordinates(scene, position), -}, { - overlayHost, - registerEntityContext, - selectEntityContext, - clearSelectedEntityContextForLayer, - removeEntityContextsForLayer, - governorRequestRender, -}) { +export function createLocalGeoJsonLayer( + { + id, + url, + name, + color, + icon = '📍', + source = 'Local JSONL', + labels = true, + labelMax = DEFAULT_LABEL_MAX, + labelGridPx = DEFAULT_LABEL_GRID_PX, + screenSpaceEventHandlerFactory = (canvas) => + new Cesium.ScreenSpaceEventHandler(canvas), + projectToWindow = (scene, position) => + Cesium.SceneTransforms.worldToWindowCoordinates(scene, position), + }, + { + overlayHost, + registerEntityContext, + selectEntityContext, + clearSelectedEntityContextForLayer, + removeEntityContextsForLayer, + governorRequestRender, + }, +) { let _dataSource = null; let _enabled = false; let _clickHandler = null; @@ -483,231 +513,279 @@ export function createLocalGeoJsonLayer({ // 1. Initialize data source if (!_dataSource) { - if (!_loadPromise) _loadPromise = (async () => { - _loadController = new AbortController(); - const baseColor = Cesium.Color.fromCssColorString(color); + if (!_loadPromise) + _loadPromise = (async () => { + _loadController = new AbortController(); + const baseColor = Cesium.Color.fromCssColorString(color); - // Fetch and parse JSON Lines (.geojsonl) into a FeatureCollection. - // The source is built into a local and committed to `_dataSource` - // only once setup finishes: a half-built source published early would - // make every later enable() skip this block, so the layer could never - // clear its error or retry. - _error = null; - let loaded = null; - // Whether the scene has actually accepted `loaded` — the two rollback - // windows (before vs after the add settles) need different cleanup. - let addedToScene = false; - try { - const response = await fetch(url, { signal: _loadController.signal }); - if (_destroyed) return; - // A 404 returns an HTML body that would otherwise die in JSON.parse - // one line later, reported as a parse error for a missing file. - if (!response.ok) { - throw new Error(`HTTP ${response.status ?? '?'}`); - } - const text = await response.text(); - if (_destroyed) return; - const lines = text.split('\n').filter(l => l.trim().length > 0); + // Fetch and parse JSON Lines (.geojsonl) into a FeatureCollection. + // The source is built into a local and committed to `_dataSource` + // only once setup finishes: a half-built source published early would + // make every later enable() skip this block, so the layer could never + // clear its error or retry. + _error = null; + let loaded = null; + // Whether the scene has actually accepted `loaded` — the two rollback + // windows (before vs after the add settles) need different cleanup. + let addedToScene = false; + try { + const response = await fetch(url, { + signal: _loadController.signal, + }); + if (_destroyed) return; + // A 404 returns an HTML body that would otherwise die in JSON.parse + // one line later, reported as a parse error for a missing file. + if (!response.ok) { + throw new Error(`HTTP ${response.status ?? '?'}`); + } + const text = await response.text(); + if (_destroyed) return; + const lines = text.split('\n').filter((l) => l.trim().length > 0); - const features = lines.map(line => JSON.parse(line)); + const features = lines.map((line) => JSON.parse(line)); - const geojson = { - type: 'FeatureCollection', - features - }; + const geojson = { + type: 'FeatureCollection', + features, + }; - // Natively parse into entities and use it as our _dataSource - loaded = await Cesium.GeoJsonDataSource.load(geojson, { - clampToGround: true, - stroke: baseColor, - fill: baseColor.withAlpha(0.3), - strokeWidth: 2, - markerSize: 8, - markerColor: baseColor, - }); + // Natively parse into entities and use it as our _dataSource + loaded = await Cesium.GeoJsonDataSource.load(geojson, { + clampToGround: true, + stroke: baseColor, + fill: baseColor.withAlpha(0.3), + strokeWidth: 2, + markerSize: 8, + markerColor: baseColor, + }); - if (_destroyed) return; - loaded.name = name; - loaded.show = false; - // Cesium's DataSourceCollection.add() returns a promise and only - // inserts on a later microtask. Without this await, a throw during - // post-processing would roll back a source the scene had not - // accepted yet — and Cesium would then insert the "removed" source - // anyway, leaving an orphan the retry would double up on. Awaiting - // also routes an add() rejection into the error path below instead - // of leaving it uncaught with healthy-looking stats. - await viewer.dataSources.add(loaded); - addedToScene = true; - if (_destroyed) { - viewer.dataSources.remove(loaded, true); - return; - } - - // Convert parsed points into 3D stems or style polygons - const entities = loaded.entities.values; - _count = entities.length; - _stemRecords = []; - _stemGeometryDirty = true; - - for (let i = 0; i < entities.length; i++) { - const feature = entities[i]; - feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer - - let pos = feature.position?.getValue(Cesium.JulianDate.now()); - - if (!pos) { - // It's a polygon or line - if (feature.polygon) { - feature.polygon.outline = true; - feature.polygon.outlineColor = baseColor; - - // Calculate center point for the stem - const hierarchy = feature.polygon.hierarchy?.getValue(Cesium.JulianDate.now()); - if (hierarchy && hierarchy.positions && hierarchy.positions.length > 0) { - pos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center; - } - } + if (_destroyed) return; + loaded.name = name; + loaded.show = false; + // Cesium's DataSourceCollection.add() returns a promise and only + // inserts on a later microtask. Without this await, a throw during + // post-processing would roll back a source the scene had not + // accepted yet — and Cesium would then insert the "removed" source + // anyway, leaving an orphan the retry would double up on. Awaiting + // also routes an add() rejection into the error path below instead + // of leaving it uncaught with healthy-looking stats. + await viewer.dataSources.add(loaded); + addedToScene = true; + if (_destroyed) { + viewer.dataSources.remove(loaded, true); + return; } - if (!pos) continue; + // Convert parsed points into 3D stems or style polygons + const entities = loaded.entities.values; + _count = entities.length; + _stemRecords = []; + _stemGeometryDirty = true; - const carto = Cesium.Cartographic.fromCartesian(pos); - const groundHeight = 0; // Ellipsoid surface until a scene sample lands - const tipHeight = 2000; // Initial Stem height + for (let i = 0; i < entities.length; i++) { + const feature = entities[i]; + feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer - const base = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, groundHeight); - const tip = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, tipHeight); - const properties = propertyObject(feature); - const recordId = String(feature.id ?? i); + let pos = feature.position?.getValue(Cesium.JulianDate.now()); - // Store references for bounded stem scaling and native picking. - feature.__localBaseCarto = carto; - feature.__localBaseCartesian = base; - registerEntityContext(feature, { - id: `${id}:${recordId}`, - layerId: id, - layerName: name, - source, - dataSource: loaded, - label: featureLabelFromProperties(properties, id), - properties, - latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(6)), - longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(6)), - }); + if (!pos) { + // It's a polygon or line + if (feature.polygon) { + feature.polygon.outline = true; + feature.polygon.outlineColor = baseColor; - // Constant properties are refreshed on the existing 450 ms source - // cadence. Cesium no longer evaluates 2-3 callbacks per entity on - // every frame, while the point/stem pick surface stays native. - feature.position = tip; - const stemPositionBuffers = [[base, tip], [base, tip]]; - feature.polyline = new Cesium.PolylineGraphics({ - positions: stemPositionBuffers[0], - width: 3.5, - material: new Cesium.ColorMaterialProperty(baseColor), - }); - feature.point = new Cesium.PointGraphics({ - pixelSize: 10, - color: baseColor, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - // Never depth-cull the anchor against the photoreal mesh — - // globe-horizon culling is handled by the pre-render occluder. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); + // Calculate center point for the stem + const hierarchy = feature.polygon.hierarchy?.getValue( + Cesium.JulianDate.now(), + ); + if ( + hierarchy && + hierarchy.positions && + hierarchy.positions.length > 0 + ) { + pos = Cesium.BoundingSphere.fromPoints( + hierarchy.positions, + ).center; + } + } + } - const priority = labelPriorityFromProperties(properties, id); - _stemRecords.push({ - id: recordId, - entity: feature, - carto, - base, - tip, - nextTip: Cesium.Cartesian3.clone(tip), - stemPositionBuffers, - stemPositionBufferIndex: 0, - groundHeight, - groundSampled: false, - lastGroundSampleMs: 0, - priority, - entry: labels ? createLocalInfrastructureOverlayEntry({ - id: recordId, + if (!pos) continue; + + const carto = Cesium.Cartographic.fromCartesian(pos); + const groundHeight = 0; // Ellipsoid surface until a scene sample lands + const tipHeight = 2000; // Initial Stem height + + const base = Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + groundHeight, + ); + const tip = Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + tipHeight, + ); + const properties = propertyObject(feature); + const recordId = String(feature.id ?? i); + + // Store references for bounded stem scaling and native picking. + feature.__localBaseCarto = carto; + feature.__localBaseCartesian = base; + registerEntityContext(feature, { + id: `${id}:${recordId}`, layerId: id, - position: tip, + layerName: name, + source, + dataSource: loaded, + label: featureLabelFromProperties(properties, id), properties, + latitude: Number( + Cesium.Math.toDegrees(carto.latitude).toFixed(6), + ), + longitude: Number( + Cesium.Math.toDegrees(carto.longitude).toFixed(6), + ), + }); + + // Constant properties are refreshed on the existing 450 ms source + // cadence. Cesium no longer evaluates 2-3 callbacks per entity on + // every frame, while the point/stem pick surface stays native. + feature.position = tip; + const stemPositionBuffers = [ + [base, tip], + [base, tip], + ]; + feature.polyline = new Cesium.PolylineGraphics({ + positions: stemPositionBuffers[0], + width: 3.5, + material: new Cesium.ColorMaterialProperty(baseColor), + }); + feature.point = new Cesium.PointGraphics({ + pixelSize: 10, + color: baseColor, + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + // Never depth-cull the anchor against the photoreal mesh — + // globe-horizon culling is handled by the pre-render occluder. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + + const priority = labelPriorityFromProperties(properties, id); + _stemRecords.push({ + id: recordId, + entity: feature, + carto, + base, + tip, + nextTip: Cesium.Cartesian3.clone(tip), + stemPositionBuffers, + stemPositionBufferIndex: 0, + groundHeight, + groundSampled: false, + lastGroundSampleMs: 0, priority, - accent: color, - }) : null, - }); - } - // Setup finished — publish it. - _dataSource = loaded; - _lastUpdate = Date.now(); - } catch (e) { - // The dataset ships with the build, so this is a broken install, - // not a blip — it has to reach the chip, not just the console. - if (!_destroyed) _error = localDatasetError(e); - // Roll the partial build back so a later enable() retries from - // scratch instead of inheriting a half-populated source. Only the - // post-add window has something in the scene to remove: a failure - // before (or inside) add() never reached the collection, and - // removing then would race Cesium's pending insert. - if (addedToScene) { - try { viewer?.dataSources?.remove(loaded, true); } catch { /* already gone */ } - } - if (_destroyed) return; - removeEntityContextsForLayer(id); - _count = 0; - _stemRecords = []; - console.error(`Failed to load ${id}:`, e); - } - - if (_destroyed) return; - // 2. Install native global click handler - if (!_clickHandler) { - _clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - if (!_enabled) return; - const picked = viewer.scene.pick(click.position); - - if (picked && picked.id && picked.id.__localLayerId === id) { - const entity = picked.id; - viewer.selectedEntity = entity; - selectEntityContext(entity); - - // We zoom to the surface base of the stem or the center of the polygon - let targetPos = null; - - if (entity.polyline) { - // If it's a stem, fly to the base - const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now()); - if (positions && positions.length > 0) { - targetPos = positions[0]; - } - } else if (entity.polygon && entity.polygon.hierarchy) { - // If it's a polygon, just fly to its center - const hierarchy = entity.polygon.hierarchy.getValue(Cesium.JulianDate.now()); - if (hierarchy && hierarchy.positions.length > 0) { - targetPos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center; - } - } - - if (targetPos) { - const carto = Cesium.Cartographic.fromCartesian(targetPos); - - // Disable interactions so Cesium doesn't magically cancel the flight - viewer.scene.screenSpaceCameraController.enableInputs = false; - - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, 5000), - duration: 1.5, - complete: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; }, - cancel: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; }, - }); + entry: labels + ? createLocalInfrastructureOverlayEntry({ + id: recordId, + layerId: id, + position: tip, + properties, + priority, + accent: color, + }) + : null, + }); + } + // Setup finished — publish it. + _dataSource = loaded; + _lastUpdate = Date.now(); + } catch (e) { + // The dataset ships with the build, so this is a broken install, + // not a blip — it has to reach the chip, not just the console. + if (!_destroyed) _error = localDatasetError(e); + // Roll the partial build back so a later enable() retries from + // scratch instead of inheriting a half-populated source. Only the + // post-add window has something in the scene to remove: a failure + // before (or inside) add() never reached the collection, and + // removing then would race Cesium's pending insert. + if (addedToScene) { + try { + viewer?.dataSources?.remove(loaded, true); + } catch { + /* already gone */ } } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - } - })(); + if (_destroyed) return; + removeEntityContextsForLayer(id); + _count = 0; + _stemRecords = []; + console.error(`Failed to load ${id}:`, e); + } + + if (_destroyed) return; + // 2. Install native global click handler + if (!_clickHandler) { + _clickHandler = screenSpaceEventHandlerFactory( + viewer.scene.canvas, + ); + _clickHandler.setInputAction((click) => { + if (!_enabled) return; + const picked = viewer.scene.pick(click.position); + + if (picked && picked.id && picked.id.__localLayerId === id) { + const entity = picked.id; + viewer.selectedEntity = entity; + selectEntityContext(entity); + + // We zoom to the surface base of the stem or the center of the polygon + let targetPos = null; + + if (entity.polyline) { + // If it's a stem, fly to the base + const positions = entity.polyline.positions.getValue( + Cesium.JulianDate.now(), + ); + if (positions && positions.length > 0) { + targetPos = positions[0]; + } + } else if (entity.polygon && entity.polygon.hierarchy) { + // If it's a polygon, just fly to its center + const hierarchy = entity.polygon.hierarchy.getValue( + Cesium.JulianDate.now(), + ); + if (hierarchy && hierarchy.positions.length > 0) { + targetPos = Cesium.BoundingSphere.fromPoints( + hierarchy.positions, + ).center; + } + } + + if (targetPos) { + const carto = Cesium.Cartographic.fromCartesian(targetPos); + + // Disable interactions so Cesium doesn't magically cancel the flight + viewer.scene.screenSpaceCameraController.enableInputs = false; + + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + 5000, + ), + duration: 1.5, + complete: () => { + viewer.scene.screenSpaceCameraController.enableInputs = true; + }, + cancel: () => { + viewer.scene.screenSpaceCameraController.enableInputs = true; + }, + }); + } + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + })(); try { await _loadPromise; } finally { @@ -741,7 +819,10 @@ export function createLocalGeoJsonLayer({ const probe = shouldRecomputeInfraLod({ nowMs: now, lastProbeMs: _lastLodProbeMs, - movedSqM: Cesium.Cartesian3.distanceSquared(cameraPos, _lastLodCameraPos), + movedSqM: Cesium.Cartesian3.distanceSquared( + cameraPos, + _lastLodCameraPos, + ), cameraHeightM: viewer.camera.positionCartographic?.height, }); _lastLodProbeMs = probe.lastProbeMs; @@ -750,7 +831,10 @@ export function createLocalGeoJsonLayer({ if (probe.recompute) _stemGeometryDirty = true; } - const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPos); + const occluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + cameraPos, + ); const visibleOverlayRecords = []; const refreshStemGeometry = _stemGeometryDirty; @@ -762,7 +846,8 @@ export function createLocalGeoJsonLayer({ // Capability can arrive late (WebGL context restore, a tileset that // finally supports sampling). A parked camera has no moveEnd to // re-open a spent budget, so the false→true edge does it. - if (canSampleGround && _lastGroundSampleCapability === false) _groundRetryArms = 0; + if (canSampleGround && _lastGroundSampleCapability === false) + _groundRetryArms = 0; _lastGroundSampleCapability = canSampleGround; let groundRetryPending = false; let groundSampleProgress = false; @@ -814,7 +899,8 @@ export function createLocalGeoJsonLayer({ const record = _stemRecords[i]; const isActive = !_lodComputed || _activeLodIds.has(record.id); const isVisible = isActive && occluder.isPointVisible(record.base); - if (record.entity.show !== isVisible) record.entity.show = isVisible; + if (record.entity.show !== isVisible) + record.entity.show = isVisible; // Records outside the globe-LOD active set carry no visible stem, // so skip every per-frame cost for them — geometry trig, the @@ -823,20 +909,32 @@ export function createLocalGeoJsonLayer({ if (!isActive) continue; const wasGroundSampled = record.groundSampled; - const terrainFloorChanged = refreshLocalTerrainFloor(viewer, record); + const terrainFloorChanged = refreshLocalTerrainFloor( + viewer, + record, + ); if (refreshStemGeometry || terrainFloorChanged) { updateLocalStemGeometry(viewer, record, now); - } else if (canSampleGround && !record.groundSampled - && now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS) { + } else if ( + canSampleGround && + !record.groundSampled && + now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS + ) { // Capability first: without it the distance below is pure waste, // once per ungrounded record per walk, forever. - const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); - if (distance < GROUND_SAMPLE_MAX_DISTANCE_M - && sampleLocalGroundHeight(viewer, record, now)) { + const distance = Cesium.Cartesian3.distance( + viewer.camera.positionWC, + record.base, + ); + if ( + distance < GROUND_SAMPLE_MAX_DISTANCE_M && + sampleLocalGroundHeight(viewer, record, now) + ) { updateLocalStemGeometry(viewer, record, now, distance); } } - if (!wasGroundSampled && record.groundSampled) groundSampleProgress = true; + if (!wasGroundSampled && record.groundSampled) + groundSampleProgress = true; // Still unsampled AND close enough for a retry to succeed: this // layer has no hold and no periodic update, so under the idle // governor the retry's preRender never arrives on a parked camera @@ -845,9 +943,15 @@ export function createLocalGeoJsonLayer({ // Gated on a sampleable scene and in-range records only, so a far // camera (or a keyless scene) stays fully idle; the distance is // only computed for still-unsampled stems. - if (canSampleGround && !record.groundSampled && !groundRetryPending - && Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) - < GROUND_SAMPLE_MAX_DISTANCE_M) { + if ( + canSampleGround && + !record.groundSampled && + !groundRetryPending && + Cesium.Cartesian3.distance( + viewer.camera.positionWC, + record.base, + ) < GROUND_SAMPLE_MAX_DISTANCE_M + ) { groundRetryPending = true; } if (isVisible && record.entry) visibleOverlayRecords.push(record); @@ -859,14 +963,17 @@ export function createLocalGeoJsonLayer({ if (groundRetryPending) scheduleGroundRetryRender(viewer); const canvas = viewer.scene.canvas; - const cohort = selectLocalInfrastructureOverlayCohort(visibleOverlayRecords, { - maxEntries: labelMax, - gridPx: labelGridPx, - width: canvas.clientWidth || canvas.width || 0, - height: canvas.clientHeight || canvas.height || 0, - cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, - project: (record) => projectToWindow(viewer.scene, record.tip), - }); + const cohort = selectLocalInfrastructureOverlayCohort( + visibleOverlayRecords, + { + maxEntries: labelMax, + gridPx: labelGridPx, + width: canvas.clientWidth || canvas.width || 0, + height: canvas.clientHeight || canvas.height || 0, + cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, + project: (record) => projectToWindow(viewer.scene, record.tip), + }, + ); _overlayPublisher.publish(cohort); }); } @@ -913,7 +1020,7 @@ export function createLocalGeoJsonLayer({ _count = 0; _lastUpdate = null; _error = null; - } + }, }; } @@ -923,24 +1030,36 @@ function compareLocalOverlayRecords(a, b) { function insertLocalCellContender(contenders, record) { let index = 0; - while (index < contenders.length && compareLocalOverlayRecords(contenders[index], record) <= 0) { + while ( + index < contenders.length && + compareLocalOverlayRecords(contenders[index], record) <= 0 + ) { index++; } contenders.splice(index, 0, record); - if (contenders.length > LOCAL_OVERLAY_CELL_SURPLUS) contenders.length = LOCAL_OVERLAY_CELL_SURPLUS; + if (contenders.length > LOCAL_OVERLAY_CELL_SURPLUS) + contenders.length = LOCAL_OVERLAY_CELL_SURPLUS; } function refreshLocalTerrainFloor(viewer, record) { const globe = viewer.scene.globe; - if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) return false; - if (Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) - >= GROUND_SAMPLE_MAX_DISTANCE_M) return false; + if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) + return false; + if ( + Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) >= + GROUND_SAMPLE_MAX_DISTANCE_M + ) + return false; // Camera motion can refine terrain after the first successful depth sample. // Reuse the loaded mesh on existing bounded walks; never arm a timer or do // another GPU readback just to maintain this floor. Keep roof elevations. const height = globe.getHeight?.(record.carto); - if (!Number.isFinite(height) || Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M - || height <= record.groundHeight) return false; + if ( + !Number.isFinite(height) || + Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M || + height <= record.groundHeight + ) + return false; setLocalGroundHeight(record, height); return true; } @@ -957,13 +1076,22 @@ function sampleLocalGroundHeight(viewer, record, now) { } catch { return false; // tiles not ready; retry on a later bounded update } - if (!Number.isFinite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) return false; + if ( + !Number.isFinite(sampled) || + Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M + ) + return false; // A coarse scene-depth sample can lie below the terrain already loaded by // the visible globe. Keep that terrain as a floor while allowing roofs and // valid below-sea-level elevations. Photoreal scenes hide the globe, so its // inactive terrain must not constrain their geometry. - const terrainHeight = globe?.show ? globe.getHeight?.(record.carto) : undefined; - if (Number.isFinite(terrainHeight) && Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M) { + const terrainHeight = globe?.show + ? globe.getHeight?.(record.carto) + : undefined; + if ( + Number.isFinite(terrainHeight) && + Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M + ) { sampled = Math.max(sampled, terrainHeight); } record.groundSampled = true; @@ -987,12 +1115,13 @@ function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { const distance = Number.isFinite(knownDistance) ? knownDistance : Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); - if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) sampleLocalGroundHeight(viewer, record, now); + if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) + sampleLocalGroundHeight(viewer, record, now); // Keep the intended screen-size scaling in close-up views too. A 5 km // minimum made a marker hundreds of metres tall beside a nearby building. const effectiveDistance = Math.max(distance, 1); const canvasHeight = viewer.scene.canvas.clientHeight || 1080; - const fov = viewer.camera.frustum.fov || (Math.PI / 3); + const fov = viewer.camera.frustum.fov || Math.PI / 3; const targetPx = 65; const fovFactor = 2 * Math.tan(fov / 2) * (targetPx / canvasHeight); const tipHeight = record.groundHeight + effectiveDistance * fovFactor; @@ -1003,12 +1132,16 @@ function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { Cesium.Ellipsoid.WGS84, record.nextTip, ); - if (Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <= LOCAL_STEM_TIP_EPSILON_SQ) { + if ( + Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <= + LOCAL_STEM_TIP_EPSILON_SQ + ) { return false; } Cesium.Cartesian3.clone(record.nextTip, record.tip); record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex; - const stemPositions = record.stemPositionBuffers[record.stemPositionBufferIndex]; + const stemPositions = + record.stemPositionBuffers[record.stemPositionBufferIndex]; stemPositions[0] = record.base; stemPositions[1] = record.tip; record.entity.position.setValue(record.tip); @@ -1050,9 +1183,10 @@ function labelPriorityFromProperties(props, layerId) { function propertyObject(entity) { const source = entity?.properties; - const raw = typeof source?.getValue === 'function' - ? source.getValue(Cesium.JulianDate.now()) - : source || {}; + const raw = + typeof source?.getValue === 'function' + ? source.getValue(Cesium.JulianDate.now()) + : source || {}; return unwrapProperties(raw); } @@ -1061,9 +1195,10 @@ function unwrapProperties(value) { if (Array.isArray(value)) return value.map(unwrapProperties); const out = {}; for (const [key, entry] of Object.entries(value)) { - out[key] = entry && typeof entry.getValue === 'function' - ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) - : unwrapProperties(entry); + out[key] = + entry && typeof entry.getValue === 'function' + ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) + : unwrapProperties(entry); } return out; } diff --git a/src/data/localGeojsonLod.js b/src/data/localGeojsonLod.js index 5c3124855..cfd082c04 100644 --- a/src/data/localGeojsonLod.js +++ b/src/data/localGeojsonLod.js @@ -58,8 +58,10 @@ export function infraLodBudget(cameraHeightM) { const height = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : INFRA_LOD_GLOBAL_HEIGHT_M; - if (height >= INFRA_LOD_GLOBAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MIN }; - if (height >= INFRA_LOD_REGIONAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MID }; + if (height >= INFRA_LOD_GLOBAL_HEIGHT_M) + return { activeLimit: INFRA_LOD_ACTIVE_MIN }; + if (height >= INFRA_LOD_REGIONAL_HEIGHT_M) + return { activeLimit: INFRA_LOD_ACTIVE_MID }; return { activeLimit: INFRA_LOD_ACTIVE_MAX }; } @@ -104,24 +106,33 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200; * @param {number} [options.maxDistancePenalty] * @returns {number} */ -export function infraRankScore(priority, distanceM, isIncumbent, { - incumbentBonus = INFRA_LOD_INCUMBENT_BONUS, - farM = INFRA_LOD_FAR_M, - maxDistancePenalty = INFRA_LOD_MAX_DISTANCE_PENALTY, -} = {}) { +export function infraRankScore( + priority, + distanceM, + isIncumbent, + { + incumbentBonus = INFRA_LOD_INCUMBENT_BONUS, + farM = INFRA_LOD_FAR_M, + maxDistancePenalty = INFRA_LOD_MAX_DISTANCE_PENALTY, + } = {}, +) { const p = Number.isFinite(priority) ? priority : 0; const far = Number.isFinite(farM) && farM > 0 ? farM : INFRA_LOD_FAR_M; const d = Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : far; - const penaltyCap = Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0 - ? maxDistancePenalty - : INFRA_LOD_MAX_DISTANCE_PENALTY; - const bonus = isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0; + const penaltyCap = + Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0 + ? maxDistancePenalty + : INFRA_LOD_MAX_DISTANCE_PENALTY; + const bonus = + isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0; return p + bonus - penaltyCap * Math.min(1, d / far); } /** Infinity-safe distance for a total sort order. */ function sortableDistance(distanceM) { - return Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : Number.MAX_VALUE; + return Number.isFinite(distanceM) && distanceM >= 0 + ? distanceM + : Number.MAX_VALUE; } /** @@ -142,35 +153,49 @@ function sortableDistance(distanceM) { * @param {Iterable|Set} [options.incumbentIds] Ids that currently hold an active stem. * @returns {{activeIds:string[], budget:{activeLimit:number}}} */ -export function selectInfraLod(candidates, { cameraHeightM, incumbentIds } = {}) { +export function selectInfraLod( + candidates, + { cameraHeightM, incumbentIds } = {}, +) { const budget = infraLodBudget(cameraHeightM); - const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []); + const incumbents = + incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []); const byId = new Map(); for (const candidate of Array.isArray(candidates) ? candidates : []) { - if (!candidate || typeof candidate.id !== 'string' || !candidate.id) continue; + if (!candidate || typeof candidate.id !== 'string' || !candidate.id) + continue; if (candidate.inView !== true) continue; - const distanceM = Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0 - ? candidate.distanceM - : Number.POSITIVE_INFINITY; + const distanceM = + Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0 + ? candidate.distanceM + : Number.POSITIVE_INFINITY; const normalized = { id: candidate.id, distanceM, - score: infraRankScore(candidate.priority, candidate.distanceM, incumbents.has(candidate.id)), + score: infraRankScore( + candidate.priority, + candidate.distanceM, + incumbents.has(candidate.id), + ), }; const current = byId.get(normalized.id); - if (!current - || normalized.score > current.score - || (normalized.score === current.score && normalized.distanceM < current.distanceM)) { + if ( + !current || + normalized.score > current.score || + (normalized.score === current.score && + normalized.distanceM < current.distanceM) + ) { byId.set(normalized.id, normalized); } } - const ranked = [...byId.values()].sort((a, b) => ( - b.score - a.score - || sortableDistance(a.distanceM) - sortableDistance(b.distanceM) - || a.id.localeCompare(b.id) - )); + const ranked = [...byId.values()].sort( + (a, b) => + b.score - a.score || + sortableDistance(a.distanceM) - sortableDistance(b.distanceM) || + a.id.localeCompare(b.id), + ); return { activeIds: ranked.slice(0, budget.activeLimit).map((entry) => entry.id), @@ -220,7 +245,9 @@ export function applyInfraEvictionGrace({ graceMs = INFRA_LOD_GRACE_MS, } = {}) { const selectedSet = new Set( - (Array.isArray(selectedIds) ? selectedIds : []).filter((id) => typeof id === 'string' && id), + (Array.isArray(selectedIds) ? selectedIds : []).filter( + (id) => typeof id === 'string' && id, + ), ); const keepIds = [...selectedSet]; const evictIds = []; @@ -241,8 +268,13 @@ export function applyInfraEvictionGrace({ // Under cap pressure, grace-period stems go first: oldest-in-grace first // (longest chance to return), then more misses, then id for determinism. - graced.sort((a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id)); - const cap = Number.isFinite(activeLimit) ? Math.max(0, Math.floor(activeLimit)) : INFRA_LOD_ACTIVE_MAX; + graced.sort( + (a, b) => + a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id), + ); + const cap = Number.isFinite(activeLimit) + ? Math.max(0, Math.floor(activeLimit)) + : INFRA_LOD_ACTIVE_MAX; const capacity = Math.max(0, cap - keepIds.length); const overflow = Math.max(0, graced.length - capacity); for (let i = 0; i < graced.length; i++) { @@ -251,7 +283,10 @@ export function applyInfraEvictionGrace({ continue; } keepIds.push(graced[i].id); - nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since }); + nextGrace.set(graced[i].id, { + misses: graced[i].misses, + since: graced[i].since, + }); } return { keepIds, evictIds, graceState: nextGrace }; @@ -307,13 +342,21 @@ export const INFRA_LOD_MOTION_EPSILON_MIN_M = 250; * @param {number} [options.minM] * @returns {number} */ -export function infraLodMotionEpsilonM(cameraHeightM, { - ratio = INFRA_LOD_MOTION_EPSILON_RATIO, - minM = INFRA_LOD_MOTION_EPSILON_MIN_M, -} = {}) { - const floor = Number.isFinite(minM) && minM >= 0 ? minM : INFRA_LOD_MOTION_EPSILON_MIN_M; - const scale = Number.isFinite(ratio) && ratio >= 0 ? ratio : INFRA_LOD_MOTION_EPSILON_RATIO; - const height = Number.isFinite(cameraHeightM) && cameraHeightM > 0 ? cameraHeightM : 0; +export function infraLodMotionEpsilonM( + cameraHeightM, + { + ratio = INFRA_LOD_MOTION_EPSILON_RATIO, + minM = INFRA_LOD_MOTION_EPSILON_MIN_M, + } = {}, +) { + const floor = + Number.isFinite(minM) && minM >= 0 ? minM : INFRA_LOD_MOTION_EPSILON_MIN_M; + const scale = + Number.isFinite(ratio) && ratio >= 0 + ? ratio + : INFRA_LOD_MOTION_EPSILON_RATIO; + const height = + Number.isFinite(cameraHeightM) && cameraHeightM > 0 ? cameraHeightM : 0; return Math.max(floor, height * scale); } @@ -355,15 +398,19 @@ export function shouldRecomputeInfraLod({ motionEpsilonM, } = {}) { const now = Number.isFinite(nowMs) ? nowMs : 0; - const last = Number.isFinite(lastProbeMs) ? lastProbeMs : Number.NEGATIVE_INFINITY; - const interval = Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0 - ? probeIntervalMs - : INFRA_LOD_MOTION_PROBE_INTERVAL_MS; + const last = Number.isFinite(lastProbeMs) + ? lastProbeMs + : Number.NEGATIVE_INFINITY; + const interval = + Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0 + ? probeIntervalMs + : INFRA_LOD_MOTION_PROBE_INTERVAL_MS; if (now - last < interval) return { recompute: false, lastProbeMs: last }; const movedSq = Number.isFinite(movedSqM) && movedSqM > 0 ? movedSqM : 0; - const epsilon = Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0 - ? motionEpsilonM - : infraLodMotionEpsilonM(cameraHeightM); + const epsilon = + Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0 + ? motionEpsilonM + : infraLodMotionEpsilonM(cameraHeightM); return { recompute: movedSq > epsilon * epsilon, lastProbeMs: now }; } diff --git a/src/tooling/format.test.mjs b/src/tooling/format.test.mjs new file mode 100644 index 000000000..f0ab4405c --- /dev/null +++ b/src/tooling/format.test.mjs @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + rm, + symlink, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { formatAdoptedFiles } from '../../scripts/format.mjs'; + +async function fixture(t, scope = ['adopted.js']) { + const root = await mkdtemp(path.join(tmpdir(), 'gev-format-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'scripts')); + await writeFile( + path.join(root, 'scripts/format-scope.json'), + JSON.stringify(scope), + ); + await writeFile( + path.join(root, '.prettierrc.json'), + JSON.stringify({ + singleQuote: true, + semi: true, + tabWidth: 2, + endOfLine: 'lf', + }), + ); + await writeFile(path.join(root, '.prettierignore'), 'ignored.js\n'); + await writeFile(path.join(root, 'adopted.js'), 'const value="yes"\r\n'); + return root; +} + +test('format check is read-only; write only changes adopted files and is repeatable', async (t) => { + const root = await fixture(t); + const outside = 'const untouched="yes"'; + await writeFile(path.join(root, 'legacy.js'), outside); + assert.deepEqual((await formatAdoptedFiles(root, '--check')).changed, [ + 'adopted.js', + ]); + assert.equal( + await readFile(path.join(root, 'adopted.js'), 'utf8'), + 'const value="yes"\r\n', + ); + await formatAdoptedFiles(root, '--write'); + assert.equal( + await readFile(path.join(root, 'adopted.js'), 'utf8'), + "const value = 'yes';\n", + ); + assert.equal(await readFile(path.join(root, 'legacy.js'), 'utf8'), outside); + assert.deepEqual((await formatAdoptedFiles(root, '--check')).changed, []); + assert.deepEqual((await formatAdoptedFiles(root, '--write')).changed, []); +}); + +test('invalid, ignored and missing scope entries fail before any file is written', async (t) => { + for (const scope of [ + [], + ['adopted.js', 'adopted.js'], + ['adopted.js', '../escape.js'], + ['adopted.js', 'ignored.js'], + ['adopted.js', 'missing.js'], + ]) { + const root = await fixture(t, scope); + await writeFile(path.join(root, 'ignored.js'), 'const ignored=1'); + await assert.rejects(formatAdoptedFiles(root, '--write')); + assert.equal( + await readFile(path.join(root, 'adopted.js'), 'utf8'), + 'const value="yes"\r\n', + ); + } +}); + +test( + 'formatting rejects a symlink outside the repository', + { skip: process.platform === 'win32' }, + async (t) => { + const root = await fixture(t, ['adopted.js', 'outside.js']); + const other = await fixture(t); + await symlink( + path.join(other, 'adopted.js'), + path.join(root, 'outside.js'), + ); + await assert.rejects( + formatAdoptedFiles(root, '--write'), + /repository files/, + ); + assert.equal( + await readFile(path.join(root, 'adopted.js'), 'utf8'), + 'const value="yes"\r\n', + ); + }, +); diff --git a/src/tooling/packageBoundaries.test.mjs b/src/tooling/packageBoundaries.test.mjs new file mode 100644 index 000000000..18c4146d4 --- /dev/null +++ b/src/tooling/packageBoundaries.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { checkPackageBoundaries } from '../../scripts/check-package-boundaries.mjs'; + +async function fixture( + t, + entry = 'export const value = 1;', + extraExports = {}, +) { + const root = await mkdtemp(path.join(tmpdir(), 'gev-boundaries-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'scripts')); + await writeFile( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'boundary-fixture', + type: 'module', + exports: { './feature': './entry.js', ...extraExports }, + }), + ); + await writeFile( + path.join(root, 'scripts/package-boundaries.json'), + JSON.stringify({ + feature: { exports: ['./feature'], modules: ['entry.js'], external: [] }, + }), + ); + await writeFile(path.join(root, 'entry.js'), entry); + return root; +} + +test('a declared browser export builds without running app setup', async (t) => { + const root = await fixture(t); + await writeFile( + path.join(root, 'vite.config.js'), + "throw new Error('must not load app config');", + ); + assert.deepEqual(await checkPackageBoundaries(root), [ + { name: 'feature', exports: 1, modules: 1 }, + ]); +}); + +test('an unclassified package export fails the boundary gate', async (t) => { + const root = await fixture(t, undefined, { './another': './another.js' }); + await assert.rejects( + checkPackageBoundaries(root), + /exactly one boundary group/, + ); +}); + +test('even an unused import of application code violates ownership', async (t) => { + const root = await fixture( + t, + "import { app } from './startup.js'; export const value = 1;", + ); + await writeFile(path.join(root, 'startup.js'), 'export const app = 2;'); + await assert.rejects(checkPackageBoundaries(root), /unowned module.*startup/); +}); + +test('Node builtins cannot enter a browser export', async (t) => { + const root = await fixture( + t, + "import fs from 'node:fs'; export const value = fs;", + ); + await assert.rejects(checkPackageBoundaries(root), /unowned module|browser/); +}); + +test('dynamic imports obey the same component ownership rule', async (t) => { + const root = await fixture( + t, + "export const load = () => import('./startup.js');", + ); + await writeFile(path.join(root, 'startup.js'), 'export const app = 2;'); + await assert.rejects(checkPackageBoundaries(root), /unowned module.*startup/); +});