Warning, /firebird/packages/firebird-ng/display/src/lib/services/event-display.service.spec.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * EventDisplayService loading and lifecycle contracts, with the rendering
0003 * stack stubbed (no WebGPU in tests):
0004 *
0005 * - returning to a display page keeps a deep-linked, command-loaded or picked
0006 * source on screen; the configured default loads only when the config
0007 * changed or the data selector applied a choice;
0008 * - the latest REQUESTED events load wins, whatever finishes last, and the
0009 * footer spinners follow the latest load;
0010 * - ThreeExtension.onEventLoaded fires once per load and once per switch;
0011 * - readiness (`window.firebird`) settles on every path, counts a loader's
0012 * work from its start, and lists failures in `window.firebird.errors`;
0013 * - failures carry their reason to the one user-visible error channel;
0014 * - the first REGISTERED painter of a piece type is its default;
0015 * - the collision intro plays on the event layer and leaves no tween behind.
0016 */
0017
0018 import { TestBed } from '@angular/core/testing';
0019 import { provideHttpClient } from '@angular/common/http';
0020 import { Group, Mesh, Object3D, PerspectiveCamera, Scene } from 'three';
0021 import {
0022 DataExchange,
0023 type DataSource,
0024 type EventDataLoader,
0025 type GeometryDataLoader,
0026 type LoaderContext,
0027 type PiecePainterConstructor,
0028 } from '@dexvis/firebird-core';
0029 import { COMMAND_HANDLERS, CommandBusService, ConfigService, ServerConfigService } from '@dexvis/app-features';
0030 import { EventDisplayService } from './event-display.service';
0031 import { ThreeService } from './three.service';
0032 import { GeometryService } from './geometry.service';
0033 import { MessageService } from './message.service';
0034 import { DataModelService } from './data-model.service';
0035 import {
0036 COLLISION_INTRO,
0037 CollisionIntro,
0038 DataSelectionService,
0039 EVENT_DATA_LAYER,
0040 EVENT_LOADERS,
0041 GEOMETRY_LOADERS,
0042 PAINTERS,
0043 ROOT_COLLECTIONS_CONFIG,
0044 ROOT_EVENT_RANGE_CONFIG,
0045 SELECTION_CONFIG_KEYS,
0046 } from '@dexvis/firebird-ng/api';
0047 import {
0048 AnimateCollisionCommandHandler,
0049 OpenDexCommandHandler,
0050 OpenGeometryCommandHandler,
0051 } from '@dexvis/firebird-ng';
0052
0053 /** A promise with its resolve handle. */
0054 interface Gate<T> {
0055 promise: Promise<T>;
0056 resolve: (value: T) => void;
0057 }
0058
0059 function gateOf<T>(): Gate<T> {
0060 let resolve!: (value: T) => void;
0061 const promise = new Promise<T>(res => { resolve = res; });
0062 return { promise, resolve };
0063 }
0064
0065 const deferred = (): Gate<void> => gateOf<void>();
0066
0067 /** Lets pending promise chains (fetch stubs, dynamic imports) run. */
0068 async function settle(): Promise<void> {
0069 for (let i = 0; i < 5; i++) await new Promise(resolve => setTimeout(resolve, 0));
0070 }
0071
0072 function dexDocument(eventIds: string[], version = '1.0'): unknown {
0073 return {
0074 type: 'firebird-dex-json',
0075 version,
0076 events: eventIds.map(id => ({ id, pieces: [] })),
0077 };
0078 }
0079
0080 /** HTTP responses by URL; anything else is a 404. */
0081 const responses = new Map<string, () => Response>();
0082 const fetched: string[] = [];
0083
0084 function serveDex(url: string, eventIds: string[], version = '1.0'): void {
0085 responses.set(url, () => new Response(JSON.stringify(dexDocument(eventIds, version)), { status: 200 }));
0086 }
0087
0088 /** GeometryService stand-in: resolves loads at once, or when the test says so. */
0089 function geometryStub() {
0090 const stub = {
0091 loads: [] as Array<string | File>,
0092 /** Gated loads: resolve with a geometry, or null for a load a newer one replaced. */
0093 gates: [] as Array<Gate<Group | null>>,
0094 gated: false,
0095 postProcessingGate: null as Gate<void> | null,
0096 geometry: { set: vi.fn() },
0097 loadGeometry: (source: string | File): Promise<Group> => {
0098 stub.loads.push(source);
0099 if (!stub.gated) return Promise.resolve(new Group());
0100 const gate = gateOf<Group | null>();
0101 stub.gates.push(gate);
0102 return gate.promise.then(result => result ?? Promise.reject(new DOMException('replaced', 'AbortError')));
0103 },
0104 postProcessing: () => stub.postProcessingGate?.promise ?? Promise.resolve(),
0105 geometryFastAndUgly: { value: false },
0106 };
0107 return stub;
0108 }
0109
0110 function threeStub() {
0111 const sceneEvent = new Group();
0112 return {
0113 init: vi.fn(async () => true),
0114 detach: vi.fn(),
0115 setSize: vi.fn(),
0116 sceneEvent,
0117 // Like ThreeService.addEventObject: under sceneEvent, on the event layer
0118 addEventObject: vi.fn((object: Object3D) => {
0119 sceneEvent.add(object);
0120 object.traverse(node => node.layers.set(EVENT_DATA_LAYER));
0121 }),
0122 sceneGeometry: new Group(),
0123 scene: new Scene(),
0124 camera: new PerspectiveCamera(),
0125 renderer: {},
0126 clipPlanes: [],
0127 views: [],
0128 invalidate: vi.fn(),
0129 notifyEventLoaded: vi.fn(),
0130 geometryChanged: vi.fn(),
0131 clearHoverHighlight: vi.fn(),
0132 addFrameCallback: vi.fn(),
0133 };
0134 }
0135
0136 const sourceName = (source: DataSource) => (typeof source === 'string' ? source : source.name);
0137
0138 /** A DEX loader like the built-in one: URLs fetch, files are read in place. */
0139 class FakeDexLoader implements EventDataLoader {
0140 readonly meta = { id: 'fake-dex', label: 'fake DEX', fileExtensions: ['.firebird.json'] };
0141 canLoad(source: DataSource): boolean {
0142 return sourceName(source).endsWith('.firebird.json');
0143 }
0144 async loadEvents(source: DataSource, context: LoaderContext) {
0145 const data = TestBed.inject(DataModelService);
0146 if (typeof source !== 'string') return data.readDexFile(source);
0147 return data.fetchDex(context.resolveUrl(source), { name: source, signal: context.signal });
0148 }
0149 }
0150
0151 /**
0152 * A ROOT loader shaped like the in-browser converter: it reads its options
0153 * from config and converts. A test can hold a conversion open with `gates`.
0154 */
0155 class FakeRootLoader implements EventDataLoader {
0156 readonly meta = { id: 'fake-root', label: 'fake ROOT', fileExtensions: ['.root'] };
0157 readonly calls: string[] = [];
0158 readonly gates = new Map<string, Gate<void>>();
0159 canLoad(source: DataSource): boolean {
0160 return sourceName(source).endsWith('.root');
0161 }
0162 async loadEvents(source: DataSource) {
0163 // The built-in loader loads the display entry through a dynamic import first
0164 await Promise.resolve();
0165 const name = sourceName(source);
0166 this.calls.push(name);
0167 await this.gates.get(name)?.promise;
0168 return DataExchange.fromDexObj(dexDocument([`${name}#0`]));
0169 }
0170 }
0171
0172 /** A ROOT geometry loader like the built-in one, over the GeometryService stand-in. */
0173 class FakeGeometryLoader implements GeometryDataLoader {
0174 readonly meta = { id: 'fake-geometry', label: 'fake geometry', fileExtensions: ['.geo.root'] };
0175 readonly millimetersPerUnit = 10;
0176 canLoad(source: DataSource): boolean {
0177 return sourceName(source).endsWith('.geo.root');
0178 }
0179 load(source: DataSource, context: LoaderContext) {
0180 return TestBed.inject(GeometryService).loadGeometry(source, { signal: context.signal });
0181 }
0182 }
0183
0184 describe('EventDisplayService loading', () => {
0185 let display: EventDisplayService;
0186 let three: ReturnType<typeof threeStub>;
0187 let geometry: ReturnType<typeof geometryStub>;
0188 let messages: { addMessage: ReturnType<typeof vi.fn> };
0189 let rootLoader: FakeRootLoader;
0190 let config: ConfigService;
0191 let commandBus: CommandBusService;
0192 let selection: DataSelectionService;
0193 let data: DataModelService;
0194 let extraProviders: unknown[];
0195
0196 const DEFAULT_GEOMETRY = 'https://h/default.geo.root';
0197
0198 function create(): void {
0199 TestBed.configureTestingModule({
0200 providers: [
0201 provideHttpClient(),
0202 { provide: ThreeService, useValue: three },
0203 { provide: GeometryService, useValue: geometry },
0204 { provide: MessageService, useValue: messages },
0205 { provide: EVENT_LOADERS, useClass: FakeDexLoader, multi: true },
0206 { provide: EVENT_LOADERS, useValue: rootLoader, multi: true },
0207 { provide: GEOMETRY_LOADERS, useClass: FakeGeometryLoader, multi: true },
0208 { provide: COMMAND_HANDLERS, useClass: OpenGeometryCommandHandler, multi: true },
0209 { provide: COMMAND_HANDLERS, useClass: OpenDexCommandHandler, multi: true },
0210 ...extraProviders as never[],
0211 ],
0212 });
0213 TestBed.inject(ServerConfigService).setUnitTestConfig({});
0214 config = TestBed.inject(ConfigService);
0215 config.getConfigOrCreate<string>(SELECTION_CONFIG_KEYS.geometry, '').value = DEFAULT_GEOMETRY;
0216 commandBus = TestBed.inject(CommandBusService);
0217 selection = TestBed.inject(DataSelectionService);
0218 data = TestBed.inject(DataModelService);
0219 display = TestBed.inject(EventDisplayService);
0220 }
0221
0222 /** The host element the display attaches to, and the disposer of the attachment. */
0223 let host: HTMLElement;
0224 let detachDisplay: (() => void) | null;
0225
0226 /**
0227 * Puts the display on a page, as `<firebird-display>` does: a page visit.
0228 * A second call is a return to the page: the previous visit detaches first.
0229 */
0230 async function visitDisplay(): Promise<void> {
0231 detachDisplay?.();
0232 detachDisplay = display.attach(host);
0233 // attach() loads once the renderer initialized
0234 await settle();
0235 }
0236
0237 /** What the display shows: the id of the current entry. */
0238 const shownEntry = () => data.currentEntry()?.id;
0239 const firebird = () => {
0240 TestBed.tick();
0241 return window.firebird!;
0242 };
0243
0244 // The command handlers reach the display through a dynamic import of the
0245 // display entry; loading it once here lets their import resolve within settle()
0246 beforeAll(async () => {
0247 await import('@dexvis/firebird-ng/display');
0248 });
0249
0250 beforeEach(() => {
0251 localStorage.clear();
0252 responses.clear();
0253 fetched.length = 0;
0254 vi.stubGlobal('fetch', async (input: string | URL | Request) => {
0255 const url = String(input);
0256 fetched.push(url);
0257 return responses.get(url)?.() ?? new Response('not here', { status: 404, statusText: 'Not Found' });
0258 });
0259 vi.spyOn(console, 'log').mockImplementation(() => undefined);
0260 vi.spyOn(console, 'error').mockImplementation(() => undefined);
0261 three = threeStub();
0262 geometry = geometryStub();
0263 messages = { addMessage: vi.fn() };
0264 rootLoader = new FakeRootLoader();
0265 extraProviders = [];
0266 host = document.createElement('div');
0267 detachDisplay = null;
0268 TestBed.resetTestingModule();
0269 });
0270
0271 afterEach(() => {
0272 vi.unstubAllGlobals();
0273 vi.restoreAllMocks();
0274 });
0275
0276 describe('returning to a display page', () => {
0277 for (const source of ['url', 'server'] as const) {
0278 it(`keeps a geometry that a startup command (${source}) loaded`, async () => {
0279 create();
0280 commandBus.queueStartupCommands([{ type: 'open-geometry', url: 'https://h/deep.geo.root', source }]);
0281 await visitDisplay();
0282 await settle();
0283 expect(geometry.loads).toEqual(['https://h/deep.geo.root']);
0284
0285 // Configure, then back to the display: the startup queue is empty now
0286 await visitDisplay();
0287 await settle();
0288 expect(geometry.loads).toEqual(['https://h/deep.geo.root']);
0289 });
0290 }
0291
0292 it('keeps a picked geometry file, and loads a geometry the config page applied', async () => {
0293 create();
0294 const file = new File(['x'], 'mine.geo.root');
0295 selection.apply({ geometry: file });
0296 await visitDisplay();
0297 await settle();
0298 expect(geometry.loads).toEqual([file]);
0299
0300 await visitDisplay();
0301 await settle();
0302 expect(geometry.loads).toEqual([file]);
0303
0304 // The config page applies the configured default again: the user asked
0305 // for it, so the next mount loads it although the value did not change
0306 selection.apply({ geometry: DEFAULT_GEOMETRY });
0307 await visitDisplay();
0308 await settle();
0309 expect(geometry.loads).toEqual([file, DEFAULT_GEOMETRY]);
0310
0311 // ...and only once
0312 await visitDisplay();
0313 await settle();
0314 expect(geometry.loads.length).toBe(2);
0315 });
0316
0317 it('loads the configured geometry on a remount once the config changed', async () => {
0318 create();
0319 commandBus.queueStartupCommands([{ type: 'open-geometry', url: 'https://h/deep.geo.root', source: 'url' }]);
0320 await visitDisplay();
0321 await settle();
0322 config.getConfig<string>(SELECTION_CONFIG_KEYS.geometry)!.value = 'https://h/other.geo.root';
0323 await visitDisplay();
0324 await settle();
0325 expect(geometry.loads).toEqual(['https://h/deep.geo.root', 'https://h/other.geo.root']);
0326 });
0327
0328 it('keeps deep-linked events over a persisted events source', async () => {
0329 create();
0330 serveDex('https://h/saved.firebird.json', ['saved']);
0331 serveDex('https://h/deep.firebird.json', ['deep']);
0332 config.getConfig<string>(SELECTION_CONFIG_KEYS.dexEvents)!.value = 'https://h/saved.firebird.json';
0333 commandBus.queueStartupCommands([{ type: 'open-dex', url: 'https://h/deep.firebird.json', source: 'url' }]);
0334 await visitDisplay();
0335 await settle();
0336 expect(shownEntry()).toBe('deep');
0337
0338 await visitDisplay();
0339 await settle();
0340 expect(fetched).toEqual(['https://h/deep.firebird.json']);
0341 expect(shownEntry()).toBe('deep');
0342 });
0343 });
0344
0345 describe('events load bookkeeping', () => {
0346 it('loads ROOT again after DEX replaced it (ROOT -> DEX -> ROOT)', async () => {
0347 create();
0348 selection.attachDisplay(parts => display.loadFromConfig(parts));
0349 serveDex('https://h/a.firebird.json', ['a']);
0350
0351 selection.apply({ events: 'https://h/r.root', eventRange: '0' });
0352 await settle();
0353 expect(shownEntry()).toBe('https://h/r.root#0');
0354
0355 selection.apply({ events: 'https://h/a.firebird.json' });
0356 await settle();
0357 expect(shownEntry()).toBe('a');
0358
0359 selection.apply({ events: 'https://h/r.root', eventRange: '0' });
0360 await settle();
0361 expect(rootLoader.calls).toEqual(['https://h/r.root', 'https://h/r.root']);
0362 expect(shownEntry()).toBe('https://h/r.root#0');
0363
0364 // The same source again is already on screen: no new conversion
0365 display.loadFromConfig({ events: true });
0366 await settle();
0367 expect(rootLoader.calls.length).toBe(2);
0368 });
0369
0370 it('shows the last REQUESTED load, and the spinner follows it', async () => {
0371 create();
0372 selection.attachDisplay(parts => display.loadFromConfig(parts));
0373 serveDex('https://h/a.firebird.json', ['a']);
0374 const slow = deferred();
0375 rootLoader.gates.set('https://h/slow.root', slow);
0376
0377 selection.apply({ events: 'https://h/slow.root', eventRange: '0' });
0378 await settle();
0379 expect(display.loadingEdm()).toBe(true);
0380
0381 selection.apply({ events: 'https://h/a.firebird.json' });
0382 await settle();
0383 expect(shownEntry()).toBe('a');
0384 expect(display.loadingDex()).toBe(false);
0385 expect(display.loadingEdm()).toBe(false);
0386
0387 // The older conversion finishes last and is not shown
0388 slow.resolve();
0389 await settle();
0390 expect(shownEntry()).toBe('a');
0391 expect(data.entries().map(entry => entry.id)).toEqual(['a']);
0392 expect(display.loadingEdm()).toBe(false);
0393 });
0394 });
0395
0396 describe('ThreeExtension.onEventLoaded', () => {
0397 it('fires once per load and once per switch', async () => {
0398 create();
0399 serveDex('https://h/two.firebird.json', ['e0', 'e1']);
0400
0401 await display.openEvents('https://h/two.firebird.json');
0402 TestBed.tick();
0403 expect(three.notifyEventLoaded).toHaveBeenCalledTimes(1);
0404 expect(three.notifyEventLoaded.mock.calls[0][0].id).toBe('e0');
0405
0406 data.setCurrentEntry(data.entries()[1]);
0407 TestBed.tick();
0408 TestBed.tick();
0409 expect(three.notifyEventLoaded).toHaveBeenCalledTimes(2);
0410 expect(three.notifyEventLoaded.mock.calls[1][0].id).toBe('e1');
0411
0412 // Reloading the same file is a new load
0413 await display.openEvents('https://h/two.firebird.json');
0414 TestBed.tick();
0415 expect(three.notifyEventLoaded).toHaveBeenCalledTimes(3);
0416 expect(three.notifyEventLoaded.mock.calls[2][0].id).toBe('e0');
0417 });
0418 });
0419
0420 describe('readiness', () => {
0421 it('counts a config-driven ROOT conversion from the moment the loader starts', async () => {
0422 create();
0423 const gate = deferred();
0424 rootLoader.gates.set('https://h/r.root', gate);
0425 config.getConfig<string>(SELECTION_CONFIG_KEYS.rootEvents)!.value = 'https://h/r.root';
0426 config.getConfig<string>(SELECTION_CONFIG_KEYS.geometry)!.value = '';
0427 await visitDisplay();
0428 // Startup ran no commands; the held conversion keeps readiness back
0429 expect(firebird().startupCommandsDone).toBe(true);
0430 expect(firebird().pendingLoads).toBe(1);
0431 expect(firebird().ready).toBe(false);
0432
0433 gate.resolve();
0434 await settle();
0435 expect(firebird().ready).toBe(true);
0436 expect(firebird().errors).toEqual([]);
0437 });
0438
0439 it('settles after a superseded geometry load, in the worker or in post-processing', async () => {
0440 create();
0441 await commandBus.runStartupCommands();
0442 geometry.gated = true;
0443
0444 // Superseded while the worker loads: the older loader rejects with its abort
0445 const first = display.openGeometry('https://h/a.geo.root');
0446 const second = display.openGeometry('https://h/b.geo.root');
0447 geometry.gates[0].resolve(null);
0448 expect(await first).toBeNull();
0449 const geometryB = new Group();
0450 geometry.gates[1].resolve(geometryB);
0451 expect(await second).toBe(geometryB);
0452 expect(three.sceneGeometry.scale.x).toBe(10);
0453 expect(firebird().pendingLoads).toBe(0);
0454 expect(firebird().ready).toBe(true);
0455
0456 // Superseded during post-processing: the older geometry stays out of the scene
0457 geometry.postProcessingGate = deferred();
0458 const third = display.openGeometry('https://h/c.geo.root');
0459 const geometryC = new Group();
0460 geometry.gates[2].resolve(geometryC);
0461 await settle();
0462 const fourth = display.openGeometry('https://h/d.geo.root');
0463 const geometryD = new Group();
0464 geometry.gates[3].resolve(geometryD);
0465 geometry.postProcessingGate.resolve();
0466 expect(await third).toBeNull();
0467 expect(await fourth).toBe(geometryD);
0468 expect(geometryD.parent).toBe(three.sceneGeometry);
0469 expect(geometryC.parent).toBeNull();
0470 expect(display.loadingGeometry()).toBe(false);
0471 expect(firebird().ready).toBe(true);
0472 });
0473
0474 it('keeps loading events when one lazy painter fails to load, and reports it', async () => {
0475 extraProviders = [{
0476 provide: PAINTERS,
0477 useValue: { forPieceType: 'BoxHit', load: () => Promise.reject(new Error('chunk 404')) },
0478 multi: true,
0479 }];
0480 create();
0481 serveDex('https://h/a.firebird.json', ['a']);
0482 await display.openEvents('https://h/a.firebird.json');
0483 await display.openEvents('https://h/a.firebird.json');
0484 expect(shownEntry()).toBe('a');
0485 expect(messages.addMessage).toHaveBeenCalledWith('error', expect.stringContaining("'BoxHit'"));
0486 expect(firebird().errors).toEqual([expect.stringContaining('chunk 404')]);
0487 expect(firebird().pendingLoads).toBe(0);
0488 });
0489 });
0490
0491 describe('errors reach the user with their reason', () => {
0492 it('names the DEX upgrade command for an old file', async () => {
0493 create();
0494 serveDex('https://h/old.firebird.json', ['old'], '0.04');
0495 config.getConfig<string>(SELECTION_CONFIG_KEYS.dexEvents)!.value = 'https://h/old.firebird.json';
0496 await visitDisplay();
0497 await settle();
0498 const reported = messages.addMessage.mock.calls.map(call => call[1] as string);
0499 expect(reported).toEqual([expect.stringMatching(/old\.firebird\.json.*0\.04.*pyrobird upgrade/)]);
0500 expect(firebird().errors).toEqual(reported);
0501 expect(firebird().ready).toBe(true);
0502 });
0503
0504 it('reports a failed startup command with the HTTP status, and still settles', async () => {
0505 create();
0506 commandBus.queueStartupCommands([{ type: 'open-dex', url: 'https://h/missing.firebird.json', source: 'url' }]);
0507 await visitDisplay();
0508 await settle();
0509 expect(firebird().errors).toEqual([
0510 "Startup command 'open-dex:https://h/missing.firebird.json' failed: " +
0511 'HTTP 404 Not Found (https://h/missing.firebird.json)',
0512 ]);
0513 expect(messages.addMessage).toHaveBeenCalledTimes(1);
0514 expect(firebird().ready).toBe(true);
0515 });
0516 });
0517 describe('the loader contract', () => {
0518 /**
0519 * A loader that records what the display hands it, answers when the test
0520 * says so, and rejects with the signal's reason when the load is aborted.
0521 */
0522 class StubLoader implements GeometryDataLoader, EventDataLoader {
0523 readonly meta;
0524 constructor(id = 'stub') {
0525 this.meta = { id, label: 'stub format', fileExtensions: ['.stub'] };
0526 }
0527 readonly calls: Array<{ source: DataSource; context: LoaderContext }> = [];
0528 readonly answers: Array<(value: never) => void> = [];
0529 readonly failures: Array<(error: Error) => void> = [];
0530 canLoad(source: DataSource): boolean {
0531 return sourceName(source).endsWith('.stub');
0532 }
0533 load(source: DataSource, context: LoaderContext): Promise<Object3D> {
0534 return this.answerLater(source, context);
0535 }
0536 loadEvents(source: DataSource, context: LoaderContext): Promise<DataExchange> {
0537 return this.answerLater(source, context);
0538 }
0539 private answerLater<T>(source: DataSource, context: LoaderContext): Promise<T> {
0540 this.calls.push({ source, context });
0541 return new Promise<T>((resolve, reject) => {
0542 this.answers.push(resolve as (value: never) => void);
0543 this.failures.push(reject);
0544 context.signal.addEventListener('abort', () => reject(context.signal.reason), { once: true });
0545 });
0546 }
0547 }
0548
0549 let stub: StubLoader;
0550 const reported = () => messages.addMessage.mock.calls.map(call => call[1] as string);
0551
0552 beforeEach(() => {
0553 stub = new StubLoader();
0554 extraProviders = [
0555 { provide: GEOMETRY_LOADERS, useValue: stub, multi: true },
0556 { provide: EVENT_LOADERS, useValue: stub, multi: true },
0557 ];
0558 });
0559
0560 it('loads the configured geometry through the claiming loader and shows what it returns', async () => {
0561 create();
0562 config.getConfig<string>(SELECTION_CONFIG_KEYS.geometry)!.value = 'asset://g/detector.stub';
0563 await visitDisplay();
0564 expect(stub.calls.map(call => call.source)).toEqual(['asset://g/detector.stub']);
0565 const { context } = stub.calls[0];
0566 expect(context.resolveUrl('asset://g/detector.stub')).toMatch(/\/assets\/g\/detector\.stub$/);
0567 expect(context.signal.aborted).toBe(false);
0568
0569 const root = new Group();
0570 stub.answers[0](root as never);
0571 await settle();
0572 expect(root.parent).toBe(three.sceneGeometry);
0573 // No millimetersPerUnit: the loader's unit is the millimeter
0574 expect(three.sceneGeometry.scale.x).toBe(1);
0575 expect(geometry.geometry.set).toHaveBeenCalledWith(root);
0576 expect(three.geometryChanged).toHaveBeenCalledTimes(1);
0577 expect(firebird().ready).toBe(true);
0578 });
0579
0580 it('aborts the older load when a newer one starts; the older ends without an error', async () => {
0581 create();
0582 const older = display.openEvents('https://h/a.stub');
0583 const newer = display.openEvents('https://h/b.stub');
0584 expect(stub.calls[0].context.signal.aborted).toBe(true);
0585 expect(stub.calls[1].context.signal.aborted).toBe(false);
0586 expect(await older).toBeNull();
0587
0588 stub.answers[1](DataExchange.fromDexObj(dexDocument(['b'])) as never);
0589 expect((await newer)?.events.map(event => event.id)).toEqual(['b']);
0590 expect(shownEntry()).toBe('b');
0591 expect(reported()).toEqual([]);
0592 });
0593
0594 it('names the formats the loaders know when none claims the source', async () => {
0595 create();
0596 config.getConfig<string>(SELECTION_CONFIG_KEYS.geometry)!.value = 'https://h/detector.iges';
0597 await visitDisplay();
0598 expect(reported()).toEqual([expect.stringContaining(
0599 "No geometry loader claims 'https://h/detector.iges'. Known formats: fake geometry (.geo.root); stub format (.stub)")]);
0600 await expect(display.openEvents('https://h/e.iges')).rejects.toThrow("No event loader claims 'https://h/e.iges'");
0601 });
0602
0603 it("reports a loader's failure with its reason", async () => {
0604 create();
0605 config.getConfig<string>(SELECTION_CONFIG_KEYS.geometry)!.value = '';
0606 config.getConfig<string>(SELECTION_CONFIG_KEYS.dexEvents)!.value = 'https://h/e.stub';
0607 await visitDisplay();
0608 stub.failures[0](new Error('HTTP 403 Forbidden (https://h/e.stub)'));
0609 await settle();
0610 expect(reported()).toEqual(["Could not load events from 'https://h/e.stub': HTTP 403 Forbidden (https://h/e.stub)"]);
0611 expect(firebird().errors).toEqual(reported());
0612 expect(firebird().ready).toBe(true);
0613 });
0614
0615 it('asks the loaders in registration order: the first claimant loads', async () => {
0616 // Another id: the same id would replace the first loader in place
0617 const late = new StubLoader('late-stub');
0618 extraProviders.push({ provide: EVENT_LOADERS, useValue: late, multi: true });
0619 create();
0620 void display.openEvents('https://h/e.stub');
0621 expect(stub.calls).toHaveLength(1);
0622 expect(late.calls).toHaveLength(0);
0623 });
0624
0625 it('loads a picked file through the claiming loader, in place', async () => {
0626 create();
0627 const file = new File(['x'], 'events.stub');
0628 selection.apply({ events: file });
0629 await visitDisplay();
0630 expect(stub.calls.map(call => call.source)).toEqual([file]);
0631 });
0632 });
0633
0634 describe('painter registration', () => {
0635 /** A painter class as the registry sees it: a constructor with static meta. */
0636 const painterClass = (id: string) =>
0637 ({ [id]: class { static meta = { id, forPieceTypes: ['spec.Piece'] }; } })[id] as unknown as PiecePainterConstructor;
0638
0639 it('makes the first registered painter the default, whichever chunk resolves first', async () => {
0640 const first = painterClass('first');
0641 const second = painterClass('second');
0642 const slowChunk = gateOf<PiecePainterConstructor>();
0643 extraProviders = [
0644 { provide: PAINTERS, useValue: { forPieceType: 'spec.Piece', load: () => slowChunk.promise }, multi: true },
0645 { provide: PAINTERS, useValue: { forPieceType: 'spec.Piece', load: () => Promise.resolve(second) }, multi: true },
0646 ];
0647 create();
0648 await settle();
0649 slowChunk.resolve(first);
0650 await settle();
0651 expect(display.paintersForType('spec.Piece')).toEqual([first, second]);
0652 });
0653
0654 it('a later painter with the same meta id replaces the earlier one in place', async () => {
0655 const original = painterClass('shared-id');
0656 const other = painterClass('other');
0657 const replacement = painterClass('shared-id');
0658 extraProviders = [
0659 { provide: PAINTERS, useValue: { forPieceType: 'spec.Piece', painterClass: original }, multi: true },
0660 { provide: PAINTERS, useValue: { forPieceType: 'spec.Piece', painterClass: other }, multi: true },
0661 { provide: PAINTERS, useValue: { forPieceType: 'spec.Piece', load: () => Promise.resolve(replacement) }, multi: true },
0662 ];
0663 create();
0664 await settle();
0665 expect(display.paintersForType('spec.Piece')).toEqual([replacement, other]);
0666 });
0667 });
0668
0669 describe('collision intro', () => {
0670 /** An intro that records what the display asks of it. */
0671 class RecordingIntro implements CollisionIntro {
0672 static played: RecordingIntro[] = [];
0673 readonly durationMs = 1000;
0674 readonly updates: number[] = [];
0675 ended = false;
0676 constructor() { RecordingIntro.played.push(this); }
0677 begin(parent: Object3D): void { parent.add(new Mesh()); }
0678 update(elapsedMs: number): void { this.updates.push(elapsedMs); }
0679 end(): void { this.ended = true; }
0680 }
0681
0682 const tweenCount = () => (display as unknown as { tweenGroup: { getAll(): unknown[] } }).tweenGroup.getAll().length;
0683 const advanceTweens = (ms: number) =>
0684 (display as unknown as { tweenGroup: { update(time: number): void } }).tweenGroup.update(performance.now() + ms);
0685
0686 beforeEach(() => {
0687 RecordingIntro.played = [];
0688 });
0689
0690 it('starts the time animation at once when no intro is registered', async () => {
0691 create();
0692 const animateTime = vi.spyOn(display, 'animateTime');
0693 await display.animateWithCollision();
0694 expect(animateTime).toHaveBeenCalledTimes(1);
0695 expect(three.sceneEvent.getObjectByName('CollisionIntro')).toBeUndefined();
0696 });
0697
0698 it('plays the intro on the event layer, then the time animation; the ended intro leaves no tween', async () => {
0699 extraProviders = [{ provide: COLLISION_INTRO, useValue: () => Promise.resolve(RecordingIntro) }];
0700 create();
0701 expect(display.hasCollisionIntro).toBe(true);
0702 const animateTime = vi.spyOn(display, 'animateTime');
0703 await display.animateWithCollision();
0704
0705 const group = three.sceneEvent.getObjectByName('CollisionIntro')!;
0706 expect(group.children[0].layers.mask).toBe(1 << EVENT_DATA_LAYER);
0707 expect(tweenCount()).toBe(1);
0708
0709 advanceTweens(2000);
0710 const [intro] = RecordingIntro.played;
0711 expect(intro.updates.at(-1)).toBe(1000);
0712 expect(intro.ended).toBe(true);
0713 expect(three.sceneEvent.getObjectByName('CollisionIntro')).toBeUndefined();
0714 expect(animateTime).toHaveBeenCalledTimes(1);
0715 // Only the time animation's tween is left
0716 expect(tweenCount()).toBe(1);
0717 display.stopTimeAnimation();
0718 expect(tweenCount()).toBe(0);
0719 });
0720
0721 it('a new playback ends the running intro', async () => {
0722 extraProviders = [{ provide: COLLISION_INTRO, useValue: () => Promise.resolve(RecordingIntro) }];
0723 create();
0724 await display.animateWithCollision();
0725 await display.animateWithCollision();
0726 expect(RecordingIntro.played.map(intro => intro.ended)).toEqual([true, false]);
0727 expect(three.sceneEvent.children.filter(child => child.name === 'CollisionIntro')).toHaveLength(1);
0728 expect(tweenCount()).toBe(1);
0729
0730 // Two requests in the same tick: the later one replaces the earlier
0731 await Promise.all([display.animateWithCollision(), display.animateWithCollision()]);
0732 expect(RecordingIntro.played.map(intro => intro.ended)).toEqual([true, true, true, false]);
0733 expect(tweenCount()).toBe(1);
0734 });
0735
0736 it('stopping the time animation stops a running intro before it starts one', async () => {
0737 extraProviders = [{ provide: COLLISION_INTRO, useValue: () => Promise.resolve(RecordingIntro) }];
0738 create();
0739 const animateTime = vi.spyOn(display, 'animateTime');
0740 await display.animateWithCollision();
0741 display.exitTimedDisplay();
0742 advanceTweens(2000);
0743 expect(RecordingIntro.played[0].ended).toBe(true);
0744 expect(animateTime).not.toHaveBeenCalled();
0745 expect(tweenCount()).toBe(0);
0746 });
0747
0748 it('the animate-collision command plays it', async () => {
0749 extraProviders = [
0750 { provide: COLLISION_INTRO, useValue: () => Promise.resolve(RecordingIntro) },
0751 { provide: COMMAND_HANDLERS, useClass: AnimateCollisionCommandHandler, multi: true },
0752 ];
0753 create();
0754 await commandBus.dispatch({ type: 'animate-collision', source: 'ui' });
0755 expect(RecordingIntro.played).toHaveLength(1);
0756 });
0757 });
0758 });