From 16c6b9198e1b761860f0cc52ebdf651540b278d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 10:23:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=B1=E5=8C=96UI=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=8A=B6=E6=80=81=E4=B8=8E=E5=87=A0=E4=BD=95?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 React-free 语义状态迁移 seam 收敛 State 级节点几何与保存前不变量校验 统一四类异步编辑器操作生命周期 补充状态迁移和不变量测试 --- .../ui-editor/nodeTransformGeometry.ts | 28 ++++ .../src/features/ui-editor/stateInvariants.ts | 57 ++++++++ .../src/features/ui-editor/stateTransition.ts | 122 ++++++++++++++++++ .../features/ui-editor/useUiEditorState.ts | 97 ++++---------- .../src/view/ui-editor/operationLifecycle.ts | 27 ++++ .../src/view/ui-editor/useUiEditorPage.ts | 118 +++++++++-------- .../tests/uiEditorState.test.ts | 30 +++++ 7 files changed, 356 insertions(+), 123 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/stateInvariants.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/stateTransition.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/operationLifecycle.ts diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts b/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts index 4a0b09382..1a167ba3c 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts @@ -1,6 +1,8 @@ import type { Node } from './types/Node'; import type { NodeId } from './types/NodeId'; +import type { State } from './types/State'; import type { Transform } from './types/Transform'; +import type { UIDesignImageId } from './types/UIDesignImageId'; export type ResizeAxis = 'horizontal' | 'vertical'; @@ -17,6 +19,32 @@ export type NodePageContext = { parentRect: PageRect; }; +/** State-level geometry seam shared by preview, inspector and transitions. */ +export function findStateNodePageContext( + state: State, + treeId: UIDesignImageId, + nodeId: NodeId, +): NodePageContext | null { + const tree = state.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + const image = state.ui_design_images[treeId]; + if ( + !tree || + !image || + !Number.isFinite(image.pixels_per_unit) || + image.pixels_per_unit <= 0 + ) { + return null; + } + const size: [number, number] = [ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ]; + if (!size.every((value) => Number.isFinite(value) && value > 0)) return null; + return findNodePageContext(tree.root, nodeId, pageRectFromSize(size)); +} + const MIN_NODE_SIZE = 1; export function pageRectFromSize(size: [number, number]): PageRect { diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/stateInvariants.ts b/apps/ai-game-creator-shell/src/features/ui-editor/stateInvariants.ts new file mode 100644 index 000000000..bcac9112a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/stateInvariants.ts @@ -0,0 +1,57 @@ +import type { Node } from './types/Node'; +import type { State } from './types/State'; + +export type UiDesignInvariantIssue = { + code: + | 'missing-image' + | 'duplicate-node' + | 'invalid-image' + | 'missing-tree-image'; + message: string; +}; + +/** Frontend display/save projection of persistable State invariants. + * Rust remains authoritative; this seam only prevents obviously invalid saves + * and gives the view a stable, user-visible failure message. + */ +export function validateUiDesignState(state: State): UiDesignInvariantIssue[] { + const issues: UiDesignInvariantIssue[] = []; + const nodeIds = new Set(); + for (const [id, image] of Object.entries(state.ui_design_images)) { + if ( + image.path.trim() === '' || + !image.pixel_size.every((value) => Number.isFinite(value) && value > 0) || + !Number.isFinite(image.pixels_per_unit) || + image.pixels_per_unit <= 0 + ) { + issues.push({ + code: 'invalid-image', + message: `界面图 ${id} 的尺寸或路径无效`, + }); + } + } + const visit = (node: Node) => { + if (nodeIds.has(node.id)) { + issues.push({ + code: 'duplicate-node', + message: `节点 ID 重复:${node.id}`, + }); + } + nodeIds.add(node.id); + for (const child of node.children) visit(child); + }; + for (const tree of state.ui_trees) { + if (!state.ui_design_images[tree.src_ui_design]) { + issues.push({ + code: 'missing-tree-image', + message: `UI 树引用了缺失界面图:${tree.src_ui_design}`, + }); + } + visit(tree.root); + } + return issues; +} + +export function firstUiDesignInvariantMessage(state: State): string | null { + return validateUiDesignState(state)[0]?.message ?? null; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/stateTransition.ts b/apps/ai-game-creator-shell/src/features/ui-editor/stateTransition.ts new file mode 100644 index 000000000..552e64a2b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/stateTransition.ts @@ -0,0 +1,122 @@ +import type { Component } from './types/Component'; +import type { Node } from './types/Node'; +import type { NodeId } from './types/NodeId'; +import type { NodeMetadata } from './types/NodeMetadata'; +import type { State } from './types/State'; +import type { UIDesignImageId } from './types/UIDesignImageId'; + +export type StateTransitionFailure = + | 'missing' + | 'invalid' + | `invalid:${string}`; + +export type StateTransitionResult = + | { ok: true; state: State } + | { ok: false; reason: StateTransitionFailure }; + +export type UiEditorCommand = + | { + type: 'set-tree-offset'; + treeId: UIDesignImageId; + min: [number, number]; + } + | { + type: 'set-node-metadata'; + treeId: UIDesignImageId; + nodeId: NodeId; + patch: Partial< + Pick< + NodeMetadata, + | 'name' + | 'description' + | 'layout_status' + | 'component_status' + | 'allow_llm_edit_layout' + | 'allow_llm_edit_component' + > + >; + } + | { + type: 'set-node-component'; + treeId: UIDesignImageId; + nodeId: NodeId; + component: Component | null; + }; + +function cloneState(state: State): State { + return structuredClone(state); +} + +function findNode(node: Node, id: NodeId): Node | null { + if (node.id === id) return node; + for (const child of node.children) { + const found = findNode(child, id); + if (found) return found; + } + return null; +} + +function imageLogicalSize( + state: State, + treeId: UIDesignImageId, +): [number, number] | null { + const image = state.ui_design_images[treeId]; + if ( + !image || + !Number.isFinite(image.pixels_per_unit) || + image.pixels_per_unit <= 0 + ) { + return null; + } + const size: [number, number] = [ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ]; + return size.every((value) => Number.isFinite(value) && value > 0) + ? size + : null; +} + +/** + * React-free semantic State transition seam. The hook is an adapter that adds + * locking/history; callers provide a command and receive a complete next State + * or a typed failure, never a partially-mutated tree. + */ +export function applyUiEditorCommand( + current: State, + command: UiEditorCommand, +): StateTransitionResult { + const next = cloneState(current); + const tree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === command.treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + + if (command.type === 'set-tree-offset') { + if (!command.min.every(Number.isFinite)) + return { ok: false, reason: 'invalid' }; + const size = imageLogicalSize(next, command.treeId); + if (!size) return { ok: false, reason: 'invalid' }; + tree.root.offset = { + min: [...command.min], + max: [command.min[0] + size[0], command.min[1] + size[1]], + }; + return { ok: true, state: next }; + } + + const node = findNode(tree.root, command.nodeId); + if (!node) return { ok: false, reason: 'missing' }; + + if (command.type === 'set-node-component') { + node.component = structuredClone(command.component); + node.metadata.component_status = 'NoProblem'; + return { ok: true, state: next }; + } + + if (command.patch.component_status !== undefined && node.component === null) { + const status = command.patch.component_status; + if (status !== 'NoProblem') return { ok: false, reason: 'invalid' }; + } + node.metadata = { ...node.metadata, ...structuredClone(command.patch) }; + return { ok: true, state: next }; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index cb6eecdcd..203e26d28 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -10,6 +10,7 @@ import { resolveReparentTransform, } from './nodeTransformGeometry'; import { validateSpriteBorder } from './spriteBorder'; +import { applyUiEditorCommand } from './stateTransition'; import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode'; import type { Component } from './types/Component'; import type { FontAsset } from './types/FontAsset'; @@ -106,12 +107,6 @@ function visitNodes(node: Node, visit: (node: Node) => void): void { for (const child of node.children) visitNodes(child, visit); } -function isProblematicComponentStatus( - status: NodeMetadata['component_status'], -): boolean { - return typeof status !== 'string'; -} - function existingNodeIds(state: State): Set { const ids = new Set(); for (const tree of state.ui_trees) { @@ -218,10 +213,13 @@ function deriveTreeOffset(state: State, treeId: UIDesignImageId): NodeOffset { const maxX = Math.max( ...existing.map( (tree) => - tree.root.offset.min[0] + treeSize(state, tree.src_ui_design)[0], + (tree.root.offset?.min?.[0] ?? 0) + + treeSize(state, tree.src_ui_design)[0], ), ); - const minY = Math.min(...existing.map((tree) => tree.root.offset.min[1])); + const minY = Math.min( + ...existing.map((tree) => tree.root.offset?.min?.[1] ?? 0), + ); return { min: [maxX + UI_TREE_PADDING, minY], max: [maxX + UI_TREE_PADDING + width, minY + height], @@ -1006,21 +1004,13 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const blocked = guard(); if (blocked) return blocked; if (!min.every(Number.isFinite)) return { ok: false, reason: 'invalid' }; - const current = stateRef.current; - const tree = current.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - ); - if (!tree) return { ok: false, reason: 'missing' }; - const size = treeSize(current, treeId); - const next = cloneState(current); - const nextTree = next.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - )!; - nextTree.root.offset = { - min: [...min], - max: [min[0] + size[0], min[1] + size[1]], - }; - commit(next); + const result = applyUiEditorCommand(stateRef.current, { + type: 'set-tree-offset', + treeId, + min, + }); + if (!result.ok) return result; + commit(result.state); return { ok: true, value: undefined }; }, [commit, guard], @@ -1209,21 +1199,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (component && !isValidComponent(component)) { return { ok: false, reason: 'invalid' }; } - const current = stateRef.current; - const tree = current.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - ); - if (!tree) return { ok: false, reason: 'missing' }; - const location = findNodeLocation(tree.root, nodeId); - if (!location) return { ok: false, reason: 'missing' }; - const next = cloneState(current); - const nextTree = next.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - )!; - const nextNode = findNodeLocation(nextTree.root, nodeId)!.node; - nextNode.component = structuredClone(component); - nextNode.metadata.component_status = 'NoProblem'; - commit(next); + const result = applyUiEditorCommand(stateRef.current, { + type: 'set-node-component', + treeId, + nodeId, + component, + }); + if (!result.ok) return result; + commit(result.state); return { ok: true, value: undefined }; }, [commit, guard], @@ -1237,38 +1220,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { ): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; - const current = stateRef.current; - const tree = current.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - ); - if (!tree) return { ok: false, reason: 'missing' }; - if (!findNodeLocation(tree.root, nodeId)) - return { ok: false, reason: 'missing' }; - const next = cloneState(current); - const node = findNodeLocation( - next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! - .root, + const result = applyUiEditorCommand(stateRef.current, { + type: 'set-node-metadata', + treeId, nodeId, - )!.node; - if ( - patch.component_status !== undefined && - node.component === null && - isProblematicComponentStatus(patch.component_status) - ) { - return { ok: false, reason: 'invalid' }; - } - if (patch.name !== undefined) node.metadata.name = patch.name; - if (patch.description !== undefined) - node.metadata.description = patch.description; - if (patch.layout_status !== undefined) - node.metadata.layout_status = patch.layout_status; - if (patch.component_status !== undefined) - node.metadata.component_status = patch.component_status; - if (patch.allow_llm_edit_layout !== undefined) - node.metadata.allow_llm_edit_layout = patch.allow_llm_edit_layout; - if (patch.allow_llm_edit_component !== undefined) - node.metadata.allow_llm_edit_component = patch.allow_llm_edit_component; - commit(next); + patch, + }); + if (!result.ok) return result; + commit(result.state); return { ok: true, value: undefined }; }, [commit, guard], diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/operationLifecycle.ts b/apps/ai-game-creator-shell/src/view/ui-editor/operationLifecycle.ts new file mode 100644 index 000000000..ecc189c59 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/operationLifecycle.ts @@ -0,0 +1,27 @@ +import { useCallback, useState } from 'react'; + +export type UiEditorOperationLifecycle = { + running: boolean; + status: string | null; + setRunning: (running: boolean) => void; + setStatus: (status: string | null) => void; + begin: () => void; + finish: () => void; + reset: () => void; +}; + +/** Shared async-operation adapter used by suggestion/recognition/merge/separation. */ +export function useUiEditorOperation(): UiEditorOperationLifecycle { + const [running, setRunning] = useState(false); + const [status, setStatus] = useState(null); + const begin = useCallback(() => { + setStatus(null); + setRunning(true); + }, []); + const finish = useCallback(() => setRunning(false), []); + const reset = useCallback(() => { + setRunning(false); + setStatus(null); + }, []); + return { running, status, setRunning, setStatus, begin, finish, reset }; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 7ab25e275..b5be54122 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -8,6 +8,10 @@ import { prepareSpriteAssetBatch, } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; +import { + findStateNodePageContext, + pageRectSize, +} from '../../features/ui-editor/nodeTransformGeometry'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; import { applySeparationProblematicStatuses, @@ -17,6 +21,7 @@ import { getStageStatusOverview, type StageStatusField, } from '../../features/ui-editor/stageStatusOverview'; +import { firstUiDesignInvariantMessage } from '../../features/ui-editor/stateInvariants'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; import type { Component } from '../../features/ui-editor/types/Component'; @@ -75,6 +80,7 @@ import { uiEditorOperationError, type UiEditorStepId, } from './model'; +import { useUiEditorOperation } from './operationLifecycle'; import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; const SEPARATION_IMPORT_BATCH_SIZE = 100; @@ -140,31 +146,6 @@ function isUiNodeEffectivelyVisible( return null; } -function findNodeContext( - node: UiNode, - nodeId: NodeId, - parentSize: { width: number; height: number }, -): { node: UiNode; parentSize: { width: number; height: number } } | null { - if (node.id === nodeId) return { node, parentSize }; - const width = - parentSize.width * - (node.layout.transform.anchor_max[0] - - node.layout.transform.anchor_min[0]) + - node.layout.transform.offset_max[0] - - node.layout.transform.offset_min[0]; - const height = - parentSize.height * - (node.layout.transform.anchor_max[1] - - node.layout.transform.anchor_min[1]) + - node.layout.transform.offset_max[1] - - node.layout.transform.offset_min[1]; - for (const child of node.children) { - const found = findNodeContext(child, nodeId, { width, height }); - if (found) return found; - } - return null; -} - function isSlaveToDescendant( images: State['ui_design_images'], candidateId: UIDesignImageId, @@ -266,16 +247,30 @@ export function useUiEditorSession( const [clearOpen, setClearOpen] = useState(false); const [pendingRemoval, setPendingRemoval] = useState(null); - const [isSuggesting, setIsSuggesting] = useState(false); - const [suggestionStatus, setSuggestionStatus] = useState(null); - const [isRecognizing, setIsRecognizing] = useState(false); - const [recognitionStatus, setRecognitionStatus] = useState( - null, - ); - const [isMerging, setIsMerging] = useState(false); - const [mergeStatus, setMergeStatus] = useState(null); - const [isSeparating, setIsSeparating] = useState(false); - const [separationStatus, setSeparationStatus] = useState(null); + const suggestionOperation = useUiEditorOperation(); + const recognitionOperation = useUiEditorOperation(); + const mergeOperation = useUiEditorOperation(); + const separationOperation = useUiEditorOperation(); + const isSuggesting = suggestionOperation.running; + const suggestionStatus = suggestionOperation.status; + const beginSuggestion = suggestionOperation.begin; + const finishSuggestion = suggestionOperation.finish; + const setSuggestionStatus = suggestionOperation.setStatus; + const isRecognizing = recognitionOperation.running; + const recognitionStatus = recognitionOperation.status; + const beginRecognition = recognitionOperation.begin; + const finishRecognition = recognitionOperation.finish; + const setRecognitionStatus = recognitionOperation.setStatus; + const isMerging = mergeOperation.running; + const mergeStatus = mergeOperation.status; + const beginMerge = mergeOperation.begin; + const finishMerge = mergeOperation.finish; + const setMergeStatus = mergeOperation.setStatus; + const isSeparating = separationOperation.running; + const separationStatus = separationOperation.status; + const beginSeparation = separationOperation.begin; + const finishSeparation = separationOperation.finish; + const setSeparationStatus = separationOperation.setStatus; const [separationRecovery, setSeparationRecovery] = useState(null); const [hasSuggested, setHasSuggested] = useState(false); @@ -586,17 +581,15 @@ export function useUiEditorSession( ); const selectedNodeContext = useMemo(() => { if (!selectedNodeId || !treeForSelectedNode) return null; - const image = images[treeForSelectedNode.src_ui_design]; - if (!image) return null; - const ppu = image.pixels_per_unit; - const width = image.pixel_size[0] / ppu; - const height = image.pixel_size[1] / ppu; - if (!Number.isFinite(width) || !Number.isFinite(height)) return null; - return findNodeContext(treeForSelectedNode.root, selectedNodeId, { - width, - height, - }); - }, [images, selectedNodeId, treeForSelectedNode]); + const context = findStateNodePageContext( + editor.state, + treeForSelectedNode.src_ui_design, + selectedNodeId, + ); + if (!context) return null; + const [width, height] = pageRectSize(context.parentRect); + return { node: context.node, parentSize: { width, height } }; + }, [editor.state, selectedNodeId, treeForSelectedNode]); async function importAssets(imported: ImportedAsset[]) { if (!importKind || !projectPath) return; @@ -997,7 +990,7 @@ export function useUiEditorSession( if (isSuggesting || isWorkflowBusy) return; setCompletionNotice(null); setSuggestionStatus(null); - setIsSuggesting(true); + beginSuggestion(); try { await editor.runWithStateLocked(async (snapshot) => { const suggestions = await invoke( @@ -1021,7 +1014,7 @@ export function useUiEditorSession( setSuggestionStatus, ); } finally { - setIsSuggesting(false); + finishSuggestion(); } } @@ -1029,7 +1022,7 @@ export function useUiEditorSession( if (isRecognizing || isWorkflowBusy) return; setCompletionNotice(null); setRecognitionStatus(null); - setIsRecognizing(true); + beginRecognition(); try { await editor.runWithStateLocked(async (snapshot) => { const result = await invoke('recognize_ui', { @@ -1066,7 +1059,7 @@ export function useUiEditorSession( setRecognitionStatus, ); } finally { - setIsRecognizing(false); + finishRecognition(); } } @@ -1074,7 +1067,7 @@ export function useUiEditorSession( // TODO: This experimental operation is intentionally outside the formal workflow. if (isMerging || isWorkflowBusy) return; setMergeStatus(null); - setIsMerging(true); + beginMerge(); try { await editor.runWithStateLocked(async (snapshot) => { const result = await invoke('merge_ui', { state: snapshot }); @@ -1093,13 +1086,13 @@ export function useUiEditorSession( } catch (cause) { setMergeStatus(cause instanceof Error ? cause.message : String(cause)); } finally { - setIsMerging(false); + finishMerge(); } } async function runSeparationWorkflow() { if (!resourceId || isWorkflowBusy) return; - setIsSeparating(true); + beginSeparation(); setSeparationStatus(null); setCompletionNotice(null); let preparedSprites: Awaited> = @@ -1303,7 +1296,7 @@ export function useUiEditorSession( setSeparationStatus, ); } finally { - setIsSeparating(false); + finishSeparation(); } } @@ -1438,6 +1431,14 @@ export function useUiEditorSession( setIsSaving(true); try { return await editor.runWithStateLocked(async (snapshot) => { + const invariantMessage = firstUiDesignInvariantMessage(snapshot); + if (invariantMessage) { + setSaveError(invariantMessage); + return { + status: 'failed' as const, + message: invariantMessage, + }; + } const snapshotSignature = JSON.stringify(snapshot); const result = await stateStore.save( resourceId, @@ -1514,6 +1515,15 @@ export function useUiEditorSession( setIsGenerating(true); try { return await editor.runWithStateLocked(async (snapshot) => { + const invariantMessage = firstUiDesignInvariantMessage(snapshot); + if (invariantMessage) { + setSaveError(invariantMessage); + return { + status: 'failed' as const, + phase: 'save' as const, + message: invariantMessage, + }; + } const snapshotSignature = JSON.stringify(snapshot); const saved = await stateStore.save( resourceId, diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 248ea7335..1c753c019 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -4,6 +4,8 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { validateComponentRecognitionPrerequisites } from '../src/features/ui-editor/requisites'; +import { validateUiDesignState } from '../src/features/ui-editor/stateInvariants'; +import { applyUiEditorCommand } from '../src/features/ui-editor/stateTransition'; 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'; @@ -117,6 +119,34 @@ function pageRoot(id: string, children: Node[] = []): Node { } describe('useUiEditorState', () => { + it('applies semantic transitions without exposing tree traversal', () => { + const state: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + ui_trees: [{ src_ui_design: 'page', root: pageRoot('root') }], + }; + const result = applyUiEditorCommand(state, { + type: 'set-tree-offset', + treeId: 'page', + min: [12, 24], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.state.ui_trees[0]?.root.offset).toEqual({ + min: [12, 24], + max: [112, 104], + }); + } + }); + + it('reports persistable state invariant failures before save', () => { + const state = structuredClone(EMPTY_UI_EDITOR_STATE); + state.ui_trees = [{ src_ui_design: 'missing', root: pageRoot('root') }]; + expect(validateUiDesignState(state)).toEqual([ + expect.objectContaining({ code: 'missing-tree-image' }), + ]); + }); + it('moves a subtree between UI trees without changing its transform', () => { const moved = nodeWithSprite('moved'); const initial: State = {