Merge branch 'main' into feat/new-workflow-templates

This commit is contained in:
Johnpaul
2025-09-12 20:25:18 +01:00
131 changed files with 4512 additions and 1927 deletions

View File

@@ -0,0 +1,30 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
vi.mock('axios')
describe('getFolderPaths', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('returns legacy API response when available', async () => {
const mockResponse = { checkpoints: ['/test/checkpoints'] }
vi.mocked(axios.get).mockResolvedValueOnce({ data: mockResponse })
const result = await api.getFolderPaths()
expect(result).toEqual(mockResponse)
})
it('returns empty object when legacy API unavailable (dynamic discovery)', async () => {
vi.mocked(axios.get).mockRejectedValueOnce(new Error())
const result = await api.getFolderPaths()
// With dynamic discovery, we don't pre-generate directories when API is unavailable
expect(result).toEqual({})
})
})

View File

@@ -237,9 +237,9 @@ describe('useSelectedLiteGraphItems', () => {
toggleSelectedNodesMode(LGraphEventMode.NEVER)
// node1 should change from ALWAYS to NEVER
// node2 should change from NEVER to ALWAYS (since it was already NEVER)
// node2 should stay NEVER (since a selected node exists which is not NEVER)
expect(node1.mode).toBe(LGraphEventMode.NEVER)
expect(node2.mode).toBe(LGraphEventMode.ALWAYS)
expect(node2.mode).toBe(LGraphEventMode.NEVER)
})
it('toggleSelectedNodesMode should set mode to ALWAYS when already in target mode', () => {

View File

@@ -121,24 +121,24 @@ describe('useTransformState', () => {
const canvasPoint = { x: 10, y: 20 }
const screenPoint = canvasToScreen(canvasPoint)
// screen = canvas * scale + offset
// x: 10 * 2 + 100 = 120
// y: 20 * 2 + 50 = 90
expect(screenPoint).toEqual({ x: 120, y: 90 })
// screen = (canvas + offset) * scale
// x: (10 + 100) * 2 = 220
// y: (20 + 50) * 2 = 140
expect(screenPoint).toEqual({ x: 220, y: 140 })
})
it('should handle zero coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: 0, y: 0 })
expect(screenPoint).toEqual({ x: 100, y: 50 })
expect(screenPoint).toEqual({ x: 200, y: 100 })
})
it('should handle negative coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: -10, y: -20 })
expect(screenPoint).toEqual({ x: 80, y: 10 })
expect(screenPoint).toEqual({ x: 180, y: 60 })
})
})
@@ -146,12 +146,12 @@ describe('useTransformState', () => {
it('should convert screen coordinates to canvas coordinates', () => {
const { screenToCanvas } = transformState
const screenPoint = { x: 120, y: 90 }
const screenPoint = { x: 220, y: 140 }
const canvasPoint = screenToCanvas(screenPoint)
// canvas = (screen - offset) / scale
// x: (120 - 100) / 2 = 10
// y: (90 - 50) / 2 = 20
// canvas = screen / scale - offset
// x: 220 / 2 - 100 = 10
// y: 140 / 2 - 50 = 20
expect(canvasPoint).toEqual({ x: 10, y: 20 })
})
@@ -183,11 +183,11 @@ describe('useTransformState', () => {
const nodeSize = [200, 100]
const bounds = getNodeScreenBounds(nodePos, nodeSize)
// Top-left: canvasToScreen(10, 20) = (120, 90)
// Top-left: canvasToScreen(10, 20) = (220, 140)
// Width: 200 * 2 = 400
// Height: 100 * 2 = 200
expect(bounds.x).toBe(120)
expect(bounds.y).toBe(90)
expect(bounds.x).toBe(220)
expect(bounds.y).toBe(140)
expect(bounds.width).toBe(400)
expect(bounds.height).toBe(200)
})
@@ -288,14 +288,14 @@ describe('useTransformState', () => {
// topLeft in screen: (-200, -120)
// bottomRight in screen: (1200, 720)
// Convert to canvas coordinates:
// topLeft: ((-200 - 100) / 2, (-120 - 50) / 2) = (-150, -85)
// bottomRight: ((1200 - 100) / 2, (720 - 50) / 2) = (550, 335)
// Convert to canvas coordinates (canvas = screen / scale - offset):
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
expect(bounds.x).toBe(-150)
expect(bounds.y).toBe(-85)
expect(bounds.width).toBe(700) // 550 - (-150)
expect(bounds.height).toBe(420) // 335 - (-85)
expect(bounds.x).toBe(-200)
expect(bounds.y).toBe(-110)
expect(bounds.width).toBe(700) // 500 - (-200)
expect(bounds.height).toBe(420) // 310 - (-110)
})
it('should handle zero margin', () => {
@@ -305,8 +305,8 @@ describe('useTransformState', () => {
const bounds = getViewportBounds(viewport, 0)
// No margin, so viewport bounds are exact
expect(bounds.x).toBe(-50) // (0 - 100) / 2
expect(bounds.y).toBe(-25) // (0 - 50) / 2
expect(bounds.x).toBe(-100) // 0 / 2 - 100
expect(bounds.y).toBe(-50) // 0 / 2 - 50
expect(bounds.width).toBe(500) // 1000 / 2
expect(bounds.height).toBe(300) // 600 / 2
})

View File

@@ -1,12 +1,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
// Mock stores
vi.mock('@/stores/graphStore')
vi.mock('@/stores/settingStore')
vi.mock('@/stores/graphStore', () => {
const getCanvas = vi.fn()
return { useCanvasStore: vi.fn(() => ({ getCanvas })) }
})
vi.mock('@/stores/settingStore', () => {
const getFn = vi.fn()
return { useSettingStore: vi.fn(() => ({ get: getFn })) }
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
@@ -17,105 +24,86 @@ vi.mock('@/scripts/app', () => ({
}
}))
function createMockLGraphCanvas(read_only = true): LGraphCanvas {
const mockCanvas: Partial<LGraphCanvas> = { read_only }
return mockCanvas as LGraphCanvas
}
function createMockPointerEvent(
buttons: PointerEvent['buttons'] = 1
): PointerEvent {
const mockEvent: Partial<PointerEvent> = {
buttons,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
return mockEvent as PointerEvent
}
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
const mockEvent: Partial<WheelEvent> = {
ctrlKey,
metaKey,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
return mockEvent as WheelEvent
}
describe('useCanvasInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCanvasStore, { partial: true }).mockReturnValue({
getCanvas: vi.fn()
})
vi.mocked(useSettingStore, { partial: true }).mockReturnValue({
get: vi.fn()
})
vi.resetAllMocks()
})
describe('handlePointer', () => {
it('should forward space+drag events to canvas when read_only is true', () => {
// Setup
const mockCanvas = { read_only: true }
it('should intercept left mouse events when canvas is read_only to enable space+drag navigation', () => {
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(mockCanvas as any)
const mockCanvas = createMockLGraphCanvas(true)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
// Create mock pointer event
const mockEvent = {
buttons: 1, // Left mouse button
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>
const mockEvent = createMockPointerEvent(1) // Left Mouse Button
handlePointer(mockEvent)
// Test
handlePointer(mockEvent as unknown as PointerEvent)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should forward middle mouse button events to canvas', () => {
// Setup
const mockCanvas = { read_only: false }
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(mockCanvas as any)
const mockCanvas = createMockLGraphCanvas(false)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
// Create mock pointer event with middle button
const mockEvent = {
buttons: 4, // Middle mouse button
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>
const mockEvent = createMockPointerEvent(4) // Middle mouse button
handlePointer(mockEvent)
// Test
handlePointer(mockEvent as unknown as PointerEvent)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should not prevent default when canvas is not in read_only mode and not middle button', () => {
// Setup
const mockCanvas = { read_only: false }
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(mockCanvas as any)
const mockCanvas = createMockLGraphCanvas(false)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
// Create mock pointer event
const mockEvent = {
buttons: 1, // Left mouse button
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>
const mockEvent = createMockPointerEvent(1)
handlePointer(mockEvent)
// Test
handlePointer(mockEvent as unknown as PointerEvent)
// Verify - should not prevent default (let media handle normally)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
it('should return early when canvas is null', () => {
// Setup
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(null as any)
vi.mocked(getCanvas).mockReturnValue(null as unknown as LGraphCanvas) // TODO: Fix misaligned types
const { handlePointer } = useCanvasInteractions()
// Create mock pointer event that would normally trigger forwarding
const mockEvent = {
buttons: 1, // Left mouse button - would trigger space+drag if canvas had read_only=true
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} satisfies Partial<PointerEvent>
const mockEvent = createMockPointerEvent(1)
handlePointer(mockEvent)
// Test
handlePointer(mockEvent as unknown as PointerEvent)
// Verify early return - no event methods should be called at all
expect(getCanvas).toHaveBeenCalled()
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
@@ -124,66 +112,42 @@ describe('useCanvasInteractions', () => {
describe('handleWheel', () => {
it('should forward ctrl+wheel events to canvas in standard nav mode', () => {
// Setup
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const { handleWheel } = useCanvasInteractions()
// Create mock wheel event with ctrl key
const mockEvent = {
ctrlKey: true,
metaKey: false,
preventDefault: vi.fn()
} satisfies Partial<WheelEvent>
// Ctrl key pressed
const mockEvent = createMockWheelEvent(true)
// Test
handleWheel(mockEvent as unknown as WheelEvent)
handleWheel(mockEvent)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should forward all wheel events to canvas in legacy nav mode', () => {
// Setup
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('legacy')
const { handleWheel } = useCanvasInteractions()
// Create mock wheel event without modifiers
const mockEvent = {
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn()
} satisfies Partial<WheelEvent>
const mockEvent = createMockWheelEvent()
handleWheel(mockEvent)
// Test
handleWheel(mockEvent as unknown as WheelEvent)
// Verify
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should not prevent default for regular wheel events in standard nav mode', () => {
// Setup
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const { handleWheel } = useCanvasInteractions()
// Create mock wheel event without modifiers
const mockEvent = {
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn()
} satisfies Partial<WheelEvent>
const mockEvent = createMockWheelEvent()
handleWheel(mockEvent)
// Test
handleWheel(mockEvent as unknown as WheelEvent)
// Verify - should not prevent default (let component handle normally)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
})
})

View File

@@ -1781,6 +1781,38 @@ describe('useNodePricing', () => {
})
})
describe('dynamic pricing - ByteDanceSeedreamNode', () => {
it('should return fallback when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run ($0.03 for one output image)')
})
it('should return $0.03/Run when sequential generation is disabled', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [
{ name: 'sequential_image_generation', value: 'disabled' },
{ name: 'max_images', value: 5 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run')
})
it('should multiply by max_images when sequential generation is enabled', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [
{ name: 'sequential_image_generation', value: 'enabled' },
{ name: 'max_images', value: 4 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.12/Run ($0.03 for one output image)')
})
})
describe('dynamic pricing - ByteDance Seedance video nodes', () => {
it('should return base 10s range for PRO 1080p on ByteDanceTextToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing()

View File

@@ -63,12 +63,14 @@ describe('useUpdateAvailableNodes', () => {
const mockStartFetchInstalled = vi.fn()
const mockIsPackInstalled = vi.fn()
const mockGetInstalledPackVersion = vi.fn()
const mockIsPackEnabled = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Default setup
mockIsPackInstalled.mockReturnValue(true)
mockIsPackEnabled.mockReturnValue(true) // Default: all packs are enabled
mockGetInstalledPackVersion.mockImplementation((id: string) => {
switch (id) {
case 'pack-1':
@@ -100,7 +102,8 @@ describe('useUpdateAvailableNodes', () => {
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled,
getInstalledPackVersion: mockGetInstalledPackVersion
getInstalledPackVersion: mockGetInstalledPackVersion,
isPackEnabled: mockIsPackEnabled
} as any)
mockUseInstalledPacks.mockReturnValue({
@@ -357,4 +360,127 @@ describe('useUpdateAvailableNodes', () => {
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4')
})
})
describe('enabledUpdateAvailableNodePacks', () => {
it('returns only enabled packs with updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// pack-1 is disabled
return id !== 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0], mockInstalledPacks[1]]),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
useUpdateAvailableNodes()
// pack-1 has updates but is disabled
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
// enabledUpdateAvailableNodePacks should be empty
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(0)
})
it('returns all packs when all are enabled', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(1)
expect(enabledUpdateAvailableNodePacks.value[0].id).toBe('pack-1')
})
})
describe('hasDisabledUpdatePacks', () => {
it('returns true when there are disabled packs with updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// pack-1 is disabled
return id !== 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(true)
})
it('returns false when all packs with updates are enabled', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(false)
})
it('returns false when no packs have updates', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(false)
})
})
describe('hasUpdateAvailable with disabled packs', () => {
it('returns false when only disabled packs have updates', () => {
mockIsPackEnabled.mockReturnValue(false) // All packs disabled
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(false)
})
it('returns true when at least one enabled pack has updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// Only pack-1 is enabled
return id === 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(true)
})
})
})

View File

@@ -11,17 +11,6 @@ LiteGraphGlobal {
"CARD_SHAPE": 4,
"CENTER": 5,
"CIRCLE_SHAPE": 3,
"COMFY_VUE_NODE_DIMENSIONS": {
"components": {
"HEADER_HEIGHT": 34,
"SLOT_HEIGHT": 24,
"STANDARD_WIDGET_HEIGHT": 30,
},
"spacing": {
"BETWEEN_SLOTS_AND_BODY": 8,
"BETWEEN_WIDGETS": 8,
},
},
"CONNECTING_LINK_COLOR": "#AFA",
"Classes": {
"InputIndicators": [Function],

View File

@@ -0,0 +1,115 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
vi.mock(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
() => ({
useVueElementTracking: vi.fn()
})
)
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
})
}))
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({
position: { x: 100, y: 50 },
startDrag: vi.fn(),
handleDrag: vi.fn(),
endDrag: vi.fn()
})
}))
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
useLOD: () => ({
lodLevel: { value: 0 },
shouldRenderWidgets: { value: true },
shouldRenderSlots: { value: true },
shouldRenderContent: { value: false },
lodCssClass: { value: '' }
}),
LODLevel: { MINIMAL: 0 }
}))
describe('LGraphNode', () => {
const mockNodeData: VueNodeData = {
id: 'test-node-123',
title: 'Test Node',
type: 'TestNode',
mode: 0,
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
const mountLGraphNode = (props: any, selectedNodeIds = new Set()) => {
return mount(LGraphNode, {
props,
global: {
provide: {
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
}
}
})
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should call resize tracking composable with node ID', () => {
mountLGraphNode({ nodeData: mockNodeData })
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
})
it('should render with data-node-id attribute', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.attributes('data-node-id')).toBe('test-node-123')
})
it('should render node title', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.text()).toContain('Test Node')
})
it('should apply selected styling when selected prop is true', () => {
const wrapper = mountLGraphNode(
{ nodeData: mockNodeData, selected: true },
new Set(['test-node-123'])
)
expect(wrapper.classes()).toContain('outline-2')
expect(wrapper.classes()).toContain('outline-black')
expect(wrapper.classes()).toContain('dark-theme:outline-white')
})
it('should apply executing animation when executing prop is true', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData, executing: true })
expect(wrapper.classes()).toContain('animate-pulse')
})
it('should emit node-click event on pointer up', async () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
await wrapper.trigger('pointerup')
expect(wrapper.emitted('node-click')).toHaveLength(1)
expect(wrapper.emitted('node-click')?.[0]).toHaveLength(3)
expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData)
})
})

