// @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', component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, component: null, 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, options: { showFrame?: boolean; selectedNodeId?: string | null; onSelectNode?: ReturnType; onNodeContextMenu?: ReturnType; } = {}, ) { return render( , ); } 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( , ); 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( , ); 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) => render( , ); 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( 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( , ); 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( , ); 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); }); });