Files
Genarrative/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
T
k88936 901534519f 优化叠加模式子节点显示
从互斥模式切回叠加模式时恢复全部直接子节点可见

补充模式切换回归测试并同步实施合同
2026-08-20 11:42:27 +08:00

475 lines
16 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 {
UiDesignStateSnapshot,
UiDesignStateStore,
} 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,
};
}
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: UiDesignStateStore = {
load: vi.fn().mockResolvedValue({ revision: 0, state }),
save: vi.fn(),
};
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('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('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('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: UiDesignStateStore = {
load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)),
save: 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('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);
});
});