补充预览拖动选择回归测试

覆盖节点点击延迟到 pointerup 的选择时机

覆盖拖动嵌套节点缩放取消失焦和区域外松开

覆盖直接拖动节点不再由 click 单独选中
This commit is contained in:
2026-09-03 13:37:53 +08:00
parent fcacdca4a4
commit 0cdd3fbd75
2 changed files with 130 additions and 1 deletions
@@ -62,6 +62,7 @@ function renderTree(
options: {
showFrame?: boolean;
selectedNodeId?: string | null;
onSelectNode?: ReturnType<typeof vi.fn>;
onNodeContextMenu?: ReturnType<typeof vi.fn>;
} = {},
) {
@@ -73,7 +74,7 @@ function renderTree(
hiddenNodeIds={hiddenNodeIds}
selectedNodeId={options.selectedNodeId ?? null}
resources={resources}
onSelectNode={vi.fn()}
onSelectNode={options.onSelectNode ?? vi.fn()}
onNodeContextMenu={options.onNodeContextMenu ?? vi.fn()}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
@@ -87,6 +88,15 @@ function renderTree(
}
describe('UI tree preview visibility', () => {
it('does not use click alone to select directly draggable nodes', () => {
const onSelectNode = vi.fn();
const rendered = renderTree('editor-overlay', new Set(), { onSelectNode });
fireEvent.click(
rendered.container.querySelector('[data-node-id="child"]')!,
);
expect(onSelectNode).not.toHaveBeenCalled();
});
it('shares node menu restrictions, disabled actions, and dismissal behavior', () => {
const onClose = vi.fn();
const onInsertChild = vi.fn();
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import type { MutableRefObject } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
@@ -59,12 +60,14 @@ function pointerEvent(
pointerId: number,
clientX: number,
clientY: number,
eventTarget: EventTarget = target,
) {
return {
button: 0,
clientX,
clientY,
currentTarget: target,
target: eventTarget,
pointerId,
preventDefault: vi.fn(),
shiftKey: false,
@@ -83,10 +86,12 @@ function renderInteraction({
activeImageId = 'page',
scale = 1,
tree = pageTree,
previewRef,
}: {
activeImageId?: string | null;
scale?: number;
tree?: UITree | null;
previewRef?: MutableRefObject<HTMLElement | null>;
} = {}) {
const canvasProjection = canvas();
const viewportRef = { current: { scale } };
@@ -98,6 +103,7 @@ function renderInteraction({
logicalSize: { width: 320, height: 180 },
spaceHeld: false,
tree: currentTree,
previewRef,
viewportRef,
}),
{ initialProps: { imageId: activeImageId, currentTree: tree } },
@@ -106,6 +112,119 @@ function renderInteraction({
}
describe('useNodeTransformInteraction', () => {
it('defers node selection until a clean pointerup', () => {
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');
});
it('does not select after a drag 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, 3, 0));
result.current.onNodePointerUp(pointerEvent(target, 1, 3, 0));
});
expect(canvasProjection.selectNode).not.toHaveBeenCalled();
expect(result.current.consumeNodeClick()).toBe(true);
expect(result.current.consumeNodeClick()).toBe(false);
act(() => {
result.current.onNodePointerDown(pointerEvent(target, 2, 0, 0), child);
result.current.onNodePointerCancel(pointerEvent(target, 2, 0, 0));
});
expect(canvasProjection.selectNode).not.toHaveBeenCalled();
});
it('does not select when a clean pointerup lands outside the preview', () => {
const preview = document.createElement('div');
vi.spyOn(preview, 'getBoundingClientRect').mockReturnValue({
bottom: 10,
height: 10,
left: 0,
right: 10,
top: 0,
width: 10,
x: 0,
y: 0,
toJSON: () => ({}),
});
const previewRef = { current: preview };
const { result, canvasProjection } = renderInteraction({ previewRef });
const target = gestureTarget();
act(() => {
result.current.onNodePointerDown(pointerEvent(target, 1, 1, 1), child);
result.current.onNodePointerUp(pointerEvent(target, 1, 20, 20));
});
expect(canvasProjection.selectNode).not.toHaveBeenCalled();
});
it('cancels the active gesture when the window loses focus', () => {
const { result, canvasProjection } = renderInteraction();
const target = gestureTarget();
act(() => {
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
window.dispatchEvent(new Event('blur'));
result.current.onNodePointerUp(pointerEvent(target, 1, 0, 0));
});
expect(canvasProjection.selectNode).not.toHaveBeenCalled();
expect(target.releasePointerCapture).toHaveBeenCalledWith(1);
});
it('leaves nested child clicks to the child when the outer gesture did not move', () => {
const { result, canvasProjection } = renderInteraction();
const target = gestureTarget();
const nestedTarget = document.createElement('span');
nestedTarget.dataset.nodeId = 'nested';
target.appendChild(nestedTarget);
act(() => {
result.current.onNodePointerDown(
pointerEvent(target, 1, 0, 0, nestedTarget),
child,
);
result.current.onNodePointerUp(
pointerEvent(target, 1, 0, 0, nestedTarget),
);
});
expect(canvasProjection.selectNode).toHaveBeenCalledWith('nested');
});
it('defers resize selection until a clean pointerup as well', () => {
const { result, canvasProjection } = renderInteraction();
const target = gestureTarget();
act(() => {
result.current.onNodeResizePointerDown(
pointerEvent(target, 1, 0, 0),
child,
'se',
);
});
expect(canvasProjection.selectNode).not.toHaveBeenCalled();
act(() => {
result.current.onNodeResizePointerUp(pointerEvent(target, 1, 1, 1));
});
expect(canvasProjection.selectNode).toHaveBeenCalledWith('child');
});
it('makes drag and resize mutually exclusive, then permits the next gesture', () => {
const { result, canvasProjection } = renderInteraction();
const dragTarget = gestureTarget();