Back to home page

EIC code displayed by LXR

 
 

    


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 });