View File

@@ -3,9 +3,9 @@ import { ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useCanvasStore } from '@/stores/graphStore'
vi.mock('@/stores/graphStore', () => ({
@@ -110,7 +110,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false
})
handleNodeSelect(event, testNodeData)
handleNodeSelect(event, testNodeData, false)
expect(mockCanvas.deselectAll).toHaveBeenCalledOnce()
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
@@ -130,7 +130,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeData)
handleNodeSelect(ctrlClickEvent, testNodeData, false)
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
@@ -149,7 +149,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeData)
handleNodeSelect(ctrlClickEvent, testNodeData, false)
expect(mockCanvas.deselect).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.select).not.toHaveBeenCalled()
@@ -167,7 +167,7 @@ describe('useNodeEventHandlers', () => {
metaKey: true
})
handleNodeSelect(metaClickEvent, testNodeData)
handleNodeSelect(metaClickEvent, testNodeData, false)
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
@@ -180,7 +180,7 @@ describe('useNodeEventHandlers', () => {
mockNode.flags.pinned = false
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData)
handleNodeSelect(event, testNodeData, false)
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
@@ -194,7 +194,7 @@ describe('useNodeEventHandlers', () => {
mockNode.flags.pinned = true
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData)
handleNodeSelect(event, testNodeData, false)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
})
@@ -207,7 +207,7 @@ describe('useNodeEventHandlers', () => {
const event = new PointerEvent('pointerdown')
expect(() => {
handleNodeSelect(event, testNodeData)
handleNodeSelect(event, testNodeData, false)
}).not.toThrow()
expect(mockCanvas.select).not.toHaveBeenCalled()
@@ -227,7 +227,7 @@ describe('useNodeEventHandlers', () => {
} as any
expect(() => {
handleNodeSelect(event, nodeData)
handleNodeSelect(event, nodeData, false)
}).not.toThrow()
expect(mockCanvas.select).not.toHaveBeenCalled()

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
// Mock the layout mutations module
vi.mock('@/renderer/core/layout/operations/layoutMutations')
const mockedUseLayoutMutations = vi.mocked(useLayoutMutations)
describe('useNodeZIndex', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('bringNodeToFront', () => {
it('should bring node to front with default source', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex()
bringNodeToFront('node1')
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Vue)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node1')
})
it('should bring node to front with custom source', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex()
bringNodeToFront('node2', LayoutSource.Canvas)
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Canvas)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node2')
})
it('should use custom layout source from options', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex({
layoutSource: LayoutSource.External
})
bringNodeToFront('node3')
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.External)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node3')
})
it('should override layout source with explicit source parameter', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex({
layoutSource: LayoutSource.External
})
bringNodeToFront('node4', LayoutSource.Canvas)
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Canvas)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node4')
})
})
})

