// @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', component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'Llm', }, component: { 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', component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, component: null, 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', component_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', component_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((resolve) => { release = resolve; }); let operation: Promise | 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', component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, component: null, 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', component: { 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.setNodeComponent('page', 'text-root', { Text: { ...('Text' in initial.ui_trees[0]!.root.component! ? initial.ui_trees[0]!.root.component!.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.component).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, }); }); });