import { fromPartial } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import type { Ref } from 'vue' import type { NodeLayout } from '@/renderer/core/layout/types' // TODO: Simplify test setup — use real layoutStore + createTestingPinia instead // of manually mocking every dependency. See https://github.com/Comfy-Org/ComfyUI_frontend/issues/10765 const testState = vi.hoisted(() => { // Imports are unavailable inside vi.hoisted() so shoehorn's fromAny cannot // be used here. This local identity function serves the same purpose // (runtime no-op cast) until the test is rewritten to use real stores. const placeholder = (v: unknown): T => v as T return { selectedNodeIds: placeholder>>(null), selectedItems: placeholder>(null), nodeLayouts: new Map>(), mutationFns: { setSource: vi.fn(), moveNode: vi.fn(), batchMoveNodes: vi.fn() }, batchUpdateNodeBounds: vi.fn(), nodeSnap: { shouldSnap: vi.fn(() => false), applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos) }, cancelAnimationFrame: vi.fn(), requestAnimationFrameCallback: null as FrameRequestCallback | null, capturedOnPan: { current: null as ((dx: number, dy: number) => void) | null }, capturedAutoPanInstance: { current: null as { updatePointer: ReturnType start: ReturnType stop: ReturnType } | null }, mockDs: { offset: [0, 0] as [number, number], scale: 1 } } }) vi.mock('pinia', () => ({ storeToRefs: (store: T) => store })) vi.mock('@/renderer/core/canvas/useAutoPan', () => ({ AutoPanController: class { updatePointer = vi.fn() start = vi.fn() stop = vi.fn() constructor(opts: { onPan: (dx: number, dy: number) => void }) { testState.capturedOnPan.current = opts.onPan testState.capturedAutoPanInstance.current = this } } })) vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: () => ({ selectedNodeIds: testState.selectedNodeIds, selectedItems: testState.selectedItems, canvas: { ds: testState.mockDs, auto_pan_speed: 10, canvas: { getBoundingClientRect: () => ({ left: 0, top: 0, right: 800, bottom: 600 }) } } }) })) vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({ useLayoutMutations: () => testState.mutationFns })) vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ layoutStore: { getNodeLayoutRef: (nodeId: string) => ref(testState.nodeLayouts.get(nodeId) ?? null), batchUpdateNodeBounds: testState.batchUpdateNodeBounds } })) vi.mock('@/renderer/extensions/vueNodes/composables/useNodeSnap', () => ({ useNodeSnap: () => testState.nodeSnap })) vi.mock('@/renderer/extensions/vueNodes/composables/useShiftKeySync', () => ({ useShiftKeySync: () => ({ trackShiftKey: () => () => {} }) })) vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({ useTransformState: () => ({ screenToCanvas: ({ x, y }: { x: number; y: number }) => ({ x: x / (testState.mockDs.scale || 1) - testState.mockDs.offset[0], y: y / (testState.mockDs.scale || 1) - testState.mockDs.offset[1] }) }) })) vi.mock('@/utils/litegraphUtil', () => ({ isLGraphGroup: () => false })) vi.mock('@vueuse/core', () => ({ createSharedComposable: (fn: () => unknown) => fn })) import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag' function pointerEvent(clientX: number, clientY: number): PointerEvent { const target = document.createElement('div') target.hasPointerCapture = vi.fn(() => false) target.setPointerCapture = vi.fn() return fromPartial({ clientX, clientY, target, pointerId: 1 }) } describe('useNodeDrag', () => { beforeEach(() => { testState.selectedNodeIds = ref(new Set()) testState.selectedItems = ref([]) testState.nodeLayouts.clear() testState.mutationFns.setSource.mockReset() testState.mutationFns.moveNode.mockReset() testState.mutationFns.batchMoveNodes.mockReset() testState.batchUpdateNodeBounds.mockReset() testState.nodeSnap.shouldSnap.mockReset() testState.nodeSnap.shouldSnap.mockReturnValue(false) testState.nodeSnap.applySnapToPosition.mockReset() testState.nodeSnap.applySnapToPosition.mockImplementation( (pos: { x: number; y: number }) => pos ) testState.cancelAnimationFrame.mockReset() testState.requestAnimationFrameCallback = null testState.capturedOnPan.current = null testState.capturedAutoPanInstance.current = null testState.mockDs.offset = [0, 0] testState.mockDs.scale = 1 vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { testState.requestAnimationFrameCallback = cb return 1 }) vi.stubGlobal('cancelAnimationFrame', testState.cancelAnimationFrame) }) it('batches multi-node drag updates into one mutation call per frame', () => { testState.selectedNodeIds.value = new Set(['1', '2']) testState.nodeLayouts.set('1', { position: { x: 100, y: 100 }, size: { width: 200, height: 120 } }) testState.nodeLayouts.set('2', { position: { x: 200, y: 180 }, size: { width: 210, height: 130 } }) const { startDrag, handleDrag } = useNodeDrag() startDrag(pointerEvent(10, 20), '1') handleDrag(pointerEvent(30, 40), '1') testState.requestAnimationFrameCallback?.(0) expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1) expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([ { nodeId: '1', position: { x: 120, y: 120 } }, { nodeId: '2', position: { x: 220, y: 200 } } ]) expect(testState.mutationFns.moveNode).not.toHaveBeenCalled() }) it('uses the same batched mutation path for single-node drags', () => { testState.selectedNodeIds.value = new Set(['1']) testState.nodeLayouts.set('1', { position: { x: 50, y: 80 }, size: { width: 180, height: 110 } }) const { startDrag, handleDrag } = useNodeDrag() startDrag(pointerEvent(5, 10), '1') handleDrag(pointerEvent(25, 30), '1') testState.requestAnimationFrameCallback?.(0) expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1) expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([ { nodeId: '1', position: { x: 70, y: 100 } } ]) expect(testState.mutationFns.moveNode).not.toHaveBeenCalled() }) it('cancels pending RAF and applies snap updates on endDrag', () => { testState.selectedNodeIds.value = new Set(['1']) testState.nodeLayouts.set('1', { position: { x: 50, y: 80 }, size: { width: 180, height: 110 } }) testState.nodeSnap.shouldSnap.mockReturnValue(true) testState.nodeSnap.applySnapToPosition.mockImplementation(({ x, y }) => ({ x: x + 5, y: y + 7 })) const { startDrag, handleDrag, endDrag } = useNodeDrag() startDrag(pointerEvent(5, 10), '1') handleDrag(pointerEvent(25, 30), '1') endDrag({} as PointerEvent, '1') expect(testState.cancelAnimationFrame).toHaveBeenCalledTimes(1) expect(testState.cancelAnimationFrame).toHaveBeenCalledWith(1) expect(testState.batchUpdateNodeBounds).toHaveBeenCalledTimes(1) expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([ { nodeId: '1', bounds: { x: 55, y: 87, width: 180, height: 110 } } ]) }) }) describe('useNodeDrag auto-pan', () => { beforeEach(() => { testState.selectedNodeIds = ref(new Set(['1'])) testState.selectedItems = ref([]) testState.nodeLayouts.clear() testState.nodeLayouts.set('1', { position: { x: 100, y: 200 }, size: { width: 200, height: 100 } }) testState.nodeLayouts.set('2', { position: { x: 300, y: 400 }, size: { width: 200, height: 100 } }) testState.mutationFns.setSource.mockReset() testState.mutationFns.moveNode.mockReset() testState.mutationFns.batchMoveNodes.mockReset() testState.batchUpdateNodeBounds.mockReset() testState.nodeSnap.shouldSnap.mockReset() testState.nodeSnap.shouldSnap.mockReturnValue(false) testState.nodeSnap.applySnapToPosition.mockReset() testState.nodeSnap.applySnapToPosition.mockImplementation( (pos: { x: number; y: number }) => pos ) testState.cancelAnimationFrame.mockReset() testState.requestAnimationFrameCallback = null testState.capturedOnPan.current = null testState.capturedAutoPanInstance.current = null testState.mockDs.offset = [0, 0] testState.mockDs.scale = 1 vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { testState.requestAnimationFrameCallback = cb return 1 }) vi.stubGlobal('cancelAnimationFrame', testState.cancelAnimationFrame) }) it('moves node when auto-pan shifts the canvas offset', () => { const drag = useNodeDrag() drag.startDrag(pointerEvent(750, 300), '1') drag.handleDrag(pointerEvent(760, 300), '1') testState.requestAnimationFrameCallback?.(0) expect(testState.mutationFns.batchMoveNodes).toHaveBeenLastCalledWith([ { nodeId: '1', position: { x: 110, y: 200 } } ]) testState.mutationFns.batchMoveNodes.mockClear() testState.mockDs.offset[0] -= 5 testState.capturedOnPan.current!(5, 0) expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([ { nodeId: '1', position: { x: 115, y: 200 } } ]) }) it('moves all selected nodes when auto-pan fires', () => { testState.selectedNodeIds.value = new Set(['1', '2']) const drag = useNodeDrag() drag.startDrag(pointerEvent(750, 300), '1') testState.mutationFns.batchMoveNodes.mockClear() testState.mockDs.offset[0] -= 5 testState.capturedOnPan.current!(5, 0) expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1) const calls = testState.mutationFns.batchMoveNodes.mock.calls[0][0] const nodeIds = calls.map((u: { nodeId: string }) => u.nodeId) expect(nodeIds).toContain('1') expect(nodeIds).toContain('2') }) it('updates auto-pan pointer on handleDrag', () => { const drag = useNodeDrag() drag.startDrag(pointerEvent(400, 300), '1') drag.handleDrag(pointerEvent(790, 300), '1') expect( testState.capturedAutoPanInstance.current!.updatePointer ).toHaveBeenCalledWith(790, 300) }) it('stops auto-pan on endDrag', () => { const drag = useNodeDrag() drag.startDrag(pointerEvent(400, 300), '1') expect(testState.capturedAutoPanInstance.current).not.toBeNull() drag.endDrag(pointerEvent(400, 300), '1') expect(testState.capturedAutoPanInstance.current!.stop).toHaveBeenCalled() }) it('does not move nodes if onPan fires after endDrag', () => { const drag = useNodeDrag() drag.startDrag(pointerEvent(400, 300), '1') const onPan = testState.capturedOnPan.current! drag.endDrag(pointerEvent(400, 300), '1') testState.mutationFns.batchMoveNodes.mockClear() onPan(5, 0) expect(testState.mutationFns.batchMoveNodes).not.toHaveBeenCalled() }) })