mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The scene chrome, cockpit, Display controls, command dock, layer panels, Context rail, welcome card and Provider Settings move verbatim into src/ui/views and mount into their hosts before the page is translated and wired. Each view renders the same elements, attributes and text as its template; Vue keeps whitespace between elements as one space, and no view markup sits under a white-space: pre rule. Template asset URLs stay literal, view files are left out of Prettier so the markup is not reflowed, and the Node component loader moves to build/ and is exported as build/register-vue for host test runners. Hosts that do not initialize Core's locale pass localePicker: false instead of editing the markup.
346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createApplication } from './application.js';
|
|
import { installTrackpadPinchZoom } from './viewer.js';
|
|
|
|
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
|
function fixture(overrides = {}) {
|
|
const events = [];
|
|
const constructors = Object.fromEntries(
|
|
phases.map((phase) => [
|
|
`create${phase}`,
|
|
({ defer }) => {
|
|
events.push(`start:${phase}`);
|
|
defer(() => events.push(`stop:${phase}`));
|
|
return { name: phase };
|
|
},
|
|
]),
|
|
);
|
|
return { events, app: createApplication({ ...constructors, ...overrides }) };
|
|
}
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
test('importing and constructing the package is inactive without browser globals', () => {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
// Views are single-file components; Node needs the loader hosts use.
|
|
'--import=gods-eye-view/build/register-vue',
|
|
'--input-type=module',
|
|
'-e',
|
|
`
|
|
globalThis.fetch = () => { throw new Error('unexpected request'); };
|
|
globalThis.setTimeout = () => { throw new Error('unexpected timer'); };
|
|
const { createApplication } = await import('gods-eye-view/application');
|
|
const fail = () => { throw new Error('unexpected construction'); };
|
|
const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail });
|
|
if (app.getState().status !== 'created') process.exit(1);
|
|
`,
|
|
],
|
|
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
|
|
);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
});
|
|
|
|
test('constructors are validated before allocating anything', () => {
|
|
assert.throws(() => createApplication({}), /Missing scene constructor/);
|
|
});
|
|
|
|
test('startup shares one promise and teardown keeps data alive for controls', async () => {
|
|
const { app, events } = fixture({
|
|
createTools({ scene, controls, data, defer }) {
|
|
assert.equal(scene.name, 'Scene');
|
|
assert.equal(controls.name, 'Controls');
|
|
assert.equal(data.name, 'Data');
|
|
defer(() => events.push('stop:Tools'));
|
|
return { name: 'Tools' };
|
|
},
|
|
});
|
|
const states = [];
|
|
app.subscribe((state) => states.push(`${state.status}:${state.phase}`));
|
|
const started = app.start();
|
|
assert.equal(started, app.start());
|
|
const components = await started;
|
|
assert.ok(Object.isFrozen(components));
|
|
assert.equal(components.tools.name, 'Tools');
|
|
assert.equal(app.getState().status, 'ready');
|
|
const stopped = app.destroy();
|
|
assert.equal(stopped, app.destroy());
|
|
await stopped;
|
|
assert.deepEqual(events.slice(-4), [
|
|
'stop:Tools',
|
|
'stop:Controls',
|
|
'stop:Data',
|
|
'stop:Scene',
|
|
]);
|
|
assert.deepEqual(app.getComponents(), {});
|
|
assert.equal(states.at(-1), 'destroyed:null');
|
|
await assert.rejects(app.start(), /destroyed/);
|
|
});
|
|
|
|
for (const phase of phases) {
|
|
test(`failure during ${phase} releases partially acquired resources`, async () => {
|
|
const { app, events } = fixture({
|
|
[`create${phase}`]({ defer }) {
|
|
defer(() => events.push('partial:released'));
|
|
throw new Error('broken constructor');
|
|
},
|
|
});
|
|
await assert.rejects(app.start(), /broken constructor/);
|
|
assert.equal(
|
|
events.filter((value) => value === 'partial:released').length,
|
|
1,
|
|
);
|
|
assert.equal(app.getState().status, 'failed');
|
|
assert.deepEqual(app.getComponents(), {});
|
|
await app.destroy();
|
|
assert.equal(
|
|
events.filter((value) => value === 'partial:released').length,
|
|
1,
|
|
);
|
|
});
|
|
}
|
|
|
|
test('destroy before startup prevents every constructor', async () => {
|
|
const { app, events } = fixture();
|
|
const started = app.start();
|
|
const failed = assert.rejects(started, { name: 'AbortError' });
|
|
await app.destroy();
|
|
await failed;
|
|
assert.deepEqual(events, []);
|
|
});
|
|
|
|
test('destroy aborts an in-flight constructor and cleans late acquisitions before resolving', async () => {
|
|
const gate = deferred();
|
|
const entered = deferred();
|
|
let observedSignal;
|
|
const { app, events } = fixture({
|
|
async createScene({ signal, defer }) {
|
|
observedSignal = signal;
|
|
entered.resolve();
|
|
await gate.promise;
|
|
defer(() => events.push('late:released'));
|
|
return {};
|
|
},
|
|
});
|
|
const started = app.start();
|
|
const failed = assert.rejects(started, { name: 'AbortError' });
|
|
await entered.promise;
|
|
let settled = false;
|
|
const stopped = app.destroy().then(() => {
|
|
settled = true;
|
|
});
|
|
assert.equal(observedSignal.aborted, true);
|
|
await Promise.resolve();
|
|
assert.equal(settled, false);
|
|
gate.resolve();
|
|
await stopped;
|
|
await failed;
|
|
assert.deepEqual(events, ['late:released']);
|
|
});
|
|
|
|
test('cleanup errors are reported after all remaining resources are attempted', async () => {
|
|
const { app, events } = fixture({
|
|
createTools({ defer }) {
|
|
defer(() => events.push('tools:earlier'));
|
|
defer(() => {
|
|
throw new Error('dispose failed');
|
|
});
|
|
return {};
|
|
},
|
|
});
|
|
await app.start();
|
|
await assert.rejects(app.destroy(), (error) => {
|
|
assert.ok(error instanceof AggregateError);
|
|
assert.equal(error.errors[0].message, 'dispose failed');
|
|
return true;
|
|
});
|
|
assert.deepEqual(events.slice(-4), [
|
|
'tools:earlier',
|
|
'stop:Controls',
|
|
'stop:Data',
|
|
'stop:Scene',
|
|
]);
|
|
assert.equal(app.getState().status, 'failed');
|
|
});
|
|
|
|
test('subscribers can destroy during startup without allowing the next constructor', async () => {
|
|
const { app, events } = fixture();
|
|
app.subscribe((state) => {
|
|
if (state.phase === 'controls') void app.destroy();
|
|
});
|
|
await assert.rejects(app.start(), { name: 'AbortError' });
|
|
await app.destroy();
|
|
assert.deepEqual(events, ['start:Scene', 'stop:Scene']);
|
|
});
|
|
|
|
test('destroy during ready notification rejects startup and tears everything down', async () => {
|
|
const { app } = fixture();
|
|
app.subscribe((state) => {
|
|
if (state.status === 'ready') void app.destroy();
|
|
});
|
|
await assert.rejects(app.start(), { name: 'AbortError' });
|
|
await app.destroy();
|
|
assert.equal(app.getState().status, 'destroyed');
|
|
});
|
|
|
|
test('cleanup registration closes when a constructor settles', async () => {
|
|
let register;
|
|
const { app } = fixture({
|
|
createScene({ defer }) {
|
|
register = defer;
|
|
return {};
|
|
},
|
|
});
|
|
await app.start();
|
|
assert.throws(() => register(() => {}), /during component construction/);
|
|
await app.destroy();
|
|
});
|
|
|
|
test('different application instances do not share lifecycle state', async () => {
|
|
const first = fixture();
|
|
const second = fixture();
|
|
await Promise.all([first.app.start(), second.app.start()]);
|
|
await first.app.destroy();
|
|
assert.equal(second.app.getState().status, 'ready');
|
|
assert.equal(second.events.length, 4);
|
|
await second.app.destroy();
|
|
});
|
|
|
|
test('the separate viewer export imports without constructing a browser viewer', async () => {
|
|
const { createApplicationViewer } =
|
|
await import('gods-eye-view/application/viewer');
|
|
assert.equal(typeof createApplicationViewer, 'function');
|
|
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
|
});
|
|
|
|
test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => {
|
|
const { applicationViewerOptions } = await import('./viewer.js');
|
|
const { StarrySky } = await import('../sky/starrySky.js');
|
|
const creditContainer = {};
|
|
const options = applicationViewerOptions(creditContainer);
|
|
// CesiumWidget skips scene.sun and scene.moon when skyBox is false.
|
|
assert.ok(options.skyBox instanceof StarrySky);
|
|
assert.equal(options.creditContainer, creditContainer);
|
|
options.skyBox.destroy();
|
|
});
|
|
|
|
function pinchFixture({ zoomEventTypes } = {}) {
|
|
const handlers = new Set();
|
|
const relayed = [];
|
|
const container = {
|
|
addEventListener(type, handler, options) {
|
|
assert.equal(type, 'wheel');
|
|
assert.deepEqual(options, { capture: true, passive: false });
|
|
handlers.add(handler);
|
|
},
|
|
removeEventListener(type, handler, capture) {
|
|
assert.equal(type, 'wheel');
|
|
assert.equal(capture, true);
|
|
handlers.delete(handler);
|
|
},
|
|
emit(event) {
|
|
for (const handler of handlers) handler(event);
|
|
},
|
|
};
|
|
const canvas = {
|
|
dispatchEvent(event) {
|
|
// A real canvas dispatch crosses the container's capture listener again.
|
|
container.emit(event);
|
|
relayed.push(event);
|
|
return true;
|
|
},
|
|
};
|
|
const controller = { zoomEventTypes };
|
|
const viewer = {
|
|
container,
|
|
canvas,
|
|
scene: { screenSpaceCameraController: controller },
|
|
};
|
|
const createEvent = (init = {}) => ({
|
|
deltaX: 0,
|
|
deltaY: 0,
|
|
deltaZ: 0,
|
|
deltaMode: 0,
|
|
screenX: 0,
|
|
screenY: 0,
|
|
clientX: 0,
|
|
clientY: 0,
|
|
ctrlKey: false,
|
|
defaultPrevented: false,
|
|
propagationStopped: false,
|
|
preventDefault() {
|
|
this.defaultPrevented = true;
|
|
},
|
|
stopPropagation() {
|
|
this.propagationStopped = true;
|
|
},
|
|
...init,
|
|
});
|
|
return { viewer, controller, container, handlers, relayed, createEvent };
|
|
}
|
|
|
|
test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => {
|
|
const original = [1, 2, 3];
|
|
const f = pinchFixture({ zoomEventTypes: original });
|
|
const dispose = installTrackpadPinchZoom(f.viewer, {
|
|
createWheelEvent: (_type, init) => f.createEvent(init),
|
|
});
|
|
assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original);
|
|
assert.equal(f.controller.zoomEventTypes.length, 4);
|
|
|
|
const plain = f.createEvent({ deltaY: 2 });
|
|
f.container.emit(plain);
|
|
assert.equal(plain.defaultPrevented, false);
|
|
const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 });
|
|
f.container.emit(lineMode);
|
|
assert.equal(lineMode.defaultPrevented, false);
|
|
assert.equal(f.relayed.length, 0);
|
|
|
|
dispose();
|
|
dispose();
|
|
assert.equal(f.controller.zoomEventTypes, original);
|
|
assert.equal(f.handlers.size, 0);
|
|
});
|
|
|
|
test('trackpad pinch relays pixel deltas once with bounded amplification', () => {
|
|
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
|
installTrackpadPinchZoom(f.viewer, {
|
|
createWheelEvent: (_type, init) => f.createEvent(init),
|
|
});
|
|
|
|
const small = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
|
f.container.emit(small);
|
|
assert.equal(small.defaultPrevented, true);
|
|
assert.equal(small.propagationStopped, true);
|
|
assert.equal(f.relayed.length, 1);
|
|
assert.equal(f.relayed[0].deltaY, 16);
|
|
assert.equal(f.relayed[0].ctrlKey, true);
|
|
|
|
const large = f.createEvent({ ctrlKey: true, deltaY: -100 });
|
|
f.container.emit(large);
|
|
assert.equal(f.relayed.length, 2);
|
|
assert.equal(f.relayed[1].deltaY, -120);
|
|
});
|
|
|
|
test('trackpad pinch falls back to the original event when relay construction fails', () => {
|
|
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
|
installTrackpadPinchZoom(f.viewer, {
|
|
createWheelEvent() {
|
|
throw new Error('unsupported');
|
|
},
|
|
});
|
|
const event = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
|
f.container.emit(event);
|
|
assert.equal(event.defaultPrevented, false);
|
|
assert.equal(event.propagationStopped, false);
|
|
assert.equal(f.relayed.length, 0);
|
|
});
|