import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; // Stub AudioContext globally before importing any modules that rely on THREE.AudioListener. // Use plain JS functions rather than vi.fn() to prevent vi.restoreAllMocks() from clearing the mock implementation. vi.hoisted(() => { vi.stubGlobal('AudioContext', function () { return { createGain: function () { return { connect: function () {}, }; }, destination: {}, }; }); }); import * as THREE from 'three'; import {Depth} from '../depth/Depth'; import {DepthOptions} from '../depth/DepthOptions'; import {Core} from './Core'; import {Options} from './Options'; import {Script} from './Script'; import { ScriptsManager, ScriptsManagerEventType, } from './components/ScriptsManager'; import {WebXRSessionManager} from './components/WebXRSessionManager'; import {XRButton} from './components/XRButton'; import {ui} from '../ui/UI'; import {colorfulTheme} from '../ui/UITheme'; function scripts(core: Core): ScriptsManager { return (core as unknown as {scriptsManager: ScriptsManager}).scriptsManager; } type SimulatorLoader = () => Promise< typeof import('../simulator/Simulator.js') >; describe('Core frame and simulator lifecycle', () => { let core: Core; const simulatorLoader = vi.fn(); beforeEach(async () => { await Core.instance?.dispose(); Core.instance = undefined; Depth.instance = undefined; simulatorLoader.mockReset(); core = new Core(simulatorLoader); core.options = new Options(); core.renderer = { render: vi.fn(), xr: { enabled: false, getDepthSensingMesh: vi.fn(), setReferenceSpaceType: vi.fn(), }, } as unknown as THREE.WebGLRenderer; core.depth.update = vi.fn(); core.input.sampleSources = vi.fn(); scripts(core).syncScriptsWithScene = vi.fn(); core.waitFrame.onFrame = vi.fn(); core.screenshotSynthesizer.onAfterRender = vi.fn(); }); afterEach(async () => { // Explicit disposal tests may already have asserted a terminal rejection. if (core.lifecycle !== 'disposed') await core.dispose(); vi.restoreAllMocks(); }); it('disposes a Core left undisposed by the test', ({onTestFinished}) => { const disposeRenderer = vi.fn(); core.renderer.dispose = disposeRenderer; expect(core.lifecycle).not.toBe('disposed'); onTestFinished(() => { expect(core.lifecycle).toBe('disposed'); expect(disposeRenderer).toHaveBeenCalledOnce(); }); }); it('shares one initialization and treats disposal as terminal', async () => { let finishInitialization: (() => void) | undefined; vi.spyOn( core as unknown as {initialize(options: Options): Promise}, 'initialize' ).mockImplementation( () => new Promise((resolve) => { finishInitialization = resolve; }) ); const first = core.init(core.options); const second = core.init(core.options); expect(second).toBe(first); await vi.waitFor(() => expect(finishInitialization).toBeDefined()); finishInitialization?.(); await first; const firstDisposal = core.dispose(); expect(core.dispose()).toBe(firstDisposal); await firstDisposal; await expect(core.init(core.options)).rejects.toThrow( 'Core cannot initialize after disposal has completed.' ); }); it('stops initialization when disposal starts', async () => { let finishInitialization: (() => void) | undefined; vi.spyOn( core as unknown as {initialize(options: Options): Promise}, 'initialize' ).mockImplementation( () => new Promise((resolve) => { finishInitialization = resolve; }) ); const initialization = core.init(core.options); await vi.waitFor(() => expect(finishInitialization).toBeDefined()); const disposal = core.dispose(); finishInitialization?.(); await expect(initialization).rejects.toThrow( 'Core initialization stopped because Core is disposing.' ); await disposal; }); it('stops the physics interval and disposes physics', async () => { vi.useFakeTimers(); const physicsStep = vi.fn(); const dispose = vi.fn(); core.physics = { physicsStep, dispose, timestep: 0.01, } as unknown as Core['physics']; const interval = setInterval(physicsStep, 10); ( core as unknown as {physicsInterval?: ReturnType} ).physicsInterval = interval; await core.dispose(); physicsStep.mockClear(); vi.advanceTimersByTime(100); expect(physicsStep).not.toHaveBeenCalled(); expect(dispose).toHaveBeenCalledOnce(); vi.useRealTimers(); }); it('reports script callback errors through ScriptsManager events', async () => { const script = new Script(); vi.spyOn(script, 'update').mockImplementation(() => { throw new Error('update failed'); }); vi.spyOn(console, 'error').mockImplementation(() => {}); const listener = vi.fn(); core.scriptsManager.addEventListener( ScriptsManagerEventType.EXCEPTION, listener ); await scripts(core).initScript(script); scripts(core).update(0, {} as XRFrame); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ context: 'update', error: expect.objectContaining({message: 'update failed'}), }) ); }); it('disposes depth after scripts and before physics and the renderer', async () => { const disposeDepth = vi.spyOn(core.depth, 'dispose'); const disposeScripts = vi.spyOn(scripts(core), 'dispose'); const disposePhysics = vi.fn(); const disposeRenderer = vi.fn(); core.physics = {dispose: disposePhysics} as unknown as Core['physics']; core.renderer.dispose = disposeRenderer; await core.dispose(); await core.dispose(); expect(disposeDepth).toHaveBeenCalledOnce(); expect(disposeScripts.mock.invocationCallOrder[0]).toBeLessThan( disposeDepth.mock.invocationCallOrder[0] ); expect(disposeDepth.mock.invocationCallOrder[0]).toBeLessThan( disposePhysics.mock.invocationCallOrder[0] ); expect(disposeDepth.mock.invocationCallOrder[0]).toBeLessThan( disposeRenderer.mock.invocationCallOrder[0] ); }); it('still disposes the renderer when depth cleanup fails', async () => { vi.spyOn(core.depth, 'dispose').mockImplementationOnce(() => { throw new Error('depth disposal failed'); }); const disposeRenderer = vi.fn(); core.renderer.dispose = disposeRenderer; await expect(core.dispose()).rejects.toThrow('depth disposal failed'); expect(disposeRenderer).toHaveBeenCalledOnce(); expect(core.lifecycle).toBe('disposed'); Core.instance = undefined; }); it('keeps depth resources across XR exit and re-entry', async () => { vi.spyOn( core as unknown as {initialize(options: Options): Promise}, 'initialize' ).mockResolvedValue(); await core.init(core.options); core.depth.init( core.camera, new DepthOptions({enabled: true, depthMesh: {enabled: true}}), core.renderer, core.registry, core.scene ); const mesh = core.depth.depthMesh; const disposeDepth = vi.spyOn(core.depth, 'dispose'); const initDepth = vi.spyOn(core.depth, 'init'); const data = new Float32Array([1, 2, 3, 4]); core.depth.depthArray[0] = data; const script = new Script(); const onStart = vi.spyOn(script, 'onXRSessionStarted'); const onEnd = vi.spyOn(script, 'onXRSessionEnded'); await scripts(core).initScript(script); const firstSession = Object.assign(new EventTarget(), { requestReferenceSpace: vi.fn().mockResolvedValue({}), }) as unknown as XRSession; const secondSession = Object.assign(new EventTarget(), { requestReferenceSpace: vi.fn().mockResolvedValue({}), }) as unknown as XRSession; await core['onXRSessionStarted'](firstSession); firstSession.dispatchEvent(new Event('end')); core['onXRSessionEnded'](); await core['onXRSessionStarted'](secondSession); expect(onStart).toHaveBeenNthCalledWith(1, firstSession); expect(onEnd).toHaveBeenCalledOnce(); expect(onStart).toHaveBeenNthCalledWith(2, secondSession); expect(disposeDepth).not.toHaveBeenCalled(); expect(initDepth).not.toHaveBeenCalled(); expect(core.depth.depthArray[0]).toBe(data); expect(core.depth.depthMesh).toBe(mesh); expect(mesh?.parent).toBe(core.scene); expect(core.lifecycle).toBe('running'); }); it('exposes additive blend mode without changing the app theme', async () => { const originalTheme = ui.theme; ui.theme = 'colorful'; (core as unknown as {lifecycleState: string}).lifecycleState = 'running'; const session = Object.assign(new EventTarget(), { environmentBlendMode: 'additive', requestReferenceSpace: vi.fn().mockResolvedValue({}), }) as unknown as XRSession; try { await core['onXRSessionStarted'](session); expect(core.environmentBlendMode).toBe('additive'); expect(ui.theme).toBe(colorfulTheme); core['onXRSessionEnded'](); expect(core.environmentBlendMode).toBeUndefined(); expect(ui.theme).toBe(colorfulTheme); } finally { core['onXRSessionEnded'](); ui.theme = originalTheme; } }); it('handles an XR session end without a preceding start', () => { const originalTheme = ui.theme; ui.theme = 'colorful'; (core as unknown as {lifecycleState: string}).lifecycleState = 'running'; try { core['onXRSessionEnded'](); expect(core.environmentBlendMode).toBeUndefined(); expect(ui.theme).toBe(colorfulTheme); } finally { ui.theme = originalTheme; } }); it('runs script callbacks and renders through one Core frame', async () => { const script = new Script(); const update = vi.spyOn(script, 'update'); await scripts(core).initScript(script); ( core as unknown as {update: (time: number, frame: XRFrame) => void} ).update(1000, {} as XRFrame); expect(update).toHaveBeenCalledWith(1000, expect.anything()); expect(core.renderer.render).toHaveBeenCalledWith(core.scene, core.camera); }); it.each([false, true])( 'updates the headset camera before scripts (postprocessing: %s)', async (postprocessing) => { if (postprocessing) { core.effects = { render: vi.fn(), dispose: vi.fn(), } as unknown as Core['effects']; } const observedPosition = new THREE.Vector3(); const script = new Script(); vi.spyOn(script, 'update').mockImplementation(() => { observedPosition.copy(core.camera.position); }); await scripts(core).initScript(script); core.renderer.xr.isPresenting = true; core.renderer.xr.updateCamera = vi.fn((camera: THREE.Camera) => { camera.position.set(0.4, 1.65, -0.2); camera.updateMatrixWorld(); }); ( core as unknown as {update: (time: number, frame: XRFrame) => void} ).update(1000, {} as XRFrame); expect(core.renderer.xr.updateCamera).toHaveBeenCalledWith(core.camera); expect(observedPosition.toArray()).toEqual([0.4, 1.65, -0.2]); } ); it('shares one in-flight simulator start and ignores later starts once running', async () => { vi.spyOn( core as unknown as {initialize(options: Options): Promise}, 'initialize' ).mockResolvedValue(); await core.init(core.options); const moduleLoaded = Promise.withResolvers>>(); const initStarted = Promise.withResolvers