View File

@@ -1,39 +1,278 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { assetService } from '@/services/assetService'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
const mockSettingStoreGet = vi.fn(() => false)
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
}))
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) =>
key === 'widgets.selectModel' ? 'Select model' : key
)
}))
vi.mock('@/services/assetService', () => ({
assetService: {
isAssetBrowserEligible: vi.fn(() => false)
}
}))
// Test factory functions
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
return {
type: 'combo',
options: {},
name: 'testWidget',
value: undefined,
...overrides
} as IBaseWidget
}
function createMockNode(comfyClass = 'TestNode'): LGraphNode {
const node = new LGraphNode('TestNode')
node.comfyClass = comfyClass
// Spy on the addWidget method
vi.spyOn(node, 'addWidget').mockReturnValue(createMockWidget())
return node
}
function createMockInputSpec(overrides: Partial<InputSpec> = {}): InputSpec {
return {
type: 'COMBO',
name: 'testInput',
...overrides
} as InputSpec
}
describe('useComboWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to defaults
mockSettingStoreGet.mockReturnValue(false)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
})
it('should handle undefined spec', () => {
const constructor = useComboWidget()
const mockNode = {
addWidget: vi.fn().mockReturnValue({ options: {} } as any)
}
const mockWidget = createMockWidget()
const mockNode = createMockNode()
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({ name: 'inputName' })
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'inputName'
}
const widget = constructor(mockNode as any, inputSpec)
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'inputName',
undefined, // default value
expect.any(Function), // callback
undefined,
expect.any(Function),
expect.objectContaining({
values: []
})
)
expect(widget).toEqual({ options: {} })
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget when asset API is disabled', () => {
mockSettingStoreGet.mockReturnValue(false) // Asset API disabled
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) // Widget is eligible
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(widget).toBe(mockWidget)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'ckpt_name',
'model1.safetensors',
expect.any(Function),
{ values: ['model1.safetensors', 'model2.safetensors'] }
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget when widget is not eligible for asset browser', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode()
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'not_eligible_widget',
options: ['option1', 'option2']
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'not_eligible_widget',
'option1',
expect.any(Function),
{ values: ['option1', 'option2'] }
)
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'not_eligible_widget',
'TestNode'
)
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget when API enabled and widget eligible', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'model1.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'ckpt_name',
'CheckpointLoaderSimple'
)
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget with options when API enabled and widget eligible', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'model1.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'ckpt_name',
'CheckpointLoaderSimple'
)
expect(widget).toBe(mockWidget)
})
it('should use asset browser widget even when inputSpec has a default value but no options', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'fallback.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
default: 'fallback.safetensors'
// Note: no options array provided
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'fallback.safetensors',
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'ckpt_name',
'CheckpointLoaderSimple'
)
expect(widget).toBe(mockWidget)
})
it('should show Select model when asset widget has undefined current value', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'Select model'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name'
// Note: no default, no options, not remote - getDefaultValue returns undefined
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'Select model', // Should fallback to this instead of undefined
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'ckpt_name',
'CheckpointLoaderSimple'
)
expect(widget).toBe(mockWidget)
})
})

