Files
ComfyUI_frontend/src/services/load3dService.test.ts
Terry Jia af43619ae1 test(load3d): add unit tests for copyLoad3dState in load3dService (#11761)
## Summary

Add 19 unit tests for `Load3dService.copyLoad3dState` (the one method
intentionally deferred from Tier 1). Brings `load3dService.ts` from
54.5% to 100% line coverage. Follow-up to Tier 1 (#11733), Tier 2, and
Tier 3a.

## Changes

- **What**: 19 new tests covering every branch of `copyLoad3dState`:
no-source-model fast path, splat fast path (with and without
`originalURL`), mesh path (existing-target-model removal, SkeletonUtils
clone, originalModel/material/upDirection/texture copy, initial
transform on clone, gizmo transform application, gizmo enable/disable
across both source and target prior states, animation copy when
present/absent), background-image vs. background-color dispatch,
light-intensity falsy fallback, perspective-vs-orthographic FOV gating,
and the always-detach + setupForModel gizmo contract.

## Review Focus

- **Coverage**: `load3dService.ts` lines 54.5% → **100%**, branches 50%
→ **90.9%**, funcs 88.9% → **100%**. Remaining uncovered lines are minor
(`loadSkeletonUtils` cache-hit path, a couple of null-map early
returns).
- **Test fixtures use real `THREE.Object3D` and `THREE.Scene`** so
production code's `.position.set(...)`, `.rotation.set(...)`,
`scene.add/remove` calls work without further stubbing.
- **`makeTarget` memoizes the gizmo manager** (`getGizmoManager: () =>
gizmoManager` rather than returning a fresh literal each call).
Production code calls `getGizmoManager()` multiple times; without
memoization, the `detach` and `setupForModel` mocks would be
unobservable from tests.
- **`state` return on `makeTarget`** exposes mutable `modelManager`,
captured `gizmoManager`/`animationManager`, and
`sceneAdded`/`sceneRemoved` arrays so tests can assert post-state
directly without casts through the production-typed `Load3d` interface.
- **Background-image test uses `createMockLGraphNode({ id, properties
})` overrides** rather than mid-test property mutation.
- **Destructuring-default gotcha**: `const { lightsIntensity = 0.8 } =
overrides` applies the default even when `undefined` is passed
explicitly. The "fallback to setLightIntensity(1)" test passes `0`
instead — production code's `intensity || 1` short-circuits the same
way.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11761-test-load3d-add-unit-tests-for-copyLoad3dState-in-load3dService-3516d73d36508142bc72d97b27b0a36b)
by [Unito](https://www.unito.io)
2026-04-29 16:51:26 -04:00

744 lines
25 KiB
TypeScript

import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLoad3dService } from '@/services/load3dService'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
const { nodeMap, useLoad3dViewerMock, skeletonCloneMock } = vi.hoisted(() => ({
nodeMap: new Map<LGraphNode, Load3d>(),
useLoad3dViewerMock: vi.fn(),
skeletonCloneMock: vi.fn()
}))
vi.mock('@/composables/useLoad3d', () => ({
nodeToLoad3dMap: nodeMap
}))
vi.mock('@/composables/useLoad3dViewer', () => ({
useLoad3dViewer: useLoad3dViewerMock
}))
vi.mock('three/examples/jsm/utils/SkeletonUtils', () => ({
clone: skeletonCloneMock
}))
// Track every node a test creates so the load3dService singleton's
// internal viewerInstances map can be drained in beforeEach without
// reaching into the module's private state.
const createdNodes = new Set<LGraphNode>()
function makeNode(id: number | string): LGraphNode {
const node = createMockLGraphNode({ id })
createdNodes.add(node)
return node
}
function makeLoad3d(): Load3d {
return {
remove: vi.fn()
} as unknown as Load3d
}
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
needApplyChanges: { value: false },
applyChanges: vi.fn().mockResolvedValue(true),
cleanup: vi.fn(),
...overrides
}
}
describe('load3dService', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeMap.clear()
const svc = useLoad3dService()
for (const node of createdNodes) svc.removeViewer(node)
createdNodes.clear()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('singleton', () => {
it('returns the same instance from useLoad3dService()', () => {
expect(useLoad3dService()).toBe(useLoad3dService())
})
})
describe('getLoad3d (sync)', () => {
it('returns null when the load3d module has not been loaded yet', () => {
// Before any async accessor has been called, the cache is empty.
// We can't easily simulate "module never loaded" because vi.mock makes
// it eagerly available, so this test verifies the behavior via missing
// entries instead.
const node = makeNode('missing')
expect(useLoad3dService().getLoad3d(node)).toBeNull()
})
it('returns null after async load when the node has no entry in the map', async () => {
const svc = useLoad3dService()
// Trigger the async loader so the sync path has a populated cache.
await svc.getLoad3dAsync(makeNode('anything'))
expect(svc.getLoad3d(makeNode('still-missing'))).toBeNull()
})
it('returns the registered Load3d instance once the map has been populated', async () => {
const svc = useLoad3dService()
const node = makeNode('a')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
expect(svc.getLoad3d(node)).toBe(load3d)
})
})
describe('getLoad3dAsync', () => {
it('returns the Load3d for a registered node', async () => {
const svc = useLoad3dService()
const node = makeNode('async-a')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await expect(svc.getLoad3dAsync(node)).resolves.toBe(load3d)
})
it('returns null for an unregistered node', async () => {
const svc = useLoad3dService()
await expect(
svc.getLoad3dAsync(makeNode('async-missing'))
).resolves.toBeNull()
})
})
describe('getNodeByLoad3d', () => {
it('finds the node owning a given Load3d instance', async () => {
const svc = useLoad3dService()
const node = makeNode('owner')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
expect(svc.getNodeByLoad3d(load3d)).toBe(node)
})
it('returns null when the Load3d instance is not in the map', async () => {
const svc = useLoad3dService()
await svc.getLoad3dAsync(makeNode('warmup'))
expect(svc.getNodeByLoad3d(makeLoad3d())).toBeNull()
})
})
describe('removeLoad3d', () => {
it('calls remove() on the instance and drops it from the map', async () => {
const svc = useLoad3dService()
const node = makeNode('to-remove')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
svc.removeLoad3d(node)
expect(load3d.remove).toHaveBeenCalled()
expect(nodeMap.has(node)).toBe(false)
})
it('is a no-op when the node has no registered Load3d', async () => {
const svc = useLoad3dService()
await svc.getLoad3dAsync(makeNode('warmup'))
expect(() => svc.removeLoad3d(makeNode('not-there'))).not.toThrow()
})
})
describe('clear', () => {
it('removes every registered Load3d', async () => {
const svc = useLoad3dService()
const a = makeNode('a')
const b = makeNode('b')
const ld1 = makeLoad3d()
const ld2 = makeLoad3d()
nodeMap.set(a, ld1)
nodeMap.set(b, ld2)
await svc.getLoad3dAsync(a)
svc.clear()
expect(nodeMap.size).toBe(0)
expect(ld1.remove).toHaveBeenCalled()
expect(ld2.remove).toHaveBeenCalled()
})
})
describe('viewer lifecycle', () => {
it('getOrCreateViewer creates a viewer on first call and reuses it on subsequent calls', async () => {
const svc = useLoad3dService()
const node = makeNode('v1')
const viewer = makeViewer()
useLoad3dViewerMock.mockReturnValue(viewer)
const first = await svc.getOrCreateViewer(node)
const second = await svc.getOrCreateViewer(node)
expect(first).toBe(viewer)
expect(second).toBe(viewer)
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
expect(useLoad3dViewerMock).toHaveBeenCalledWith(node)
})
it('getOrCreateViewerSync uses the supplied factory once and caches the result', () => {
const svc = useLoad3dService()
const node = makeNode('v-sync')
const viewer = makeViewer()
const factory = vi.fn().mockReturnValue(viewer)
const first = svc.getOrCreateViewerSync(
node,
factory as unknown as typeof useLoad3dViewerMock
)
const second = svc.getOrCreateViewerSync(
node,
factory as unknown as typeof useLoad3dViewerMock
)
expect(first).toBe(viewer)
expect(second).toBe(viewer)
expect(factory).toHaveBeenCalledTimes(1)
})
it('removeViewer calls cleanup and forgets the viewer', async () => {
const svc = useLoad3dService()
const node = makeNode('v2')
const viewer = makeViewer()
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
svc.removeViewer(node)
expect(viewer.cleanup).toHaveBeenCalled()
useLoad3dViewerMock.mockClear()
const fresh = makeViewer()
useLoad3dViewerMock.mockReturnValue(fresh)
const result = await svc.getOrCreateViewer(node)
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
expect(result).toBe(fresh)
})
it('removeViewer is safe when no viewer has been created for the node', () => {
const svc = useLoad3dService()
expect(() => svc.removeViewer(makeNode('never'))).not.toThrow()
})
})
describe('handleViewerClose', () => {
it('removes the viewer without applying changes when none are pending', async () => {
const svc = useLoad3dService()
const node = makeNode('close-clean')
const viewer = makeViewer({ needApplyChanges: { value: false } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await svc.handleViewerClose(node)
expect(viewer.applyChanges).not.toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
it('applies changes and syncs the node config when changes are pending', async () => {
const svc = useLoad3dService()
const syncLoad3dConfig = vi.fn()
const node = Object.assign(makeNode('close-dirty'), {
syncLoad3dConfig
}) as LGraphNode
const viewer = makeViewer({ needApplyChanges: { value: true } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await svc.handleViewerClose(node)
expect(viewer.applyChanges).toHaveBeenCalled()
expect(syncLoad3dConfig).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
it('skips syncLoad3dConfig when the node does not define it', async () => {
const svc = useLoad3dService()
const node = makeNode('close-no-sync')
const viewer = makeViewer({ needApplyChanges: { value: true } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await expect(svc.handleViewerClose(node)).resolves.toBeUndefined()
expect(viewer.applyChanges).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
})
describe('handleViewportRefresh', () => {
it('returns silently when the load3d is null', () => {
expect(() => useLoad3dService().handleViewportRefresh(null)).not.toThrow()
})
it('toggles the camera through the opposite type and back, then updates controls', () => {
const controls = { update: vi.fn() }
const load3d = {
handleResize: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
toggleCamera: vi.fn(),
getControlsManager: vi.fn().mockReturnValue({ controls })
} as unknown as Load3d
useLoad3dService().handleViewportRefresh(load3d)
expect(load3d.handleResize).toHaveBeenCalled()
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'orthographic')
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'perspective')
expect(controls.update).toHaveBeenCalled()
})
it('toggles in the reverse direction when starting from orthographic', () => {
const controls = { update: vi.fn() }
const load3d = {
handleResize: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('orthographic'),
toggleCamera: vi.fn(),
getControlsManager: vi.fn().mockReturnValue({ controls })
} as unknown as Load3d
useLoad3dService().handleViewportRefresh(load3d)
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'perspective')
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'orthographic')
})
})
describe('copyLoad3dState', () => {
type SourceOverrides = Partial<{
currentModel: THREE.Object3D | null
isSplat: boolean
originalURL: string | null
originalModel: unknown
materialMode: string
currentUpDirection: string
appliedTexture: unknown
gizmoEnabled: boolean
hasAnimations: boolean
cameraType: 'perspective' | 'orthographic'
backgroundInfo: { type: 'image' | 'color' }
lightsIntensity: number | undefined
fov: number
}>
function makeSource(overrides: SourceOverrides = {}): Load3d {
const {
currentModel = null,
isSplat = false,
originalURL = null,
originalModel = null,
materialMode = 'original',
currentUpDirection = 'original',
appliedTexture = null,
gizmoEnabled = false,
hasAnimations = false,
cameraType = 'perspective',
backgroundInfo = { type: 'color' },
lightsIntensity = 0.8,
fov = 35
} = overrides
const ambient = { intensity: 0.5 }
const main = { intensity: lightsIntensity }
return {
modelManager: { currentModel, originalURL },
getGizmoManager: () => ({
isEnabled: () => gizmoEnabled,
getInitialTransform: () => ({
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 4, y: 5, z: 6 }
})
}),
isSplatModel: () => isSplat,
getModelManager: () => ({
originalModel,
materialMode,
currentUpDirection,
appliedTexture
}),
getGizmoTransform: () => ({
position: { x: 7, y: 8, z: 9 },
rotation: { x: 0.4, y: 0.5, z: 0.6 },
scale: { x: 10, y: 11, z: 12 }
}),
hasAnimations: () => hasAnimations,
getCurrentCameraType: () => cameraType,
getCameraState: () => ({ snapshot: true }),
getSceneManager: () => ({
scene: new THREE.Scene(),
currentBackgroundColor: '#abcdef',
gridHelper: { visible: true },
getCurrentBackgroundInfo: () => backgroundInfo
}),
getLightingManager: () => ({ lights: [ambient, main] }),
getCameraManager: () => ({ perspectiveCamera: { fov } })
} as unknown as Load3d
}
type TargetState = {
modelManager: {
currentModel: THREE.Object3D | null
originalModel: unknown
materialMode: string
currentUpDirection: string
appliedTexture: unknown
}
gizmoManager: {
isEnabled: () => boolean
detach: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
}
animationManager: {
setupModelAnimations: ReturnType<typeof vi.fn>
}
sceneRemoved: THREE.Object3D[]
sceneAdded: THREE.Object3D[]
}
function makeTarget(
opts: {
gizmoEnabled?: boolean
existingModel?: THREE.Object3D | null
} = {}
) {
const { gizmoEnabled = false, existingModel = null } = opts
const scene = new THREE.Scene()
const sceneRemoved: THREE.Object3D[] = []
const sceneAdded: THREE.Object3D[] = []
const sceneRemove = vi.fn((o: THREE.Object3D) => {
sceneRemoved.push(o)
scene.remove(o)
})
const sceneAdd = vi.fn((o: THREE.Object3D) => {
sceneAdded.push(o)
scene.add(o)
})
const modelManager = {
currentModel: existingModel as THREE.Object3D | null,
originalModel: null as unknown,
materialMode: 'original',
currentUpDirection: 'original',
appliedTexture: null as unknown
}
const animationManager = {
setupModelAnimations: vi.fn()
}
// Memoize the gizmo manager so production code's repeated
// `target.getGizmoManager()` calls reach the same vi.fn instances.
const gizmoManager = {
isEnabled: () => gizmoEnabled,
detach: vi.fn(),
setupForModel: vi.fn()
}
const target = {
getGizmoManager: () => gizmoManager,
getModelManager: () => modelManager,
getSceneManager: () => ({
scene: {
add: sceneAdd,
remove: sceneRemove
} as unknown as THREE.Scene
}),
loadModel: vi.fn().mockResolvedValue(undefined),
setMaterialMode: vi.fn(),
setUpDirection: vi.fn(),
applyGizmoTransform: vi.fn(),
setGizmoEnabled: vi.fn(),
animationManager,
toggleCamera: vi.fn(),
setCameraState: vi.fn(),
setBackgroundColor: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setLightIntensity: vi.fn(),
setFOV: vi.fn()
} as unknown as Load3d
const state: TargetState = {
modelManager,
gizmoManager,
animationManager,
sceneRemoved,
sceneAdded
}
return { target, state }
}
function makeModel(): THREE.Object3D {
return new THREE.Object3D()
}
it('copies camera/scene/lighting/FOV even when there is no source model', async () => {
const source = makeSource({ currentModel: null, lightsIntensity: 2 })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.toggleCamera).toHaveBeenCalledWith('perspective')
expect(target.setCameraState).toHaveBeenCalledWith({ snapshot: true })
expect(target.setBackgroundColor).toHaveBeenCalledWith('#abcdef')
expect(target.toggleGrid).toHaveBeenCalledWith(true)
expect(target.setLightIntensity).toHaveBeenCalledWith(2)
expect(target.setFOV).toHaveBeenCalledWith(35)
expect(skeletonCloneMock).not.toHaveBeenCalled()
expect(target.loadModel).not.toHaveBeenCalled()
})
it('uses target.loadModel(originalURL) for splat models, never invoking SkeletonUtils.clone', async () => {
const source = makeSource({
currentModel: makeModel(),
isSplat: true,
originalURL: 'http://example.com/scan.splat'
})
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.loadModel).toHaveBeenCalledWith(
'http://example.com/scan.splat'
)
expect(skeletonCloneMock).not.toHaveBeenCalled()
})
it('skips loadModel for splat models when originalURL is null', async () => {
const source = makeSource({
currentModel: makeModel(),
isSplat: true,
originalURL: null
})
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.loadModel).not.toHaveBeenCalled()
})
it('removes the target existing model from the scene before adding the clone', async () => {
const existing = makeModel()
existing.name = 'existing'
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget({ existingModel: existing })
const clone = makeModel()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.sceneRemoved).toContain(existing)
expect(state.sceneAdded).toContain(clone)
})
it('clones the source model via SkeletonUtils and assigns it as the target current model', async () => {
const sourceModel = makeModel()
const clone = makeModel()
const source = makeSource({ currentModel: sourceModel })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(skeletonCloneMock).toHaveBeenCalledWith(sourceModel)
expect(state.modelManager.currentModel).toBe(clone)
})
it('copies originalModel, material mode, up direction, and applied texture from source to target', async () => {
const sourceOriginal = { kind: 'gltf' }
const texture = { id: 'tex1' }
const source = makeSource({
currentModel: makeModel(),
originalModel: sourceOriginal,
materialMode: 'wireframe',
currentUpDirection: '+y',
appliedTexture: texture
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.modelManager.originalModel).toBe(sourceOriginal)
expect(state.modelManager.materialMode).toBe('wireframe')
expect(state.modelManager.currentUpDirection).toBe('+y')
expect(state.modelManager.appliedTexture).toBe(texture)
expect(target.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(target.setUpDirection).toHaveBeenCalledWith('+y')
})
it('positions the clone at the source initial transform', async () => {
const clone = makeModel()
const source = makeSource({ currentModel: makeModel() })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(clone.position.toArray()).toEqual([1, 2, 3])
expect(clone.rotation.toArray().slice(0, 3)).toEqual([0.1, 0.2, 0.3])
expect(clone.scale.toArray()).toEqual([4, 5, 6])
})
it('applies the source gizmo transform to the target', async () => {
const source = makeSource({ currentModel: makeModel() })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.applyGizmoTransform).toHaveBeenCalledWith(
{ x: 7, y: 8, z: 9 },
{ x: 0.4, y: 0.5, z: 0.6 },
{ x: 10, y: 11, z: 12 }
)
})
it('enables the gizmo on target when the source had it enabled', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: true
})
const { target } = makeTarget({ gizmoEnabled: false })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
})
it('enables the gizmo on target when the target previously had it enabled, even if source did not', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: false
})
const { target } = makeTarget({ gizmoEnabled: true })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
})
it('does not enable the gizmo when neither side had it', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: false
})
const { target } = makeTarget({ gizmoEnabled: false })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).not.toHaveBeenCalled()
})
it('forwards animation setup when the source has animations', async () => {
const sourceOriginal = { kind: 'gltf' }
const clone = makeModel()
const source = makeSource({
currentModel: makeModel(),
originalModel: sourceOriginal,
hasAnimations: true
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.animationManager.setupModelAnimations).toHaveBeenCalledWith(
clone,
sourceOriginal
)
})
it('does not forward animation setup when the source has none', async () => {
const source = makeSource({
currentModel: makeModel(),
hasAnimations: false
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.animationManager.setupModelAnimations).not.toHaveBeenCalled()
})
it('forwards an image background to setBackgroundImage when the source node has a configured path', async () => {
const node = createMockLGraphNode({
id: 'bg-source',
properties: { 'Scene Config': { backgroundImage: '3d/bg.png' } }
})
createdNodes.add(node)
const source = makeSource({ backgroundInfo: { type: 'image' } })
nodeMap.set(node, source)
// Warm the cache so `getNodeByLoad3d` finds the source.
await useLoad3dService().getLoad3dAsync(node)
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setBackgroundImage).toHaveBeenCalledWith('3d/bg.png')
})
it('clears the background when the source background type is not image', async () => {
const source = makeSource({ backgroundInfo: { type: 'color' } })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setBackgroundImage).toHaveBeenCalledWith('')
})
it('falls back to setLightIntensity(1) when the second light intensity is falsy', async () => {
const source = makeSource({ lightsIntensity: 0 })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setLightIntensity).toHaveBeenCalledWith(1)
})
it('skips setFOV when the source camera is orthographic', async () => {
const source = makeSource({ cameraType: 'orthographic' })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setFOV).not.toHaveBeenCalled()
})
it('always detaches the target gizmo at the start of the copy', async () => {
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.gizmoManager.detach).toHaveBeenCalled()
})
it('calls setupForModel on the target gizmo with the freshly cloned model', async () => {
const clone = makeModel()
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.gizmoManager.setupForModel).toHaveBeenCalledWith(clone)
})
})
})