Warning, /firebird/packages/firebird-ng/display/src/lib/services/three.service.spec.ts is written in an unsupported language. File is not indexed.
0001 import { TestBed } from '@angular/core/testing';
0002 import { ThreeService } from './three.service';
0003 import { PerfService } from './perf.service';
0004 import type { ThreeExtension } from '@dexvis/firebird-ng/api';
0005
0006 /** A promise with its resolve/reject handles. */
0007 function deferred() {
0008 let resolve!: () => void;
0009 let reject!: (error: unknown) => void;
0010 const promise = new Promise<void>((res, rej) => { resolve = () => res(); reject = rej; });
0011 return { promise, resolve, reject };
0012 }
0013
0014 /** The private members these specs drive. */
0015 type ThreeInternals = {
0016 createScene: (element: HTMLElement) => Promise<void>;
0017 attachRenderer: (element: HTMLElement) => void;
0018 startRendering: () => void;
0019 stopRendering: () => void;
0020 renderLoop: () => void;
0021 initialized: boolean;
0022 shouldRender: boolean;
0023 renderRequested: boolean;
0024 animationFrameId: number | null;
0025 views: unknown[];
0026 renderer: unknown;
0027 extensions: ThreeExtension[];
0028 frameContext: unknown;
0029 frameCallbacks: Array<() => void>;
0030 };
0031
0032 describe('ThreeService', () => {
0033 let service: ThreeService;
0034 let internals: ThreeInternals;
0035
0036 beforeEach(() => {
0037 TestBed.configureTestingModule({
0038 providers: [ThreeService, { provide: PerfService, useValue: { updateStats: () => undefined } }],
0039 });
0040 service = TestBed.inject(ThreeService);
0041 internals = service as unknown as ThreeInternals;
0042 });
0043
0044 it('should throw error if container not found when using string id', async () => {
0045 // This test doesn't require WebGPU - it fails before renderer creation
0046 await expect(service.init('nonexistent-id')).rejects.toThrow(/Container element #nonexistent-id not found/);
0047 });
0048
0049 describe('init re-entrancy (scene creation stubbed: no WebGPU in tests)', () => {
0050 let creation: ReturnType<typeof deferred>;
0051 let createScene: ReturnType<typeof vi.fn>;
0052 let attachRenderer: ReturnType<typeof vi.fn>;
0053 let startRendering: ReturnType<typeof vi.fn>;
0054
0055 beforeEach(() => {
0056 creation = deferred();
0057 createScene = vi.fn(() => creation.promise);
0058 attachRenderer = vi.fn();
0059 startRendering = vi.fn();
0060 internals.createScene = createScene as unknown as ThreeInternals['createScene'];
0061 internals.attachRenderer = attachRenderer as unknown as ThreeInternals['attachRenderer'];
0062 internals.startRendering = startRendering as unknown as ThreeInternals['startRendering'];
0063 });
0064
0065 it('creates the scene once for concurrent calls, and only the latest call attaches', async () => {
0066 const first = document.createElement('div');
0067 const second = document.createElement('div');
0068 const firstInit = service.init(first);
0069 const secondInit = service.init(second);
0070 creation.resolve();
0071
0072 expect(await firstInit).toBe(false);
0073 expect(await secondInit).toBe(true);
0074 expect(createScene).toHaveBeenCalledTimes(1);
0075 expect(createScene).toHaveBeenCalledWith(first);
0076 expect(attachRenderer).toHaveBeenCalledTimes(1);
0077 expect(attachRenderer).toHaveBeenCalledWith(second);
0078 expect(startRendering).toHaveBeenCalledTimes(1);
0079 });
0080
0081 it('does not start the loop when the page detached while the scene was being created', async () => {
0082 const pending = service.init(document.createElement('div'));
0083 service.detach();
0084 creation.resolve();
0085
0086 expect(await pending).toBe(false);
0087 expect(startRendering).not.toHaveBeenCalled();
0088 });
0089
0090 it('re-attaches on a later visit without creating the scene again', async () => {
0091 creation.resolve();
0092 expect(await service.init(document.createElement('div'))).toBe(true);
0093 service.detach();
0094 const revisit = document.createElement('div');
0095 expect(await service.init(revisit)).toBe(true);
0096
0097 expect(createScene).toHaveBeenCalledTimes(1);
0098 expect(attachRenderer).toHaveBeenCalledWith(revisit);
0099 expect(startRendering).toHaveBeenCalledTimes(2);
0100 });
0101
0102 it('retries the creation after a failed one', async () => {
0103 const failing = service.init(document.createElement('div'));
0104 creation.reject(new Error('no adapter'));
0105 await expect(failing).rejects.toThrow('no adapter');
0106
0107 createScene.mockImplementation(() => Promise.resolve());
0108 expect(await service.init(document.createElement('div'))).toBe(true);
0109 expect(createScene).toHaveBeenCalledTimes(2);
0110 });
0111 });
0112
0113 describe('render loop isolation', () => {
0114 function stubFrame() {
0115 const view = {
0116 dirty: true,
0117 controls: { update: () => undefined },
0118 renderFullFrame: vi.fn(),
0119 renderOverlays: vi.fn(),
0120 dispose: vi.fn(),
0121 };
0122 internals.initialized = true;
0123 internals.views = [view];
0124 internals.renderer = { domElement: document.createElement('canvas'), dispose: vi.fn() };
0125 internals.frameContext = { deltaTime: 0, renderer: internals.renderer, camera: null, invalidate: () => undefined };
0126 return view;
0127 }
0128
0129 function runOneFrame() {
0130 service.invalidate();
0131 internals.shouldRender = true;
0132 internals.renderLoop();
0133 internals.stopRendering();
0134 }
0135
0136 beforeEach(() => {
0137 // The loop schedules its next tick; nothing must run behind the test.
0138 vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
0139 vi.stubGlobal('cancelAnimationFrame', vi.fn());
0140 });
0141
0142 afterEach(() => {
0143 internals.stopRendering();
0144 vi.unstubAllGlobals();
0145 });
0146
0147 it('disables a throwing onFrame hook and keeps rendering and the other hooks', () => {
0148 const view = stubFrame();
0149 const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined);
0150 const bad: ThreeExtension = { onFrame: vi.fn(() => { throw new Error('boom'); }) };
0151 const good: ThreeExtension = { onFrame: vi.fn() };
0152 internals.extensions = [bad, good];
0153
0154 runOneFrame();
0155 runOneFrame();
0156
0157 expect(bad.onFrame).toHaveBeenCalledTimes(1);
0158 expect(good.onFrame).toHaveBeenCalledTimes(2);
0159 expect(view.renderFullFrame).toHaveBeenCalledTimes(2);
0160 expect(service.renderedFrameCount).toBe(2);
0161 expect(errors).toHaveBeenCalledTimes(1);
0162 errors.mockRestore();
0163 });
0164
0165 it('removes a throwing frame callback and keeps the others', () => {
0166 stubFrame();
0167 const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined);
0168 const bad = vi.fn(() => { throw new Error('boom'); });
0169 const good = vi.fn();
0170 service.addFrameCallback(bad);
0171 service.addFrameCallback(good);
0172
0173 runOneFrame();
0174 runOneFrame();
0175
0176 expect(bad).toHaveBeenCalledTimes(1);
0177 expect(good).toHaveBeenCalledTimes(2);
0178 expect(internals.frameCallbacks).toEqual([good]);
0179 errors.mockRestore();
0180 });
0181
0182 it('stays idle on demand when nothing invalidated', () => {
0183 const view = stubFrame();
0184 runOneFrame();
0185 view.dirty = false;
0186 internals.shouldRender = true;
0187 internals.renderLoop();
0188 internals.stopRendering();
0189 expect(view.renderFullFrame).toHaveBeenCalledTimes(1);
0190 });
0191 });
0192
0193 // On-demand scheduling over several views: dirty flags decide WHETHER a
0194 // frame renders, and a rendered frame repaints EVERY view (a skipped view
0195 // would show cleared background once the frame is presented).
0196 describe('render scheduling with several views', () => {
0197 type StubView = { dirty: boolean; controls: { update: () => void }; renderTo: ReturnType<typeof vi.fn>; dispose: () => void };
0198
0199 function stubViews(count: number): StubView[] {
0200 const views = Array.from({ length: count }, () => ({
0201 dirty: false,
0202 controls: { update: () => undefined },
0203 renderTo: vi.fn(),
0204 dispose: () => undefined,
0205 }));
0206 internals.initialized = true;
0207 internals.views = views;
0208 internals.renderer = {
0209 domElement: document.createElement('canvas'),
0210 setScissorTest: vi.fn(),
0211 setViewport: vi.fn(),
0212 dispose: vi.fn(),
0213 };
0214 internals.renderRequested = false;
0215 return views;
0216 }
0217
0218 function tick() {
0219 internals.shouldRender = true;
0220 internals.renderLoop();
0221 internals.stopRendering();
0222 }
0223
0224 beforeEach(() => {
0225 vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
0226 vi.stubGlobal('cancelAnimationFrame', vi.fn());
0227 });
0228
0229 afterEach(() => {
0230 internals.stopRendering();
0231 vi.unstubAllGlobals();
0232 });
0233
0234 it('invalidate() requests a frame and marks every view dirty', () => {
0235 const views = stubViews(3);
0236 service.invalidate();
0237 expect(internals.renderRequested).toBe(true);
0238 expect(views.every(view => view.dirty)).toBe(true);
0239 });
0240
0241 it('renders nothing while no flag is set', () => {
0242 const views = stubViews(2);
0243 tick();
0244 expect(views.map(view => view.renderTo.mock.calls.length)).toEqual([0, 0]);
0245 expect(service.renderedFrameCount).toBe(0);
0246 });
0247
0248 it('repaints all views when only one view is dirty, then clears every flag', () => {
0249 const views = stubViews(3);
0250 views[1].dirty = true;
0251 tick();
0252 expect(views.map(view => view.renderTo.mock.calls.length)).toEqual([1, 1, 1]);
0253 expect(views.map(view => view.dirty)).toEqual([false, false, false]);
0254 expect(service.renderedFrameCount).toBe(1);
0255
0256 tick();
0257 expect(service.renderedFrameCount).toBe(1);
0258 });
0259
0260 it('consumes an invalidate() in one frame', () => {
0261 const views = stubViews(2);
0262 service.invalidate();
0263 tick();
0264 tick();
0265 expect(views.map(view => view.renderTo.mock.calls.length)).toEqual([1, 1]);
0266 expect(internals.renderRequested).toBe(false);
0267 });
0268 });
0269 });