diff --git a/src/components/graph/TransformPane.test.ts b/src/components/graph/TransformPane.spec.ts similarity index 100% rename from src/components/graph/TransformPane.test.ts rename to src/components/graph/TransformPane.spec.ts diff --git a/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts b/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts new file mode 100644 index 000000000..4d40ff969 --- /dev/null +++ b/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts @@ -0,0 +1,239 @@ +import type { LGraphCanvas } from '@comfyorg/litegraph' +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync' + +// Mock LiteGraph canvas +const createMockCanvas = (): Partial => ({ + canvas: document.createElement('canvas'), + ds: { + offset: [0, 0], + scale: 1 + } as any // Mock the DragAndScale type +}) + +describe('useCanvasTransformSync', () => { + let mockCanvas: LGraphCanvas + let syncFn: ReturnType + let callbacks: { + onStart: ReturnType + onUpdate: ReturnType + onStop: ReturnType + } + + beforeEach(() => { + vi.useFakeTimers() + mockCanvas = createMockCanvas() as LGraphCanvas + syncFn = vi.fn() + callbacks = { + onStart: vi.fn(), + onUpdate: vi.fn(), + onStop: vi.fn() + } + + // Mock requestAnimationFrame + global.requestAnimationFrame = vi.fn((cb) => { + setTimeout(cb, 16) // Simulate 60fps + return 1 + }) + global.cancelAnimationFrame = vi.fn() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('should auto-start sync when canvas is provided', async () => { + const { isActive } = useCanvasTransformSync(mockCanvas, syncFn, callbacks) + + await nextTick() + + expect(isActive.value).toBe(true) + expect(callbacks.onStart).toHaveBeenCalledOnce() + expect(syncFn).toHaveBeenCalledWith(mockCanvas) + }) + + it('should not auto-start when autoStart is false', async () => { + const { isActive } = useCanvasTransformSync(mockCanvas, syncFn, callbacks, { + autoStart: false + }) + + await nextTick() + + expect(isActive.value).toBe(false) + expect(callbacks.onStart).not.toHaveBeenCalled() + expect(syncFn).not.toHaveBeenCalled() + }) + + it('should not start when canvas is null', async () => { + const { isActive } = useCanvasTransformSync(null, syncFn, callbacks) + + await nextTick() + + expect(isActive.value).toBe(false) + expect(callbacks.onStart).not.toHaveBeenCalled() + }) + + it('should manually start and stop sync', async () => { + const { isActive, startSync, stopSync } = useCanvasTransformSync( + mockCanvas, + syncFn, + callbacks, + { autoStart: false } + ) + + // Start manually + startSync() + await nextTick() + + expect(isActive.value).toBe(true) + expect(callbacks.onStart).toHaveBeenCalledOnce() + + // Stop manually + stopSync() + await nextTick() + + expect(isActive.value).toBe(false) + expect(callbacks.onStop).toHaveBeenCalledOnce() + }) + + it('should call sync function on each frame', async () => { + useCanvasTransformSync(mockCanvas, syncFn, callbacks) + + await nextTick() + + // Advance timers to trigger additional frames (initial call + 3 more = 4 total) + vi.advanceTimersByTime(48) // 3 additional frames at 16ms each + await nextTick() + + expect(syncFn).toHaveBeenCalledTimes(4) // Initial call + 3 timed calls + expect(syncFn).toHaveBeenCalledWith(mockCanvas) + }) + + it('should provide timing information in onUpdate callback', async () => { + // Mock performance.now to return predictable values + const mockNow = vi.spyOn(performance, 'now') + mockNow.mockReturnValueOnce(0).mockReturnValueOnce(5) // 5ms duration + + useCanvasTransformSync(mockCanvas, syncFn, callbacks) + + await nextTick() + + expect(callbacks.onUpdate).toHaveBeenCalledWith(5) + }) + + it('should handle sync function that throws errors', async () => { + const errorSyncFn = vi.fn().mockImplementation(() => { + throw new Error('Sync failed') + }) + + // Creating the composable should not throw + expect(() => { + useCanvasTransformSync(mockCanvas, errorSyncFn, callbacks) + }).not.toThrow() + + await nextTick() + + // Even though sync function throws, the composable should handle it gracefully + expect(errorSyncFn).toHaveBeenCalled() + expect(callbacks.onStart).toHaveBeenCalled() + }) + + it('should not start if already active', async () => { + const { startSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks) + + await nextTick() + + // Try to start again + startSync() + await nextTick() + + // Should only be called once from auto-start + expect(callbacks.onStart).toHaveBeenCalledOnce() + }) + + it('should not stop if already inactive', async () => { + const { stopSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks, { + autoStart: false + }) + + // Try to stop when not started + stopSync() + await nextTick() + + expect(callbacks.onStop).not.toHaveBeenCalled() + }) + + it('should clean up on component unmount', async () => { + const TestComponent = { + setup() { + const { isActive } = useCanvasTransformSync( + mockCanvas, + syncFn, + callbacks + ) + return { isActive } + }, + template: '
{{ isActive }}
' + } + + const wrapper = mount(TestComponent) + await nextTick() + + expect(callbacks.onStart).toHaveBeenCalled() + + // Unmount component + wrapper.unmount() + await nextTick() + + expect(callbacks.onStop).toHaveBeenCalled() + expect(global.cancelAnimationFrame).toHaveBeenCalled() + }) + + it('should work without callbacks', async () => { + const { isActive } = useCanvasTransformSync(mockCanvas, syncFn) + + await nextTick() + + expect(isActive.value).toBe(true) + expect(syncFn).toHaveBeenCalledWith(mockCanvas) + }) + + it('should stop sync when canvas becomes null during sync', async () => { + let currentCanvas: any = mockCanvas + const dynamicSyncFn = vi.fn(() => { + // Simulate canvas becoming null during sync + currentCanvas = null + }) + + const { isActive } = useCanvasTransformSync( + currentCanvas, + dynamicSyncFn, + callbacks + ) + + await nextTick() + + expect(isActive.value).toBe(true) + + // Advance time to trigger sync + vi.advanceTimersByTime(16) + await nextTick() + + // Should handle null canvas gracefully + expect(dynamicSyncFn).toHaveBeenCalled() + }) + + it('should use cancelAnimationFrame when stopping', async () => { + const { stopSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks) + + await nextTick() + + stopSync() + + expect(global.cancelAnimationFrame).toHaveBeenCalledWith(1) + }) +}) diff --git a/tests-ui/tests/composables/graph/useTransformSettling.test.ts b/tests-ui/tests/composables/graph/useTransformSettling.test.ts new file mode 100644 index 000000000..2bc6342c8 --- /dev/null +++ b/tests-ui/tests/composables/graph/useTransformSettling.test.ts @@ -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 '@/composables/graph/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(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: '
{{ isTransforming }}
' + } + + 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 }) + ) + }) +}) diff --git a/src/composables/graph/useWidgetValue.test.ts b/tests-ui/tests/composables/graph/useWidgetValue.test.ts similarity index 99% rename from src/composables/graph/useWidgetValue.test.ts rename to tests-ui/tests/composables/graph/useWidgetValue.test.ts index a2fb9717f..a125ff53d 100644 --- a/src/composables/graph/useWidgetValue.test.ts +++ b/tests-ui/tests/composables/graph/useWidgetValue.test.ts @@ -9,14 +9,13 @@ import { } from 'vitest' import { ref } from 'vue' -import type { SimplifiedWidget } from '@/types/simplifiedWidget' - import { useBooleanWidgetValue, useNumberWidgetValue, useStringWidgetValue, useWidgetValue -} from './useWidgetValue' +} from '@/composables/graph/useWidgetValue' +import type { SimplifiedWidget } from '@/types/simplifiedWidget' describe('useWidgetValue', () => { let mockWidget: SimplifiedWidget diff --git a/src/utils/spatial/QuadTree.test.ts b/tests-ui/tests/utils/spatial/QuadTree.test.ts similarity index 99% rename from src/utils/spatial/QuadTree.test.ts rename to tests-ui/tests/utils/spatial/QuadTree.test.ts index 7e1cd76c2..ea31e5682 100644 --- a/src/utils/spatial/QuadTree.test.ts +++ b/tests-ui/tests/utils/spatial/QuadTree.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { type Bounds, QuadTree } from './QuadTree' +import { type Bounds, QuadTree } from '@/utils/spatial/QuadTree' describe('QuadTree', () => { let quadTree: QuadTree