mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-04 05:02:17 +00:00
merge main into rh-test
This commit is contained in:
@@ -1,126 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { app } from '@/scripts/app'
|
||||
import * as settingStore from '@/stores/settingStore'
|
||||
|
||||
// Mock the app and canvas
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
canvas: null as HTMLCanvasElement | null
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the setting store
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useCanvasInteractions', () => {
|
||||
let mockCanvas: HTMLCanvasElement
|
||||
let mockSettingStore: { get: ReturnType<typeof vi.fn> }
|
||||
let canvasInteractions: ReturnType<typeof useCanvasInteractions>
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create mock canvas element
|
||||
mockCanvas = document.createElement('canvas')
|
||||
mockCanvas.dispatchEvent = vi.fn()
|
||||
app.canvas!.canvas = mockCanvas
|
||||
|
||||
// Mock setting store
|
||||
mockSettingStore = { get: vi.fn() }
|
||||
vi.mocked(settingStore.useSettingStore).mockReturnValue(
|
||||
mockSettingStore as any
|
||||
)
|
||||
|
||||
canvasInteractions = useCanvasInteractions()
|
||||
})
|
||||
|
||||
describe('handleWheel', () => {
|
||||
it('should check navigation mode from settings', () => {
|
||||
mockSettingStore.get.mockReturnValue('standard')
|
||||
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
ctrlKey: true,
|
||||
deltaY: -100
|
||||
})
|
||||
|
||||
canvasInteractions.handleWheel(wheelEvent)
|
||||
|
||||
expect(mockSettingStore.get).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.NavigationMode'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not forward regular wheel events in standard mode', () => {
|
||||
mockSettingStore.get.mockReturnValue('standard')
|
||||
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
deltaY: -100
|
||||
})
|
||||
|
||||
canvasInteractions.handleWheel(wheelEvent)
|
||||
|
||||
expect(mockCanvas.dispatchEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward all wheel events to canvas in legacy mode', () => {
|
||||
mockSettingStore.get.mockReturnValue('legacy')
|
||||
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
deltaY: -100,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
canvasInteractions.handleWheel(wheelEvent)
|
||||
|
||||
expect(mockCanvas.dispatchEvent).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing canvas gracefully', () => {
|
||||
;(app.canvas as any).canvas = null
|
||||
mockSettingStore.get.mockReturnValue('standard')
|
||||
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
ctrlKey: true,
|
||||
deltaY: -100
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
canvasInteractions.handleWheel(wheelEvent)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('forwardEventToCanvas', () => {
|
||||
it('should dispatch event to canvas element', () => {
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
deltaY: -100,
|
||||
ctrlKey: true
|
||||
})
|
||||
|
||||
canvasInteractions.forwardEventToCanvas(wheelEvent)
|
||||
|
||||
expect(mockCanvas.dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.any(WheelEvent)
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle missing canvas gracefully', () => {
|
||||
;(app.canvas as any).canvas = null
|
||||
|
||||
const wheelEvent = new WheelEvent('wheel', {
|
||||
deltaY: -100
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
canvasInteractions.forwardEventToCanvas(wheelEvent)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
270
tests-ui/tests/composables/graph/useSelectionState.test.ts
Normal file
270
tests-ui/tests/composables/graph/useSelectionState.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { type Ref, ref } from 'vue'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
// Test interfaces
|
||||
interface TestNodeConfig {
|
||||
type?: string
|
||||
mode?: LGraphEventMode
|
||||
flags?: { collapsed?: boolean }
|
||||
pinned?: boolean
|
||||
removable?: boolean
|
||||
}
|
||||
|
||||
interface TestNode {
|
||||
type: string
|
||||
mode: LGraphEventMode
|
||||
flags?: { collapsed?: boolean }
|
||||
pinned?: boolean
|
||||
removable?: boolean
|
||||
isSubgraphNode: () => boolean
|
||||
}
|
||||
|
||||
type MockedItem = TestNode | { type: string; isNode: boolean }
|
||||
|
||||
// Mock all stores
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
|
||||
useNodeHelpStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
|
||||
useNodeLibrarySidebarTab: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(),
|
||||
isImageNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
filterOutputNodes: vi.fn()
|
||||
}))
|
||||
|
||||
const createTestNode = (config: TestNodeConfig = {}): TestNode => {
|
||||
return {
|
||||
type: config.type || 'TestNode',
|
||||
mode: config.mode || LGraphEventMode.ALWAYS,
|
||||
flags: config.flags,
|
||||
pinned: config.pinned,
|
||||
removable: config.removable,
|
||||
isSubgraphNode: () => false
|
||||
}
|
||||
}
|
||||
|
||||
// Mock comment/connection objects
|
||||
const mockComment = { type: 'comment', isNode: false }
|
||||
const mockConnection = { type: 'connection', isNode: false }
|
||||
|
||||
describe('useSelectionState', () => {
|
||||
// Mock store instances
|
||||
let mockSelectedItems: Ref<MockedItem[]>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Setup mock canvas store with proper ref
|
||||
mockSelectedItems = ref([])
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
selectedItems: mockSelectedItems,
|
||||
// Add minimal required properties for the store
|
||||
$id: 'canvas',
|
||||
$state: {} as any,
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {} as any
|
||||
} as any)
|
||||
|
||||
// Setup mock node def store
|
||||
vi.mocked(useNodeDefStore).mockReturnValue({
|
||||
fromLGraphNode: vi.fn((node: TestNode) => {
|
||||
if (node?.type === 'TestNode') {
|
||||
return { nodePath: 'test.TestNode', name: 'TestNode' }
|
||||
}
|
||||
return null
|
||||
}),
|
||||
// Add minimal required properties for the store
|
||||
$id: 'nodeDef',
|
||||
$state: {} as any,
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {} as any
|
||||
} as any)
|
||||
|
||||
// Setup mock sidebar tab store
|
||||
const mockToggleSidebarTab = vi.fn()
|
||||
vi.mocked(useSidebarTabStore).mockReturnValue({
|
||||
activeSidebarTabId: null,
|
||||
toggleSidebarTab: mockToggleSidebarTab,
|
||||
// Add minimal required properties for the store
|
||||
$id: 'sidebarTab',
|
||||
$state: {} as any,
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {} as any
|
||||
} as any)
|
||||
|
||||
// Setup mock node help store
|
||||
const mockOpenHelp = vi.fn()
|
||||
const mockCloseHelp = vi.fn()
|
||||
const mockNodeHelpStore = {
|
||||
isHelpOpen: false,
|
||||
currentHelpNode: null,
|
||||
openHelp: mockOpenHelp,
|
||||
closeHelp: mockCloseHelp,
|
||||
// Add minimal required properties for the store
|
||||
$id: 'nodeHelp',
|
||||
$state: {} as any,
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {} as any
|
||||
}
|
||||
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any)
|
||||
|
||||
// Setup mock composables
|
||||
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
|
||||
id: 'node-library-tab',
|
||||
title: 'Node Library',
|
||||
type: 'custom',
|
||||
render: () => null
|
||||
} as any)
|
||||
|
||||
// Setup mock utility functions
|
||||
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
|
||||
const typedItem = item as { isNode?: boolean }
|
||||
return typedItem?.isNode !== false
|
||||
})
|
||||
vi.mocked(isImageNode).mockImplementation((node: unknown) => {
|
||||
const typedNode = node as { type?: string }
|
||||
return typedNode?.type === 'ImageNode'
|
||||
})
|
||||
vi.mocked(filterOutputNodes).mockImplementation(
|
||||
(nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any
|
||||
)
|
||||
})
|
||||
|
||||
describe('Selection Detection', () => {
|
||||
test('should return false when nothing selected', () => {
|
||||
const { hasAnySelection } = useSelectionState()
|
||||
expect(hasAnySelection.value).toBe(false)
|
||||
})
|
||||
|
||||
test('should return true when items selected', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const node1 = createTestNode()
|
||||
const node2 = createTestNode()
|
||||
mockSelectedItems.value = [node1, node2]
|
||||
|
||||
const { hasAnySelection } = useSelectionState()
|
||||
expect(hasAnySelection.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type Filtering', () => {
|
||||
test('should pick only LGraphNodes from mixed selections', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const graphNode = createTestNode()
|
||||
mockSelectedItems.value = [graphNode, mockComment, mockConnection]
|
||||
|
||||
const { selectedNodes } = useSelectionState()
|
||||
expect(selectedNodes.value).toHaveLength(1)
|
||||
expect(selectedNodes.value[0]).toEqual(graphNode)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node State Computation', () => {
|
||||
test('should detect bypassed nodes', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS })
|
||||
mockSelectedItems.value = [bypassedNode]
|
||||
|
||||
const { selectedNodes } = useSelectionState()
|
||||
const isBypassed = selectedNodes.value.some(
|
||||
(n) => n.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
expect(isBypassed).toBe(true)
|
||||
})
|
||||
|
||||
test('should detect pinned/collapsed states', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const pinnedNode = createTestNode({ pinned: true })
|
||||
const collapsedNode = createTestNode({ flags: { collapsed: true } })
|
||||
mockSelectedItems.value = [pinnedNode, collapsedNode]
|
||||
|
||||
const { selectedNodes } = useSelectionState()
|
||||
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
|
||||
const isCollapsed = selectedNodes.value.some(
|
||||
(n) => n.flags?.collapsed === true
|
||||
)
|
||||
const isBypassed = selectedNodes.value.some(
|
||||
(n) => n.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
expect(isPinned).toBe(true)
|
||||
expect(isCollapsed).toBe(true)
|
||||
expect(isBypassed).toBe(false)
|
||||
})
|
||||
|
||||
test('should provide non-reactive state computation', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const node = createTestNode({ pinned: true })
|
||||
mockSelectedItems.value = [node]
|
||||
|
||||
const { selectedNodes } = useSelectionState()
|
||||
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
|
||||
const isCollapsed = selectedNodes.value.some(
|
||||
(n) => n.flags?.collapsed === true
|
||||
)
|
||||
const isBypassed = selectedNodes.value.some(
|
||||
(n) => n.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
|
||||
expect(isPinned).toBe(true)
|
||||
expect(isCollapsed).toBe(false)
|
||||
expect(isBypassed).toBe(false)
|
||||
|
||||
// Test with empty selection using new composable instance
|
||||
mockSelectedItems.value = []
|
||||
const { selectedNodes: newSelectedNodes } = useSelectionState()
|
||||
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
|
||||
expect(newIsPinned).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
277
tests-ui/tests/composables/graph/useTransformSettling.test.ts
Normal file
277
tests-ui/tests/composables/graph/useTransformSettling.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
|
||||
describe('useTransformSettling', () => {
|
||||
let element: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.removeChild(element)
|
||||
})
|
||||
|
||||
it('should track wheel events and settle after delay', async () => {
|
||||
const { isTransforming } = useTransformSettling(element)
|
||||
|
||||
// Initially not transforming
|
||||
expect(isTransforming.value).toBe(false)
|
||||
|
||||
// Dispatch wheel event
|
||||
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Should be transforming
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Advance time but not past settle delay
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Advance past settle delay (default 200ms)
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should reset settle timer on subsequent wheel events', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 300
|
||||
})
|
||||
|
||||
// First wheel event
|
||||
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Advance time partially
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Another wheel event should reset the timer
|
||||
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Advance 200ms more - should still be transforming
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Need another 100ms to settle (300ms total from last event)
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should track pan events when trackPan is enabled', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
trackPan: true,
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
// Pointer down should start transform
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Pointer move should keep it active
|
||||
vi.advanceTimersByTime(100)
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Should still be transforming
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Pointer up
|
||||
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Should still be transforming until settle delay
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Advance past settle delay
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not track pan events when trackPan is disabled', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
trackPan: false
|
||||
})
|
||||
|
||||
// Pointer events should not trigger transform
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle pointer cancel events', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
trackPan: true,
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
// Start panning
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// Cancel instead of up
|
||||
element.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Should still settle normally
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with ref target', async () => {
|
||||
const targetRef = ref<HTMLElement | null>(null)
|
||||
const { isTransforming } = useTransformSettling(targetRef)
|
||||
|
||||
// No target yet
|
||||
expect(isTransforming.value).toBe(false)
|
||||
|
||||
// Set target
|
||||
targetRef.value = element
|
||||
await nextTick()
|
||||
|
||||
// Now events should work
|
||||
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should use capture phase for events', async () => {
|
||||
const captureHandler = vi.fn()
|
||||
const bubbleHandler = vi.fn()
|
||||
|
||||
// Add handlers to verify capture phase
|
||||
element.addEventListener('wheel', captureHandler, true)
|
||||
element.addEventListener('wheel', bubbleHandler, false)
|
||||
|
||||
const { isTransforming } = useTransformSettling(element)
|
||||
|
||||
// Create child element
|
||||
const child = document.createElement('div')
|
||||
element.appendChild(child)
|
||||
|
||||
// Dispatch event on child
|
||||
child.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Capture handler should be called before bubble handler
|
||||
expect(captureHandler).toHaveBeenCalled()
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
element.removeEventListener('wheel', captureHandler, true)
|
||||
element.removeEventListener('wheel', bubbleHandler, false)
|
||||
})
|
||||
|
||||
it('should throttle pointer move events', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
trackPan: true,
|
||||
pointerMoveThrottle: 50,
|
||||
settleDelay: 100
|
||||
})
|
||||
|
||||
// Start panning
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
// Fire many pointer move events rapidly
|
||||
for (let i = 0; i < 10; i++) {
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
vi.advanceTimersByTime(5) // 5ms between events
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Should still be transforming
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
// End panning
|
||||
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
|
||||
// Advance past settle delay
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should clean up event listeners when component unmounts', async () => {
|
||||
const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener')
|
||||
|
||||
// Create a test component
|
||||
const TestComponent = {
|
||||
setup() {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
trackPan: true
|
||||
})
|
||||
return { isTransforming }
|
||||
},
|
||||
template: '<div>{{ isTransforming }}</div>'
|
||||
}
|
||||
|
||||
const wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
// Unmount component
|
||||
wrapper.unmount()
|
||||
|
||||
// Should have removed all event listeners
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'wheel',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ capture: true })
|
||||
)
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ capture: true })
|
||||
)
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointermove',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ capture: true })
|
||||
)
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ capture: true })
|
||||
)
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ capture: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('should use passive listeners when specified', async () => {
|
||||
const addEventListenerSpy = vi.spyOn(element, 'addEventListener')
|
||||
|
||||
useTransformSettling(element, {
|
||||
passive: true,
|
||||
trackPan: true
|
||||
})
|
||||
|
||||
// Check that passive option was used for appropriate events
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'wheel',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ passive: true, capture: true })
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointermove',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ passive: true, capture: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
503
tests-ui/tests/composables/graph/useWidgetValue.test.ts
Normal file
503
tests-ui/tests/composables/graph/useWidgetValue.test.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import {
|
||||
type MockedFunction,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
useBooleanWidgetValue,
|
||||
useNumberWidgetValue,
|
||||
useStringWidgetValue,
|
||||
useWidgetValue
|
||||
} from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
describe('useWidgetValue', () => {
|
||||
let mockWidget: SimplifiedWidget<string>
|
||||
let mockEmit: MockedFunction<(event: 'update:modelValue', value: any) => void>
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockWidget = {
|
||||
name: 'testWidget',
|
||||
type: 'string',
|
||||
value: 'initial',
|
||||
callback: vi.fn()
|
||||
}
|
||||
mockEmit = vi.fn()
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should initialize with modelValue', () => {
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'test value',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('test value')
|
||||
})
|
||||
|
||||
it('should use defaultValue when modelValue is null', () => {
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: null as any,
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('default')
|
||||
})
|
||||
|
||||
it('should use defaultValue when modelValue is undefined', () => {
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: undefined as any,
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onChange handler', () => {
|
||||
it('should update localValue immediately', () => {
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange('new value')
|
||||
expect(localValue.value).toBe('new value')
|
||||
})
|
||||
|
||||
it('should emit update:modelValue event', () => {
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange('new value')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
|
||||
})
|
||||
|
||||
// useGraphNodeMaanger's createWrappedWidgetCallback makes the callback right now instead of useWidgetValue
|
||||
// it('should call widget callback if it exists', () => {
|
||||
// const { onChange } = useWidgetValue({
|
||||
// widget: mockWidget,
|
||||
// modelValue: 'initial',
|
||||
// defaultValue: '',
|
||||
// emit: mockEmit
|
||||
// })
|
||||
|
||||
// onChange('new value')
|
||||
// expect(mockWidget.callback).toHaveBeenCalledWith('new value')
|
||||
// })
|
||||
|
||||
it('should not error if widget callback is undefined', () => {
|
||||
const widgetWithoutCallback = { ...mockWidget, callback: undefined }
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: widgetWithoutCallback,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(() => onChange('new value')).not.toThrow()
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
|
||||
})
|
||||
|
||||
it('should handle null values', () => {
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange(null as any)
|
||||
expect(localValue.value).toBe('default')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
|
||||
})
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange(undefined as any)
|
||||
expect(localValue.value).toBe('default')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type safety', () => {
|
||||
it('should handle type mismatches with warning', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: numberWidget,
|
||||
modelValue: 10,
|
||||
defaultValue: 0,
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
// Pass string to number widget
|
||||
onChange('not a number' as any)
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'useWidgetValue: Type mismatch for widget numberWidget. Expected number, got string'
|
||||
)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0) // Uses defaultValue
|
||||
})
|
||||
|
||||
it('should accept values of matching type', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: numberWidget,
|
||||
modelValue: 10,
|
||||
defaultValue: 0,
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange(25)
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled()
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transform function', () => {
|
||||
it('should apply transform function to new values', () => {
|
||||
const transform = vi.fn((value: string) => value.toUpperCase())
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit,
|
||||
transform
|
||||
})
|
||||
|
||||
onChange('hello')
|
||||
expect(transform).toHaveBeenCalledWith('hello')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'HELLO')
|
||||
})
|
||||
|
||||
it('should skip type checking when transform is provided', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const transform = (value: string) => parseInt(value, 10) || 0
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: numberWidget,
|
||||
modelValue: 10,
|
||||
defaultValue: 0,
|
||||
emit: mockEmit,
|
||||
transform
|
||||
})
|
||||
|
||||
onChange('123')
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled()
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 123)
|
||||
})
|
||||
})
|
||||
|
||||
describe('external updates', () => {
|
||||
it('should update localValue when modelValue changes', async () => {
|
||||
const modelValue = ref('initial')
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: modelValue.value,
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('initial')
|
||||
|
||||
// Simulate parent updating modelValue
|
||||
modelValue.value = 'updated externally'
|
||||
|
||||
// Re-create the composable with new value (simulating prop change)
|
||||
const { localValue: newLocalValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: modelValue.value,
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(newLocalValue.value).toBe('updated externally')
|
||||
})
|
||||
|
||||
it('should handle external null values', async () => {
|
||||
const modelValue = ref<string | null>('initial')
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: modelValue.value!,
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('initial')
|
||||
|
||||
// Simulate external update to null
|
||||
modelValue.value = null
|
||||
const { localValue: newLocalValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: modelValue.value as any,
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(newLocalValue.value).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useStringWidgetValue helper', () => {
|
||||
it('should handle string values correctly', () => {
|
||||
const stringWidget: SimplifiedWidget<string> = {
|
||||
name: 'textWidget',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
stringWidget,
|
||||
'initial',
|
||||
mockEmit
|
||||
)
|
||||
|
||||
expect(localValue.value).toBe('initial')
|
||||
|
||||
onChange('new string')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new string')
|
||||
})
|
||||
|
||||
it('should transform undefined to empty string', () => {
|
||||
const stringWidget: SimplifiedWidget<string> = {
|
||||
name: 'textWidget',
|
||||
type: 'string',
|
||||
value: '',
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
|
||||
|
||||
onChange(undefined as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '')
|
||||
})
|
||||
|
||||
it('should convert non-string values to string', () => {
|
||||
const stringWidget: SimplifiedWidget<string> = {
|
||||
name: 'textWidget',
|
||||
type: 'string',
|
||||
value: '',
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
|
||||
|
||||
onChange(123 as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNumberWidgetValue helper', () => {
|
||||
it('should handle number values correctly', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'sliderWidget',
|
||||
type: 'number',
|
||||
value: 50,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { localValue, onChange } = useNumberWidgetValue(
|
||||
numberWidget,
|
||||
25,
|
||||
mockEmit
|
||||
)
|
||||
|
||||
expect(localValue.value).toBe(25)
|
||||
|
||||
onChange(75)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 75)
|
||||
})
|
||||
|
||||
it('should handle array values from PrimeVue Slider', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'sliderWidget',
|
||||
type: 'number',
|
||||
value: 50,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
|
||||
|
||||
// PrimeVue Slider can emit number[]
|
||||
onChange([42, 100] as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'sliderWidget',
|
||||
type: 'number',
|
||||
value: 50,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
|
||||
|
||||
onChange([] as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
|
||||
})
|
||||
|
||||
it('should convert string numbers', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 0,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
|
||||
|
||||
onChange('42' as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
|
||||
})
|
||||
|
||||
it('should handle invalid number conversions', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 0,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
|
||||
|
||||
onChange('not-a-number' as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBooleanWidgetValue helper', () => {
|
||||
it('should handle boolean values correctly', () => {
|
||||
const boolWidget: SimplifiedWidget<boolean> = {
|
||||
name: 'toggleWidget',
|
||||
type: 'boolean',
|
||||
value: false,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { localValue, onChange } = useBooleanWidgetValue(
|
||||
boolWidget,
|
||||
true,
|
||||
mockEmit
|
||||
)
|
||||
|
||||
expect(localValue.value).toBe(true)
|
||||
|
||||
onChange(false)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
|
||||
})
|
||||
|
||||
it('should convert truthy values to true', () => {
|
||||
const boolWidget: SimplifiedWidget<boolean> = {
|
||||
name: 'toggleWidget',
|
||||
type: 'boolean',
|
||||
value: false,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useBooleanWidgetValue(boolWidget, false, mockEmit)
|
||||
|
||||
onChange('truthy' as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', true)
|
||||
})
|
||||
|
||||
it('should convert falsy values to false', () => {
|
||||
const boolWidget: SimplifiedWidget<boolean> = {
|
||||
name: 'toggleWidget',
|
||||
type: 'boolean',
|
||||
value: false,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useBooleanWidgetValue(boolWidget, true, mockEmit)
|
||||
|
||||
onChange(0 as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle rapid onChange calls', () => {
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange('value1')
|
||||
onChange('value2')
|
||||
onChange('value3')
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledTimes(3)
|
||||
expect(mockEmit).toHaveBeenNthCalledWith(1, 'update:modelValue', 'value1')
|
||||
expect(mockEmit).toHaveBeenNthCalledWith(2, 'update:modelValue', 'value2')
|
||||
expect(mockEmit).toHaveBeenNthCalledWith(3, 'update:modelValue', 'value3')
|
||||
})
|
||||
|
||||
it('should handle widget with all properties undefined', () => {
|
||||
const minimalWidget = {
|
||||
name: 'minimal',
|
||||
type: 'unknown'
|
||||
} as SimplifiedWidget<any>
|
||||
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: minimalWidget,
|
||||
modelValue: 'test',
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('test')
|
||||
expect(() => onChange('new')).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user