Files
Genarrative/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx
k88936 f9e1e5d2a7
Project CI / Backend tests (push) Successful in 7m35s
Project CI / Repository checks (push) Successful in 3m2s
Project CI / Frontend tests (push) Successful in 3m24s
Project CI / Native shell tests (push) Successful in 18m52s
Fix/修复ui editor选择逻辑 (#270)
close #266
当前实现: 按下就重新选择

实际上应该: 松开而且没有发生拖动才重新选择

before:
![shotmd-1788407266-compressed.webp](/attachments/251eccf6-ae02-4ae6-b9d2-4eb1fbf67df3)

after:
![shotmd-1788418953-compressed.webp](/attachments/5c05b506-ae08-4cb1-83c8-8da8e1590a7f)

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/270
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-04 16:30:43 +08:00

428 lines
12 KiB
TypeScript

// @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 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<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
}
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);
});
});