// @vitest-environment jsdom import { act, cleanup, fireEvent, render, renderHook, screen, waitFor, } from '@testing-library/react'; import { createElement, type ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); vi.mock('../src/components/AssetImporter', () => ({ AssetImporter: ({ open }: { open: boolean }) => open ? createElement('div', { role: 'dialog' }, '素材导入器') : null, })); vi.mock('../src/components/modal/ThemedModal', () => ({ ThemedModal: ({ open, children }: { open: boolean; children: ReactNode }) => open ? createElement('div', { role: 'dialog' }, children) : null, })); import { invoke } from '@tauri-apps/api/core'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { State } from '../src/features/ui-editor/types/State'; import type { IUiDesignStateStore, UiDesignStateSnapshot, } from '../src/features/ui-editor/uiDesignStateStore'; import UiEditorPage from '../src/view/ui-editor'; import { useUiEditorSession } from '../src/view/ui-editor/useUiEditorPage'; class TestResizeObserver { constructor(private callback: ResizeObserverCallback) {} observe(target: Element) { this.callback( [ { target, contentRect: { width: 800, height: 600 }, } as ResizeObserverEntry, ], this as unknown as ResizeObserver, ); } disconnect() {} } const EMPTY_SNAPSHOT: UiDesignStateSnapshot = { revision: 0, state: { ui_trees: [], ui_design_images: {}, sprite_assets: {}, font_assets: {}, }, }; 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: undefined, children, }; } function stateWithPages(pageIds: string[]): State { return { ui_design_images: Object.fromEntries( pageIds.map((id) => [ id, { metadata: { name: id, description: '', role: 'Page', slave_to: null, }, path: `assets/${id}.png`, pixel_size: [320, 180], pixels_per_unit: 1, }, ]), ), ui_trees: pageIds.map((id) => ({ src_ui_design: id, root: node(`${id}-root`), })), sprite_assets: { panel: { asset_id: 'panel', metadata: { name: 'Panel', asset_type: '' }, path: 'assets/panel.png', pixel_size: [32, 32], pixels_per_unit: 1, border: { left: 0, right: 0, top: 0, bottom: 0 }, }, }, font_assets: { body: { asset_id: 'body', metadata: { family_name: '测试字体', face_name: 'Regular', weight: 400, italic: false, format: 'TrueType', source_file_name: 'body.ttf', }, path: 'assets/fonts/body.ttf', content_sha256: 'a'.repeat(64), }, }, }; } async function renderLoadedSession(state: State) { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, state }), save: vi.fn(), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; const hook = renderHook(() => useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), ); await waitFor(() => expect(hook.result.current.save.isLoading).toBe(false)); return hook; } describe('UiEditorPage', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', TestResizeObserver); vi.mocked(invoke).mockReset().mockResolvedValue(undefined); }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); it('keeps the wallet entry in the resource editor header', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn(), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( createElement(UiEditorPage, { projectPath: '/tmp/ui-editor-wallet', resourceId: 'ui-resource', stateStore, walletEntry: createElement( 'button', { type: 'button', 'aria-label': '打开账户资产' }, '泥点', ), }), ); const walletEntry = await screen.findByRole('button', { name: '打开账户资产', }); expect(walletEntry.closest('header')).not.toBeNull(); expect( screen.getAllByRole('button', { name: '打开账户资产' }), ).toHaveLength(1); }); it('cancels image preview scopes when the resource changes and on unmount', async () => { vi.mocked(invoke) .mockReset() .mockImplementation(async (command) => { if (command === 'read_local_project_image_preview') { return { dataUrl: 'data:image/png;base64,cHJldmlldw==' }; } return undefined; }); vi.stubGlobal('__TAURI__', { core: { invoke } }); const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, state: stateWithPages(['page']), }), save: vi.fn(), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; const hook = renderHook( ({ resourceId }) => useUiEditorSession('/tmp/ui-editor', resourceId, stateStore), { initialProps: { resourceId: 'ui-resource-a' } }, ); await waitFor(() => expect(invoke).toHaveBeenCalledWith( 'read_local_project_image_preview', expect.objectContaining({ scopeId: expect.any(String) }), ), ); const firstScopeId = vi .mocked(invoke) .mock.calls.find( ([command]) => command === 'read_local_project_image_preview', )?.[1]?.scopeId; hook.rerender({ resourceId: 'ui-resource-b' }); await waitFor(() => expect(invoke).toHaveBeenCalledWith( 'cancel_local_project_resource_preview_scope', { scopeId: firstScopeId }, ), ); await waitFor(() => expect( vi .mocked(invoke) .mock.calls.filter( ([command]) => command === 'read_local_project_image_preview', ), ).toHaveLength(4), ); const secondScopeId = vi .mocked(invoke) .mock.calls.filter( ([command]) => command === 'read_local_project_image_preview', ) .at(-1)?.[1]?.scopeId; hook.unmount(); expect(invoke).toHaveBeenCalledWith( 'cancel_local_project_resource_preview_scope', { scopeId: secondScopeId }, ); }); it('does not cancel a preview scope before state loading creates one', () => { vi.mocked(invoke).mockClear(); const stateStore: IUiDesignStateStore = { load: vi.fn(() => new Promise(() => undefined)), save: vi.fn(), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; const hook = renderHook(() => useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), ); hook.unmount(); expect(invoke).not.toHaveBeenCalledWith( 'cancel_local_project_resource_preview_scope', expect.anything(), ); }); it('uses the durable Tauri store for a project UI resource', async () => { vi.mocked(invoke) .mockResolvedValueOnce({ revision: 0, state: { ui_trees: [], ui_design_images: {}, sprite_assets: {}, font_assets: {}, }, }) .mockResolvedValueOnce({ status: 'saved', state: { ui_trees: [], ui_design_images: {}, sprite_assets: {}, font_assets: {}, }, revision: 1, committedProjectRevision: 1, }); render( createElement(UiEditorPage, { projectPath: '/tmp/ui-editor-project', projectId: 'local-project-draft', resourceId: 'generated-ui-1', } as never), ); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('load_ui_design_state', { input: { projectPath: '/tmp/ui-editor-project', expectedProjectId: 'local-project-draft', assetId: 'generated-ui-1', }, }); }); fireEvent.click(screen.getByRole('button', { name: '保存' })); fireEvent.click(screen.getByRole('button', { name: '仍然保存' })); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('save_ui_design_state', { input: { projectPath: '/tmp/ui-editor-project', expectedProjectId: 'local-project-draft', assetId: 'generated-ui-1', expectedRevision: 0, state: { ui_trees: [], ui_design_images: {}, sprite_assets: {}, font_assets: {}, }, }, }); }); }); it('renders split input tools over a real empty State', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); expect( screen.getByRole('navigation', { name: 'UI 编辑流程' }), ).toBeTruthy(); expect(screen.getByRole('button', { name: '导入界面图' })).toBeTruthy(); expect(screen.getByRole('button', { name: '导入独立素材' })).toBeTruthy(); expect(screen.getByText('从界面图开始')).toBeTruthy(); expect(screen.queryByText('Pause Dialog')).toBeNull(); }); it('renders the overview that belongs to the active workflow stage', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); expect(screen.getByRole('heading', { name: '导入概览' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: /识别界面结构/ })); fireEvent.click(screen.getByRole('button', { name: '仍然继续' })); expect(screen.getByRole('heading', { name: '识别概览' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: /自动切分素材/ })); fireEvent.click(screen.getByRole('button', { name: '仍然继续' })); expect( screen.getByRole('heading', { name: '自动切分素材概览' }), ).toBeTruthy(); }); it('opens a completed workflow directly at the asset separation review stage', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 3, state: stateWithPages(['gameplay-page']), }), save: vi.fn(), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( createElement(UiEditorPage, { projectPath: '/tmp/ui-editor-final-review', resourceId: 'ui-resource', stateStore, initialStep: 'asset-separation', initialFurthestStepIndex: 2, }), ); expect( await screen.findByRole('heading', { name: '自动切分素材概览' }), ).toBeTruthy(); expect( screen .getByRole('navigation', { name: 'UI 编辑流程' }) .querySelector('button[aria-current="step"]')?.textContent, ).toContain('自动切分素材'); }); it('keeps the pending binding count informational instead of navigable', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); fireEvent.click(screen.getByRole('button', { name: /自动切分素材/ })); fireEvent.click(screen.getByRole('button', { name: '仍然继续' })); expect( screen.queryByRole('button', { name: /待处理.*定位下一项/ }), ).toBeNull(); }); it('switches tools freely without inventing completed workflow state', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); fireEvent.click(screen.getByRole('button', { name: /自动切分素材/ })); expect(screen.getByRole('heading', { name: '检查发现问题' })).toBeTruthy(); }); it('opens the design-image importer independently', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); fireEvent.click(screen.getByRole('button', { name: '导入界面图' })); expect(screen.getByText('素材导入器')).toBeTruthy(); }); it('switches from a selected node to the sprite inspector', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; act(() => { result.current.input.selectDesignImage('page'); result.current.input.selectNode(rootId); }); expect(result.current.canvas.selectedNodeId).toBe(rootId); act(() => result.current.input.selectSprite('panel')); expect(result.current.canvas.selectedNodeId).toBeNull(); expect(result.current.inspector.selectedSpriteId).toBe('panel'); act(() => result.current.input.selectFont('body')); expect(result.current.inspector.selectedFontId).toBe('body'); act(() => result.current.input.selectDesignImage('page')); expect(result.current.inspector.selectedFontId).toBeNull(); act(() => result.current.input.selectFont('body')); act(() => result.current.input.selectSprite('panel')); expect(result.current.inspector.selectedFontId).toBeNull(); act(() => result.current.input.selectFont('body')); act(() => result.current.input.selectNode(rootId)); expect(result.current.inspector.selectedFontId).toBeNull(); }); it('keeps node preview visibility transient and attached to global node IDs', async () => { const { result } = await renderLoadedSession( stateWithPages(['page-a', 'page-b']), ); const rootId = 'page-a-root'; let insertedNodeId: string | undefined; act(() => { const inserted = result.current.input.insertNode(rootId, 'page-a'); if (inserted?.ok) insertedNodeId = inserted.value; }); const stateBeforeToggle = JSON.stringify(result.current.canvas.tree); act(() => { result.current.canvas.toggleNodePreviewVisibility(rootId); }); expect(result.current.canvas.isNodePreviewVisible(rootId)).toBe(false); expect(result.current.canvas.hiddenNodeIds.has(rootId)).toBe(true); expect(JSON.stringify(result.current.canvas.tree)).toBe(stateBeforeToggle); act(() => result.current.input.selectDesignImage('page-b')); expect(result.current.canvas.hiddenNodeIds.has(rootId)).toBe(true); act(() => result.current.input.selectDesignImage('page-a')); expect(result.current.canvas.hiddenNodeIds.has(rootId)).toBe(true); expect(insertedNodeId).toBeTruthy(); act(() => result.current.canvas.toggleNodePreviewVisibility(insertedNodeId!), ); expect(result.current.canvas.hiddenNodeIds.has(insertedNodeId!)).toBe(true); act(() => result.current.input.deleteNode(insertedNodeId!, 'page-a')); expect(result.current.canvas.hiddenNodeIds.has(insertedNodeId!)).toBe( false, ); act(() => result.current.dialogs.clearState()); expect(result.current.canvas.hiddenNodeIds.size).toBe(0); }); it('clears selection when deleting a node removes the selected descendant', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; let parentId: string | undefined; let childId: string | undefined; act(() => { parentId = result.current.input.insertNode(rootId, 'page')?.value; childId = result.current.input.insertNode(parentId!, 'page')?.value; result.current.input.selectNode(childId!); }); act(() => result.current.input.deleteNode(parentId!, 'page')); expect(result.current.canvas.selectedNodeId).toBeNull(); expect(result.current.history.canUndo).toBe(true); }); it('deletes the selected node from the page with Delete', async () => { const state = stateWithPages(['page']); state.ui_trees[0]!.root.children = [node('page-child')]; const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, state }), save: vi.fn(), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( createElement(UiEditorPage, { projectPath: '/tmp/ui-editor-delete-keyboard', resourceId: 'ui-resource', stateStore, }), ); await waitFor(() => { expect( screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'), ).toBe(false); }); fireEvent.click(screen.getByText('page-child')); await screen.findByDisplayValue('page-child'); fireEvent.keyDown(window, { key: 'Delete' }); await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); }); it('does not delete a selected node when Delete originates inside a dialog', async () => { const state = stateWithPages(['page']); state.ui_trees[0]!.root.children = [node('page-child')]; const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, state }), save: vi.fn(), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( createElement(UiEditorPage, { projectPath: '/tmp/ui-editor-delete-dialog', resourceId: 'ui-resource', stateStore, }), ); await waitFor(() => { expect( screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'), ).toBe(false); }); fireEvent.click(screen.getByText('page-child')); await screen.findByDisplayValue('page-child'); const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); document.body.appendChild(dialog); fireEvent.keyDown(dialog, { key: 'Delete' }); expect(screen.queryAllByText('page-child').length).toBeGreaterThan(0); dialog.remove(); }); it('keeps Inspector status highlighting separate from node navigation', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; let otherNodeId: string | undefined; act(() => { otherNodeId = result.current.input.insertNode(rootId, 'page')?.value; result.current.input.selectNode(rootId); result.current.input.highlightStatusField(rootId, 'layout_status'); }); expect(result.current.inspector.highlightedStatusField).toMatchObject({ nodeId: rootId, field: 'layout_status', requestId: 1, }); act(() => result.current.input.highlightStatusField(rootId, 'layout_status'), ); expect(result.current.inspector.highlightedStatusField).toMatchObject({ nodeId: rootId, field: 'layout_status', requestId: 2, }); act(() => result.current.input.selectNode(rootId)); expect(result.current.inspector.highlightedStatusField).toBeNull(); act(() => { result.current.input.highlightStatusField( otherNodeId!, 'component_status', ); result.current.inspector.setNodeMetadata({ component_status: 'NoProblem', }); }); expect(result.current.inspector.highlightedStatusField).toBeNull(); }); it('retargets the status highlight to every overview navigation result', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; let otherNodeId: string | undefined; act(() => { otherNodeId = result.current.input.insertNode(rootId, 'page')?.value; result.current.input.focusNode('page', rootId); result.current.input.highlightStatusField(rootId, 'layout_status'); }); expect(result.current.inspector.highlightedStatusField).toMatchObject({ nodeId: rootId, field: 'layout_status', requestId: 1, }); act(() => { result.current.input.focusNode('page', otherNodeId!); result.current.input.highlightStatusField(otherNodeId!, 'layout_status'); }); expect(result.current.inspector.highlightedStatusField).toMatchObject({ nodeId: otherNodeId, field: 'layout_status', requestId: 2, }); }); it('shares exclusive child visibility between tree actions and final preview state', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; let parentId: string | undefined; let childAId: string | undefined; let childBId: string | undefined; let childBDescendantId: string | undefined; act(() => { parentId = result.current.input.insertNode(rootId, 'page')?.value; childAId = result.current.input.insertNode(parentId!, 'page')?.value; childBId = result.current.input.insertNode(parentId!, 'page')?.value; childBDescendantId = result.current.input.insertNode( childBId!, 'page', )?.value; }); act(() => result.current.input.selectNode(parentId!)); act(() => result.current.inspector.setNodeChildrenDisplayMode('Exclusive')); expect(parentId && childAId && childBId).toBeTruthy(); expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(true); expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(false); expect( result.current.canvas.isNodePreviewVisible(childBDescendantId!), ).toBe(false); act(() => result.current.canvas.selectExclusiveChild(childBId!)); expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(false); expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(true); expect( result.current.canvas.isNodePreviewVisible(childBDescendantId!), ).toBe(true); act(() => result.current.canvas.selectExclusiveChild(childAId!)); act(() => result.current.canvas.toggleNodePreviewVisibility(childAId!)); expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(false); expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(false); act(() => result.current.canvas.toggleNodePreviewVisibility(childBId!)); expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(false); expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(true); expect( result.current.canvas.isNodePreviewVisible(childBDescendantId!), ).toBe(true); act(() => result.current.canvas.selectExclusiveChild(childAId!)); expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(true); expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(false); act(() => result.current.inspector.setNodeChildrenDisplayMode('Stack')); expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(true); expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(true); expect( result.current.canvas.isNodePreviewVisible(childBDescendantId!), ).toBe(true); }); it('shows a page-level save failure and allows a retry', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn().mockRejectedValue(new Error('临时存储不可用')), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( createElement(UiEditorPage, { projectPath: '/tmp/ui-editor', resourceId: 'ui-resource', stateStore, }), ); fireEvent.click(await screen.findByRole('button', { name: '保存' })); fireEvent.click(await screen.findByRole('button', { name: '仍然保存' })); expect((await screen.findByRole('alert')).textContent).toContain( '保存失败,请稍后重试。', ); expect(screen.getByRole('alert').textContent).not.toContain( '临时存储不可用', ); expect(screen.getByRole('button', { name: '保存' })).toBeTruthy(); expect(stateStore.save).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole('button', { name: '保存' })); fireEvent.click(await screen.findByRole('button', { name: '仍然保存' })); expect(stateStore.save).toHaveBeenCalledTimes(2); }); it('keeps the dedicated message for a save conflict', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn().mockResolvedValue({ status: 'conflict', currentRevision: 1, }), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( createElement(UiEditorPage, { projectPath: '/tmp/ui-editor', resourceId: 'ui-resource', stateStore, }), ); fireEvent.click(await screen.findByRole('button', { name: '保存' })); fireEvent.click(await screen.findByRole('button', { name: '仍然保存' })); expect((await screen.findByRole('alert')).textContent).toContain( '资源已在别处更新;请重新加载后再保存。', ); }); });