mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-11 06:50:05 +00:00
Compare commits
3 Commits
test/cover
...
test/cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
241cd97109 | ||
|
|
b8d175dd6d | ||
|
|
a62d8e7b69 |
@@ -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 = {}
|
||||
|
||||
926
src/extensions/core/load3d/Load3d.test.ts
Normal file
926
src/extensions/core/load3d/Load3d.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user