4db6d28603
* 提示词:完善组件建议,增加容器背景与节点大小一致的实现 * 支持Delete删除节点 ctrl + -缩放 * 步骤结束弹窗提醒   * UI 树快捷删除  * zoom滑动条样式 before:  after:  Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/278 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
723 lines
24 KiB
TypeScript
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(
|
|
'资源已在别处更新;请重新加载后再保存。',
|
|
);
|
|
});
|
|
});
|