Files
Genarrative/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
T
k88936 81dc7b0cd6 前端移除分析参考图步骤与界面图元数据编辑
- model.ts 收敛为“识别界面结构 / 自动切分素材”两步并删除界面角色标签表

- 删除 suggestion 异步操作、结果通知分支、uiDesignSuggestions 与其 ts-rs 生成类型

- InputSidebar、InspectorSidebar、PreviewWorkspace 的界面图显示名改用 path basename,Inspector 只保留尺寸与资源 ID

- 删除因步骤退役而不可达的 ImportOverview,ToolNavigation 改为两格,删除“清空界面归属”提示

- requisites 删除角色/归属前置校验与参考图分析结果校验,importAdapter 不再写入界面图元数据

- 测试夹具去掉界面图元数据,删除界面角色与归属用例,历史用例改用 sprite 名称断言
2026-09-23 13:30:04 +08:00

996 lines
33 KiB
TypeScript

// @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('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();
});
});