View File

@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest'
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
import WidgetColorPicker from '@/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue'
import WidgetFileUpload from '@/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue'
import WidgetInputNumber from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import WidgetMarkdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue'
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
import WidgetSlider from '@/renderer/extensions/vueNodes/widgets/components/WidgetSlider.vue'
import WidgetTextarea from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue'
import WidgetToggleSwitch from '@/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue'
import {
@@ -20,15 +20,15 @@ describe('widgetRegistry', () => {
// Test number type mappings
describe('number types', () => {
it('should map int types to slider widget', () => {
expect(getComponent('int')).toBe(WidgetSlider)
expect(getComponent('INT')).toBe(WidgetSlider)
expect(getComponent('int')).toBe(WidgetInputNumber)
expect(getComponent('INT')).toBe(WidgetInputNumber)
})
it('should map float types to slider widget', () => {
expect(getComponent('float')).toBe(WidgetSlider)
expect(getComponent('FLOAT')).toBe(WidgetSlider)
expect(getComponent('number')).toBe(WidgetSlider)
expect(getComponent('slider')).toBe(WidgetSlider)
expect(getComponent('float')).toBe(WidgetInputNumber)
expect(getComponent('FLOAT')).toBe(WidgetInputNumber)
expect(getComponent('number')).toBe(WidgetInputNumber)
expect(getComponent('slider')).toBe(WidgetInputNumber)
})
})

View File

@@ -0,0 +1,203 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: vi.fn(() => ({
getRegisteredNodeTypes: vi.fn(
() =>
new Set([
'CheckpointLoaderSimple',
'LoraLoader',
'VAELoader',
'TestNode'
])
)
}))
}))
// Test data constants
const MOCK_ASSETS = {
checkpoints: {
id: 'uuid-1',
name: 'model1.safetensors',
tags: ['models', 'checkpoints'],
size: 123456
},
loras: {
id: 'uuid-2',
name: 'model2.safetensors',
tags: ['models', 'loras'],
size: 654321
},
vae: {
id: 'uuid-3',
name: 'vae1.safetensors',
tags: ['models', 'vae'],
size: 789012
}
} as const
// Helper functions
function mockApiResponse(assets: any[], options = {}) {
const response = {
assets,
total: assets.length,
has_more: false,
...options
}
vi.mocked(api.fetchApi).mockResolvedValueOnce(Response.json(response))
return response
}
function mockApiError(status: number, statusText = 'Error') {
vi.mocked(api.fetchApi).mockResolvedValueOnce(
new Response(null, { status, statusText })
)
}
describe('assetService', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(api, 'fetchApi')
})
describe('getAssetModelFolders', () => {
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
const assets = [
{
id: 'uuid-1',
name: 'checkpoint1.safetensors',
tags: ['models', 'checkpoints'],
size: 123456
},
{
id: 'uuid-2',
name: 'config.yaml',
tags: ['models', 'configs'], // Blacklisted
size: 654321
},
{
id: 'uuid-3',
name: 'vae1.safetensors',
tags: ['models', 'vae'],
size: 789012
}
]
mockApiResponse(assets)
const result = await assetService.getAssetModelFolders()
expect(api.fetchApi).toHaveBeenCalledWith('/assets?include_tags=models')
expect(result).toHaveLength(2)
const folderNames = result.map((f) => f.name)
expect(folderNames).toEqual(['checkpoints', 'vae'])
expect(folderNames).not.toContain('configs')
})
it('should handle empty responses', async () => {
mockApiResponse([])
const emptyResult = await assetService.getAssetModelFolders()
expect(emptyResult).toHaveLength(0)
})
it('should handle network errors', async () => {
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Network error'
)
})
it('should handle HTTP errors', async () => {
mockApiError(500)
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Unable to load model folders: Server returned 500. Please try again.'
)
})
})
describe('getAssetModels', () => {
it('should return filtered models for folder', async () => {
const assets = [
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
{
id: 'uuid-4',
name: 'missing-model.safetensors',
tags: ['models', 'checkpoints', 'missing'], // Has missing tag
size: 654321
}
]
mockApiResponse(assets)
const result = await assetService.getAssetModels('checkpoints')
expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models,checkpoints'
)
expect(result).toEqual([
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
])
})
it('should handle errors and empty responses', async () => {
// Empty response
mockApiResponse([])
const emptyResult = await assetService.getAssetModels('nonexistent')
expect(emptyResult).toEqual([])
// Network error
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
'Network error'
)
// HTTP error
mockApiError(404)
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
'Unable to load models for checkpoints: Server returned 404. Please try again.'
)
})
})
describe('isAssetBrowserEligible', () => {
it('should return true for eligible widget names with registered node types', () => {
expect(
assetService.isAssetBrowserEligible(
'ckpt_name',
'CheckpointLoaderSimple'
)
).toBe(true)
expect(
assetService.isAssetBrowserEligible('lora_name', 'LoraLoader')
).toBe(true)
expect(assetService.isAssetBrowserEligible('vae_name', 'VAELoader')).toBe(
true
)
})
it('should return false for non-eligible widget names', () => {
expect(assetService.isAssetBrowserEligible('seed', 'TestNode')).toBe(
false
)
expect(assetService.isAssetBrowserEligible('steps', 'TestNode')).toBe(
false
)
expect(
assetService.isAssetBrowserEligible('sampler_name', 'TestNode')
).toBe(false)
expect(assetService.isAssetBrowserEligible('', 'TestNode')).toBe(false)
})
it('should return false for eligible widget names with unregistered node types', () => {
expect(
assetService.isAssetBrowserEligible('ckpt_name', 'UnknownNode')
).toBe(false)
expect(
assetService.isAssetBrowserEligible('lora_name', 'UnknownNode')
).toBe(false)
})
})
})

