mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
## 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)
744 lines
25 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|