// @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('@tauri-apps/plugin-clipboard-manager', () => ({ writeText: 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 { writeText } from '@tauri-apps/plugin-clipboard-manager'; 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, { 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(); }); 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: 1, }), ); 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('restarts inspector animation for every item in a multi-item cycle', async () => { const state = stateWithPages(['page']); state.ui_trees[0]!.root.children = [ node('review-a'), node('review-b'), node('review-c'), ]; for (const [index, child] of state.ui_trees[0]!.root.children.entries()) { child.metadata.layout_status = { NeedReview: `请检查 ${index}` }; } const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, state }), save: vi.fn(), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; const scrollSpy = vi.fn(); Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: scrollSpy, }); const cancel = vi.fn(); const play = vi.fn(); const getAnimationsSpy = vi.fn(() => [ { animationName: 'ui-editor-status-attention-arrive', cancel, play, } as unknown as Animation, ]); Object.defineProperty(Element.prototype, 'getAnimations', { configurable: true, value: getAnimationsSpy, }); try { render( createElement(UiEditorPage, { projectPath: '/tmp/ui-editor-cycle-animation', resourceId: 'ui-resource', stateStore, initialStep: 'structure-recognition', initialFurthestStepIndex: 0, }), ); const button = await screen.findByRole('button', { name: '待用户检查 3,定位下一项', }); for (const reason of ['请检查 0', '请检查 1', '请检查 2', '请检查 0']) { fireEvent.click(button); await screen.findByText(reason); } expect(getAnimationsSpy).toHaveBeenCalledTimes(4); expect(cancel).toHaveBeenCalledTimes(4); expect(play).toHaveBeenCalledTimes(4); expect(scrollSpy).toHaveBeenCalledTimes(4); } finally { delete (Element.prototype as Element & { getAnimations?: unknown }) .getAnimations; delete (Element.prototype as Element & { scrollIntoView?: unknown }) .scrollIntoView; } }); it('keeps the status highlight after the tree handles controlled selection', async () => { const state = stateWithPages(['page']); state.ui_trees[0]!.root.children = [ node('review-a'), node('review-b'), node('review-c'), ]; for (const child of state.ui_trees[0]!.root.children) { child.metadata.layout_status = { NeedReview: '请检查' }; } const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, state }), save: vi.fn(), generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn(), }); render( createElement(UiEditorPage, { projectPath: '/tmp/ui-editor-cycle-highlight', resourceId: 'ui-resource', stateStore, initialStep: 'structure-recognition', initialFurthestStepIndex: 0, }), ); const button = await screen.findByRole('button', { name: '待用户检查 3,定位下一项', }); try { for (let index = 0; index < 4; index += 1) { fireEvent.click(button); await waitFor(() => { expect( document.querySelector('[data-status-attention]'), ).not.toBeNull(); }); } } finally { delete (Element.prototype as Element & { scrollIntoView?: unknown }) .scrollIntoView; } }); 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('deletes an inspector node from its own tree without an explicit tree id', async () => { const { result } = await renderLoadedSession( stateWithPages(['page-a', 'page-b']), ); let otherTreeNodeId: string | undefined; act(() => { const inserted = result.current.input.insertNode('page-b-root', 'page-b'); if (inserted?.ok) otherTreeNodeId = inserted.value; }); expect(otherTreeNodeId).toBeTruthy(); // 选中非激活界面图里的节点:Inspector 删除按钮不给 treeId,必须落到它所在的树。 act(() => result.current.input.selectDesignImage('page-a')); act(() => result.current.input.selectNode(otherTreeNodeId!)); act(() => result.current.inspector.deleteNode(otherTreeNodeId!)); const otherTree = result.current.canvas.uiTrees.find( (tree) => tree.src_ui_design === 'page-b', ); expect(otherTree?.root.children.map((child) => child.id)).not.toContain( otherTreeNodeId, ); }); 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.queryAllByText('page-child')).toHaveLength(0), ); }); 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('dialog')).textContent).toContain( '保存失败,请稍后重试。', ); expect(screen.getByRole('button', { name: '重试保存' })).toBeTruthy(); expect(stateStore.save).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole('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('dialog')).textContent).toContain( '资源已在别处更新;请重新加载后再保存。', ); }); it('shows a generated path modal and copies the project-relative path', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn().mockResolvedValue({ status: 'saved', state: structuredClone(EMPTY_SNAPSHOT.state), revision: 1, committedProjectRevision: 1, }), generateCode: vi.fn().mockResolvedValue({ relativePath: 'ui/generated-example.js', treeExports: ['Example'], treeCount: 1, nodeCount: 2, }), }; vi.mocked(writeText).mockResolvedValue(undefined); 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: '仍然保存并生成' }), ); const dialog = await screen.findByRole('dialog'); expect(dialog.textContent).toContain('代码已生成'); expect(dialog.textContent).toContain('ui/generated-example.js'); fireEvent.click(screen.getByRole('button', { name: '复制路径' })); await waitFor(() => expect(writeText).toHaveBeenCalledWith('ui/generated-example.js'), ); expect(screen.getByRole('button', { name: '已复制' })).toBeTruthy(); }); it('shows a save-success modal without a generated path', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn().mockResolvedValue({ status: 'saved', state: structuredClone(EMPTY_SNAPSHOT.state), revision: 1, committedProjectRevision: 1, }), generateCode: vi.fn().mockRejectedValue(new Error('not requested')), }; 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: '仍然保存' })); const dialog = await screen.findByRole('dialog'); expect(dialog.textContent).toContain('保存成功'); expect(dialog.textContent).not.toContain('生成文件路径'); }); it('reports partial success when saving succeeds but generation fails', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn().mockResolvedValue({ status: 'saved', state: structuredClone(EMPTY_SNAPSHOT.state), revision: 1, committedProjectRevision: 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: '仍然保存并生成' }), ); const dialog = await screen.findByRole('dialog'); expect(dialog.textContent).toContain('项目已保存,但代码生成失败'); expect(dialog.textContent).toContain('生成服务不可用'); expect(screen.getByRole('button', { name: '重试保存并生成' })).toBeTruthy(); }); it('shows a manual-copy hint when the native clipboard rejects a path', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn().mockResolvedValue({ status: 'saved', state: structuredClone(EMPTY_SNAPSHOT.state), revision: 1, committedProjectRevision: 1, }), generateCode: vi.fn().mockResolvedValue({ relativePath: 'ui/generated-example.js', treeExports: ['Example'], treeCount: 1, nodeCount: 2, }), }; vi.mocked(writeText).mockRejectedValue(new Error('clipboard unavailable')); 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: '仍然保存并生成' }), ); fireEvent.click(await screen.findByRole('button', { name: '复制路径' })); expect(await screen.findByText('复制失败,请手动复制路径。')).toBeTruthy(); }); it.each([ ['manual', 'saved', 1], ['manual', 'unchanged', 1], ['auto', 'saved', 1], ['auto', 'unchanged', 0], ['manual', 'conflict', 0], ['manual', 'failure', 0], ] as const)( 'records the real %s save outcome %s', async (saveSource, status, count) => { vi.mocked(invoke).mockImplementation(async (command) => command === 'capture_analytics_context' ? { route: { user_id: 'A', destination_origin: 'https://example.com', }, editor_session_id: 'session', client_version: '1', } : undefined, ); const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: status === 'failure' ? vi.fn().mockRejectedValue(new Error('write failed')) : vi.fn().mockResolvedValue({ status, revision: 1, state: EMPTY_SNAPSHOT.state, committedProjectRevision: 3, }), generateCode: vi.fn(), }; const hook = renderHook(() => useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), ); await waitFor(() => expect(hook.result.current.save.isLoading).toBe(false), ); await act(async () => { await hook.result.current.save.save({ saveSource }); }); const calls = vi .mocked(invoke) .mock.calls.filter( ([command]) => command === 'record_analytics_ui_save', ); expect(calls).toHaveLength(count); if (count) expect(calls[0][1]).toMatchObject({ projectPath: '/tmp/ui-editor', saveSource, changed: status === 'saved', }); }, ); it.each([true, false])( 'records combined save only after successful code generation: %s', async (success) => { vi.mocked(invoke).mockImplementation(async (command) => command === 'capture_analytics_context' ? { route: { user_id: 'A', destination_origin: null }, editor_session_id: 'session', client_version: '1', } : undefined, ); let complete!: () => void; const generation = new Promise((resolve) => { complete = resolve; }); const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn().mockResolvedValue({ status: 'saved', revision: 1, state: EMPTY_SNAPSHOT.state, committedProjectRevision: 3, }), generateCode: vi.fn(async () => { await generation; if (!success) throw new Error('generation failed'); return { relativePath: 'ui/generated.js', treeExports: [], treeCount: 0, nodeCount: 0, }; }), }; const hook = renderHook(() => useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), ); await waitFor(() => expect(hook.result.current.save.isLoading).toBe(false), ); let operation!: Promise; await act(async () => { operation = hook.result.current.save.saveAndGenerateCode(); await Promise.resolve(); }); expect( vi .mocked(invoke) .mock.calls.filter( ([command]) => command === 'record_analytics_ui_save', ), ).toHaveLength(0); await act(async () => { complete(); await operation; }); expect( vi .mocked(invoke) .mock.calls.filter( ([command]) => command === 'record_analytics_ui_save', ), ).toHaveLength(success ? 1 : 0); }, ); });