From e0c034a49bccf323c44106c035fb14fd13bcb4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 17 Aug 2026 15:57:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EUI=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E6=A6=82=E8=A7=88=E4=B8=8E=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 递归统计布局识别状态并显示识别概览。 支持待检查和阻塞节点的循环跨界面聚焦。 抽取通用状态查询与一次性聚焦请求并补充测试和约定。 --- .../src/features/ui-editor/AGENTS.md | 4 + .../features/ui-editor/stageStatusOverview.ts | 85 ++++++++++ .../ui-editor/components/InputSidebar.tsx | 19 ++- .../components/RecognitionOverview.tsx | 145 ++++++++++++++++++ .../view/ui-editor/components/UiTreePanel.tsx | 20 ++- .../components/preview/PreviewWorkspace.tsx | 46 +++++- .../src/view/ui-editor/model.ts | 8 + .../view/ui-editor/useUiEditorNodeFocus.ts | 38 +++++ .../src/view/ui-editor/useUiEditorPage.ts | 29 +++- .../tests/stageStatusOverview.test.ts | 89 +++++++++++ 10 files changed, 468 insertions(+), 15 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/RecognitionOverview.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorNodeFocus.ts create mode 100644 apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md b/apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md index 982fed804..3e171131c 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md +++ b/apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md @@ -29,3 +29,7 @@ - 阶段命令可以只返回该阶段的 DTO;调用方负责将 DTO 显式合并到同一个内存 `State`。仅用于调试的验证入口与正式工作流分离。 - 节点布局与组件是独立通道:`layout_status` / `components_status` 分别记录阶段状态,`allow_llm_edit_layout` / `allow_llm_edit_component` 分别授权 LLM 修改;人工编辑不隐式改变状态或授权。页面根节点可承载组件,但其 Transform 不可修改。 + +- “组件结构”阶段显示“识别概览”,递归统计 `State.ui_trees` 中全部 Node,不按 `source` 过滤;“已识别 Node”为全部节点数,“待用户检查”为 `layout_status` 是 `NeedReview` 或 `Blocked` 的数量,“已通过”为 `Passed` 数量,“必须修复”为 `Blocked` 数量。`components_status` 的阶段概览属于后续“绑定概览”。 +- “待用户检查”和“必须修复”分别按 `ui_trees` 顺序及树的前序遍历循环定位下一项;各自游标只属于概览组件的临时 UI 状态,树变化后重置,不写入正式 `State`。 +- 概览定位 Node 时必须统一完成所属界面图激活、节点选择、Inspector 展示、UI Tree 祖先展开与滚动,以及预览画布对节点的带留白适配;状态查询与定位行为应可复用,不在统计卡点击处理器内复制树遍历和跨面板协调。 diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts b/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts new file mode 100644 index 000000000..18651e830 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts @@ -0,0 +1,85 @@ +import type { Node as UiNode } from './types/Node'; +import type { NodeMetadata } from './types/NodeMetadata'; +import type { StageStatus } from './types/StageStatus'; +import type { UIDesignImageId } from './types/UIDesignImageId'; +import type { UITree } from './types/UITree'; + +export type StageStatusField = Extract< + keyof NodeMetadata, + 'layout_status' | 'components_status' +>; + +export type UiTreeNodeTarget = { + treeId: UIDesignImageId; + node: UiNode; +}; + +export type StageStatusOverview = { + total: number; + needsAttention: number; + passed: number; + blocked: number; +}; + +export function isNeedReview(status: StageStatus): boolean { + return typeof status === 'object' && 'NeedReview' in status; +} + +export function collectUiTreeNodeTargets( + uiTrees: UITree[], +): UiTreeNodeTarget[] { + const targets: UiTreeNodeTarget[] = []; + + const visit = (treeId: UIDesignImageId, node: UiNode) => { + targets.push({ treeId, node }); + for (const child of node.children) visit(treeId, child); + }; + + for (const tree of uiTrees) visit(tree.src_ui_design, tree.root); + return targets; +} + +export function getStageStatusOverview( + uiTrees: UITree[], + field: StageStatusField, +): StageStatusOverview { + const overview: StageStatusOverview = { + total: 0, + needsAttention: 0, + passed: 0, + blocked: 0, + }; + + for (const { node } of collectUiTreeNodeTargets(uiTrees)) { + const status = node.metadata[field]; + overview.total += 1; + if (status === 'Passed') overview.passed += 1; + if (status === 'Blocked') overview.blocked += 1; + if (status === 'Blocked' || isNeedReview(status)) { + overview.needsAttention += 1; + } + } + + return overview; +} + +export function getStageStatusTargets( + uiTrees: UITree[], + field: StageStatusField, + matches: (status: StageStatus) => boolean, +): UiTreeNodeTarget[] { + return collectUiTreeNodeTargets(uiTrees).filter(({ node }) => + matches(node.metadata[field]), + ); +} + +export function getNextUiTreeNodeTarget( + targets: UiTreeNodeTarget[], + previousNodeId: string | null, +): UiTreeNodeTarget | null { + if (targets.length === 0) return null; + const previousIndex = targets.findIndex( + ({ node }) => node.id === previousNodeId, + ); + return targets[(previousIndex + 1) % targets.length] ?? null; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 175f9e189..4021ad86c 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -2,6 +2,7 @@ import { Image as ImageIcon, Plus, ScanSearch } from 'lucide-react'; import type { UiEditorPageController } from '../useUiEditorPage'; import { ImportOverview } from './ImportOverview'; +import { RecognitionOverview } from './RecognitionOverview'; import { UiTreePanel } from './UiTreePanel'; export function InputSidebar({ @@ -204,17 +205,25 @@ export function InputSidebar({ - + {activeTool === 'components' ? ( + + ) : activeTool === 'input' ? ( + + ) : null} {activeTool !== 'input' ? ( { controller.insertNode(parentId); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/RecognitionOverview.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/RecognitionOverview.tsx new file mode 100644 index 000000000..48743c282 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/RecognitionOverview.tsx @@ -0,0 +1,145 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { + getNextUiTreeNodeTarget, + getStageStatusOverview, + getStageStatusTargets, + isNeedReview, +} from '../../../features/ui-editor/stageStatusOverview'; +import type { NodeId } from '../../../features/ui-editor/types/NodeId'; +import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; +import type { UITree } from '../../../features/ui-editor/types/UITree'; + +export function RecognitionOverview({ + uiTrees, + onFocusNode, +}: { + uiTrees: UITree[]; + onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void; +}) { + const [lastAttentionNodeId, setLastAttentionNodeId] = useState( + null, + ); + const [lastBlockedNodeId, setLastBlockedNodeId] = useState( + null, + ); + const overview = useMemo( + () => getStageStatusOverview(uiTrees, 'layout_status'), + [uiTrees], + ); + const attentionTargets = useMemo( + () => + getStageStatusTargets( + uiTrees, + 'layout_status', + (status) => status === 'Blocked' || isNeedReview(status), + ), + [uiTrees], + ); + const blockedTargets = useMemo( + () => + getStageStatusTargets( + uiTrees, + 'layout_status', + (status) => status === 'Blocked', + ), + [uiTrees], + ); + + useEffect(() => { + setLastAttentionNodeId(null); + setLastBlockedNodeId(null); + }, [uiTrees]); + + const focusNextAttention = () => { + const target = getNextUiTreeNodeTarget( + attentionTargets, + lastAttentionNodeId, + ); + if (!target) return; + setLastAttentionNodeId(target.node.id); + onFocusNode(target.treeId, target.node.id); + }; + + const focusNextBlocked = () => { + const target = getNextUiTreeNodeTarget(blockedTargets, lastBlockedNodeId); + if (!target) return; + setLastBlockedNodeId(target.node.id); + onFocusNode(target.treeId, target.node.id); + }; + + return ( +
+ + Overview + +

识别概览

+
+ + + + +
+
+ ); +} + +function OverviewValue({ label, value }: { label: string; value: number }) { + return ( +
+ + {value} + + + {label} + +
+ ); +} + +function OverviewAction({ + label, + value, + tone, + disabled, + onClick, +}: { + label: string; + value: number; + tone: 'warning' | 'danger'; + disabled: boolean; + onClick: () => void; +}) { + const toneClass = + tone === 'danger' + ? 'border-red-200 bg-red-50 text-red-800 hover:border-red-300 hover:bg-red-100' + : 'border-amber-200 bg-amber-50 text-amber-900 hover:border-amber-300 hover:bg-amber-100'; + + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx index 91faa8798..a7aeb9c00 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -1,20 +1,28 @@ import { ChevronDown, Layers3 } from 'lucide-react'; -import { useEffect, useState, type MouseEvent as ReactMouseEvent } from 'react'; import { - Tree, + type MouseEvent as ReactMouseEvent, + useEffect, + useRef, + useState, +} from 'react'; +import { adjustMoveIndex, type MoveHandler, type NodeRendererProps, + Tree, + type TreeApi, } from 'react-arborist'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; +import type { UiEditorNodeFocusRequest } from '../model'; type UiTreePanelProps = { treeId: UIDesignImageId | null; root: UiNode | null; selectedNodeId: NodeId | null; + focusRequest: UiEditorNodeFocusRequest | null; onSelectNode: (id: NodeId) => void; onInsertNode: (parentId: NodeId) => void; onInsertNodeAfter: (nodeId: NodeId) => void; @@ -86,12 +94,14 @@ export function UiTreePanel({ treeId, root, selectedNodeId, + focusRequest, onSelectNode, onInsertNode, onInsertNodeAfter, onDeleteNode, onMoveNode, }: UiTreePanelProps) { + const treeApiRef = useRef | undefined>(undefined); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; @@ -109,6 +119,11 @@ export function UiTreePanel({ }; }, [contextMenu]); + useEffect(() => { + if (!focusRequest || focusRequest.treeId !== treeId) return; + void treeApiRef.current?.scrollTo(focusRequest.nodeId, 'center'); + }, [focusRequest, treeId]); + const handleMove: MoveHandler = ({ dragIds, parentId, @@ -143,6 +158,7 @@ export function UiTreePanel({
{root ? ( + ref={treeApiRef} data={[root]} width="100%" height={256} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 34b49300d..4c244010b 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -23,6 +23,7 @@ import { } from 'react'; import type { UiEditorPageController } from '../../useUiEditorPage'; +import { findNodePageContext } from './nodeTransformGeometry'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; @@ -34,6 +35,7 @@ export function PreviewWorkspace({ const { editor, activeImage, activeImageId, previewUrls } = controller; const viewportElementRef = useRef(null); const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 }); + const handledFocusRequestIdRef = useRef(null); const panRef = useRef | null>(null); const [viewport, setViewportState] = useState( viewportRef.current, @@ -120,11 +122,53 @@ export function PreviewWorkspace({ useEffect(() => { fitToCanvas(); - controller.clearNodeSelection(); // This effect intentionally follows the active image, not every controller render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeImageId, fitToCanvas]); + useEffect(() => { + const request = controller.focusRequest; + if ( + !request || + request.requestId === handledFocusRequestIdRef.current || + request.treeId !== activeImageId || + !tree || + !logicalSize + ) { + return; + } + const context = findNodePageContext(tree.root, request.nodeId, { + min: [0, 0], + max: [logicalSize.width, logicalSize.height], + }); + if (!context) return; + const element = viewportElementRef.current; + const size = { + width: element?.clientWidth || canvasSize.width, + height: element?.clientHeight || canvasSize.height, + }; + handledFocusRequestIdRef.current = request.requestId; + setViewport( + fitViewportToBounds({ + bounds: { + x: context.rect.min[0], + y: context.rect.min[1], + width: context.rect.max[0] - context.rect.min[0], + height: context.rect.max[1] - context.rect.min[1], + }, + canvasSize: size, + }), + ); + }, [ + activeImageId, + canvasSize.height, + canvasSize.width, + controller.focusRequest, + logicalSize, + setViewport, + tree, + ]); + useEffect(() => { const isEditableTarget = (target: EventTarget | null) => { const element = target instanceof HTMLElement ? target : null; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts index 9139e837f..4959833c7 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts @@ -1,9 +1,17 @@ +import type { NodeId } from '../../features/ui-editor/types/NodeId'; +import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole'; import type { RemovalImpact } from '../../features/ui-editor/useUiEditorState'; export type UiEditorToolId = 'input' | 'components' | 'assets' | 'layout'; export type UiEditorImportKind = 'design-image' | 'sprite'; +export type UiEditorNodeFocusRequest = { + treeId: UIDesignImageId; + nodeId: NodeId; + requestId: number; +}; + export type PendingResourceRemoval = { kind: UiEditorImportKind; id: string; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorNodeFocus.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorNodeFocus.ts new file mode 100644 index 000000000..fa7b1e702 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorNodeFocus.ts @@ -0,0 +1,38 @@ +import { useCallback, useRef, useState } from 'react'; + +import type { NodeId } from '../../features/ui-editor/types/NodeId'; +import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; +import type { UiEditorNodeFocusRequest } from './model'; + +export function useUiEditorNodeFocus({ + activateImage, + clearSpriteSelection, + selectNode, +}: { + activateImage: (id: UIDesignImageId) => void; + clearSpriteSelection: () => void; + selectNode: (id: NodeId) => void; +}) { + const nextRequestIdRef = useRef(0); + const [focusRequest, setFocusRequest] = + useState(null); + + const focusNode = useCallback( + (treeId: UIDesignImageId, nodeId: NodeId) => { + activateImage(treeId); + clearSpriteSelection(); + selectNode(nodeId); + nextRequestIdRef.current += 1; + setFocusRequest({ + treeId, + nodeId, + requestId: nextRequestIdRef.current, + }); + }, + [activateImage, clearSpriteSelection, selectNode], + ); + + const clearFocusRequest = useCallback(() => setFocusRequest(null), []); + + return { focusRequest, focusNode, clearFocusRequest }; +} 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 dd546edc7..c3e6398bc 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 @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ImportedAsset } from '../../components/AssetImporter'; +import { applyBindingResult } from '../../features/ui-editor/binding'; import { prepareDesignImageBatch, prepareSpriteAssetBatch, @@ -14,7 +15,6 @@ import { validateLayoutReviewPrerequisites, } from '../../features/ui-editor/prerequisites'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; -import { applyBindingResult } from '../../features/ui-editor/binding'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { Component } from '../../features/ui-editor/types/Component'; import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO'; @@ -44,6 +44,7 @@ import { uiEditorOperationError, type UiEditorToolId, } from './model'; +import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; const ASSET_BATCH_SIZE = 5; @@ -62,6 +63,11 @@ export function useUiEditorPage( const [selectedSpriteId, setSelectedSpriteId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); + const { focusRequest, focusNode, clearFocusRequest } = useUiEditorNodeFocus({ + activateImage: setActiveImageId, + clearSpriteSelection: () => setSelectedSpriteId(null), + selectNode: setSelectedNodeId, + }); const [keepChildrenUnchanged, setKeepChildrenUnchanged] = useState(false); const [importKind, setImportKind] = useState(null); const [previewUrls, setPreviewUrls] = useState>({}); @@ -82,6 +88,7 @@ export function useUiEditorPage( const [mergeStatus, setMergeStatus] = useState(null); const [isBinding, setIsBinding] = useState(false); const [bindingStatus, setBindingStatus] = useState(null); + const { replaceState } = editor; useEffect(() => { if (!resourceId) { @@ -94,7 +101,7 @@ export function useUiEditorPage( .load(resourceId) .then((state) => { if (!cancelled) { - editor.replaceState(state); + replaceState(state); } }) .catch((cause: unknown) => { @@ -110,7 +117,7 @@ export function useUiEditorPage( return () => { cancelled = true; }; - }, [editor.replaceState, resourceId, stateStore]); + }, [replaceState, resourceId, stateStore]); const images = editor.state.ui_design_images; const sprites = editor.state.sprite_assets; @@ -289,6 +296,7 @@ export function useUiEditorPage( } function clearState() { + clearFocusRequest(); editor.clearState(); setImageOrder([]); setActiveImageId(null); @@ -300,22 +308,26 @@ export function useUiEditorPage( } function selectTool(tool: UiEditorToolId) { + clearFocusRequest(); setActiveTool(tool); setIssues(null); } function selectDesignImage(id: UIDesignImageId) { + clearFocusRequest(); setActiveImageId(id); setSelectedSpriteId(null); setSelectedNodeId(null); } function selectNode(id: NodeId) { + clearFocusRequest(); setSelectedSpriteId(null); setSelectedNodeId(id); } function clearNodeSelection() { + clearFocusRequest(); setSelectedNodeId(null); } @@ -441,6 +453,7 @@ export function useUiEditorPage( } function selectSprite(id: SpriteAssetId) { + clearFocusRequest(); setSelectedNodeId(null); setSelectedSpriteId(id); } @@ -562,9 +575,7 @@ export function useUiEditorPage( index < allSpriteIds.length; index += ASSET_BATCH_SIZE ) { - batches.push( - allSpriteIds.slice(index, index + ASSET_BATCH_SIZE), - ); + batches.push(allSpriteIds.slice(index, index + ASSET_BATCH_SIZE)); } if (batches.length === 0) batches.push([]); let current = snapshot; @@ -578,7 +589,9 @@ export function useUiEditorPage( current = applyBindingResult(current, result); editor.replaceState(current); } - setBindingStatus(`组件绑定完成(${batches.length}/${batches.length})。`); + setBindingStatus( + `组件绑定完成(${batches.length}/${batches.length})。`, + ); }); } catch (cause) { setBindingStatus(cause instanceof Error ? cause.message : String(cause)); @@ -598,6 +611,7 @@ export function useUiEditorPage( activeImageId, selectedSpriteId, selectedNodeId, + focusRequest, importKind, previewUrls, status, @@ -619,6 +633,7 @@ export function useUiEditorPage( selectDesignImage, selectSprite, selectNode, + focusNode, clearNodeSelection, setNodeTransform, updateNodeTransform, diff --git a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts new file mode 100644 index 000000000..db6108fa9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectUiTreeNodeTargets, + getNextUiTreeNodeTarget, + getStageStatusOverview, + getStageStatusTargets, + isNeedReview, +} from '../src/features/ui-editor/stageStatusOverview'; +import type { Node } from '../src/features/ui-editor/types/Node'; +import type { StageStatus } from '../src/features/ui-editor/types/StageStatus'; +import type { UITree } from '../src/features/ui-editor/types/UITree'; + +function node(id: string, status: StageStatus, children: Node[] = []): Node { + return { + id, + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + metadata: { + name: id, + description: '', + layout_status: status, + components_status: 'Pending', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'System', + }, + components: [], + children, + }; +} + +const trees: UITree[] = [ + { + src_ui_design: 'page-a', + root: node('root-a', 'Passed', [ + node('review-a', { NeedReview: '请检查' }), + node('blocked-a', 'Blocked'), + ]), + }, + { + src_ui_design: 'page-b', + root: node('root-b', 'Pending', [node('passed-b', 'Passed')]), + }, +]; + +describe('stageStatusOverview', () => { + it('counts all nodes and keeps the agreed overlapping status totals', () => { + expect(getStageStatusOverview(trees, 'layout_status')).toEqual({ + total: 5, + needsAttention: 2, + passed: 2, + blocked: 1, + }); + }); + + it('uses tree order and preorder traversal for navigation targets', () => { + expect(collectUiTreeNodeTargets(trees).map(({ node }) => node.id)).toEqual([ + 'root-a', + 'review-a', + 'blocked-a', + 'root-b', + 'passed-b', + ]); + }); + + it('cycles from the previous target and restarts when it disappeared', () => { + const targets = getStageStatusTargets( + trees, + 'layout_status', + (status) => status === 'Blocked' || isNeedReview(status), + ); + + expect(getNextUiTreeNodeTarget(targets, null)?.node.id).toBe('review-a'); + expect(getNextUiTreeNodeTarget(targets, 'review-a')?.node.id).toBe( + 'blocked-a', + ); + expect(getNextUiTreeNodeTarget(targets, 'blocked-a')?.node.id).toBe( + 'review-a', + ); + expect(getNextUiTreeNodeTarget(targets, 'removed')?.node.id).toBe( + 'review-a', + ); + }); +});