Files
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

395 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, 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 { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/ExclusiveChildrenTabs';
import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer';
import { UiNodeContextMenu } from '../src/view/ui-editor/components/UiNodeContextMenu';
afterEach(() => {
cleanup();
});
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_display_mode: 'Stack',
children,
};
}
function exclusiveNode(id: string, children: UiNode[]): UiNode {
return { ...node(id, children), children_display_mode: 'Exclusive' };
}
const tree: UITree = {
src_ui_design: 'page',
root: node('root', [node('child', [node('grandchild')])]),
};
const resources = {
previewUrls: {},
sprites: {},
fonts: {},
fontFaces: {},
};
function renderTree(
renderMode: 'editor-overlay' | 'final-preview',
hiddenNodeIds: ReadonlySet<string>,
options: {
showFrame?: boolean;
selectedNodeId?: string | null;
onSelectNode?: ReturnType<typeof vi.fn>;
onNodeContextMenu?: ReturnType<typeof vi.fn>;
} = {},
) {
return render(
<UiTreeRenderer
tree={tree}
renderMode={renderMode}
showFrame={options.showFrame ?? false}
hiddenNodeIds={hiddenNodeIds}
selectedNodeId={options.selectedNodeId ?? null}
resources={resources}
onSelectNode={options.onSelectNode ?? vi.fn()}
onNodeContextMenu={options.onNodeContextMenu ?? vi.fn()}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
onNodePointerCancel={vi.fn()}
onNodeResizePointerDown={vi.fn()}
onNodeResizePointerMove={vi.fn()}
onNodeResizePointerUp={vi.fn()}
onNodeResizePointerCancel={vi.fn()}
viewportScale={1}
/>,
);
}
describe('UI tree preview visibility', () => {
it('disables native text selection on preview node layers', () => {
const rendered = renderTree('editor-overlay', new Set());
expect(
rendered.container
.querySelector('[data-node-id="child"]')
?.classList.contains('select-none'),
).toBe(true);
});
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();
const onInsertSibling = vi.fn();
const onDelete = vi.fn();
render(
<UiNodeContextMenu
nodeId="page-root"
position={{ x: window.innerWidth, y: window.innerHeight }}
isPageRoot
disabled
onClose={onClose}
onInsertChild={onInsertChild}
onInsertSibling={onInsertSibling}
onDelete={onDelete}
/>,
);
const menu = screen.getByRole('menu') as HTMLDivElement;
expect(menu.style.left).toBe(`${window.innerWidth - 184}px`);
expect(menu.style.top).toBe(`${window.innerHeight - 56}px`);
expect(
(
screen.getByRole('menuitem', {
name: '新增子节点',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(screen.queryByRole('menuitem', { name: '新增同级节点' })).toBeNull();
expect(
screen.queryByRole('menuitem', { name: '删除节点及子节点' }),
).toBeNull();
fireEvent.click(screen.getByRole('menuitem', { name: '新增子节点' }));
expect(onInsertChild).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('maps a Godot HBoxContainer to preview CSS and disables child free-transform handles', () => {
const hboxTree: UITree = {
src_ui_design: 'page',
root: {
...node('root', [node('first'), node('second')]),
layout: {
...node('root').layout,
container: { HBox: { alignment: 'Begin', separation: 12 } },
},
},
};
const rendered = render(
<UiTreeRenderer
tree={hboxTree}
renderMode="editor-overlay"
showFrame
hiddenNodeIds={new Set()}
selectedNodeId="first"
resources={resources}
onSelectNode={vi.fn()}
onNodeContextMenu={vi.fn()}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
onNodePointerCancel={vi.fn()}
onNodeResizePointerDown={vi.fn()}
onNodeResizePointerMove={vi.fn()}
onNodeResizePointerUp={vi.fn()}
onNodeResizePointerCancel={vi.fn()}
viewportScale={1}
/>,
);
const root = rendered.container.querySelector(
'[data-node-id="root"]',
) as HTMLDivElement;
const first = rendered.container.querySelector(
'[data-node-id="first"]',
) as HTMLDivElement;
expect(root.style.display).toBe('flex');
expect(root.style.flexDirection).toBe('row');
expect(root.style.gap).toBe('12px');
expect(first.style.position).toBe('relative');
expect(screen.queryByLabelText('nw 调整控制点')).toBeNull();
});
it('shows final-preview node frames and resize handles only when requested', () => {
const withoutFrames = renderTree('final-preview', new Set(), {
selectedNodeId: 'child',
});
const plainChild = withoutFrames.container.querySelector(
'[data-node-id="child"]',
) as HTMLDivElement;
expect(plainChild.style.outline).toBe('');
expect(screen.queryByLabelText('nw 调整控制点')).toBeNull();
withoutFrames.unmount();
const withFrames = renderTree('final-preview', new Set(), {
showFrame: true,
selectedNodeId: 'child',
});
const framedChild = withFrames.container.querySelector(
'[data-node-id="child"]',
) as HTMLDivElement;
expect(framedChild.style.outline).toBe('2px solid rgb(249 115 22)');
expect(screen.getByText('child')).toBeTruthy();
expect(screen.getAllByLabelText(/调整控制点$/)).toHaveLength(8);
});
it('hides a node subtree in editor overlay and final preview', () => {
const editorOverlay = renderTree('editor-overlay', new Set(['child']));
expect(
editorOverlay.container.querySelector('[data-node-id="root"]'),
).toBeTruthy();
expect(
editorOverlay.container.querySelector('[data-node-id="child"]'),
).toBeNull();
expect(
editorOverlay.container.querySelector('[data-node-id="grandchild"]'),
).toBeNull();
const finalPreview = renderTree('final-preview', new Set(['root']));
expect(finalPreview.container.querySelector('[data-node-id]')).toBeNull();
});
it('renders only the first unhidden child for an exclusive parent', () => {
const exclusiveTree: UITree = {
src_ui_design: 'page',
root: exclusiveNode('root', [node('child-a'), node('child-b')]),
};
const renderExclusive = (hiddenNodeIds: ReadonlySet<string>) =>
render(
<UiTreeRenderer
tree={exclusiveTree}
renderMode="final-preview"
showFrame={false}
hiddenNodeIds={hiddenNodeIds}
selectedNodeId={null}
resources={resources}
onSelectNode={vi.fn()}
onNodeContextMenu={vi.fn()}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
onNodePointerCancel={vi.fn()}
onNodeResizePointerDown={vi.fn()}
onNodeResizePointerMove={vi.fn()}
onNodeResizePointerUp={vi.fn()}
onNodeResizePointerCancel={vi.fn()}
viewportScale={1}
/>,
);
const first = renderExclusive(new Set());
expect(
first.container.querySelector('[data-node-id="child-a"]'),
).toBeTruthy();
expect(
first.container.querySelector('[data-node-id="child-b"]'),
).toBeNull();
first.unmount();
const second = renderExclusive(new Set(['child-a']));
expect(
second.container.querySelector('[data-node-id="child-a"]'),
).toBeNull();
expect(
second.container.querySelector('[data-node-id="child-b"]'),
).toBeTruthy();
second.unmount();
const none = renderExclusive(new Set(['child-a', 'child-b']));
expect(none.container.querySelector('[data-node-id="child-a"]')).toBeNull();
expect(none.container.querySelector('[data-node-id="child-b"]')).toBeNull();
});
it('uses a highlighted tab bar for exclusive child switching', () => {
const parent = exclusiveNode('parent', [node('child-a'), node('child-b')]);
const onSelectChild = vi.fn();
render(
<ExclusiveChildrenTabs
parent={parent}
isChildVisible={(nodeId) => nodeId === 'child-a'}
onSelectChild={onSelectChild}
/>,
);
expect(
screen
.getByRole('tab', { name: 'child-a' })
.getAttribute('aria-selected'),
).toBe('true');
expect(
screen
.getByRole('tab', { name: 'child-b' })
.getAttribute('aria-selected'),
).toBe('false');
fireEvent.click(screen.getByRole('tab', { name: 'child-b' }));
expect(onSelectChild).toHaveBeenCalledWith('child-b');
});
it('keeps exclusive tab pointer gestures out of the parent node', () => {
const onSelectChild = vi.fn();
const onNodePointerDown = vi.fn();
const parent = exclusiveNode('parent', [node('child-a'), node('child-b')]);
render(
<UiTreeRenderer
tree={{ src_ui_design: 'page', root: node('root', [parent]) }}
renderMode="final-preview"
showFrame={false}
hiddenNodeIds={new Set()}
selectedNodeId="parent"
resources={resources}
onSelectNode={vi.fn()}
onNodeContextMenu={vi.fn()}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
onNodePointerCancel={vi.fn()}
onNodeResizePointerDown={vi.fn()}
onNodeResizePointerMove={vi.fn()}
onNodeResizePointerUp={vi.fn()}
onNodeResizePointerCancel={vi.fn()}
onSelectExclusiveChild={onSelectChild}
viewportScale={1}
/>,
);
const tab = screen.getByRole('tab', { name: 'child-b' });
fireEvent.pointerDown(tab, { button: 0, pointerId: 1 });
fireEvent.click(tab);
expect(onNodePointerDown).not.toHaveBeenCalled();
expect(onSelectChild).toHaveBeenCalledWith('child-b');
});
it('opens a context-menu target only from editor overlay nodes', () => {
const onNodeContextMenu = vi.fn();
const onSelectNode = vi.fn();
const editorOverlay = render(
<UiTreeRenderer
tree={tree}
renderMode="editor-overlay"
showFrame={false}
hiddenNodeIds={new Set()}
selectedNodeId={null}
resources={resources}
onSelectNode={onSelectNode}
onNodeContextMenu={onNodeContextMenu}
onNodePointerDown={vi.fn()}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
onNodePointerCancel={vi.fn()}
onNodeResizePointerDown={vi.fn()}
onNodeResizePointerMove={vi.fn()}
onNodeResizePointerUp={vi.fn()}
onNodeResizePointerCancel={vi.fn()}
viewportScale={1}
/>,
);
const child = editorOverlay.container.querySelector(
'[data-node-id="child"]',
) as HTMLDivElement;
fireEvent.contextMenu(child, { clientX: 32, clientY: 48 });
expect(onSelectNode).toHaveBeenCalledWith('child');
expect(onNodeContextMenu).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ id: 'child' }),
false,
);
editorOverlay.unmount();
const finalPreview = renderTree('final-preview', new Set(), {
onNodeContextMenu,
});
fireEvent.contextMenu(
finalPreview.container.querySelector('[data-node-id="child"]')!,
);
expect(onNodeContextMenu).toHaveBeenCalledTimes(1);
});
});