Compare commits

..

3 Commits

Author SHA1 Message Date
bymyself
241cd97109 refactor: replace as never casts with typed fixtures in Load3d tests
Address CodeRabbit review feedback: use CameraState type import,
THREE.Vector3 constructors, and as unknown as THREE.Object3D casts
instead of as never to maintain type safety in test fixtures.
2026-04-10 18:30:26 -07:00
GitHub Action
b8d175dd6d [automated] Apply ESLint and Oxfmt fixes 2026-04-11 01:00:44 +00:00
bymyself
a62d8e7b69 test: add unit tests for Load3d 3D viewer facade 2026-04-10 17:57:02 -07:00
2 changed files with 994 additions and 690 deletions

View File

@@ -4,7 +4,6 @@ import { ref } from 'vue'
import { useCoreCommands } from '@/composables/useCoreCommands'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -27,51 +26,23 @@ vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: undefined,
selectedItems: new Set(),
selected_nodes: null as Record<string, unknown> | null,
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
selectItems: vi.fn(),
deleteSelected: vi.fn(),
setDirty: vi.fn(),
fitViewToSelectionAnimated: vi.fn(),
empty: false,
ds: {
scale: 1,
element: { width: 800, height: 600 },
changeScale: vi.fn()
},
state: {
readOnly: false,
selectionChanged: false
},
graph: {
add: vi.fn(),
convertToSubgraph: vi.fn(),
rootGraph: {}
},
select: vi.fn(),
canvas: {
dispatchEvent: vi.fn()
},
setGraph: vi.fn()
selectItems: vi.fn()
}
return {
app: {
clean: vi.fn(() => {
// Simulate app.clean() calling graph.clear() only when not in subgraph
if (!mockCanvas.subgraph) {
mockGraphClear()
}
}),
canvas: mockCanvas,
rootGraph: {
clear: mockGraphClear,
_nodes: []
},
queuePrompt: vi.fn(),
refreshComboInNodes: vi.fn(),
openClipspace: vi.fn(),
ui: { loadFile: vi.fn() }
clear: mockGraphClear
}
}
}
})
@@ -79,9 +50,7 @@ vi.mock('@/scripts/app', () => {
vi.mock('@/scripts/api', () => ({
api: {
dispatchCustomEvent: vi.fn(),
apiURL: vi.fn(() => 'http://localhost:8188'),
interrupt: vi.fn(),
freeMemory: vi.fn()
apiURL: vi.fn(() => 'http://localhost:8188')
}
}))
@@ -120,17 +89,12 @@ vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn(() => ({}))
}))
const mockToastStore = vi.hoisted(() => ({
add: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => mockToastStore)
vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({}))
}))
const mockChangeTracker = vi.hoisted(() => ({
checkState: vi.fn(),
undo: vi.fn(),
redo: vi.fn()
checkState: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: {
@@ -145,29 +109,22 @@ vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: vi.fn(() => ({}))
}))
const mockCanvasStore = vi.hoisted(() => ({
getCanvas: vi.fn(),
canvas: null as unknown,
linearMode: false,
updateSelectedItems: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => mockCanvasStore),
useCanvasStore: vi.fn(() => ({
getCanvas: () => app.canvas,
canvas: app.canvas
})),
useTitleEditorStore: vi.fn(() => ({
titleEditorTarget: null
}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: { id: 'dark-default', light_theme: false }
}))
useColorPaletteStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
logout: vi.fn()
}))
useAuthActions: vi.fn(() => ({}))
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
@@ -177,88 +134,10 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
}))
const mockIsActiveSubscription = vi.hoisted(() => ({ value: true }))
const mockShowSubscriptionDialog = vi.hoisted(() => vi.fn())
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: mockIsActiveSubscription,
showSubscriptionDialog: mockShowSubscriptionDialog
}))
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
userEmail: ref(''),
resolvedUserInfo: ref(null)
}))
}))
const mockSelectedItems = vi.hoisted(() => ({
getSelectedNodes: vi.fn((): unknown[] => []),
toggleSelectedNodesMode: vi.fn()
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: vi.fn(() => mockSelectedItems)
}))
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: vi.fn(() => ({
unpackSubgraph: vi.fn()
}))
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
staticUrls: {
githubIssues: 'https://github.com/issues',
discord: 'https://discord.gg/test',
forum: 'https://forum.test.com'
},
buildDocsUrl: vi.fn(() => 'https://docs.test.com')
}))
}))
vi.mock('@/composables/useModelSelectorDialog', () => ({
useModelSelectorDialog: vi.fn(() => ({
show: vi.fn()
}))
}))
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
useWorkflowTemplateSelectorDialog: vi.fn(() => ({
show: vi.fn()
}))
}))
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({
useAssetBrowserDialog: vi.fn(() => ({
browse: vi.fn()
}))
}))
vi.mock('@/platform/assets/utils/createModelNodeFromAsset', () => ({
createModelNodeFromAsset: vi.fn()
}))
vi.mock('@/platform/support/config', () => ({
buildSupportUrl: vi.fn(() => 'https://support.test.com')
}))
const mockTelemetry = vi.hoisted(() => ({
trackWorkflowCreated: vi.fn(),
trackRunButton: vi.fn(),
trackWorkflowExecution: vi.fn(),
trackHelpResourceClicked: vi.fn(),
trackEnterLinear: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: vi.fn(),
showAbout: vi.fn()
isActiveSubscription: { value: true },
showSubscriptionDialog: vi.fn()
}))
}))
@@ -275,9 +154,13 @@ describe('useCoreCommands', () => {
const createMockSubgraph = () => {
const mockNodes = [
// Mock input node
createMockNode(1, 'SubgraphInputNode'),
// Mock output node
createMockNode(2, 'SubgraphOutputNode'),
// Mock user node
createMockNode(3, 'SomeUserNode'),
// Another mock user node
createMockNode(4, 'AnotherUserNode')
]
@@ -346,38 +229,31 @@ describe('useCoreCommands', () => {
} satisfies ReturnType<typeof useSettingStore>
}
function findCommand(id: string) {
const cmd = useCoreCommands().find((c) => c.id === id)
if (!cmd) throw new Error(`Command '${id}' not found`)
return cmd
}
beforeEach(() => {
vi.clearAllMocks()
// Set up Pinia
setActivePinia(createPinia())
// Reset app state
app.canvas.subgraph = undefined
app.canvas.selectedItems = new Set()
app.canvas.state.readOnly = false
app.canvas.state.selectionChanged = false
Object.defineProperty(app.canvas, 'empty', { value: false, writable: true })
mockCanvasStore.linearMode = false
mockCanvasStore.getCanvas.mockReturnValue(app.canvas)
mockIsActiveSubscription.value = true
// Mock settings store
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false))
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true))
vi.stubGlobal(
'open',
vi.fn().mockReturnValue({ focus: vi.fn(), closed: false })
)
// Mock global confirm
global.confirm = vi.fn().mockReturnValue(true)
})
describe('ClearWorkflow command', () => {
it('should clear main graph when not in subgraph', async () => {
await findCommand('Comfy.ClearWorkflow').function()
const commands = useCoreCommands()
const clearCommand = commands.find(
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
)!
// Execute the command
await clearCommand.function()
expect(app.clean).toHaveBeenCalled()
expect(app.rootGraph.clear).toHaveBeenCalled()
@@ -385,29 +261,46 @@ describe('useCoreCommands', () => {
})
it('should preserve input/output nodes when clearing subgraph', async () => {
// Set up subgraph context
app.canvas.subgraph = mockSubgraph
await findCommand('Comfy.ClearWorkflow').function()
const commands = useCoreCommands()
const clearCommand = commands.find(
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
)!
// Execute the command
await clearCommand.function()
expect(app.clean).toHaveBeenCalled()
expect(app.rootGraph.clear).not.toHaveBeenCalled()
// Should only remove user nodes, not input/output nodes
const subgraph = app.canvas.subgraph!
expect(subgraph.remove).toHaveBeenCalledTimes(2)
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2])
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3])
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0])
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1])
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2]) // user1
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3]) // user2
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0]) // input1
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1]) // output1
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
})
it('should respect confirmation setting', async () => {
// Mock confirmation required
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false))
await findCommand('Comfy.ClearWorkflow').function()
global.confirm = vi.fn().mockReturnValue(false) // User cancels
const commands = useCoreCommands()
const clearCommand = commands.find(
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
)!
// Execute the command
await clearCommand.function()
// Should not clear anything when user cancels
expect(app.clean).not.toHaveBeenCalled()
expect(app.rootGraph.clear).not.toHaveBeenCalled()
expect(api.dispatchCustomEvent).not.toHaveBeenCalled()
@@ -415,6 +308,17 @@ describe('useCoreCommands', () => {
})
describe('Canvas clipboard commands', () => {
function findCommand(id: string) {
return useCoreCommands().find((cmd) => cmd.id === id)!
}
beforeEach(() => {
app.canvas.selectedItems = new Set()
vi.mocked(app.canvas.copyToClipboard).mockClear()
vi.mocked(app.canvas.pasteFromClipboard).mockClear()
vi.mocked(app.canvas.selectItems).mockClear()
})
it('should copy selected items when selection exists', async () => {
app.canvas.selectedItems = new Set([
{}
@@ -437,540 +341,14 @@ describe('useCoreCommands', () => {
expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith()
})
it('should paste with connect option', async () => {
await findCommand('Comfy.Canvas.PasteFromClipboardWithConnect').function()
expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: true
})
})
it('should select all items', async () => {
await findCommand('Comfy.Canvas.SelectAll').function()
// No arguments means "select all items on canvas"
expect(app.canvas.selectItems).toHaveBeenCalledWith()
})
})
describe('Undo/Redo commands', () => {
it('Undo should call changeTracker.undo', async () => {
await findCommand('Comfy.Undo').function()
expect(mockChangeTracker.undo).toHaveBeenCalled()
})
it('Redo should call changeTracker.redo', async () => {
await findCommand('Comfy.Redo').function()
expect(mockChangeTracker.redo).toHaveBeenCalled()
})
})
describe('Zoom commands', () => {
it('ZoomIn should increase scale and mark dirty', async () => {
await findCommand('Comfy.Canvas.ZoomIn').function()
expect(app.canvas.ds.changeScale).toHaveBeenCalled()
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('ZoomOut should decrease scale and mark dirty', async () => {
await findCommand('Comfy.Canvas.ZoomOut').function()
expect(app.canvas.ds.changeScale).toHaveBeenCalled()
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('ToggleLock should toggle readOnly state', async () => {
app.canvas.state.readOnly = false
await findCommand('Comfy.Canvas.ToggleLock').function()
expect(app.canvas.state.readOnly).toBe(true)
await findCommand('Comfy.Canvas.ToggleLock').function()
expect(app.canvas.state.readOnly).toBe(false)
})
it('Lock should set readOnly to true', async () => {
await findCommand('Comfy.Canvas.Lock').function()
expect(app.canvas.state.readOnly).toBe(true)
})
it('Unlock should set readOnly to false', async () => {
app.canvas.state.readOnly = true
await findCommand('Comfy.Canvas.Unlock').function()
expect(app.canvas.state.readOnly).toBe(false)
})
})
describe('Canvas delete command', () => {
it('should delete selected items when selection exists', async () => {
app.canvas.selectedItems = new Set([
{}
]) as typeof app.canvas.selectedItems
await findCommand('Comfy.Canvas.DeleteSelectedItems').function()
expect(app.canvas.deleteSelected).toHaveBeenCalled()
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should dispatch no-items-selected event when nothing selected', async () => {
app.canvas.selectedItems = new Set()
await findCommand('Comfy.Canvas.DeleteSelectedItems').function()
expect(app.canvas.canvas.dispatchEvent).toHaveBeenCalled()
expect(app.canvas.deleteSelected).not.toHaveBeenCalled()
})
})
describe('ToggleLinkVisibility command', () => {
it('should hide links when currently visible', async () => {
const mockStore = createMockSettingStore(false)
mockStore.get = vi.fn().mockReturnValue(LiteGraph.SPLINE_LINK)
vi.mocked(useSettingStore).mockReturnValue(mockStore)
await findCommand('Comfy.Canvas.ToggleLinkVisibility').function()
expect(mockStore.set).toHaveBeenCalledWith(
'Comfy.LinkRenderMode',
LiteGraph.HIDDEN_LINK
)
})
it('should restore links when currently hidden', async () => {
const mockStore = createMockSettingStore(false)
mockStore.get = vi.fn().mockReturnValue(LiteGraph.HIDDEN_LINK)
vi.mocked(useSettingStore).mockReturnValue(mockStore)
await findCommand('Comfy.Canvas.ToggleLinkVisibility').function()
expect(mockStore.set).toHaveBeenCalledWith(
'Comfy.LinkRenderMode',
expect.any(Number)
)
})
})
describe('ToggleMinimap command', () => {
it('should toggle minimap visibility setting', async () => {
const mockStore = createMockSettingStore(false)
mockStore.get = vi.fn().mockReturnValue(false)
vi.mocked(useSettingStore).mockReturnValue(mockStore)
await findCommand('Comfy.Canvas.ToggleMinimap').function()
expect(mockStore.set).toHaveBeenCalledWith('Comfy.Minimap.Visible', true)
})
})
describe('QueuePrompt commands', () => {
it('should show subscription dialog when not subscribed', async () => {
mockIsActiveSubscription.value = false
await findCommand('Comfy.QueuePrompt').function()
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
expect(app.queuePrompt).not.toHaveBeenCalled()
mockIsActiveSubscription.value = true
})
it('should queue prompt when subscribed', async () => {
await findCommand('Comfy.QueuePrompt').function()
expect(app.queuePrompt).toHaveBeenCalledWith(0, 1)
expect(mockTelemetry.trackRunButton).toHaveBeenCalled()
expect(mockTelemetry.trackWorkflowExecution).toHaveBeenCalled()
})
it('should queue prompt at front', async () => {
await findCommand('Comfy.QueuePromptFront').function()
expect(app.queuePrompt).toHaveBeenCalledWith(-1, 1)
})
})
describe('QueueSelectedOutputNodes command', () => {
it('should show error toast when no output nodes selected', async () => {
await findCommand('Comfy.QueueSelectedOutputNodes').function()
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(app.queuePrompt).not.toHaveBeenCalled()
})
})
describe('MoveSelectedNodes commands', () => {
function setupMoveTest() {
const mockNode = createMockLGraphNode({ id: 1 })
mockNode.pos = [100, 200] as [number, number]
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
const mockStore = createMockSettingStore(false)
mockStore.get = vi.fn().mockReturnValue(10)
vi.mocked(useSettingStore).mockReturnValue(mockStore)
return mockNode
}
it('should move nodes up by grid size', async () => {
const mockNode = setupMoveTest()
await findCommand('Comfy.Canvas.MoveSelectedNodes.Up').function()
expect(mockNode.pos).toEqual([100, 190])
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should move nodes down by grid size', async () => {
const mockNode = setupMoveTest()
await findCommand('Comfy.Canvas.MoveSelectedNodes.Down').function()
expect(mockNode.pos).toEqual([100, 210])
})
it('should move nodes left by grid size', async () => {
const mockNode = setupMoveTest()
await findCommand('Comfy.Canvas.MoveSelectedNodes.Left').function()
expect(mockNode.pos).toEqual([90, 200])
})
it('should move nodes right by grid size', async () => {
const mockNode = setupMoveTest()
await findCommand('Comfy.Canvas.MoveSelectedNodes.Right').function()
expect(mockNode.pos).toEqual([110, 200])
})
it('should not move when no nodes selected', async () => {
mockSelectedItems.getSelectedNodes.mockReturnValue([])
await findCommand('Comfy.Canvas.MoveSelectedNodes.Up').function()
expect(app.canvas.setDirty).not.toHaveBeenCalled()
})
})
describe('ToggleLinear command', () => {
it('should toggle linear mode and track telemetry when entering', async () => {
mockCanvasStore.linearMode = false
await findCommand('Comfy.ToggleLinear').function()
expect(mockCanvasStore.linearMode).toBe(true)
expect(mockTelemetry.trackEnterLinear).toHaveBeenCalledWith({
source: 'keybind'
})
})
it('should use provided source metadata', async () => {
mockCanvasStore.linearMode = false
await findCommand('Comfy.ToggleLinear').function({
source: 'menu'
})
expect(mockTelemetry.trackEnterLinear).toHaveBeenCalledWith({
source: 'menu'
})
})
})
describe('ToggleQPOV2 command', () => {
it('should toggle queue panel v2 setting', async () => {
const mockStore = createMockSettingStore(false)
mockStore.get = vi.fn().mockReturnValue(false)
vi.mocked(useSettingStore).mockReturnValue(mockStore)
await findCommand('Comfy.ToggleQPOV2').function()
expect(mockStore.set).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
})
})
describe('Memory commands', () => {
it('UnloadModels should show error when setting is disabled', async () => {
const mockStore = createMockSettingStore(false)
mockStore.get = vi.fn().mockReturnValue(false)
vi.mocked(useSettingStore).mockReturnValue(mockStore)
await findCommand('Comfy.Memory.UnloadModels').function()
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(api.freeMemory).not.toHaveBeenCalled()
})
it('UnloadModels should call api.freeMemory when setting is enabled', async () => {
const mockStore = createMockSettingStore(false)
mockStore.get = vi.fn().mockReturnValue(true)
vi.mocked(useSettingStore).mockReturnValue(mockStore)
await findCommand('Comfy.Memory.UnloadModels').function()
expect(api.freeMemory).toHaveBeenCalledWith({
freeExecutionCache: false
})
})
it('UnloadModelsAndExecutionCache should call api.freeMemory with cache flag', async () => {
const mockStore = createMockSettingStore(false)
mockStore.get = vi.fn().mockReturnValue(true)
vi.mocked(useSettingStore).mockReturnValue(mockStore)
await findCommand('Comfy.Memory.UnloadModelsAndExecutionCache').function()
expect(api.freeMemory).toHaveBeenCalledWith({
freeExecutionCache: true
})
})
})
describe('FitView command', () => {
it('should show error toast when canvas is empty', async () => {
Object.defineProperty(app.canvas, 'empty', {
value: true,
writable: true
})
await findCommand('Comfy.Canvas.FitView').function()
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(app.canvas.fitViewToSelectionAnimated).not.toHaveBeenCalled()
})
it('should fit view when canvas has content', async () => {
Object.defineProperty(app.canvas, 'empty', {
value: false,
writable: true
})
await findCommand('Comfy.Canvas.FitView').function()
expect(app.canvas.fitViewToSelectionAnimated).toHaveBeenCalled()
})
})
describe('Interrupt command', () => {
it('should call api.interrupt and show toast', async () => {
await findCommand('Comfy.Interrupt').function()
expect(api.interrupt).toHaveBeenCalled()
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'info' })
)
})
})
describe('OpenWorkflow command', () => {
it('should call app.ui.loadFile', async () => {
await findCommand('Comfy.OpenWorkflow').function()
expect(app.ui.loadFile).toHaveBeenCalled()
})
})
describe('RefreshNodeDefinitions command', () => {
it('should call app.refreshComboInNodes', async () => {
await findCommand('Comfy.RefreshNodeDefinitions').function()
expect(app.refreshComboInNodes).toHaveBeenCalled()
})
})
describe('OpenClipspace command', () => {
it('should call app.openClipspace', async () => {
await findCommand('Comfy.OpenClipspace').function()
expect(app.openClipspace).toHaveBeenCalled()
})
})
describe('ToggleTheme command', () => {
it('should switch from dark to light theme', async () => {
const mockStore = createMockSettingStore(false)
vi.mocked(useSettingStore).mockReturnValue(mockStore)
await findCommand('Comfy.ToggleTheme').function()
expect(mockStore.set).toHaveBeenCalledWith(
'Comfy.ColorPalette',
expect.any(String)
)
})
})
describe('ToggleSelectedNodes commands', () => {
it('Mute should toggle selected nodes mode and mark dirty', async () => {
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Mute').function()
expect(mockSelectedItems.toggleSelectedNodesMode).toHaveBeenCalled()
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('Bypass should toggle selected nodes mode and mark dirty', async () => {
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Bypass').function()
expect(mockSelectedItems.toggleSelectedNodesMode).toHaveBeenCalled()
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('Pin should toggle pin state on each selected node', async () => {
const mockNode = createMockLGraphNode({ id: 1 })
Object.defineProperty(mockNode, 'pinned', {
value: false,
writable: true
})
mockNode.pin = vi.fn()
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Pin').function()
expect(mockNode.pin).toHaveBeenCalledWith(true)
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('Collapse should collapse each selected node', async () => {
const mockNode = createMockLGraphNode({ id: 1 })
mockNode.collapse = vi.fn()
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Collapse').function()
expect(mockNode.collapse).toHaveBeenCalled()
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('Resize should compute and set optimal size', async () => {
const mockNode = createMockLGraphNode({ id: 1 })
mockNode.computeSize = vi.fn().mockReturnValue([200, 100])
mockNode.setSize = vi.fn()
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
await findCommand('Comfy.Canvas.Resize').function()
expect(mockNode.computeSize).toHaveBeenCalled()
expect(mockNode.setSize).toHaveBeenCalledWith([200, 100])
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
})
})
describe('Help commands', () => {
it('OpenComfyUIIssues should open GitHub issues and track telemetry', async () => {
await findCommand('Comfy.Help.OpenComfyUIIssues').function()
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'github',
is_external: true,
source: 'menu'
})
expect(window.open).toHaveBeenCalledWith(
'https://github.com/issues',
'_blank'
)
})
it('OpenComfyUIDocs should open docs and track telemetry', async () => {
await findCommand('Comfy.Help.OpenComfyUIDocs').function()
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'docs',
is_external: true,
source: 'menu'
})
expect(window.open).toHaveBeenCalledWith(
'https://docs.test.com',
'_blank'
)
})
it('OpenComfyOrgDiscord should open Discord and track telemetry', async () => {
await findCommand('Comfy.Help.OpenComfyOrgDiscord').function()
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'discord',
is_external: true,
source: 'menu'
})
expect(window.open).toHaveBeenCalledWith(
'https://discord.gg/test',
'_blank'
)
})
it('OpenComfyUIForum should open forum and track telemetry', async () => {
await findCommand('Comfy.Help.OpenComfyUIForum').function()
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
is_external: true,
source: 'menu'
})
expect(window.open).toHaveBeenCalledWith(
'https://forum.test.com',
'_blank'
)
})
})
describe('GroupSelectedNodes command', () => {
it('should show error toast when nothing selected', async () => {
app.canvas.selectedItems = new Set()
await findCommand('Comfy.Graph.GroupSelectedNodes').function()
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
describe('ConvertToSubgraph command', () => {
it('should show error toast when conversion fails', async () => {
app.canvas.graph!.convertToSubgraph = vi.fn().mockReturnValue(null)
await findCommand('Comfy.Graph.ConvertToSubgraph').function()
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('should select the new subgraph node on success', async () => {
const mockNode = createMockLGraphNode({ id: 1 })
app.canvas.graph!.convertToSubgraph = vi
.fn()
.mockReturnValue({ node: mockNode })
await findCommand('Comfy.Graph.ConvertToSubgraph').function()
expect(app.canvas.select).toHaveBeenCalledWith(mockNode)
expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalled()
})
})
describe('ContactSupport command', () => {
it('should open support URL in new window', async () => {
await findCommand('Comfy.ContactSupport').function()
expect(window.open).toHaveBeenCalledWith(
'https://support.test.com',
'_blank',
'noopener,noreferrer'
)
})
})
describe('Subgraph metadata commands', () => {
beforeEach(() => {
mockSubgraph.extra = {}

View File

@@ -0,0 +1,926 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mock all sub-managers as classes (vi.fn().mockImplementation won't work as constructors)
vi.mock('./SceneManager', () => {
class MockSceneManager {
scene = { add: vi.fn(), remove: vi.fn(), traverse: vi.fn(), clear: vi.fn() }
gridHelper = { visible: true, position: { set: vi.fn() } }
backgroundTexture = null
backgroundMesh = null
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
toggleGrid = vi.fn()
setBackgroundColor = vi.fn()
setBackgroundImage = vi.fn().mockResolvedValue(undefined)
removeBackgroundImage = vi.fn()
setBackgroundRenderMode = vi.fn()
handleResize = vi.fn()
renderBackground = vi.fn()
captureScene = vi.fn().mockResolvedValue({
scene: 'data:scene',
mask: 'data:mask',
normal: 'data:normal'
})
updateBackgroundSize = vi.fn()
}
return { SceneManager: MockSceneManager }
})
vi.mock('./CameraManager', () => {
class MockCameraManager {
activeCamera = {
position: {
set: vi.fn(),
clone: vi.fn().mockReturnValue({ x: 0, y: 0, z: 0 }),
copy: vi.fn()
},
rotation: { clone: vi.fn(), copy: vi.fn() },
zoom: 1
}
perspectiveCamera = {
position: {
set: vi.fn(),
clone: vi.fn().mockReturnValue({ x: 0, y: 0, z: 0 }),
copy: vi.fn()
},
lookAt: vi.fn(),
updateProjectionMatrix: vi.fn(),
aspect: 1,
fov: 35
}
orthographicCamera = {
position: { set: vi.fn(), clone: vi.fn(), copy: vi.fn() }
}
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
setControls = vi.fn()
getCurrentCameraType = vi.fn().mockReturnValue('perspective')
toggleCamera = vi.fn()
setFOV = vi.fn()
setCameraState = vi.fn()
getCameraState = vi.fn().mockReturnValue({
position: { x: 10, y: 10, z: 10 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
})
handleResize = vi.fn()
updateAspectRatio = vi.fn()
setupForModel = vi.fn()
}
return { CameraManager: MockCameraManager }
})
vi.mock('./ControlsManager', () => {
class MockControlsManager {
controls = {
target: {
set: vi.fn(),
clone: vi.fn().mockReturnValue({ x: 0, y: 0, z: 0 }),
copy: vi.fn()
},
update: vi.fn(),
dispose: vi.fn(),
object: {}
}
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
update = vi.fn()
updateCamera = vi.fn()
}
return { ControlsManager: MockControlsManager }
})
vi.mock('./LightingManager', () => {
class MockLightingManager {
lights: never[] = []
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
setLightIntensity = vi.fn()
}
return { LightingManager: MockLightingManager }
})
vi.mock('./ViewHelperManager', () => {
class MockViewHelperManager {
viewHelper = {
render: vi.fn(),
update: vi.fn(),
dispose: vi.fn(),
animating: false,
visible: true,
center: null
}
viewHelperContainer = document.createElement('div')
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
createViewHelper = vi.fn()
update = vi.fn()
handleResize = vi.fn()
visibleViewHelper = vi.fn()
recreateViewHelper = vi.fn()
}
return { ViewHelperManager: MockViewHelperManager }
})
vi.mock('./LoaderManager', () => {
class MockLoaderManager {
init = vi.fn()
dispose = vi.fn()
loadModel = vi.fn().mockResolvedValue(undefined)
}
return { LoaderManager: MockLoaderManager }
})
vi.mock('./SceneModelManager', () => {
class MockSceneModelManager {
currentModel = null
originalModel = null
originalFileName: string | null = null
originalURL: string | null = null
originalRotation = null
currentUpDirection = 'original'
materialMode = 'original'
showSkeleton = false
originalMaterials = new WeakMap()
normalMaterial = {}
standardMaterial = {}
wireframeMaterial = {}
depthMaterial = {}
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
clearModel = vi.fn()
setupModel = vi.fn()
addModelToScene = vi.fn()
setOriginalModel = vi.fn()
setUpDirection = vi.fn()
setMaterialMode = vi.fn()
setupModelMaterials = vi.fn()
hasSkeleton = vi.fn().mockReturnValue(false)
setShowSkeleton = vi.fn()
containsSplatMesh = vi.fn().mockReturnValue(false)
}
return { SceneModelManager: MockSceneModelManager }
})
vi.mock('./RecordingManager', () => {
class MockRecordingManager {
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
startRecording = vi.fn().mockResolvedValue(undefined)
stopRecording = vi.fn()
getIsRecording = vi.fn().mockReturnValue(false)
getRecordingDuration = vi.fn().mockReturnValue(0)
getRecordingData = vi.fn().mockReturnValue(null)
exportRecording = vi.fn()
clearRecording = vi.fn()
}
return { RecordingManager: MockRecordingManager }
})
vi.mock('./AnimationManager', () => {
class MockAnimationManager {
animationClips: never[] = []
animationActions: never[] = []
isAnimationPlaying = false
currentAnimation = null
selectedAnimationIndex = 0
animationSpeed = 1.0
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
update = vi.fn()
setupModelAnimations = vi.fn()
setAnimationSpeed = vi.fn()
updateSelectedAnimation = vi.fn()
toggleAnimation = vi.fn()
getAnimationTime = vi.fn().mockReturnValue(0)
getAnimationDuration = vi.fn().mockReturnValue(0)
setAnimationTime = vi.fn()
}
return { AnimationManager: MockAnimationManager }
})
vi.mock('./ModelExporter', () => ({
ModelExporter: {
exportGLB: vi.fn().mockResolvedValue(undefined),
exportOBJ: vi.fn().mockResolvedValue(undefined),
exportSTL: vi.fn().mockResolvedValue(undefined)
}
}))
// Mock THREE.js — only the parts Load3d itself uses directly
vi.mock('three', () => {
const mockDomElement = document.createElement('canvas')
Object.defineProperty(mockDomElement, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(mockDomElement, 'clientHeight', {
value: 600,
configurable: true
})
class MockWebGLRenderer {
domElement = mockDomElement
autoClear = false
outputColorSpace = ''
toneMapping = 0
toneMappingExposure = 1
setSize = vi.fn()
setClearColor = vi.fn()
getClearColor = vi.fn()
getClearAlpha = vi.fn().mockReturnValue(1)
setViewport = vi.fn()
setScissor = vi.fn()
setScissorTest = vi.fn()
clear = vi.fn()
render = vi.fn()
dispose = vi.fn()
forceContextLoss = vi.fn()
}
class MockClock {
getDelta = vi.fn().mockReturnValue(0.016)
}
class MockVector3 {
x: number
y: number
z: number
constructor(x = 0, y = 0, z = 0) {
this.x = x
this.y = y
this.z = z
}
clone() {
return new MockVector3(this.x, this.y, this.z)
}
copy(v: MockVector3) {
this.x = v.x
this.y = v.y
this.z = v.z
return this
}
set(x: number, y: number, z: number) {
this.x = x
this.y = y
this.z = z
return this
}
}
class MockBox3 {
min = new MockVector3()
setFromObject() {
return this
}
getSize(v: MockVector3) {
v.x = 1
v.y = 1
v.z = 1
return v
}
getCenter(v: MockVector3) {
v.x = 0
v.y = 0
v.z = 0
return v
}
}
return {
WebGLRenderer: MockWebGLRenderer,
Clock: MockClock,
Vector3: MockVector3,
Box3: MockBox3,
SRGBColorSpace: 'srgb',
// Needed by sub-manager mocks at import time
Scene: vi.fn(),
PerspectiveCamera: vi.fn(),
OrthographicCamera: vi.fn(),
GridHelper: vi.fn(),
Color: vi.fn(),
BufferGeometry: vi.fn()
}
})
vi.mock('three/examples/jsm/controls/OrbitControls', () => ({
OrbitControls: vi.fn()
}))
vi.mock('three/examples/jsm/helpers/ViewHelper', () => ({
ViewHelper: vi.fn()
}))
// Indirect dependencies pulled in by mocked modules
vi.mock('@/i18n', () => ({ t: vi.fn((key: string) => key) }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn().mockReturnValue({ get: vi.fn() })
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn().mockReturnValue({ addAlert: vi.fn() })
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((p: string) => p),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: { getRandParam: vi.fn().mockReturnValue('&rand=1'), canvas: null }
}))
vi.mock('@/scripts/metadata/ply', () => ({
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
}))
vi.mock('@/base/common/downloadUtil', () => ({ downloadBlob: vi.fn() }))
import * as THREE from 'three'
import Load3d from './Load3d'
import type { CameraState } from './interfaces'
function createContainer(): HTMLDivElement {
const el = document.createElement('div')
Object.defineProperty(el, 'clientWidth', { value: 800 })
Object.defineProperty(el, 'clientHeight', { value: 600 })
document.body.appendChild(el)
return el
}
describe('Load3d', () => {
let load3d: Load3d
let container: HTMLDivElement
// Extra instances created in tests — tracked for cleanup
const extraInstances: Load3d[] = []
function createInstance(
options?: ConstructorParameters<typeof Load3d>[1]
): Load3d {
const instance = new Load3d(container, options)
vi.advanceTimersByTime(150)
extraInstances.push(instance)
return instance
}
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
container = createContainer()
load3d = new Load3d(container)
vi.advanceTimersByTime(150)
})
afterEach(() => {
extraInstances.forEach((i) => i.remove())
extraInstances.length = 0
vi.useRealTimers()
load3d.remove()
container.remove()
})
describe('constructor', () => {
it('appends the renderer canvas to the container', () => {
expect(container.querySelector('canvas')).not.toBeNull()
})
it('sets target dimensions from options', () => {
const sized = createInstance({ width: 1024, height: 768 })
expect(sized.targetWidth).toBe(1024)
expect(sized.targetHeight).toBe(768)
expect(sized.targetAspectRatio).toBeCloseTo(1024 / 768)
})
it('sets viewer mode from options', () => {
const viewer = createInstance({ isViewerMode: true })
expect(viewer.isViewerMode).toBe(true)
})
})
describe('isActive', () => {
it('returns false when no activity flags are set and initial render is done', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.STATUS_MOUSE_ON_NODE = false
load3d.STATUS_MOUSE_ON_SCENE = false
load3d.STATUS_MOUSE_ON_VIEWER = false
expect(load3d.isActive()).toBe(false)
})
it('returns true when mouse is on node', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.STATUS_MOUSE_ON_NODE = true
expect(load3d.isActive()).toBe(true)
})
it('returns true when mouse is on scene', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.STATUS_MOUSE_ON_SCENE = true
expect(load3d.isActive()).toBe(true)
})
it('returns true when mouse is on viewer', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.STATUS_MOUSE_ON_VIEWER = true
expect(load3d.isActive()).toBe(true)
})
it('returns true before initial render is done', () => {
load3d.INITIAL_RENDER_DONE = false
expect(load3d.isActive()).toBe(true)
})
it('returns true when animation is playing', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.animationManager.isAnimationPlaying = true
expect(load3d.isActive()).toBe(true)
})
it('returns true when recording is active', () => {
load3d.INITIAL_RENDER_DONE = true
vi.mocked(load3d.recordingManager.getIsRecording).mockReturnValue(true)
expect(load3d.isActive()).toBe(true)
})
})
describe('getTargetSize / setTargetSize', () => {
it('returns current target dimensions', () => {
load3d.setTargetSize(640, 480)
expect(load3d.getTargetSize()).toEqual({ width: 640, height: 480 })
})
it('updates aspect ratio', () => {
load3d.setTargetSize(1920, 1080)
expect(load3d.targetAspectRatio).toBeCloseTo(1920 / 1080)
})
})
describe('addEventListener / removeEventListener', () => {
it('delegates to eventManager', () => {
const callback = vi.fn()
load3d.addEventListener('test', callback)
load3d.eventManager.emitEvent('test', 'payload')
expect(callback).toHaveBeenCalledWith('payload')
load3d.removeEventListener('test', callback)
load3d.eventManager.emitEvent('test', 'payload2')
expect(callback).toHaveBeenCalledTimes(1)
})
})
describe('scene delegation', () => {
it('toggleGrid delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.toggleGrid(false)
expect(load3d.sceneManager.toggleGrid).toHaveBeenCalledWith(false)
expect(renderSpy).toHaveBeenCalled()
})
it('setBackgroundColor delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setBackgroundColor('#ff0000')
expect(load3d.sceneManager.setBackgroundColor).toHaveBeenCalledWith(
'#ff0000'
)
expect(renderSpy).toHaveBeenCalled()
})
it('setBackgroundRenderMode delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setBackgroundRenderMode('panorama')
expect(load3d.sceneManager.setBackgroundRenderMode).toHaveBeenCalledWith(
'panorama'
)
expect(renderSpy).toHaveBeenCalled()
})
it('removeBackgroundImage delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.removeBackgroundImage()
expect(load3d.sceneManager.removeBackgroundImage).toHaveBeenCalled()
expect(renderSpy).toHaveBeenCalled()
})
it('captureScene delegates to sceneManager', () => {
load3d.captureScene(512, 512)
expect(load3d.sceneManager.captureScene).toHaveBeenCalledWith(512, 512)
})
})
describe('camera delegation', () => {
it('toggleCamera delegates and updates controls and viewHelper', () => {
load3d.toggleCamera('orthographic')
expect(load3d.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(load3d.controlsManager.updateCamera).toHaveBeenCalled()
expect(load3d.viewHelperManager.recreateViewHelper).toHaveBeenCalled()
})
it('getCurrentCameraType delegates', () => {
load3d.getCurrentCameraType()
expect(load3d.cameraManager.getCurrentCameraType).toHaveBeenCalled()
})
it('setFOV delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setFOV(60)
expect(load3d.cameraManager.setFOV).toHaveBeenCalledWith(60)
expect(renderSpy).toHaveBeenCalled()
})
it('setCameraState delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
const state: CameraState = {
position: new THREE.Vector3(1, 2, 3),
target: new THREE.Vector3(0, 0, 0),
zoom: 1,
cameraType: 'perspective'
}
load3d.setCameraState(state)
expect(load3d.cameraManager.setCameraState).toHaveBeenCalledWith(state)
expect(renderSpy).toHaveBeenCalled()
})
it('getCameraState delegates', () => {
load3d.getCameraState()
expect(load3d.cameraManager.getCameraState).toHaveBeenCalled()
})
})
describe('model delegation', () => {
it('setMaterialMode delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setMaterialMode('wireframe')
expect(load3d.modelManager.setMaterialMode).toHaveBeenCalledWith(
'wireframe'
)
expect(renderSpy).toHaveBeenCalled()
})
it('setUpDirection delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setUpDirection('+z')
expect(load3d.modelManager.setUpDirection).toHaveBeenCalledWith('+z')
expect(renderSpy).toHaveBeenCalled()
})
it('getCurrentModel returns modelManager.currentModel', () => {
expect(load3d.getCurrentModel()).toBeNull()
})
it('isSplatModel delegates to modelManager', () => {
load3d.isSplatModel()
expect(load3d.modelManager.containsSplatMesh).toHaveBeenCalled()
})
})
describe('lighting delegation', () => {
it('setLightIntensity delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setLightIntensity(5)
expect(load3d.lightingManager.setLightIntensity).toHaveBeenCalledWith(5)
expect(renderSpy).toHaveBeenCalled()
})
})
describe('clearModel', () => {
it('disposes animations and clears model, then renders', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.clearModel()
expect(load3d.animationManager.dispose).toHaveBeenCalled()
expect(load3d.modelManager.clearModel).toHaveBeenCalled()
expect(renderSpy).toHaveBeenCalled()
})
})
describe('animation methods', () => {
it('hasAnimations returns false when empty', () => {
expect(load3d.hasAnimations()).toBe(false)
})
it('hasAnimations returns true when clips exist', () => {
load3d.animationManager.animationClips = [
{ name: 'clip' } as THREE.AnimationClip
]
expect(load3d.hasAnimations()).toBe(true)
})
it('setAnimationSpeed delegates', () => {
load3d.setAnimationSpeed(2.0)
expect(load3d.animationManager.setAnimationSpeed).toHaveBeenCalledWith(
2.0
)
})
it('updateSelectedAnimation delegates', () => {
load3d.updateSelectedAnimation(1)
expect(
load3d.animationManager.updateSelectedAnimation
).toHaveBeenCalledWith(1)
})
it('toggleAnimation delegates', () => {
load3d.toggleAnimation(true)
expect(load3d.animationManager.toggleAnimation).toHaveBeenCalledWith(true)
})
it('getAnimationTime delegates', () => {
load3d.getAnimationTime()
expect(load3d.animationManager.getAnimationTime).toHaveBeenCalled()
})
it('getAnimationDuration delegates', () => {
load3d.getAnimationDuration()
expect(load3d.animationManager.getAnimationDuration).toHaveBeenCalled()
})
it('setAnimationTime delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setAnimationTime(0.5)
expect(load3d.animationManager.setAnimationTime).toHaveBeenCalledWith(0.5)
expect(renderSpy).toHaveBeenCalled()
})
})
describe('recording methods', () => {
it('isRecording delegates', () => {
load3d.isRecording()
expect(load3d.recordingManager.getIsRecording).toHaveBeenCalled()
})
it('getRecordingDuration delegates', () => {
load3d.getRecordingDuration()
expect(load3d.recordingManager.getRecordingDuration).toHaveBeenCalled()
})
it('getRecordingData delegates', () => {
load3d.getRecordingData()
expect(load3d.recordingManager.getRecordingData).toHaveBeenCalled()
})
it('exportRecording delegates', () => {
load3d.exportRecording('test.mp4')
expect(load3d.recordingManager.exportRecording).toHaveBeenCalledWith(
'test.mp4'
)
})
it('clearRecording delegates', () => {
load3d.clearRecording()
expect(load3d.recordingManager.clearRecording).toHaveBeenCalled()
})
it('startRecording hides view helper and delegates', async () => {
await load3d.startRecording()
expect(load3d.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(
false
)
expect(load3d.recordingManager.startRecording).toHaveBeenCalled()
})
it('stopRecording shows view helper and emits event', () => {
const emitSpy = vi.spyOn(load3d.eventManager, 'emitEvent')
load3d.stopRecording()
expect(load3d.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(
true
)
expect(load3d.recordingManager.stopRecording).toHaveBeenCalled()
expect(emitSpy).toHaveBeenCalledWith('recordingStatusChange', false)
})
})
describe('skeleton methods', () => {
it('hasSkeleton delegates', () => {
load3d.hasSkeleton()
expect(load3d.modelManager.hasSkeleton).toHaveBeenCalled()
})
it('setShowSkeleton delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setShowSkeleton(true)
expect(load3d.modelManager.setShowSkeleton).toHaveBeenCalledWith(true)
expect(renderSpy).toHaveBeenCalled()
})
it('getShowSkeleton reads modelManager state', () => {
load3d.modelManager.showSkeleton = true
expect(load3d.getShowSkeleton()).toBe(true)
})
})
describe('exportModel', () => {
it('throws when no model is loaded', async () => {
load3d.modelManager.currentModel = null
await expect(load3d.exportModel('glb')).rejects.toThrow(
'No model to export'
)
})
it('throws for unsupported format', async () => {
load3d.modelManager.currentModel = {
clone: vi.fn().mockReturnValue({})
} as unknown as THREE.Object3D
load3d.modelManager.originalFileName = 'test'
const promise = load3d.exportModel('xyz')
// exportModel uses setTimeout(resolve, 10) internally
vi.advanceTimersByTime(50)
await expect(promise).rejects.toThrow('Unsupported export format: xyz')
})
it('calls correct exporter and emits loading events for glb', async () => {
load3d.modelManager.currentModel = {
clone: vi.fn().mockReturnValue({})
} as unknown as THREE.Object3D
load3d.modelManager.originalFileName = 'test'
const { ModelExporter } = await import('./ModelExporter')
const emitSpy = vi.spyOn(load3d.eventManager, 'emitEvent')
const promise = load3d.exportModel('glb')
await vi.advanceTimersByTimeAsync(50)
await promise
expect(ModelExporter.exportGLB).toHaveBeenCalled()
expect(emitSpy).toHaveBeenCalledWith(
'exportLoadingStart',
'Exporting as GLB...'
)
expect(emitSpy).toHaveBeenCalledWith('exportLoadingEnd', null)
})
})
describe('loadModel', () => {
it('resets managers and delegates to loaderManager', async () => {
await load3d.loadModel('http://example.com/model.glb', 'model.glb')
expect(load3d.cameraManager.reset).toHaveBeenCalled()
expect(load3d.controlsManager.reset).toHaveBeenCalled()
expect(load3d.modelManager.clearModel).toHaveBeenCalled()
expect(load3d.animationManager.dispose).toHaveBeenCalled()
expect(load3d.loaderManager.loadModel).toHaveBeenCalledWith(
'http://example.com/model.glb',
'model.glb'
)
})
it('sets up animations when model has been loaded', async () => {
const mockModel = {} as unknown as THREE.Object3D
load3d.modelManager.currentModel = mockModel
load3d.modelManager.originalModel = {} as unknown as THREE.Object3D
await load3d.loadModel('http://example.com/model.glb', 'model.glb')
expect(load3d.animationManager.setupModelAnimations).toHaveBeenCalledWith(
mockModel,
load3d.modelManager.originalModel
)
})
it('serializes concurrent loadModel calls', async () => {
let resolveFirst!: () => void
const firstPromise = new Promise<void>((r) => {
resolveFirst = r
})
vi.mocked(load3d.loaderManager.loadModel)
.mockImplementationOnce(() => firstPromise)
.mockResolvedValueOnce(undefined)
const p1 = load3d.loadModel('url1')
const p2 = load3d.loadModel('url2')
resolveFirst()
await p1
await p2
expect(load3d.loaderManager.loadModel).toHaveBeenCalledTimes(2)
})
})
describe('captureThumbnail', () => {
it('throws when no model is loaded', async () => {
load3d.modelManager.currentModel = null
await expect(load3d.captureThumbnail()).rejects.toThrow(
'No model loaded for thumbnail capture'
)
})
})
describe('remove', () => {
it('disposes all managers and renderer', () => {
load3d.remove()
expect(load3d.sceneManager.dispose).toHaveBeenCalled()
expect(load3d.cameraManager.dispose).toHaveBeenCalled()
expect(load3d.controlsManager.dispose).toHaveBeenCalled()
expect(load3d.lightingManager.dispose).toHaveBeenCalled()
expect(load3d.viewHelperManager.dispose).toHaveBeenCalled()
expect(load3d.loaderManager.dispose).toHaveBeenCalled()
expect(load3d.modelManager.dispose).toHaveBeenCalled()
expect(load3d.recordingManager.dispose).toHaveBeenCalled()
expect(load3d.animationManager.dispose).toHaveBeenCalled()
})
})
describe('context menu behavior', () => {
it('calls onContextMenu callback on right-click without drag', () => {
const contextMenuFn = vi.fn()
const instance = createInstance({ onContextMenu: contextMenuFn })
const canvas = instance.renderer.domElement
canvas.dispatchEvent(
new MouseEvent('mousedown', { button: 2, clientX: 100, clientY: 100 })
)
canvas.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: 100,
clientY: 100,
cancelable: true,
bubbles: true
})
)
expect(contextMenuFn).toHaveBeenCalled()
})
it('suppresses context menu after right-drag beyond threshold', () => {
const contextMenuFn = vi.fn()
const instance = createInstance({ onContextMenu: contextMenuFn })
const canvas = instance.renderer.domElement
canvas.dispatchEvent(
new MouseEvent('mousedown', { button: 2, clientX: 100, clientY: 100 })
)
canvas.dispatchEvent(
new MouseEvent('mousemove', { buttons: 2, clientX: 150, clientY: 150 })
)
canvas.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: 150,
clientY: 150,
cancelable: true,
bubbles: true
})
)
expect(contextMenuFn).not.toHaveBeenCalled()
})
it('does not fire context menu in viewer mode', () => {
const contextMenuFn = vi.fn()
const instance = createInstance({
onContextMenu: contextMenuFn,
isViewerMode: true
})
const canvas = instance.renderer.domElement
canvas.dispatchEvent(
new MouseEvent('mousedown', { button: 2, clientX: 100, clientY: 100 })
)
canvas.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: 100,
clientY: 100,
cancelable: true,
bubbles: true
})
)
expect(contextMenuFn).not.toHaveBeenCalled()
})
})
describe('handleResize with getDimensions callback', () => {
it('uses getDimensions callback to update target size', () => {
const getDimensions = vi.fn().mockReturnValue({ width: 400, height: 300 })
const instance = createInstance({ getDimensions })
instance.handleResize()
expect(instance.targetWidth).toBe(400)
expect(instance.targetHeight).toBe(300)
})
it('keeps existing dimensions when getDimensions returns null', () => {
const getDimensions = vi.fn().mockReturnValue(null)
const instance = createInstance({
getDimensions,
width: 100,
height: 50
})
instance.handleResize()
expect(instance.targetWidth).toBe(100)
expect(instance.targetHeight).toBe(50)
})
})
})