Warning, /firebird/packages/firebird-ng/src/lib/provide-firebird.spec.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * provideFirebird() installs Firebird's built-ins as named sub-features, and
0003 * every registry follows one precedence rule: registration order, a later
0004 * contribution with the id of an earlier one replacing it in place,
0005 * `withoutFeatures()` dropping parts by feature id. Development builds warn
0006 * when two packs register factories for one piece type.
0007 */
0008 import { TestBed } from '@angular/core/testing';
0009 import { ApplicationInitStatus, Injectable, provideZonelessChangeDetection } from '@angular/core';
0010 import { provideHttpClient } from '@angular/common/http';
0011 import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
0012 import {
0013 AppCommand,
0014 AppFeatureInput,
0015 CommandBusService,
0016 CommandHandler,
0017 ConfigService,
0018 SERVER_CONFIG_OPTIONS,
0019 URL_ALIASES,
0020 URL_SHORTHANDS,
0021 withCommandHandler,
0022 withoutFeatures,
0023 withServerConfig,
0024 withUrlAlias,
0025 withUrlShorthand,
0026 } from '@dexvis/app-features';
0027 import { getEventPieceFactory } from '@dexvis/firebird-core/model';
0028 import type { DataExchange, DataLoaderMeta, EventDataLoader, EventPiece, EventPieceFactory } from '@dexvis/firebird-core';
0029 import { EventPiecePainter } from '@dexvis/firebird-core';
0030 import { provideFirebird } from './provide-firebird';
0031 import {
0032 DATA_SELECTOR_TABS,
0033 defaultFirebirdConfig,
0034 firebirdFeatures,
0035 firebirdPack,
0036 GEOMETRY_CUT_LIST_CONFIG,
0037 GEOMETRY_ROOT_FILTER_CONFIG,
0038 GEOMETRY_THEME_CONFIG,
0039 GEOMETRY_THEMES,
0040 injectCameraPresets,
0041 injectEventLoaders,
0042 injectGeometryLoaders,
0043 LAZY_THREE_EXTENSIONS,
0044 PAINTERS,
0045 resolveRootGeometryRules,
0046 ROOT_GEOMETRY_RULES,
0047 selectRootGeometryLoadRules,
0048 TRAJECTORY_EXCLUDED_COLLECTIONS_CONFIG,
0049 withCameraPreset,
0050 withDataSelectorTab,
0051 withEventLoader,
0052 withEventPiece,
0053 withGeometryTheme,
0054 withPainter,
0055 } from '@dexvis/firebird-ng/api';
0056 import { withFirebirdBuiltins } from './with-firebird-builtins';
0057
0058 function boot(...features: AppFeatureInput[]): void {
0059 TestBed.configureTestingModule({
0060 providers: [
0061 provideZonelessChangeDetection(),
0062 provideHttpClient(),
0063 provideHttpClientTesting(),
0064 provideFirebird(...features),
0065 ],
0066 });
0067 }
0068
0069 /** Runs the app initializers: config defaults, then the server config (answered with an empty one). */
0070 async function initialize(): Promise<void> {
0071 const initStatus = TestBed.inject(ApplicationInitStatus);
0072 TestBed.inject(HttpTestingController).expectOne('assets/config.jsonc').flush('{}');
0073 await initStatus.donePromise;
0074 }
0075
0076 const eventLoaderIds = () => TestBed.runInInjectionContext(() => injectEventLoaders().map(loader => loader.meta.id));
0077 const tabIds = () => (TestBed.inject(DATA_SELECTOR_TABS, null) ?? []).map(tab => tab.id);
0078 const cameraPresetNames = () => TestBed.runInInjectionContext(() => injectCameraPresets().map(preset => preset.name));
0079
0080 @Injectable()
0081 class PackDexLoader implements EventDataLoader {
0082 readonly meta: DataLoaderMeta = { id: 'firebird-dex', label: 'Pack DEX', fileExtensions: ['.json'] };
0083 canLoad(): boolean { return true; }
0084 loadEvents(): Promise<DataExchange> { return Promise.reject(new Error('not called in this spec')); }
0085 }
0086
0087 @Injectable()
0088 class PackCameraPresetHandler implements CommandHandler {
0089 readonly type = 'camera-preset';
0090 readonly executed: AppCommand[] = [];
0091 execute(command: AppCommand): void { this.executed.push(command); }
0092 }
0093
0094 /** A replacement decoder for the built-in BoxHit type. */
0095 @Injectable()
0096 class PackBoxHitFactory implements EventPieceFactory {
0097 readonly type = 'BoxHit';
0098 fromDexObject(): EventPiece { throw new Error('not used'); }
0099 }
0100
0101 /** Two packs that both ship a decoder for one piece type. */
0102 @Injectable()
0103 class RingFactoryA implements EventPieceFactory {
0104 readonly type = 'spec.Ring';
0105 fromDexObject(): EventPiece { throw new Error('not used'); }
0106 }
0107
0108 @Injectable()
0109 class RingFactoryB implements EventPieceFactory {
0110 readonly type = 'spec.Ring';
0111 fromDexObject(): EventPiece { throw new Error('not used'); }
0112 }
0113
0114 describe('provideFirebird built-ins', () => {
0115 beforeEach(() => localStorage.clear());
0116
0117 it('installs every built-in part', () => {
0118 boot();
0119 expect(TestBed.inject(SERVER_CONFIG_OPTIONS).defaults).toBe(defaultFirebirdConfig);
0120 expect(TestBed.inject(URL_SHORTHANDS).map(shorthand => shorthand.param)).toEqual(['geometry', 'dex', 'event']);
0121 expect(eventLoaderIds()).toEqual(['firebird-dex', 'root2dex', 'edm4eic-root']);
0122 expect(TestBed.runInInjectionContext(() => injectGeometryLoaders().map(loader => loader.meta.id))).toEqual(['root-geometry']);
0123 expect(tabIds()).toEqual(['presets', 'physics', 'manual']);
0124 expect(TestBed.inject(LAZY_THREE_EXTENSIONS, null)?.length).toBe(1);
0125 expect(TestBed.inject(GEOMETRY_THEMES, null)?.map(theme => theme.id)).toEqual(['grey']);
0126 expect(cameraPresetNames()).toEqual(['front', 'back', 'right', 'left', 'top', 'bottom', 'home']);
0127 expect(TestBed.inject(CommandBusService).knownTypes).toEqual(expect.arrayContaining([
0128 'open-geometry', 'open-dex', 'show-event', 'set-config', 'camera-preset', 'animate-collision',
0129 ]));
0130 });
0131
0132 it('installs the built-ins once when the application passes them again', () => {
0133 boot(withFirebirdBuiltins());
0134 expect(eventLoaderIds()).toEqual(['firebird-dex', 'root2dex', 'edm4eic-root']);
0135 expect(tabIds()).toEqual(['presets', 'physics', 'manual']);
0136 expect(TestBed.inject(LAZY_THREE_EXTENSIONS, null)?.length).toBe(1);
0137 });
0138
0139 it('leaves the geometry pipeline off: no theme, no edit rules, no cut list', async () => {
0140 boot();
0141 await initialize();
0142 const config = TestBed.inject(ConfigService);
0143 const filterName = config.declare(GEOMETRY_ROOT_FILTER_CONFIG).value;
0144 const cutListName = config.declare(GEOMETRY_CUT_LIST_CONFIG).value;
0145 expect(filterName).toBe('off');
0146 expect(cutListName).toBe('off');
0147 expect(config.declare(GEOMETRY_THEME_CONFIG).value).toBe('off');
0148 // What a geometry load posts to the worker (GeometryService builds it the same way)
0149 const rules = await resolveRootGeometryRules(TestBed.inject(ROOT_GEOMETRY_RULES, null) ?? []);
0150 expect(selectRootGeometryLoadRules(rules, filterName, cutListName)).toEqual({ editRules: [], cutList: [] });
0151 });
0152
0153 it('keeps every sim hit collection in MC-truth trajectories until a pack lists some', async () => {
0154 boot();
0155 await initialize();
0156 expect(TestBed.inject(ConfigService).declare(TRAJECTORY_EXCLUDED_COLLECTIONS_CONFIG).value).toBe('');
0157 });
0158
0159 it('drops a built-in part by its feature id', () => {
0160 boot(withoutFeatures('firebird.navigation-cube', 'data-selector-tab:physics', 'firebird.server-conversion'));
0161 expect(TestBed.inject(LAZY_THREE_EXTENSIONS, null)).toBeNull();
0162 expect(tabIds()).toEqual(['presets', 'manual']);
0163 expect(eventLoaderIds()).toEqual(['firebird-dex', 'root2dex']);
0164 });
0165 });
0166
0167 /** An event loader class with its own id that claims nothing. */
0168 function specLoader(id: string) {
0169 @Injectable()
0170 class SpecLoader implements EventDataLoader {
0171 readonly meta: DataLoaderMeta = { id, label: id, fileExtensions: [`.${id}`] };
0172 canLoad(): boolean { return false; }
0173 loadEvents(): Promise<DataExchange> { return Promise.reject(new Error('not called in this spec')); }
0174 }
0175 return SpecLoader;
0176 }
0177
0178 class PackBoxHitPainter extends EventPiecePainter {
0179 paint(): void { /* nothing to draw */ }
0180 }
0181
0182 describe('registration order', () => {
0183 it('registers the application features after the built-ins, depth first in argument order', () => {
0184 boot(
0185 firebirdPack('outer', withEventLoader(specLoader('a')), firebirdFeatures(withEventLoader(specLoader('b')))),
0186 withEventLoader(specLoader('c')),
0187 );
0188 expect(eventLoaderIds()).toEqual(['firebird-dex', 'root2dex', 'edm4eic-root', 'a', 'b', 'c']);
0189 });
0190
0191 it('keeps a built-in painter the default of its type when a pack adds another', () => {
0192 boot(firebirdPack('pack', withPainter(PackBoxHitPainter, { forPieceType: 'BoxHit' })));
0193 const boxHitPainters = (TestBed.inject(PAINTERS, null) ?? []).filter(painter => painter.forPieceType === 'BoxHit');
0194 expect(boxHitPainters.length).toBe(2);
0195 expect(boxHitPainters[0].load).toBeTypeOf('function');
0196 expect(boxHitPainters[1].painterClass).toBe(PackBoxHitPainter);
0197 });
0198 });
0199
0200 describe('dropping a pack', () => {
0201 it('restores the built-in parts the pack had replaced', () => {
0202 const load = () => Promise.resolve(class {});
0203 boot(
0204 firebirdPack('my-experiment',
0205 withCameraPreset({ name: 'home', position: [0, 7000, 0], target: [0, 0, 0], up: [1, 0, 0] }),
0206 withDataSelectorTab({ id: 'manual', label: 'Pack manual', order: 30, load }),
0207 ),
0208 withoutFeatures('my-experiment'),
0209 );
0210 const home = TestBed.runInInjectionContext(() => injectCameraPresets().find(preset => preset.name === 'home'));
0211 expect(home).toEqual({ name: 'home', direction: [0, 1, 0], up: [1, 0, 0], fitGeometry: true });
0212 const manual = (TestBed.inject(DATA_SELECTOR_TABS, null) ?? []).find(tab => tab.id === 'manual');
0213 expect(manual?.label).toBe('Manual');
0214 });
0215 });
0216
0217 describe('registry precedence: a later registration with the same id replaces the earlier one in place', () => {
0218 it('data selector tabs, by tab id', () => {
0219 const load = () => Promise.resolve(class {});
0220 boot(firebirdPack('pack', withDataSelectorTab({ id: 'manual', label: 'My files', order: 30, load })));
0221 const tabs = TestBed.inject(DATA_SELECTOR_TABS, null) ?? [];
0222 expect(tabs.map(tab => `${tab.id}:${tab.label}`)).toEqual(['presets:Presets', 'physics:Physics', 'manual:My files']);
0223 });
0224
0225 it('event loaders, by meta.id: the replacement keeps the built-in place in the claiming order', () => {
0226 boot(firebirdPack('pack', withEventLoader(PackDexLoader)));
0227 expect(eventLoaderIds()).toEqual(['firebird-dex', 'root2dex', 'edm4eic-root']);
0228 expect(TestBed.runInInjectionContext(() => injectEventLoaders()[0])).toBeInstanceOf(PackDexLoader);
0229 });
0230
0231 it('command handlers, by type', async () => {
0232 boot(firebirdPack('pack', withCommandHandler(PackCameraPresetHandler)));
0233 await TestBed.inject(CommandBusService).dispatch({ type: 'camera-preset', name: 'top' });
0234 const handler = TestBed.inject(CommandBusService) as unknown as { handlersByType: Map<string, CommandHandler> };
0235 expect(handler.handlersByType.get('camera-preset')).toBeInstanceOf(PackCameraPresetHandler);
0236 });
0237
0238 it('camera presets, by name', () => {
0239 boot(firebirdPack('pack', withCameraPreset({ name: 'home', position: [0, 1, 2], target: [0, 0, 0] })));
0240 expect(cameraPresetNames()).toEqual(['front', 'back', 'right', 'left', 'top', 'bottom', 'home']);
0241 const home = TestBed.runInInjectionContext(() => injectCameraPresets().find(preset => preset.name === 'home'));
0242 expect(home).toEqual({ name: 'home', position: [0, 1, 2], target: [0, 0, 0] });
0243 });
0244
0245 it('geometry themes, by id', () => {
0246 const load = () => Promise.resolve([]);
0247 boot(firebirdPack('pack', withGeometryTheme({ id: 'grey', label: 'Pack grey', load })));
0248 expect(TestBed.inject(GEOMETRY_THEMES, null)?.map(theme => theme.label)).toEqual(['Pack grey']);
0249 });
0250
0251 it('URL aliases, by prefix', () => {
0252 boot(firebirdPack('a', withUrlAlias('data://', 'https://a/')), firebirdPack('b', withUrlAlias('data://', 'https://b/')));
0253 expect(TestBed.inject(URL_ALIASES)).toEqual([{ prefix: 'data://', base: 'https://b/' }]);
0254 });
0255
0256 it('piece factories, by type: a pack replaces a built-in decoder without a warning', () => {
0257 const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
0258 boot(firebirdPack('pack', withEventPiece(PackBoxHitFactory)));
0259 TestBed.inject(ApplicationInitStatus);
0260 expect(getEventPieceFactory('BoxHit')).toBeInstanceOf(PackBoxHitFactory);
0261 expect(warn.mock.calls.map(call => String(call[0]))).not.toContainEqual(expect.stringContaining('Piece type'));
0262 warn.mockRestore();
0263 });
0264
0265 it('two packs shipping factories for one piece type draw a development warning that names both', () => {
0266 const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
0267 boot(firebirdPack('pack-a', withEventPiece(RingFactoryA)), firebirdPack('pack-b', withEventPiece(RingFactoryB)));
0268 TestBed.inject(ApplicationInitStatus);
0269 expect(getEventPieceFactory('spec.Ring')).toBeInstanceOf(RingFactoryB);
0270 const message = warn.mock.calls.map(call => String(call[0])).find(text => text.includes(`Piece type 'spec.Ring'`));
0271 expect(message).toContain('RingFactoryA (pack-a)');
0272 expect(message).toContain('RingFactoryB (pack-b)');
0273 warn.mockRestore();
0274 });
0275
0276 it('a pack that replaces a built-in feature by id draws no collision warning', () => {
0277 const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
0278 boot(firebirdPack('pack', withCameraPreset({ name: 'home', position: [0, 1, 2], target: [0, 0, 0] })));
0279 expect(warn).not.toHaveBeenCalled();
0280 warn.mockRestore();
0281 });
0282
0283 it('the server config and the URL shorthands are built-in parts: a pack replaces them without a warning', () => {
0284 const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
0285 boot(firebirdPack('pack', withUrlShorthand('dex', 'open-file'), withServerConfig({ url: 'my/config.jsonc' })));
0286 expect(warn).not.toHaveBeenCalled();
0287 expect(TestBed.inject(URL_SHORTHANDS)).toContainEqual({ param: 'dex', commandType: 'open-file' });
0288 expect(TestBed.inject(SERVER_CONFIG_OPTIONS)).toEqual({ url: 'my/config.jsonc' });
0289 warn.mockRestore();
0290 });
0291 });