Files
k88936 9fd160efc5
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / Native shell tests (pull_request) Successful in 22m4s
合并批量组件绑定历史记录
批处理中的前置绑定批次跳过历史,最后一批使用非跳过替换统一形成撤销边界

为跳过后记录历史的状态基线补充撤销回归测试
2026-09-03 14:42:38 +08:00

752 lines
22 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/requisites';
import type { FontAsset } from '../src/features/ui-editor/types/FontAsset';
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 font(id: string): FontAsset {
return {
asset_id: id,
metadata: {
family_name: '测试字体',
face_name: 'Regular',
weight: 400,
italic: false,
format: 'TrueType',
source_file_name: 'test.ttf',
},
path: `assets/fonts/${id}.ttf`,
content_sha256: 'a'.repeat(64),
};
}
function nodeWithSprite(id: string): Node {
return {
id,
layout: {
transform: {
anchor_min: [0, 0],
anchor_max: [0, 0],
offset_min: [0, 0],
offset_max: [32, 32],
},
custom_minimum_size: [0, 0],
size_flags_horizontal: 1,
size_flags_vertical: 1,
size_flags_stretch_ratio: 1,
container: 'None',
},
metadata: {
name: 'Image',
description: '',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'Llm',
},
components: [
{
Image: {
target_graphic: id,
image_type: { Simple: { preserve_aspect: false } },
},
},
],
children: [],
};
}
function pageRoot(id: string, children: Node[] = []): Node {
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: 'Stack',
children,
};
}
describe('useUiEditorState', () => {
it('moves a subtree between UI trees without changing its transform', () => {
const moved = nodeWithSprite('moved');
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: {
source: image('Source'),
target: image('Target'),
},
ui_trees: [
{ src_ui_design: 'source', root: pageRoot('source-root', [moved]) },
{ src_ui_design: 'target', root: pageRoot('target-root') },
],
};
const { result } = renderHook(() => useUiEditorState(initial));
const originalTransform = structuredClone(moved.layout.transform);
act(() => {
expect(
result.current.moveNode({
sourceTreeId: 'source',
targetTreeId: 'target',
nodeId: 'moved',
targetParentId: 'target-root',
targetIndex: 0,
}),
).toEqual({ ok: true, value: undefined });
});
expect(result.current.state.ui_trees[0]?.root.children).toEqual([]);
expect(result.current.state.ui_trees[1]?.root.children[0]).toMatchObject({
id: 'moved',
layout: { transform: originalTransform },
});
});
it('keeps page position stable when reparenting within one UI tree', () => {
const moved = nodeWithSprite('moved');
moved.layout.transform = {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
};
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
ui_trees: [
{
src_ui_design: 'page',
root: pageRoot('page-root', [
{
...pageRoot('source-parent'),
layout: {
...pageRoot('source-parent').layout,
transform: {
anchor_min: [0, 0],
anchor_max: [0.5, 0.5],
offset_min: [0, 0],
offset_max: [0, 0],
},
},
children: [moved],
},
{
...pageRoot('target-parent'),
layout: {
...pageRoot('target-parent').layout,
transform: {
anchor_min: [0.5, 0.5],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
},
},
]),
},
],
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
expect(
result.current.moveNode({
sourceTreeId: 'page',
targetTreeId: 'page',
nodeId: 'moved',
targetParentId: 'target-parent',
targetIndex: 0,
}),
).toEqual({ ok: true, value: undefined });
});
expect(
result.current.state.ui_trees[0]?.root.children[0]?.children,
).toEqual([]);
expect(
result.current.state.ui_trees[0]?.root.children[1]?.children[0]?.layout
.transform,
).toMatchObject({
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [-50, -40],
offset_max: [-50, -40],
});
});
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(result.current.state.ui_trees).toEqual(
expect.arrayContaining([
expect.objectContaining({
root: expect.objectContaining({
metadata: expect.objectContaining({
layout_status: 'NoProblem',
components_status: 'NoProblem',
}),
}),
}),
]),
);
expect(
validateComponentRecognitionPrerequisites(result.current.state),
).toEqual([]);
});
it('marks manually inserted nodes as passed before component binding', () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
result.current.addDesignImages([{ id: 'page', image: image('Page') }]);
});
const rootId = result.current.state.ui_trees[0]!.root.id;
act(() => {
expect(result.current.insertNode('page', rootId)).toEqual({
ok: true,
value: expect.any(String),
});
});
expect(
result.current.state.ui_trees[0]!.root.children[0]?.metadata,
).toMatchObject({
layout_status: 'NoProblem',
components_status: 'NoProblem',
});
});
it('rejects self-references and slave_to cycles', () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
result.current.addDesignImages([
{ id: 'page', image: image('Page') },
{ id: 'section', image: image('Section') },
]);
result.current.setImageRole('page', 'Page');
result.current.setImageSlaveTo('section', 'page');
});
act(() => {
expect(result.current.setImageSlaveTo('page', 'page')).toEqual({
ok: false,
reason: 'invalid:slave_to 不能形成循环',
});
expect(result.current.setImageSlaveTo('page', 'section')).toEqual({
ok: false,
reason: 'invalid:slave_to 不能形成循环',
});
});
expect(result.current.state.ui_design_images.page?.metadata.slave_to).toBe(
null,
);
});
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',
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: '页面根节点',
description: '',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
},
components: [],
children_display_mode: 'Stack',
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,
clearedFontCount: 0,
},
});
});
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).toHaveLength(1);
expect(result.current.state.ui_design_images.page).toBeUndefined();
expect(
result.current.state.ui_design_images.child?.metadata.slave_to,
).toBeNull();
});
it('merges identical sprite and font resources idempotently and rejects identity conflicts', () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
expect(result.current.addSpriteAssets([sprite('panel')])).toEqual({
ok: true,
value: undefined,
});
expect(result.current.addFontAssets([font('body')])).toEqual({
ok: true,
value: undefined,
});
expect(
result.current.addSpriteAssets([sprite('panel'), sprite('icon')]),
).toEqual({ ok: true, value: undefined });
expect(
result.current.addFontAssets([font('body'), font('title')]),
).toEqual({ ok: true, value: undefined });
});
expect(Object.keys(result.current.state.sprite_assets)).toEqual([
'panel',
'icon',
]);
expect(Object.keys(result.current.state.font_assets)).toEqual([
'body',
'title',
]);
const conflicting = font('body');
conflicting.path = 'assets/fonts/other.ttf';
act(() => {
expect(result.current.addFontAssets([conflicting])).toEqual({
ok: false,
reason: 'duplicate',
});
});
expect(result.current.state.font_assets.body?.path).toBe(
'assets/fonts/body.ttf',
);
});
it('accepts and clears bound Text font references without deleting project files', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
font_assets: { body: font('body') },
ui_design_images: { page: image('Page') },
ui_trees: [
{
src_ui_design: 'page',
root: {
...nodeWithSprite('unused'),
id: 'text-root',
components: [
{
Text: {
content: '你好',
font: { Bound: 'body' },
font_style: 'Normal',
font_sizing: { Fixed: 14 },
color: [255, 255, 255, 255],
alignment: 'UpperLeft',
horizontal_overflow: 'Wrap',
vertical_overflow: 'Truncate',
line_spacing: 1,
},
},
],
},
},
],
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
expect(
result.current.setNodeComponents('page', 'text-root', [
{
Text: {
...initial.ui_trees[0]!.root.components[0]!.Text!,
font: { Bound: 'body' },
},
},
]),
).toEqual({ ok: true, value: undefined });
expect(result.current.removeFontAsset('body', { dryRun: true })).toEqual({
ok: true,
value: {
removedResourceCount: 1,
removedTreeCount: 0,
clearedSlaveToCount: 0,
clearedTargetGraphicCount: 0,
clearedFontCount: 1,
},
});
result.current.removeFontAsset('body', { dryRun: false });
});
expect(result.current.state.font_assets.body).toBeUndefined();
expect(result.current.state.ui_trees[0]?.root.components[0]).toMatchObject({
Text: { font: 'SystemFont' },
});
});
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',
}),
]);
});
it('records, undoes, redoes, and clears redo after a new edit', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.setImageName('page', '第一次');
});
expect(result.current.historyState).toEqual({
canUndo: true,
canRedo: false,
});
act(() => {
expect(result.current.undo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'Page',
);
expect(result.current.historyState).toEqual({
canUndo: false,
canRedo: true,
});
act(() => {
expect(result.current.redo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'第一次',
);
act(() => {
result.current.setImageName('page', '第二次');
expect(result.current.redo()).toBe(false);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'第二次',
);
});
it('does not record no-op edits and resets history when replacing loaded state', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.setImageName('page', 'Page');
});
expect(result.current.historyState.canUndo).toBe(false);
act(() => {
result.current.setImageName('page', '编辑后');
result.current.replaceState(initial, { history: 'reset' });
});
expect(result.current.historyState).toEqual({
canUndo: false,
canRedo: false,
});
act(() => {
result.current.setImageName('page', '清空前');
result.current.clearState();
});
expect(result.current.state).toEqual(EMPTY_UI_EDITOR_STATE);
expect(result.current.historyState).toEqual({
canUndo: false,
canRedo: false,
});
});
it('records each replacement as an independent history entry', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.replaceState({
...initial,
ui_design_images: { page: image('中间') },
});
result.current.replaceState({
...initial,
ui_design_images: { page: image('最终') },
});
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'最终',
);
act(() => {
expect(result.current.undo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'中间',
);
});
it('records one history entry after skipped replacement batches', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.replaceState(
{
...initial,
ui_design_images: { page: image('第一批') },
},
{ history: 'skip' },
);
result.current.replaceState(
{
...initial,
ui_design_images: { page: image('最终') },
},
{ history: 'record' },
);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'最终',
);
expect(result.current.historyState).toEqual({
canUndo: true,
canRedo: false,
});
act(() => {
expect(result.current.undo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'Page',
);
expect(result.current.historyState).toEqual({
canUndo: false,
canRedo: true,
});
});
});