import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { useLoad3dViewer } from '@/composables/useLoad3dViewer' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import { createLoad3d } from '@/extensions/core/load3d/createLoad3d' import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { useToastStore } from '@/platform/updates/common/toastStore' import { useLoad3dService } from '@/services/load3dService' import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' vi.mock('@/services/load3dService', () => ({ useLoad3dService: vi.fn() })) vi.mock('@/platform/updates/common/toastStore', () => ({ useToastStore: vi.fn() })) vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({ default: { uploadFile: vi.fn(), splitFilePath: vi.fn((path: string) => { const parts = path.split('/') return [parts.slice(0, -1).join('/'), parts[parts.length - 1] ?? ''] }), getResourceURL: vi.fn( (subfolder: string, filename: string, type: string) => `api/view?type=${type}&subfolder=${encodeURIComponent(subfolder)}&filename=${filename}` ) } })) vi.mock('@/scripts/api', () => ({ api: { apiURL: vi.fn((url: string) => `/${url}`) } })) vi.mock('@/i18n', () => ({ t: vi.fn((key) => key) })) vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: vi.fn() })) vi.mock('@/extensions/core/load3d/createLoad3d', () => ({ createLoad3d: vi.fn() })) function createMockSceneManager(): Load3d['sceneManager'] { const mock: Partial = { scene: {} as Load3d['sceneManager']['scene'], backgroundScene: {} as Load3d['sceneManager']['backgroundScene'], backgroundCamera: {} as Load3d['sceneManager']['backgroundCamera'], currentBackgroundColor: '#282828', gridHelper: { visible: true } as Load3d['sceneManager']['gridHelper'], getCurrentBackgroundInfo: vi.fn().mockReturnValue({ type: 'color', value: '#282828' }) } return mock as Load3d['sceneManager'] } describe('useLoad3dViewer', () => { let mockLoad3d: Partial let mockSourceLoad3d: Partial let mockLoad3dService: ReturnType let mockToastStore: ReturnType let mockNode: LGraphNode beforeEach(() => { vi.clearAllMocks() mockNode = createMockLGraphNode({ properties: { 'Scene Config': { backgroundColor: '#282828', showGrid: true, backgroundImage: '', backgroundRenderMode: 'tiled' }, 'Camera Config': { cameraType: 'perspective', fov: 75 }, 'Light Config': { intensity: 1 }, 'Model Config': { upDirection: 'original', materialMode: 'original' }, 'Resource Folder': '' }, graph: { setDirtyCanvas: vi.fn() } as Partial as LGraph, widgets: [] }) mockLoad3d = { setBackgroundColor: vi.fn(), toggleGrid: vi.fn(), toggleCamera: vi.fn(), setFOV: vi.fn(), setLightIntensity: vi.fn(), setBackgroundImage: vi.fn().mockResolvedValue(undefined), setUpDirection: vi.fn(), setMaterialMode: vi.fn(), exportModel: vi.fn().mockResolvedValue(undefined), handleResize: vi.fn(), updateStatusMouseOnViewer: vi.fn(), getCameraState: vi.fn().mockReturnValue({ position: { x: 0, y: 0, z: 0 }, target: { x: 0, y: 0, z: 0 }, zoom: 1, cameraType: 'perspective' }), forceRender: vi.fn(), remove: vi.fn(), setTargetSize: vi.fn(), loadModel: vi.fn().mockResolvedValue(undefined), setCameraState: vi.fn(), addEventListener: vi.fn(), hasAnimations: vi.fn().mockReturnValue(false), isSplatModel: vi.fn().mockReturnValue(false), isPlyModel: vi.fn().mockReturnValue(false), getCurrentModelCapabilities: vi.fn().mockReturnValue({ fitToViewer: true, requiresMaterialRebuild: false, gizmoTransform: true, lighting: true, exportable: true, materialModes: ['original', 'normal', 'wireframe'], fitTargetSize: 5 }), setGizmoEnabled: vi.fn(), setGizmoMode: vi.fn(), setBackgroundRenderMode: vi.fn(), getGizmoTransform: vi.fn().mockReturnValue({ position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } }) } mockSourceLoad3d = { getCurrentCameraType: vi.fn().mockReturnValue('perspective'), getCameraState: vi.fn().mockReturnValue({ position: { x: 1, y: 1, z: 1 }, target: { x: 0, y: 0, z: 0 }, zoom: 1, cameraType: 'perspective' }), sceneManager: createMockSceneManager(), lightingManager: { lights: [null, { intensity: 1 }] } as Load3d['lightingManager'], cameraManager: { perspectiveCamera: { fov: 75 } } as Load3d['cameraManager'], modelManager: { currentUpDirection: 'original', materialMode: 'original' } as Load3d['modelManager'], setBackgroundImage: vi.fn().mockResolvedValue(undefined), setBackgroundRenderMode: vi.fn(), forceRender: vi.fn() } vi.mocked(Load3d).mockImplementation(function () { Object.assign(this, mockLoad3d) }) vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d) mockLoad3dService = { copyLoad3dState: vi.fn().mockResolvedValue(undefined), handleViewportRefresh: vi.fn(), getLoad3d: vi.fn().mockReturnValue(mockSourceLoad3d) } as Partial> as ReturnType< typeof useLoad3dService > vi.mocked(useLoad3dService).mockReturnValue(mockLoad3dService) mockToastStore = { addAlert: vi.fn() } as Partial> as ReturnType< typeof useToastStore > vi.mocked(useToastStore).mockReturnValue(mockToastStore) }) afterEach(() => { vi.restoreAllMocks() }) describe('initialization', () => { it('should initialize viewer with source Load3d state', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(createLoad3d).toHaveBeenCalledWith(containerRef, { width: undefined, height: undefined, getDimensions: undefined, isViewerMode: false }) expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith( mockSourceLoad3d, mockLoad3d ) expect(viewer.cameraType.value).toBe('perspective') expect(viewer.backgroundColor.value).toBe('#282828') expect(viewer.showGrid.value).toBe(true) expect(viewer.lightIntensity.value).toBe(1) expect(viewer.fov.value).toBe(75) expect(viewer.upDirection.value).toBe('original') expect(viewer.materialMode.value).toBe('original') }) it('should handle background image during initialization', async () => { vi.mocked( mockSourceLoad3d.sceneManager!.getCurrentBackgroundInfo ).mockReturnValue({ type: 'image', value: '' }) ;( mockNode.properties!['Scene Config'] as Record ).backgroundImage = 'test-image.jpg' const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(viewer.backgroundImage.value).toBe('test-image.jpg') expect(viewer.hasBackgroundImage.value).toBe(true) }) it('should handle initialization errors', async () => { vi.mocked(createLoad3d).mockImplementationOnce(() => { throw new Error('Load3d creation failed') }) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(mockToastStore.addAlert).toHaveBeenCalledWith( 'toastMessages.failedToInitializeLoad3dViewer' ) }) }) describe('error handling', () => { it('should handle watcher errors gracefully', async () => { vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce( function () { throw new Error('Color update failed') } ) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundColor.value = '#ff0000' await nextTick() expect(mockToastStore.addAlert).toHaveBeenCalledWith( 'toastMessages.failedToUpdateBackgroundColor' ) }) }) describe('exportModel', () => { it('should export model successfully', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) await viewer.exportModel('glb') expect(mockLoad3d.exportModel).toHaveBeenCalledWith('glb') }) it('should handle export errors', async () => { vi.mocked(mockLoad3d.exportModel!).mockRejectedValueOnce( new Error('Export failed') ) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) await viewer.exportModel('glb') expect(mockToastStore.addAlert).toHaveBeenCalledWith( 'toastMessages.failedToExportModel' ) }) it('should not export when load3d is not initialized', async () => { const viewer = useLoad3dViewer(mockNode) await viewer.exportModel('glb') expect(mockLoad3d.exportModel).not.toHaveBeenCalled() }) }) describe('UI interaction methods', () => { it('should handle resize', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.handleResize() expect(mockLoad3d.handleResize).toHaveBeenCalled() }) it('should handle mouse enter', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.handleMouseEnter() expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true) }) it('should handle mouse leave', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.handleMouseLeave() expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false) }) it('should sync hover state when mouseenter fires before init', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') viewer.handleMouseEnter() await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true) }) }) describe('restoreInitialState', () => { it('should restore all properties to initial values', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) ;( mockNode.properties!['Scene Config'] as Record ).backgroundColor = '#ff0000' ;( mockNode.properties!['Scene Config'] as Record ).showGrid = false viewer.restoreInitialState() expect( (mockNode.properties!['Scene Config'] as Record) .backgroundColor ).toBe('#282828') expect( (mockNode.properties!['Scene Config'] as Record) .showGrid ).toBe(true) expect( (mockNode.properties!['Camera Config'] as Record) .cameraType ).toBe('perspective') expect( (mockNode.properties!['Camera Config'] as Record).fov ).toBe(75) expect( (mockNode.properties!['Light Config'] as Record) .intensity ).toBe(1) }) it('should preserve unknown fields on Model Config when restoring', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) ;( mockNode.properties!['Model Config'] as Record ).futureField = 'preserve-me' viewer.restoreInitialState() expect( (mockNode.properties!['Model Config'] as Record) .futureField ).toBe('preserve-me') }) }) describe('applyChanges', () => { it('should apply all changes to source and node', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundColor.value = '#ff0000' viewer.showGrid.value = false const result = await viewer.applyChanges() expect(result).toBe(true) expect( (mockNode.properties!['Scene Config'] as Record) .backgroundColor ).toBe('#ff0000') expect( (mockNode.properties!['Scene Config'] as Record) .showGrid ).toBe(false) expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith( mockLoad3d, mockSourceLoad3d ) expect(mockSourceLoad3d.forceRender).toHaveBeenCalled() expect(mockNode.graph!.setDirtyCanvas).toHaveBeenCalledWith(true, true) }) it('should handle background image during apply', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundImage.value = 'new-bg.jpg' await viewer.applyChanges() expect(mockSourceLoad3d.setBackgroundImage).toHaveBeenCalledWith( 'new-bg.jpg' ) }) it('should return false when no load3d instances', async () => { const viewer = useLoad3dViewer(mockNode) const result = await viewer.applyChanges() expect(result).toBe(false) }) it('should preserve unknown fields on Model Config when applying', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) ;( mockNode.properties!['Model Config'] as Record ).futureField = 'preserve-me' await viewer.applyChanges() expect( (mockNode.properties!['Model Config'] as Record) .futureField ).toBe('preserve-me') }) }) describe('refreshViewport', () => { it('should refresh viewport', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.refreshViewport() expect(mockLoad3dService.handleViewportRefresh).toHaveBeenCalledWith( mockLoad3d ) }) }) describe('handleBackgroundImageUpdate', () => { it('should upload and set background image', async () => { vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce( 'uploaded-image.jpg' ) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) await viewer.handleBackgroundImageUpdate(file) expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d') expect(viewer.backgroundImage.value).toBe('uploaded-image.jpg') expect(viewer.hasBackgroundImage.value).toBe(true) }) it('should use resource folder for upload', async () => { mockNode.properties['Resource Folder'] = 'subfolder' vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce( 'uploaded-image.jpg' ) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) await viewer.handleBackgroundImageUpdate(file) expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder') }) it('should clear background image when file is null', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.backgroundImage.value = 'existing.jpg' viewer.hasBackgroundImage.value = true await viewer.handleBackgroundImageUpdate(null) expect(viewer.backgroundImage.value).toBe('') expect(viewer.hasBackgroundImage.value).toBe(false) }) it('should handle upload errors', async () => { vi.mocked(Load3dUtils.uploadFile).mockRejectedValueOnce( new Error('Upload failed') ) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) await viewer.handleBackgroundImageUpdate(file) expect(mockToastStore.addAlert).toHaveBeenCalledWith( 'toastMessages.failedToUploadBackgroundImage' ) }) it('should work in standalone mode without a node', async () => { vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce( 'uploaded-image.jpg' ) const viewer = useLoad3dViewer() const containerRef = document.createElement('div') await viewer.initializeStandaloneViewer(containerRef, 'model.glb') const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) await viewer.handleBackgroundImageUpdate(file) expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d') expect(viewer.backgroundImage.value).toBe('uploaded-image.jpg') }) }) describe('handleModelDrop', () => { it('refreshes the capability refs after the dropped model loads, so the sidebar reflects the new model', async () => { vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce( '3d/dropped.splat' ) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(viewer.canUseLighting.value).toBe(true) expect(viewer.canUseGizmo.value).toBe(true) expect(viewer.canExport.value).toBe(true) expect([...viewer.materialModes.value]).toEqual([ 'original', 'normal', 'wireframe' ]) vi.mocked(mockLoad3d.isSplatModel!).mockReturnValueOnce(true) vi.mocked(mockLoad3d.getCurrentModelCapabilities!).mockReturnValueOnce({ fitToViewer: true, requiresMaterialRebuild: false, gizmoTransform: true, lighting: false, exportable: false, materialModes: [], fitTargetSize: 20 }) const file = new File([''], 'dropped.splat') await viewer.handleModelDrop(file) expect(mockLoad3d.loadModel).toHaveBeenCalledWith( expect.stringContaining('dropped.splat') ) expect(viewer.canUseLighting.value).toBe(false) expect(viewer.canExport.value).toBe(false) expect(viewer.isSplatModel.value).toBe(true) expect([...viewer.materialModes.value]).toEqual([]) }) it('alerts and does not call loadModel when there is no active load3d instance', async () => { const viewer = useLoad3dViewer(mockNode) const file = new File([''], 'whatever.glb') await viewer.handleModelDrop(file) expect(mockToastStore.addAlert).toHaveBeenCalledWith( 'toastMessages.no3dScene' ) expect(mockLoad3d.loadModel).not.toHaveBeenCalled() }) it('alerts and skips loadModel when the file upload fails', async () => { vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('') const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) vi.mocked(mockLoad3d.loadModel!).mockClear() const file = new File([''], 'whatever.glb') await viewer.handleModelDrop(file) expect(mockToastStore.addAlert).toHaveBeenCalledWith( 'toastMessages.fileUploadFailed' ) expect(mockLoad3d.loadModel).not.toHaveBeenCalled() }) }) describe('cleanup', () => { it('should clean up resources', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.cleanup() expect(mockLoad3d.remove).toHaveBeenCalled() }) it('should handle cleanup when not initialized', () => { const viewer = useLoad3dViewer(mockNode) expect(() => viewer.cleanup()).not.toThrow() }) }) describe('edge cases', () => { it('should handle missing container ref', async () => { const viewer = useLoad3dViewer(mockNode) await viewer.initializeViewer(null!, mockSourceLoad3d as Load3d) expect(Load3d).not.toHaveBeenCalled() }) it('should handle orthographic camera', async () => { vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue( 'orthographic' ) mockSourceLoad3d.cameraManager = { perspectiveCamera: { fov: 75 } } as Partial as Load3d['cameraManager'] delete (mockNode.properties!['Camera Config'] as Record) .cameraType const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(viewer.cameraType.value).toBe('orthographic') }) it('should handle missing lights', async () => { mockSourceLoad3d.lightingManager!.lights = [] const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(viewer.lightIntensity.value).toBe(1) // Default value }) }) describe('standalone mode persistence', () => { it('should save and restore configuration in standalone mode', async () => { const viewer = useLoad3dViewer() const containerRef = document.createElement('div') const model1 = 'model1.glb' const model2 = 'model2.glb' await viewer.initializeStandaloneViewer(containerRef, model1) expect(viewer.isStandaloneMode.value).toBe(true) viewer.backgroundColor.value = '#ff0000' viewer.showGrid.value = false viewer.cameraType.value = 'orthographic' viewer.fov.value = 45 viewer.lightIntensity.value = 2 viewer.backgroundImage.value = 'test.jpg' viewer.backgroundRenderMode.value = 'tiled' viewer.upDirection.value = '+y' viewer.materialMode.value = 'wireframe' await nextTick() await viewer.initializeStandaloneViewer(containerRef, model2) expect(viewer.backgroundColor.value).toBe('#282828') expect(viewer.showGrid.value).toBe(true) expect(viewer.backgroundImage.value).toBe('') await viewer.initializeStandaloneViewer(containerRef, model1) expect(viewer.backgroundColor.value).toBe('#ff0000') expect(viewer.showGrid.value).toBe(false) expect(viewer.cameraType.value).toBe('orthographic') expect(viewer.fov.value).toBe(45) expect(viewer.lightIntensity.value).toBe(2) expect(viewer.backgroundImage.value).toBe('test.jpg') expect(viewer.hasBackgroundImage.value).toBe(true) expect(viewer.backgroundRenderMode.value).toBe('tiled') expect(viewer.upDirection.value).toBe('+y') expect(viewer.materialMode.value).toBe('wireframe') await viewer.initializeStandaloneViewer(containerRef, model2) expect(viewer.backgroundColor.value).toBe('#282828') }) it('should save configuration during cleanup in standalone mode', async () => { const viewer = useLoad3dViewer() const containerRef = document.createElement('div') const modelUrl = 'model_cleanup.glb' await viewer.initializeStandaloneViewer(containerRef, modelUrl) viewer.backgroundColor.value = '#0000ff' await nextTick() viewer.cleanup() const newViewer = useLoad3dViewer() await newViewer.initializeStandaloneViewer(containerRef, modelUrl) expect(newViewer.backgroundColor.value).toBe('#0000ff') }) }) describe('gizmo controls', () => { it('should initialize gizmo state from node model config', async () => { ;(mockNode.properties!['Model Config'] as Record).gizmo = { enabled: true, mode: 'rotate' } const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(viewer.gizmoEnabled.value).toBe(true) expect(viewer.gizmoMode.value).toBe('rotate') }) it('should default gizmo to disabled translate when no config', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) expect(viewer.gizmoEnabled.value).toBe(false) expect(viewer.gizmoMode.value).toBe('translate') }) it('should persist gizmo state in applyChanges', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.gizmoEnabled.value = true viewer.gizmoMode.value = 'rotate' await viewer.applyChanges() const modelConfig = mockNode.properties!['Model Config'] as Record< string, unknown > const gizmo = modelConfig.gizmo as Record expect(gizmo.enabled).toBe(true) expect(gizmo.mode).toBe('rotate') }) it('should save gizmo transform from load3d in applyChanges', async () => { vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue({ position: { x: 1, y: 2, z: 3 }, rotation: { x: 0.1, y: 0.2, z: 0.3 }, scale: { x: 2, y: 2, z: 2 } }) const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) await viewer.applyChanges() const modelConfig = mockNode.properties!['Model Config'] as Record< string, unknown > const gizmo = modelConfig.gizmo as { position: { x: number; y: number; z: number } rotation: { x: number; y: number; z: number } scale: { x: number; y: number; z: number } } expect(gizmo.position).toEqual({ x: 1, y: 2, z: 3 }) expect(gizmo.rotation).toEqual({ x: 0.1, y: 0.2, z: 0.3 }) expect(gizmo.scale).toEqual({ x: 2, y: 2, z: 2 }) }) it('should restore gizmo state in restoreInitialState', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) viewer.gizmoEnabled.value = true viewer.gizmoMode.value = 'rotate' viewer.restoreInitialState() const modelConfig = mockNode.properties!['Model Config'] as Record< string, unknown > const gizmo = modelConfig.gizmo as Record expect(gizmo.enabled).toBe(false) expect(gizmo.mode).toBe('translate') }) it('should restore gizmo state from standalone config cache', async () => { const viewer = useLoad3dViewer() const containerRef = document.createElement('div') const model1 = 'gizmo_model1.glb' await viewer.initializeStandaloneViewer(containerRef, model1) viewer.gizmoEnabled.value = true viewer.gizmoMode.value = 'rotate' await nextTick() viewer.cleanup() const restoredViewer = useLoad3dViewer() await restoredViewer.initializeStandaloneViewer(containerRef, model1) expect(restoredViewer.gizmoEnabled.value).toBe(true) expect(restoredViewer.gizmoMode.value).toBe('rotate') }) }) })