Files
k88936 4db6d28603
Project CI / Repository checks (push) Successful in 3m6s
Project CI / Frontend tests (push) Successful in 3m44s
Project CI / Backend tests (push) Successful in 8m16s
Project CI / Native shell tests (push) Successful in 20m48s
UI编辑器一批细节优化 (#278)
* 提示词:完善组件建议,增加容器背景与节点大小一致的实现
* 支持Delete删除节点 ctrl + -缩放
* 步骤结束弹窗提醒
![shotmd-1788599068.jpg](/attachments/99a1e4aa-d778-41bf-9dda-0df703723407)
![shotmd-1788605840.jpg](/attachments/31ab883d-32bc-467b-9308-616b83387a79)

* UI 树快捷删除
![shotmd-1788605655.jpg](/attachments/3d451201-3f68-4eb6-b558-864ecf0cb5a3)

* zoom滑动条样式

before:
![image.png](/attachments/e4a04304-e6eb-4da6-9352-99e093fee42e)
after:
![shotmd-1788606577.jpg](/attachments/6cdc377c-f2a3-4a7e-b78e-772a3fdb13b7)

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/278
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-08 11:56:36 +08:00

723 lines
24 KiB
TypeScript

// @vitest-environment jsdom
import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor,
} from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
import { 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(_callback: ResizeObserverCallback) {}
observe() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver);
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',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
},
components: [],
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', () => {
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;
});
Object.defineProperty(window, '__TAURI__', {
configurable: true,
value: { 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 visual binding 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: 'visual-binding',
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,
}),
);
const child = await screen.findByText('page-child');
fireEvent.click(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,
}),
);
const child = await screen.findByText('page-child');
fireEvent.click(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!,
'components_status',
);
result.current.inspector.setNodeMetadata({
components_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(
'资源已在别处更新;请重新加载后再保存。',
);
});
});