f9e1e5d2a7
close #266 当前实现: 按下就重新选择 实际上应该: 松开而且没有发生拖动才重新选择 before:  after:  Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/270 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
395 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|