80ff184605
新增 Rust 字体校验、复制登记与安全读取链路 新增项目字体面板、Text 字体绑定和预览回退 清理废弃 UI 设计持久化入口并补齐测试文档
382 lines
11 KiB
TypeScript
382 lines
11 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 { 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,
|
|
transform: {
|
|
anchor_min: [0, 0],
|
|
anchor_max: [0, 0],
|
|
offset_min: [0, 0],
|
|
offset_max: [32, 32],
|
|
},
|
|
metadata: {
|
|
name: 'Image',
|
|
description: '',
|
|
layout_status: 'Passed',
|
|
components_status: 'Pending',
|
|
allow_llm_edit_layout: true,
|
|
allow_llm_edit_component: true,
|
|
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: '',
|
|
layout_status: 'Passed',
|
|
components_status: 'Pending',
|
|
allow_llm_edit_layout: true,
|
|
allow_llm_edit_component: true,
|
|
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,
|
|
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).toEqual([]);
|
|
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('dry-runs and clears 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: 'body',
|
|
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.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: null },
|
|
});
|
|
});
|
|
|
|
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',
|
|
}),
|
|
]);
|
|
});
|
|
});
|