// @vitest-environment jsdom import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { UITree } from '../src/features/ui-editor/types/UITree'; import { useNodeTransformInteraction } from '../src/view/ui-editor/components/preview/useNodeTransformInteraction'; import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; function node(id: string, children: UiNode[] = []): UiNode { return { id, layout: { transform: { anchor_min: [0, 0], anchor_max: [1, 1], offset_min: [0, 0], offset_max: [0, 0], }, custom_minimum_size: [0, 0], size_flags_horizontal: 1, size_flags_vertical: 1, size_flags_stretch_ratio: 1, container: 'None', }, metadata: { name: id, description: '', layout_status: 'NoProblem', components_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, components: [], children, }; } const child = node('child'); const pageTree: UITree = { src_ui_design: 'page', root: node('root', [child]), }; function gestureTarget() { const target = document.createElement('div'); Object.assign(target, { setPointerCapture: vi.fn(), hasPointerCapture: vi.fn(() => true), releasePointerCapture: vi.fn(), }); return target; } function pointerEvent( target: HTMLDivElement, pointerId: number, clientX: number, clientY: number, ) { return { button: 0, clientX, clientY, currentTarget: target, pointerId, preventDefault: vi.fn(), shiftKey: false, stopPropagation: vi.fn(), } as never; } function canvas() { return { selectNode: vi.fn(), updateNodeTransform: vi.fn(), } as Pick; } function renderInteraction({ activeImageId = 'page', scale = 1, tree = pageTree, }: { activeImageId?: string | null; scale?: number; tree?: UITree | null; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; const hook = renderHook( ({ imageId, currentTree }) => useNodeTransformInteraction({ activeImageId: imageId, canvas: canvasProjection, logicalSize: { width: 320, height: 180 }, spaceHeld: false, tree: currentTree, viewportRef, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); return { ...hook, canvasProjection, viewportRef }; } describe('useNodeTransformInteraction', () => { it('makes drag and resize mutually exclusive, then permits the next gesture', () => { const { result, canvasProjection } = renderInteraction(); const dragTarget = gestureTarget(); const resizeTarget = gestureTarget(); act(() => { result.current.onNodePointerDown( pointerEvent(dragTarget, 1, 0, 0), child, ); result.current.onNodeResizePointerDown( pointerEvent(resizeTarget, 2, 0, 0), child, 'se', ); result.current.onNodePointerMove(pointerEvent(dragTarget, 1, 12, 8)); }); expect(resizeTarget.setPointerCapture).not.toHaveBeenCalled(); expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1); act(() => { result.current.onNodePointerUp(pointerEvent(dragTarget, 1, 12, 8)); result.current.onNodeResizePointerDown( pointerEvent(resizeTarget, 2, 0, 0), child, 'se', ); }); expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2); }); it('cancels a gesture when its tree changes and ignores its later events', () => { const { result, rerender, canvasProjection } = renderInteraction(); const target = gestureTarget(); act(() => { result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); }); act(() => { rerender({ imageId: 'other', currentTree: { ...pageTree, src_ui_design: 'other' }, }); }); act(() => { result.current.onNodePointerMove(pointerEvent(target, 1, 12, 8)); }); expect(target.releasePointerCapture).toHaveBeenCalledWith(1); expect(canvasProjection.updateNodeTransform).not.toHaveBeenCalled(); }); it('cancels instead of persisting a non-finite transform', () => { const { result, canvasProjection, viewportRef } = renderInteraction(); const target = gestureTarget(); act(() => { result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); viewportRef.current.scale = Number.NaN; result.current.onNodePointerMove(pointerEvent(target, 1, 12, 8)); }); expect(target.releasePointerCapture).toHaveBeenCalledWith(1); expect(canvasProjection.updateNodeTransform).not.toHaveBeenCalled(); }); it('releases a captured pointer when the preview unmounts', () => { const { result, unmount } = renderInteraction(); const target = gestureTarget(); act(() => { result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); unmount(); }); expect(target.releasePointerCapture).toHaveBeenCalledWith(1); }); });