Files
Genarrative/apps/ai-game-creator-shell/tests/uiEditorState.test.ts
T
k88936 9ea3b3cfe6 重构 UI 编辑器逻辑以增强页面树操作:
- 添加页面树同步逻辑,确保 `Page` 节点与 UI 树一致。
- 实现节点的新增、删除、移动和元数据更新功能。
- 扩展节点 Inspector,支持查看和修改选中节点的名称、描述和变换属性。
- 重构相关测试,覆盖新的页面树和节点操作功能。
- 引入嵌套节点坐标计算逻辑,确保节点相对位置一致性。
2026-08-18 21:10:27 +08:00

240 lines
7.5 KiB
TypeScript

// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
validateComponentRecognitionPrerequisites,
} from '../src/features/ui-editor/prerequisites';
import type { Node } from '../src/features/ui-editor/types/Node';
import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset';
import type { State } from '../src/features/ui-editor/types/State';
import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage';
import {
EMPTY_UI_EDITOR_STATE,
useUiEditorState,
} from '../src/features/ui-editor/useUiEditorState';
function image(name: string): UIDesignImage {
return {
metadata: { name, role: null, slave_to: null },
path: `assets/${name}.png`,
pixel_size: [100, 80],
pixels_per_unit: 1,
};
}
function sprite(id: string): SpriteAsset {
return {
asset_id: id,
metadata: { name: id, asset_type: '' },
path: `assets/${id}.png`,
pixel_size: [32, 32],
pixels_per_unit: 1,
border: { left: 0, right: 0, top: 0, bottom: 0 },
};
}
function nodeWithSprite(id: string): Node {
return {
id,
transform: {
anchor_min: [0, 0],
anchor_max: [0, 0],
offset_min: [0, 0],
offset_max: [32, 32],
},
metadata: { name: 'Image', description: '', status: 'Passed', source: 'Llm' },
components: [
{
Image: {
target_graphic: id,
image_type: { Simple: { preserve_aspect: false } },
},
},
],
children: [],
};
}
describe('useUiEditorState', () => {
it('exposes focused operations over one shared state', () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
expect(
result.current.addDesignImages([
{ id: 'page-a', image: image('Page A') },
{ id: 'section-a', image: image('Section A') },
]),
).toEqual({ ok: true, value: undefined });
result.current.setImageRole('page-a', 'Page');
result.current.setImageRole('section-a', 'Section');
result.current.setImageSlaveTo('section-a', 'page-a');
result.current.setImageName('section-a', '任务页');
});
expect(result.current.state.ui_design_images['section-a']).toMatchObject({
metadata: { name: '任务页', role: 'Section', slave_to: 'page-a' },
});
expect(validateComponentRecognitionPrerequisites(result.current.state)).toEqual([]);
});
it('adds a batch atomically and rejects duplicates and limits', () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
result.current.addDesignImages([
{ id: 'a', image: image('A') },
{ id: 'b', image: image('B') },
{ id: 'c', image: image('C') },
]);
});
const beforeFailure = structuredClone(result.current.state);
act(() => {
expect(
result.current.addDesignImages([
{ id: 'd', image: image('D') },
{ id: 'e', image: image('E') },
]),
).toEqual({ ok: false, reason: 'limit' });
});
expect(result.current.state).toEqual(beforeFailure);
act(() => {
expect(
result.current.addDesignImages([{ id: 'a', image: image('Again') }]),
).toEqual({ ok: false, reason: 'duplicate' });
});
expect(result.current.state).toEqual(beforeFailure);
expect(result.current.state).toEqual(beforeFailure);
});
it('owns the async State lock and always releases it', async () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
result.current.addDesignImages([{ id: 'a', image: image('A') }]);
});
let release: (() => void) | undefined;
const waitForRelease = new Promise<void>((resolve) => {
release = resolve;
});
let operation: Promise<string> | undefined;
await act(async () => {
operation = result.current.runWithStateLocked(async (snapshot) => {
expect(snapshot).toEqual(result.current.state);
expect(snapshot).not.toBe(result.current.state);
await waitForRelease;
return 'recognized';
});
await Promise.resolve();
});
expect(result.current.isLocked).toBe(true);
act(() => {
expect(result.current.setImageName('a', 'Locked')).toEqual({
ok: false,
reason: 'locked',
});
});
await expect(
result.current.runWithStateLocked(async () => undefined),
).rejects.toThrow('UI editor State is already locked');
await act(async () => {
release?.();
await operation;
});
expect(result.current.isLocked).toBe(false);
await act(async () => {
await expect(
result.current.runWithStateLocked(async () => {
throw new Error('recognition failed');
}),
).rejects.toThrow('recognition failed');
});
expect(result.current.isLocked).toBe(false);
});
it('dry-runs and then fully cleans referenced resources', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: {
page: { ...image('Page'), metadata: { name: 'Page', role: 'Page', slave_to: null } },
child: { ...image('Child'), metadata: { name: 'Child', role: 'Section', slave_to: 'page' } },
},
sprite_assets: { panel: sprite('panel') },
ui_trees: [
{
src_ui_design: 'page',
root: {
id: 'page-root',
transform: {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
metadata: {
name: '页面根节点',
description: '',
status: 'Passed',
source: 'System',
},
components: [],
children: [nodeWithSprite('panel')],
},
},
],
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
expect(result.current.removeSpriteAsset('panel', { dryRun: true })).toEqual({
ok: true,
value: {
removedResourceCount: 1,
removedTreeCount: 0,
clearedSlaveToCount: 0,
clearedTargetGraphicCount: 1,
},
});
});
expect(result.current.state.sprite_assets.panel).toBeDefined();
act(() => {
result.current.removeSpriteAsset('panel', { dryRun: false });
result.current.removeDesignImage('page', { dryRun: false });
});
expect(result.current.state.sprite_assets.panel).toBeUndefined();
expect(result.current.state.ui_trees).toEqual([]);
expect(result.current.state.ui_design_images.page).toBeUndefined();
expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBeNull();
});
it('keeps setters local and defers workflow errors to prerequisites', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: {
page: { ...image('Page'), metadata: { name: 'Page', role: 'Page', slave_to: null } },
child: { ...image('Child'), metadata: { name: 'Child', role: 'Section', slave_to: 'page' } },
},
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.setImageRole('page', null);
});
expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBe('page');
expect(validateComponentRecognitionPrerequisites(result.current.state)).toEqual([
expect.objectContaining({ code: 'invalid-slave-to', resourceId: 'child' }),
]);
});
});