Files
ComfyUI_frontend/src/composables/useLoad3d.test.ts
Terry Jia 861d737041 FE-702: rehydrate 3D viewer on subgraph re-entry via persistent ready hook (#12294)
## Summary
When a Preview3D / Load3D / SaveGLB node lives inside a subgraph, the 3D
viewer correctly displays the model the first time you enter the
subgraph but is blank after exiting and re-entering — even though
`node.properties['Last Time Model File']` is still populated and the
underlying file is on disk.

Fix: introduce a persistent companion to `waitForLoad3d` in
`useLoad3d.ts`:

- `onLoad3dReady(callback)` — registers a callback that fires on *every*
(re-)initialization of the `Load3d` instance for a given node, not just
the first one. Cleared automatically when the node is removed from the
graph (chained into `node.onRemoved` alongside the existing
`pendingCallbacks` cleanup).
- `waitForLoad3d` keeps its original one-shot semantics so callbacks
that install per-node side effects (e.g. wrapping `node.onExecuted`,
setting `sceneWidget.serializeValue`) do not chain on remount.
- When `onLoad3dReady` is registered after a `Load3d` instance already
exists, the callback fires synchronously as well, so the same code path
covers both initial setup and subsequent rehydrations.

Preview3D / Load3D / SaveGLB move the "reapply state from
`node.properties` / `model_file` widget to the Load3d viewer" block from
`waitForLoad3d` to `onLoad3dReady`.
First mount and every subsequent remount now run identical rehydration
code, with `node.properties['Last Time Model File']` (already
workflow-JSON-serialised) as the single source of truth.

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/e4b0fe6f-c898-4210-b545-7ad6883ed722

after

https://github.com/user-attachments/assets/a4a28490-071d-4694-87a8-5eaa501ac168

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12294-FE-702-rehydrate-3D-viewer-on-subgraph-re-entry-via-persistent-ready-hook-3616d73d3650811e93e7dedb32762711)
by [Unito](https://www.unito.io)
2026-05-16 05:42:04 -04:00

1696 lines
55 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { Pinia } from 'pinia'
import { getActivePinia } from 'pinia'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import type { Size } from '@/lib/litegraph/src/interfaces'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import {
createMockCanvasPointerEvent,
createMockLGraphNode
} from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/extensions/core/load3d/Load3d', () => ({
default: vi.fn()
}))
vi.mock('@/extensions/core/load3d/createLoad3d', () => ({
createLoad3d: vi.fn()
}))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn(),
uploadFile: vi.fn(),
mapSceneLightIntensityToHdri: vi.fn(
(scene: number, min: number, max: number) => {
const span = max - min
const t = span > 0 ? (scene - min) / span : 0
const clampedT = Math.min(1, Math.max(0, t))
const mapped = clampedT * 5
const minHdri = 0.25
return Math.min(5, Math.max(minHdri, mapped))
}
)
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getServerFeature: vi.fn(() => false)
}
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
getActivePinia: vi.fn(() => null)
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
isAssetPreviewSupported: vi.fn(() => false),
persistThumbnail: vi.fn().mockResolvedValue(undefined)
}))
describe('useLoad3d', () => {
let mockLoad3d: Partial<Load3d>
let mockNode: LGraphNode
let mockToastStore: ReturnType<typeof useToastStore>
beforeEach(() => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
mockNode = createMockLGraphNode({
properties: {
'Scene Config': {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
},
'Model Config': {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
},
'Camera Config': {
cameraType: 'perspective',
fov: 75,
state: null
},
'Light Config': {
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
},
'Resource Folder': ''
},
widgets: [
{ name: 'width', value: 512, type: 'number' } as IWidget,
{ name: 'height', value: 512, type: 'number' } as IWidget
],
graph: {
setDirtyCanvas: vi.fn()
} as Partial<LGraph> as LGraph,
flags: {},
onMouseEnter: undefined,
onMouseLeave: undefined,
onResize: undefined,
onDrawBackground: undefined
})
const mockCanvas = document.createElement('canvas')
mockCanvas.hidden = false
mockLoad3d = {
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setCameraState: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
refreshViewport: vi.fn(),
updateStatusMouseOnNode: vi.fn(),
updateStatusMouseOnScene: vi.fn(),
handleResize: vi.fn(),
toggleAnimation: vi.fn(),
setAnimationSpeed: vi.fn(),
updateSelectedAnimation: vi.fn(),
startRecording: vi.fn().mockResolvedValue(undefined),
stopRecording: vi.fn(),
getRecordingDuration: vi.fn().mockReturnValue(10),
exportRecording: vi.fn(),
clearRecording: vi.fn(),
exportModel: vi.fn().mockResolvedValue(undefined),
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
}),
hasSkeleton: vi.fn().mockReturnValue(false),
setShowSkeleton: vi.fn(),
loadHDRI: vi.fn().mockResolvedValue(undefined),
setHDRIEnabled: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIIntensity: vi.fn(),
clearHDRI: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
setGizmoEnabled: vi.fn(),
setGizmoMode: vi.fn(),
resetGizmoTransform: vi.fn(),
applyGizmoTransform: vi.fn(),
fitToViewer: 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 }
}),
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
setAnimationTime: vi.fn(),
renderer: {
domElement: mockCanvas
} as Partial<Load3d['renderer']> as Load3d['renderer']
}
vi.mocked(Load3d).mockImplementation(function (this: Load3d) {
Object.assign(this, mockLoad3d)
return this
})
vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d)
mockToastStore = {
addAlert: vi.fn()
} as Partial<ReturnType<typeof useToastStore>> as ReturnType<
typeof useToastStore
>
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initialization', () => {
it('should initialize Load3d with container and node', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(createLoad3d).toHaveBeenCalledWith(
containerRef,
expect.objectContaining({
width: 512,
height: 512,
getDimensions: expect.any(Function),
onContextMenu: expect.any(Function)
})
)
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
})
it('should restore configurations from node', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true)
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000')
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled')
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75)
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
})
it('should set up node event handlers', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockNode.onMouseEnter).toBeDefined()
expect(mockNode.onMouseLeave).toBeDefined()
expect(mockNode.onResize).toBeDefined()
expect(mockNode.onDrawBackground).toBeDefined()
// Test the handlers
mockNode.onMouseEnter?.(createMockCanvasPointerEvent(0, 0))
expect(mockLoad3d.refreshViewport).toHaveBeenCalled()
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(true)
mockNode.onMouseLeave?.(createMockCanvasPointerEvent(0, 0))
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(false)
mockNode.onResize?.([512, 512] as Size)
expect(mockLoad3d.handleResize).toHaveBeenCalled()
})
it('should handle collapsed state', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.flags.collapsed = true
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
})
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
mockNode.widgets!.push({
name: 'model_file',
value: 'test.glb',
type: 'text'
} as IWidget)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
})
it('should restore camera config from node properties', async () => {
;(
mockNode.properties!['Camera Config'] as Record<string, unknown>
).state = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
}
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
expect(composable.cameraConfig.value.state).toEqual({
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
})
})
it('should set preview mode when no width/height widgets', async () => {
mockNode.widgets = []
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.isPreview.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToInitializeLoad3dViewer'
)
})
it('should handle missing container or node', async () => {
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(null!)
expect(createLoad3d).not.toHaveBeenCalled()
})
it('should accept ref as parameter', () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
})
it('passes getZoomScale callback to createLoad3d', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(createLoad3d).toHaveBeenCalledWith(
containerRef,
expect.objectContaining({ getZoomScale: expect.any(Function) })
)
})
})
describe('zoom watcher', () => {
it('calls load3d.handleResize after debounce when canvas appScalePercentage changes', async () => {
vi.useFakeTimers()
const canvasStore = reactive({ appScalePercentage: 100 })
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
canvasStore as unknown as ReturnType<typeof useCanvasStore>
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
vi.mocked(mockLoad3d.handleResize!).mockClear()
canvasStore.appScalePercentage = 200
await nextTick()
expect(mockLoad3d.handleResize).not.toHaveBeenCalled()
vi.advanceTimersByTime(150)
expect(mockLoad3d.handleResize).toHaveBeenCalledOnce()
vi.useRealTimers()
})
it('debounces rapid zoom changes into a single handleResize call', async () => {
vi.useFakeTimers()
const canvasStore = reactive({ appScalePercentage: 100 })
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
canvasStore as unknown as ReturnType<typeof useCanvasStore>
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
vi.mocked(mockLoad3d.handleResize!).mockClear()
canvasStore.appScalePercentage = 150
await nextTick()
canvasStore.appScalePercentage = 200
await nextTick()
canvasStore.appScalePercentage = 250
await nextTick()
vi.advanceTimersByTime(150)
expect(mockLoad3d.handleResize).toHaveBeenCalledOnce()
vi.useRealTimers()
})
})
describe('preserves existing node callbacks through initializeLoad3d', () => {
// Regression: FE-214 — undo triggers rootGraph.clear() which fires
// node.onRemoved on the outgoing node. addWidget() chains a cleanup that
// unregisters the component widget from the DOM widget store. If
// initializeLoad3d overwrites node.onRemoved instead of chaining, that
// cleanup is lost and the interactive UI persists with a stale reference.
it('chains node.onRemoved with a preexisting callback', async () => {
const existingOnRemoved = vi.fn()
mockNode.onRemoved = existingOnRemoved
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onRemoved?.()
expect(existingOnRemoved).toHaveBeenCalledTimes(1)
})
it('chains node.onResize with a preexisting callback', async () => {
const existingOnResize = vi.fn()
mockNode.onResize = existingOnResize
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onResize?.([512, 512] as Size)
expect(existingOnResize).toHaveBeenCalledTimes(1)
})
})
describe('waitForLoad3d', () => {
it('should execute callback immediately if Load3d exists', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const callback = vi.fn()
composable.waitForLoad3d(callback)
expect(callback).toHaveBeenCalledWith(mockLoad3d)
})
it('should queue callback if Load3d does not exist', () => {
const composable = useLoad3d(mockNode)
const callback = vi.fn()
composable.waitForLoad3d(callback)
expect(callback).not.toHaveBeenCalled()
})
it('should execute queued callbacks after initialization', async () => {
const composable = useLoad3d(mockNode)
const callback1 = vi.fn()
const callback2 = vi.fn()
composable.waitForLoad3d(callback1)
composable.waitForLoad3d(callback2)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(callback1).toHaveBeenCalledWith(mockLoad3d)
expect(callback2).toHaveBeenCalledWith(mockLoad3d)
})
})
describe('configuration watchers', () => {
it('should update scene config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
vi.mocked(mockLoad3d.toggleGrid!).mockClear()
vi.mocked(mockLoad3d.setBackgroundColor!).mockClear()
vi.mocked(mockLoad3d.setBackgroundImage!).mockClear()
composable.sceneConfig.value = {
showGrid: false,
backgroundColor: '#ffffff',
backgroundImage: 'test.jpg',
backgroundRenderMode: 'panorama'
}
await nextTick()
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ffffff')
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('test.jpg')
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith(
'panorama'
)
expect(mockNode.properties['Scene Config']).toEqual({
showGrid: false,
backgroundColor: '#ffffff',
backgroundImage: 'test.jpg',
backgroundRenderMode: 'panorama'
})
})
it('should update model config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
vi.mocked(mockLoad3d.setUpDirection!).mockClear()
vi.mocked(mockLoad3d.setMaterialMode!).mockClear()
composable.modelConfig.value.upDirection = '+y'
composable.modelConfig.value.materialMode = 'wireframe'
await nextTick()
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
const savedModelConfig = mockNode.properties['Model Config'] as Record<
string,
unknown
>
expect(savedModelConfig.upDirection).toBe('+y')
expect(savedModelConfig.materialMode).toBe('wireframe')
expect(savedModelConfig.showSkeleton).toBe(false)
})
it('should update camera config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
vi.mocked(mockLoad3d.setFOV!).mockClear()
composable.cameraConfig.value.cameraType = 'orthographic'
composable.cameraConfig.value.fov = 90
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'orthographic',
fov: 90,
state: null
})
})
it('should update light config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
vi.mocked(mockLoad3d.setLightIntensity!).mockClear()
composable.lightConfig.value.intensity = 10
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
expect(mockNode.properties['Light Config']).toMatchObject({
intensity: 10
})
})
})
describe('animation controls', () => {
it('should toggle animation playback', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.playing.value = true
await nextTick()
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
composable.playing.value = false
await nextTick()
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(false)
})
it('should update animation speed', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.selectedSpeed.value = 2
await nextTick()
expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
})
it('should update selected animation', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.selectedAnimation.value = 1
await nextTick()
expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(1)
})
})
describe('recording controls', () => {
it('should start recording', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await composable.handleStartRecording()
expect(mockLoad3d.startRecording).toHaveBeenCalled()
expect(composable.isRecording.value).toBe(true)
})
it('should stop recording', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleStopRecording()
expect(mockLoad3d.stopRecording).toHaveBeenCalled()
expect(composable.isRecording.value).toBe(false)
expect(composable.recordingDuration.value).toBe(10)
expect(composable.hasRecording.value).toBe(true)
})
it('should export recording with timestamp', async () => {
const dateSpy = vi
.spyOn(Date.prototype, 'toISOString')
.mockReturnValue('2024-01-01T12:00:00.000Z')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleExportRecording()
expect(mockLoad3d.exportRecording).toHaveBeenCalledWith(
'2024-01-01T12-00-00-000Z-scene-recording.mp4'
)
dateSpy.mockRestore()
})
it('should clear recording', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.hasRecording.value = true
composable.recordingDuration.value = 10
composable.handleClearRecording()
expect(mockLoad3d.clearRecording).toHaveBeenCalled()
expect(composable.hasRecording.value).toBe(false)
expect(composable.recordingDuration.value).toBe(0)
})
})
describe('background image handling', () => {
it('should upload and set background image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(composable.sceneConfig.value.backgroundImage).toBe(
'uploaded-image.jpg'
)
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith(
'uploaded-image.jpg'
)
})
it('should use resource folder for upload', async () => {
mockNode.properties!['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
})
it('should clear background image when file is null', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.sceneConfig.value.backgroundImage = 'existing.jpg'
await composable.handleBackgroundImageUpdate(null)
expect(composable.sceneConfig.value.backgroundImage).toBe('')
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('')
})
})
describe('model export', () => {
it('should export model successfully', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await composable.handleExportModel('glb')
expect(mockLoad3d.exportModel).toHaveBeenCalledWith('glb')
})
it('should show alert when no Load3d instance', async () => {
const composable = useLoad3d(mockNode)
await composable.handleExportModel('glb')
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dSceneToExport'
)
})
it('should handle export errors', async () => {
vi.mocked(mockLoad3d.exportModel!).mockRejectedValueOnce(
new Error('Export failed')
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await composable.handleExportModel('glb')
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToExportModel'
)
})
})
describe('mouse interactions', () => {
it('should handle mouse enter on scene', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleMouseEnter()
expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(true)
})
it('should handle mouse leave on scene', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleMouseLeave()
expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(false)
})
})
describe('event handling', () => {
it('should add event listeners on initialization', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const expectedEvents = [
'materialModeChange',
'backgroundColorChange',
'backgroundRenderModeChange',
'lightIntensityChange',
'fovChange',
'cameraTypeChange',
'showGridChange',
'upDirectionChange',
'backgroundImageChange',
'backgroundImageLoadingStart',
'backgroundImageLoadingEnd',
'modelLoadingStart',
'modelLoadingEnd',
'modelReady',
'skeletonVisibilityChange',
'exportLoadingStart',
'exportLoadingEnd',
'recordingStatusChange',
'animationListChange',
'animationProgressChange',
'cameraChanged'
]
expectedEvents.forEach((event) => {
expect(mockLoad3d.addEventListener).toHaveBeenCalledWith(
event,
expect.any(Function)
)
})
})
it('should handle materialModeChange event', async () => {
let materialModeHandler: ((mode: string) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'materialModeChange') {
materialModeHandler = handler as (mode: string) => void
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
materialModeHandler?.('wireframe')
expect(composable.modelConfig.value.materialMode).toBe('wireframe')
})
it('should handle loading events', async () => {
let modelLoadingStartHandler: (() => void) | undefined
let modelLoadingEndHandler: (() => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'modelLoadingStart') {
modelLoadingStartHandler = handler as () => void
} else if (event === 'modelLoadingEnd') {
modelLoadingEndHandler = handler as () => void
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
modelLoadingStartHandler?.()
expect(composable.loading.value).toBe(true)
expect(composable.loadingMessage.value).toBe('load3d.loadingModel')
modelLoadingEndHandler?.()
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('should handle recordingStatusChange event', async () => {
let recordingStatusHandler: ((status: boolean) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'recordingStatusChange') {
recordingStatusHandler = handler as (status: boolean) => void
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
recordingStatusHandler?.(false)
expect(composable.isRecording.value).toBe(false)
expect(composable.recordingDuration.value).toBe(10)
expect(composable.hasRecording.value).toBe(true)
})
})
describe('cleanup', () => {
it('should remove event listeners and clean up resources', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.cleanup()
expect(mockLoad3d.removeEventListener).toHaveBeenCalled()
expect(mockLoad3d.remove).toHaveBeenCalled()
expect(nodeToLoad3dMap.has(mockNode)).toBe(false)
})
it('should handle cleanup when not initialized', () => {
const composable = useLoad3d(mockNode)
expect(() => composable.cleanup()).not.toThrow()
})
})
describe('handleModelDrop', () => {
it('should upload file, construct URL, and load model', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/uploaded/model.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'model.glb', {
type: 'model/gltf-binary'
})
await composable.handleModelDrop(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/uploaded/model.glb'
)
})
it('should use resource folder for upload subfolder', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'uploaded',
'model.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/uploaded/model.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/uploaded/model.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'model.glb', {
type: 'model/gltf-binary'
})
await composable.handleModelDrop(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
})
it('should not load model when load3d is not initialized', async () => {
const composable = useLoad3d(mockNode)
const file = new File([''], 'model.glb', {
type: 'model/gltf-binary'
})
await composable.handleModelDrop(file)
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dScene'
)
})
})
describe('hdri controls', () => {
it('should call setHDRIEnabled when hdriConfig.enabled changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
}
await nextTick()
expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true)
})
it('should call setHDRIAsBackground when hdriConfig.showAsBackground changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, showAsBackground: true }
}
await nextTick()
expect(mockLoad3d.setHDRIAsBackground).toHaveBeenCalledWith(true)
})
it('should call setHDRIIntensity when hdriConfig.intensity changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, intensity: 2.5 }
}
await nextTick()
expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5)
})
it('should upload file, load HDRI and update hdriConfig', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=env.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=env.hdr'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'env.hdr', { type: 'image/x-hdr' })
await composable.handleHDRIFileUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith(
'http://localhost/view?filename=env.hdr'
)
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('3d/env.hdr')
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
})
it('should clear HDRI when file is null', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: {
enabled: true,
hdriPath: '3d/env.hdr',
showAsBackground: true,
intensity: 1
}
}
await composable.handleHDRIFileUpdate(null)
expect(mockLoad3d.clearHDRI).toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
})
})
describe('edge cases', () => {
it('should handle null node ref', () => {
const nodeRef = ref(null)
const composable = useLoad3d(nodeRef)
const callback = vi.fn()
composable.waitForLoad3d(callback)
expect(callback).not.toHaveBeenCalled()
})
it('should handle missing configurations', async () => {
delete mockNode.properties!['Scene Config']
delete mockNode.properties!['Model Config']
delete mockNode.properties!['Camera Config']
delete mockNode.properties!['Light Config']
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
// Should not throw and should use defaults
expect(createLoad3d).toHaveBeenCalled()
})
it('should handle background image with existing config', async () => {
;(
mockNode.properties!['Scene Config'] as {
backgroundImage: string
}
).backgroundImage = 'existing.jpg'
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
})
})
describe('gizmo controls', () => {
it('should include default gizmo config in modelConfig', () => {
const composable = useLoad3d(mockNode)
expect(composable.modelConfig.value.gizmo).toEqual({
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})
})
it('should restore gizmo config from node properties', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: true,
mode: 'rotate',
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 composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.modelConfig.value.gizmo).toEqual({
enabled: true,
mode: 'rotate',
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 2, y: 2, z: 2 }
})
})
it('should add default gizmo config when missing from saved config', async () => {
mockNode.properties!['Model Config'] = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
}
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.modelConfig.value.gizmo).toBeDefined()
expect(composable.modelConfig.value.gizmo!.enabled).toBe(false)
})
it('should add default scale when gizmo config lacks scale', async () => {
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
{
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 }
}
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.modelConfig.value.gizmo!.scale).toEqual({
x: 1,
y: 1,
z: 1
})
})
it('handleToggleGizmo should enable gizmo and update config', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
expect(mockLoad3d.setGizmoEnabled).toHaveBeenCalledWith(true)
expect(composable.modelConfig.value.gizmo!.enabled).toBe(true)
})
it('handleToggleGizmo should disable gizmo and update config', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
composable.handleToggleGizmo(false)
expect(mockLoad3d.setGizmoEnabled).toHaveBeenLastCalledWith(false)
expect(composable.modelConfig.value.gizmo!.enabled).toBe(false)
})
it('handleSetGizmoMode should set mode and update config', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleSetGizmoMode('rotate')
expect(mockLoad3d.setGizmoMode).toHaveBeenCalledWith('rotate')
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('handleResetGizmoTransform should call resetGizmoTransform', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleResetGizmoTransform()
expect(mockLoad3d.resetGizmoTransform).toHaveBeenCalled()
})
it('should persist gizmo config to node properties via modelConfig watcher', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
composable.handleSetGizmoMode('rotate')
await nextTick()
const savedConfig = mockNode.properties['Model Config'] as {
gizmo: { enabled: boolean; mode: string }
}
expect(savedConfig.gizmo.enabled).toBe(true)
expect(savedConfig.gizmo.mode).toBe('rotate')
})
it('should register gizmoTransformChange event handler', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const gizmoEventCall = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)
expect(gizmoEventCall).toBeDefined()
})
it('gizmoTransformChange event should update modelConfig', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const gizmoEventCall = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)
const handler = gizmoEventCall![1] as (data: unknown) => void
handler({
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0.5, y: 0.6, z: 0.7 },
scale: { x: 3, y: 3, z: 3 },
enabled: true,
mode: 'rotate'
})
expect(composable.modelConfig.value.gizmo!.position).toEqual({
x: 5,
y: 6,
z: 7
})
expect(composable.modelConfig.value.gizmo!.rotation).toEqual({
x: 0.5,
y: 0.6,
z: 0.7
})
expect(composable.modelConfig.value.gizmo!.scale).toEqual({
x: 3,
y: 3,
z: 3
})
expect(composable.modelConfig.value.gizmo!.enabled).toBe(true)
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('should reset gizmo config on model switch (not first load)', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleToggleGizmo(true)
composable.handleSetGizmoMode('rotate')
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const loadingStartCall = addEventCalls.find(
([event]) => event === 'modelLoadingStart'
)
const loadingStartHandler = loadingStartCall![1] as () => void
const loadingEndCall = addEventCalls.find(
([event]) => event === 'modelLoadingEnd'
)
const loadingEndHandler = loadingEndCall![1] as () => void
loadingEndHandler()
loadingStartHandler()
expect(composable.modelConfig.value.gizmo).toEqual({
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
})
})
it('should not call gizmo methods when load3d is not initialized', () => {
const composable = useLoad3d(mockNode)
// These should not throw
composable.handleToggleGizmo(true)
composable.handleSetGizmoMode('rotate')
composable.handleResetGizmoTransform()
expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled()
expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled()
expect(mockLoad3d.resetGizmoTransform).not.toHaveBeenCalled()
})
})
describe('handleFitToViewer', () => {
it('persists post-fit position and scale into modelConfig.gizmo so reload reapplies the transform via applyGizmoConfigToLoad3d', async () => {
const fitTransform = {
position: { x: 0, y: -1.25, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 0.42, y: 0.42, z: 0.42 }
}
vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue(fitTransform)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleFitToViewer()
expect(mockLoad3d.fitToViewer).toHaveBeenCalledOnce()
expect(composable.modelConfig.value.gizmo!.position).toEqual(
fitTransform.position
)
expect(composable.modelConfig.value.gizmo!.scale).toEqual(
fitTransform.scale
)
// Rotation is owned by upDirection — fit must not overwrite it.
expect(composable.modelConfig.value.gizmo!.rotation).toEqual({
x: 0,
y: 0,
z: 0
})
})
it('is a no-op when load3d is not initialized', () => {
const composable = useLoad3d(mockNode)
// No initializeLoad3d() call.
composable.handleFitToViewer()
expect(mockLoad3d.fitToViewer).not.toHaveBeenCalled()
})
it('does not throw when modelConfig.gizmo is missing', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.modelConfig.value.gizmo = undefined
expect(() => composable.handleFitToViewer()).not.toThrow()
expect(mockLoad3d.fitToViewer).toHaveBeenCalledOnce()
// Without a gizmo slot we silently skip persistence — getGizmoTransform
// is not called because the early-return saves the read.
expect(mockLoad3d.getGizmoTransform).not.toHaveBeenCalled()
})
})
describe('modelReady event handler (thumbnail capture)', () => {
let originalFetch: typeof globalThis.fetch
beforeEach(() => {
originalFetch = globalThis.fetch
globalThis.fetch = vi.fn().mockResolvedValue({
blob: () => Promise.resolve(new Blob(['x'], { type: 'image/png' }))
} as unknown as Response)
})
afterEach(() => {
globalThis.fetch = originalFetch
})
async function getModelReadyHandler() {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const call = vi
.mocked(mockLoad3d.addEventListener!)
.mock.calls.find(([event]) => event === 'modelReady')
return { composable, handler: call![1] as () => void }
}
it('registers a modelReady listener separate from modelLoadingEnd', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const events = vi
.mocked(mockLoad3d.addEventListener!)
.mock.calls.map(([event]) => event)
expect(events).toContain('modelReady')
expect(events).toContain('modelLoadingEnd')
expect(composable).toBeDefined()
})
it('does not call captureThumbnail when asset preview is unsupported', async () => {
const { isAssetPreviewSupported } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(false)
const { handler } = await getModelReadyHandler()
handler()
await Promise.resolve()
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
})
it('captures thumbnail and persists it when asset preview is supported and a model_file widget has a value', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'',
'cube.glb'
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
const modelWidget = {
name: 'model_file',
value: 'cube.glb [output]'
} as unknown as IWidget
mockNode.widgets = [modelWidget]
const { handler } = await getModelReadyHandler()
handler()
// Two awaits: one for captureThumbnail, one for fetch().blob() chain.
await new Promise((r) => setTimeout(r, 0))
expect(mockLoad3d.captureThumbnail).toHaveBeenCalledWith(256, 256)
expect(persistThumbnail).toHaveBeenCalledWith(
'cube.glb',
expect.any(Blob)
)
})
it('skips persistence when the model widget has no value', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
mockNode.widgets = [
{ name: 'model_file', value: '' } as unknown as IWidget
]
const { handler } = await getModelReadyHandler()
handler()
await new Promise((r) => setTimeout(r, 0))
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
expect(persistThumbnail).not.toHaveBeenCalled()
})
it('swallows captureThumbnail rejections silently', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'',
'broken.glb'
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
vi.mocked(mockLoad3d.captureThumbnail!).mockRejectedValue(
new Error('webgl context lost')
)
mockNode.widgets = [
{ name: 'model_file', value: 'broken.glb' } as unknown as IWidget
]
const { handler } = await getModelReadyHandler()
expect(() => handler()).not.toThrow()
await new Promise((r) => setTimeout(r, 0))
expect(persistThumbnail).not.toHaveBeenCalled()
})
})
describe('waitForLoad3d / onLoad3dReady', () => {
it('fires waitForLoad3d callback when load3d initializes, then drops it', async () => {
const composable = useLoad3d(mockNode)
const cb = vi.fn()
composable.waitForLoad3d(cb)
expect(cb).not.toHaveBeenCalled()
await composable.initializeLoad3d(document.createElement('div'))
expect(cb).toHaveBeenCalledTimes(1)
composable.cleanup()
await composable.initializeLoad3d(document.createElement('div'))
expect(cb).toHaveBeenCalledTimes(1)
})
it('fires onLoad3dReady callback on every (re-)initialization', async () => {
const composable = useLoad3d(mockNode)
const cb = vi.fn()
composable.onLoad3dReady(cb)
expect(cb).not.toHaveBeenCalled()
await composable.initializeLoad3d(document.createElement('div'))
expect(cb).toHaveBeenCalledTimes(1)
composable.cleanup()
await composable.initializeLoad3d(document.createElement('div'))
expect(cb).toHaveBeenCalledTimes(2)
composable.cleanup()
await composable.initializeLoad3d(document.createElement('div'))
expect(cb).toHaveBeenCalledTimes(3)
})
it('fires onLoad3dReady synchronously when load3d already exists', async () => {
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
const cb = vi.fn()
composable.onLoad3dReady(cb)
expect(cb).toHaveBeenCalledTimes(1)
})
it('clears persistent callbacks when the node is removed', async () => {
const composable = useLoad3d(mockNode)
const cb = vi.fn()
composable.onLoad3dReady(cb)
await composable.initializeLoad3d(document.createElement('div'))
expect(cb).toHaveBeenCalledTimes(1)
mockNode.onRemoved?.()
composable.cleanup()
await composable.initializeLoad3d(document.createElement('div'))
expect(cb).toHaveBeenCalledTimes(1)
})
it('isolates a throwing callback so subsequent callbacks and event wiring still run', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const composable = useLoad3d(mockNode)
const throwing = vi.fn(() => {
throw new Error('boom')
})
const after = vi.fn()
composable.waitForLoad3d(throwing)
composable.onLoad3dReady(after)
await composable.initializeLoad3d(document.createElement('div'))
expect(throwing).toHaveBeenCalledTimes(1)
expect(after).toHaveBeenCalledTimes(1)
expect(mockLoad3d.addEventListener).toHaveBeenCalled()
expect(mockToastStore.addAlert).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Load3d ready callback failed:',
expect.any(Error)
)
consoleErrorSpy.mockRestore()
})
it('isolates a throwing callback in the synchronous already-mounted path', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(document.createElement('div'))
const throwing = vi.fn(() => {
throw new Error('boom')
})
expect(() => composable.waitForLoad3d(throwing)).not.toThrow()
expect(() => composable.onLoad3dReady(throwing)).not.toThrow()
expect(throwing).toHaveBeenCalledTimes(2)
consoleErrorSpy.mockRestore()
})
it('cleans up callback maps when the node is removed before initializeLoad3d runs', async () => {
const leakedWait = vi.fn()
const leakedReady = vi.fn()
const composable = useLoad3d(mockNode)
composable.waitForLoad3d(leakedWait)
composable.onLoad3dReady(leakedReady)
mockNode.onRemoved?.()
await composable.initializeLoad3d(document.createElement('div'))
expect(leakedWait).not.toHaveBeenCalled()
expect(leakedReady).not.toHaveBeenCalled()
})
it('chains the onRemoved cleanup only once per node', () => {
const originalOnRemoved = vi.fn()
mockNode.onRemoved = originalOnRemoved
const composable = useLoad3d(mockNode)
composable.waitForLoad3d(vi.fn())
composable.onLoad3dReady(vi.fn())
composable.onLoad3dReady(vi.fn())
mockNode.onRemoved?.()
expect(originalOnRemoved).toHaveBeenCalledTimes(1)
})
})
})