View File

@@ -2,7 +2,9 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
import { useModelStore } from '@/stores/modelStore'
import { useSettingStore } from '@/stores/settingStore'
// Mock the api
vi.mock('@/scripts/api', () => ({
@@ -13,7 +15,34 @@ vi.mock('@/scripts/api', () => ({
}
}))
function enableMocks() {
// Mock the assetService
vi.mock('@/services/assetService', () => ({
assetService: {
getAssetModelFolders: vi.fn(),
getAssetModels: vi.fn()
}
}))
// Mock the settingStore
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn()
}))
function enableMocks(useAssetAPI = false) {
// Mock settingStore to return the useAssetAPI setting
const mockSettingStore = {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'Comfy.Assets.UseAssetAPI') {
return useAssetAPI
}
return false
})
}
vi.mocked(useSettingStore, { partial: true }).mockReturnValue(
mockSettingStore
)
// Mock experimental API - returns objects with name and folders properties
vi.mocked(api.getModels).mockResolvedValue([
{ name: 'sdxl.safetensors', pathIndex: 0 },
{ name: 'sdv15.safetensors', pathIndex: 0 },
@@ -23,6 +52,18 @@ function enableMocks() {
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
{ name: 'vae', folders: ['/path/to/vae'] }
])
// Mock asset API - also returns objects with name and folders properties
vi.mocked(assetService.getAssetModelFolders).mockResolvedValue([
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
{ name: 'vae', folders: ['/path/to/vae'] }
])
vi.mocked(assetService.getAssetModels).mockResolvedValue([
{ name: 'sdxl.safetensors', pathIndex: 0 },
{ name: 'sdv15.safetensors', pathIndex: 0 },
{ name: 'noinfo.safetensors', pathIndex: 0 }
])
vi.mocked(api.viewMetadata).mockImplementation((_, model) => {
if (model === 'noinfo.safetensors') {
return Promise.resolve({})
@@ -46,26 +87,25 @@ describe('useModelStore', () => {
beforeEach(async () => {
setActivePinia(createPinia())
store = useModelStore()
vi.resetAllMocks()
})
it('should load models', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore).not.toBeNull()
if (!folderStore) return
expect(Object.keys(folderStore.models).length).toBe(3)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
it('should load model metadata', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore).not.toBeNull()
if (!folderStore) return
const model = folderStore.models['0/sdxl.safetensors']
expect(folderStore).toBeDefined()
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('Title of sdxl.safetensors')
expect(model.architecture_id).toBe('stable-diffusion-xl-base-v1')
@@ -79,11 +119,11 @@ describe('useModelStore', () => {
it('should handle no metadata', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore).not.toBeNull()
if (!folderStore) return
const model = folderStore.models['0/noinfo.safetensors']
expect(folderStore).toBeDefined()
const model = folderStore!.models['0/noinfo.safetensors']
await model.load()
expect(model.file_name).toBe('noinfo.safetensors')
expect(model.title).toBe('noinfo')
@@ -95,6 +135,7 @@ describe('useModelStore', () => {
it('should cache model information', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
expect(api.getModels).toHaveBeenCalledTimes(0)
await store.getLoadedModelFolder('checkpoints')
@@ -102,4 +143,36 @@ describe('useModelStore', () => {
await store.getLoadedModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(1)
})
describe('API switching functionality', () => {
it('should use experimental API for complete workflow when UseAssetAPI setting is false', async () => {
enableMocks(false) // useAssetAPI = false
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
expect(api.getModels).toHaveBeenCalledWith('checkpoints')
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(0)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(0)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
it('should use asset API for complete workflow when UseAssetAPI setting is true', async () => {
enableMocks(true) // useAssetAPI = true
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledWith('checkpoints')
expect(api.getModelFolders).toHaveBeenCalledTimes(0)
expect(api.getModels).toHaveBeenCalledTimes(0)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
})
})

View File

@@ -21,45 +21,44 @@ const EXPECTED_DEFAULT_TYPES = [
type NodeDefStoreType = typeof import('@/stores/nodeDefStore')
// Create minimal but valid ComfyNodeDefImpl for testing
function createMockNodeDef(name: string): ComfyNodeDefImpl {
const def: ComfyNodeDefV1 = {
name,
display_name: name,
category: 'test',
python_module: 'nodes',
description: '',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false
}
return new ComfyNodeDefImpl(def)
}
const MOCK_NODE_NAMES = [
'CheckpointLoaderSimple',
'ImageOnlyCheckpointLoader',
'LoraLoader',
'LoraLoaderModelOnly',
'VAELoader',
'ControlNetLoader',
'UNETLoader',
'UpscaleModelLoader',
'StyleModelLoader',
'GLIGENLoader'
] as const
const mockNodeDefsByName = Object.fromEntries(
MOCK_NODE_NAMES.map((name) => [name, createMockNodeDef(name)])
)
// Mock nodeDefStore dependency - modelToNodeStore relies on this for registration
// Most tests expect this to be populated; tests that need empty state can override
vi.mock('@/stores/nodeDefStore', async (importOriginal) => {
const original = await importOriginal<NodeDefStoreType>()
const { ComfyNodeDefImpl } = original
// Create minimal but valid ComfyNodeDefImpl for testing
function createMockNodeDef(name: string): ComfyNodeDefImpl {
const def: ComfyNodeDefV1 = {
name,
display_name: name,
category: 'test',
python_module: 'nodes',
description: '',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false
}
return new ComfyNodeDefImpl(def)
}
const MOCK_NODE_NAMES = [
'CheckpointLoaderSimple',
'ImageOnlyCheckpointLoader',
'LoraLoader',
'LoraLoaderModelOnly',
'VAELoader',
'ControlNetLoader',
'UNETLoader',
'UpscaleModelLoader',
'StyleModelLoader',
'GLIGENLoader'
] as const
const mockNodeDefsByName = Object.fromEntries(
MOCK_NODE_NAMES.map((name) => [name, createMockNodeDef(name)])
)
return {
...original,
@@ -72,6 +71,7 @@ vi.mock('@/stores/nodeDefStore', async (importOriginal) => {
describe('useModelToNodeStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('modelToNodeMap', () => {
@@ -288,12 +288,58 @@ describe('useModelToNodeStore', () => {
})
it('should not register when nodeDefStore is empty', () => {
// Create fresh Pinia for this test to avoid state persistence
setActivePinia(createPinia())
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
nodeDefsByName: {}
})
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(modelToNodeStore.getNodeProvider('checkpoints')).toBeUndefined()
// Restore original mock for subsequent tests
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
nodeDefsByName: mockNodeDefsByName
})
})
})
describe('getRegisteredNodeTypes', () => {
it('should return a Set instance', () => {
const modelToNodeStore = useModelToNodeStore()
const result = modelToNodeStore.getRegisteredNodeTypes()
expect(result).toBeInstanceOf(Set)
})
it('should return empty set when nodeDefStore is empty', () => {
// Create fresh Pinia for this test to avoid state persistence
setActivePinia(createPinia())
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
nodeDefsByName: {}
})
const modelToNodeStore = useModelToNodeStore()
const result = modelToNodeStore.getRegisteredNodeTypes()
expect(result.size).toBe(0)
// Restore original mock for subsequent tests
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
nodeDefsByName: mockNodeDefsByName
})
})
it('should contain node types for efficient Set.has() lookups', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const result = modelToNodeStore.getRegisteredNodeTypes()
// Test Set.has() functionality which assetService depends on
expect(result.has('CheckpointLoaderSimple')).toBe(true)
expect(result.has('LoraLoader')).toBe(true)
expect(result.has('NonExistentNode')).toBe(false)
})
})

View File

@@ -113,7 +113,7 @@ describe('useSubgraphStore', () => {
})
it('should allow subgraphs to be edited', async () => {
await mockFetch({ 'test.json': mockGraph })
store.editBlueprint(store.typePrefix + 'test')
await store.editBlueprint(store.typePrefix + 'test')
//check active graph
expect(comfyApp.loadGraphData).toHaveBeenCalled()
})

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil'
describe('mathUtil', () => {
describe('gcd', () => {
it('should compute greatest common divisor correctly', () => {
expect(gcd(48, 18)).toBe(6)
expect(gcd(100, 25)).toBe(25)
expect(gcd(17, 13)).toBe(1)
expect(gcd(0, 5)).toBe(5)
expect(gcd(5, 0)).toBe(5)
})
})
describe('lcm', () => {
it('should compute least common multiple correctly', () => {
expect(lcm(4, 6)).toBe(12)
expect(lcm(15, 20)).toBe(60)
expect(lcm(7, 11)).toBe(77)
})
})
describe('computeUnionBounds', () => {
it('should return null for empty input', () => {
expect(computeUnionBounds([])).toBe(null)
})
// Tests for tuple format (ReadOnlyRect)
it('should work with ReadOnlyRect tuple format', () => {
const tuples: ReadOnlyRect[] = [
[10, 20, 30, 40] as const, // bounds: 10,20 to 40,60
[50, 10, 20, 30] as const // bounds: 50,10 to 70,40
]
const result = computeUnionBounds(tuples)
expect(result).toEqual({
x: 10, // min(10, 50)
y: 10, // min(20, 10)
width: 60, // max(40, 70) - min(10, 50) = 70 - 10
height: 50 // max(60, 40) - min(20, 10) = 60 - 10
})
})
it('should handle single ReadOnlyRect tuple', () => {
const tuple: ReadOnlyRect = [10, 20, 30, 40] as const
const result = computeUnionBounds([tuple])
expect(result).toEqual({
x: 10,
y: 20,
width: 30,
height: 40
})
})
it('should handle tuple format with negative dimensions', () => {
const tuples: ReadOnlyRect[] = [
[100, 50, -20, -10] as const, // x+width=80, y+height=40
[90, 45, 15, 20] as const // x+width=105, y+height=65
]
const result = computeUnionBounds(tuples)
expect(result).toEqual({
x: 90, // min(100, 90)
y: 45, // min(50, 45)
width: 15, // max(80, 105) - min(100, 90) = 105 - 90
height: 20 // max(40, 65) - min(50, 45) = 65 - 45
})
})
it('should maintain optimal performance with SoA tuples', () => {
// Test that array access is as expected for typical selection sizes
const tuples: ReadOnlyRect[] = Array.from(
{ length: 10 },
(_, i) =>
[
i * 20, // x
i * 15, // y
100 + i * 5, // width
80 + i * 3 // height
] as const
)
const result = computeUnionBounds(tuples)
expect(result).toBeTruthy()
expect(result!.x).toBe(0)
expect(result!.y).toBe(0)
expect(result!.width).toBe(325)
expect(result!.height).toBe(242)
})
})
})