// @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', component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, component: null, children, }; } const child = node('child'); const pageTree: UITree = { src_ui_design: 'page', root: node('root', [child]), }; function transformedNode( id: string, transform: UiNode['layout']['transform'], children: UiNode[] = [], ) { return { ...node(id, children), layout: { ...node(id).layout, transform }, }; } 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, eventTarget: EventTarget = target, ) { return { button: 0, clientX, clientY, currentTarget: target, target: eventTarget, 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, keepChildrenUnchanged = false, onPreviewTransform, selectedNodeId = null, }: { activeImageId?: string | null; scale?: number; tree?: UITree | null; keepChildrenUnchanged?: boolean; onPreviewTransform?: ( nodeId: string, transform: UiNode['layout']['transform'] | null, ) => void; selectedNodeId?: string | 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, keepChildrenUnchanged, onPreviewTransform, selectedNodeId, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); return { ...hook, canvasProjection, viewportRef }; } describe('useNodeTransformInteraction', () => { it('moves the current selection when dragging another node without selecting the hit node', () => { const selected = transformedNode('selected', { anchor_min: [0, 0], anchor_max: [0, 0], offset_min: [20, 20], offset_max: [80, 70], }); const tree: UITree = { src_ui_design: 'page', root: node('root', [selected, child]), }; const { result, canvasProjection } = renderInteraction({ tree, selectedNodeId: 'selected', }); const target = gestureTarget(); act(() => { result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0)); }); expect(canvasProjection.selectNode).not.toHaveBeenCalled(); expect(canvasProjection.updateNodeTransform).toHaveBeenCalledWith( 'page', 'selected', expect.objectContaining({ offset_min: [24, 20], offset_max: [84, 70], }), ); }); it('prevents native text selection when starting a node drag', () => { const { result } = renderInteraction(); const target = gestureTarget(); const event = pointerEvent(target, 1, 0, 0); act(() => { result.current.onNodePointerDown(event, child); }); expect(event.preventDefault).toHaveBeenCalledTimes(1); }); it('cleans preview transforms for the selected parent when dragging from a child', () => { const nestedChild = transformedNode('child', { anchor_min: [0, 0], anchor_max: [0, 0], offset_min: [10, 10], offset_max: [60, 50], }); const parent = transformedNode( 'parent', { anchor_min: [0, 0], anchor_max: [0, 0], offset_min: [20, 20], offset_max: [120, 100], }, [nestedChild], ); const tree: UITree = { src_ui_design: 'page', root: node('root', [parent]), }; const onPreviewTransform = vi.fn(); const { result } = renderInteraction({ tree, keepChildrenUnchanged: true, onPreviewTransform, selectedNodeId: 'parent', }); const target = gestureTarget(); act(() => { result.current.onNodePointerDown( pointerEvent(target, 1, 0, 0), nestedChild, ); result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); result.current.onNodePointerCancel(pointerEvent(target, 1, 10, 5)); }); expect(onPreviewTransform).toHaveBeenCalledWith('parent', null); expect(onPreviewTransform).toHaveBeenCalledWith('child', null); }); it('previews stable child page rectangles when the parent moves', () => { const nestedChild = transformedNode('child', { anchor_min: [0, 0], anchor_max: [0, 0], offset_min: [10, 10], offset_max: [60, 50], }); const parent = transformedNode( 'parent', { anchor_min: [0, 0], anchor_max: [0, 0], offset_min: [20, 20], offset_max: [120, 100], }, [nestedChild], ); const tree: UITree = { src_ui_design: 'page', root: node('root', [parent]), }; const onPreviewTransform = vi.fn(); const { result } = renderInteraction({ tree, keepChildrenUnchanged: true, onPreviewTransform, }); const target = gestureTarget(); act(() => { result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), parent); result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); }); expect(onPreviewTransform).toHaveBeenNthCalledWith( 1, 'parent', expect.objectContaining({ offset_min: [30, 25], offset_max: [130, 105] }), ); expect(onPreviewTransform).toHaveBeenNthCalledWith( 2, 'child', expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), ); }); it('previews stable child page rectangles when the parent resizes', () => { const nestedChild = transformedNode('child', { anchor_min: [0, 0], anchor_max: [0, 0], offset_min: [10, 10], offset_max: [60, 50], }); const parent = transformedNode( 'parent', { anchor_min: [0, 0], anchor_max: [0, 0], offset_min: [20, 20], offset_max: [120, 100], }, [nestedChild], ); const tree: UITree = { src_ui_design: 'page', root: node('root', [parent]), }; const onPreviewTransform = vi.fn(); const { result } = renderInteraction({ tree, keepChildrenUnchanged: true, onPreviewTransform, }); const target = gestureTarget(); act(() => { result.current.onNodeResizePointerDown( pointerEvent(target, 1, 0, 0), parent, 'nw', ); result.current.onNodeResizePointerMove(pointerEvent(target, 1, 10, 5)); }); expect(onPreviewTransform).toHaveBeenNthCalledWith( 2, 'child', expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), ); }); 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).not.toHaveBeenCalled(); act(() => { result.current.onNodePointerUp(pointerEvent(dragTarget, 1, 12, 8)); result.current.onNodeResizePointerDown( pointerEvent(resizeTarget, 2, 0, 0), child, 'se', ); }); expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1); expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2); }); it('defers selection until a clean pointerup and suppresses follow-up click', () => { const { result, canvasProjection } = renderInteraction(); const target = gestureTarget(); act(() => { result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); }); expect(canvasProjection.selectNode).not.toHaveBeenCalled(); act(() => { result.current.onNodePointerUp(pointerEvent(target, 1, 1, 1)); }); expect(canvasProjection.selectNode).toHaveBeenCalledWith('child'); expect(result.current.consumeNodeClick()).toBe(true); expect(result.current.consumeNodeClick()).toBe(false); }); it('does not select after a moved gesture or pointer cancellation', () => { const { result, canvasProjection } = renderInteraction(); const target = gestureTarget(); act(() => { result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0)); }); expect(canvasProjection.selectNode).not.toHaveBeenCalled(); expect(result.current.consumeNodeClick()).toBe(true); act(() => { result.current.onNodePointerDown(pointerEvent(target, 2, 0, 0), child); result.current.onNodePointerCancel(pointerEvent(target, 2, 0, 0)); }); expect(canvasProjection.selectNode).not.toHaveBeenCalled(); expect(result.current.consumeNodeClick()).toBe(false); }); 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); }); });