diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift index 68f8fc9d..54288f56 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift @@ -61,6 +61,15 @@ extension CatalogTreeNode { } } +/// 侧栏树的可见行投影:节点 + 呈现深度;身份即节点身份(折叠只影响可见 +/// 集合,不影响行身份与选中)。服务器管理侧栏与首页目标树共用同一类型, +/// 折叠集合由各自的浏览状态 owner 给出。 +struct CatalogTreeRow: Identifiable { + let node: CatalogTreeNode + let depth: Int + var id: NodeID { node.id } +} + /// 目录树快照:侧栏、活动目标级联、移动目的地与删除确认共用的同一份 /// 非敏感 projection;UI 不再持有原始目录副本(issue #41)。 struct CatalogTreeSnapshot: Equatable { @@ -97,6 +106,32 @@ struct CatalogTreeSnapshot: Equatable { } } +extension CatalogTreeSnapshot { + /// 折叠投影后的可见行(深度优先):收起分组的子树不出现,其余保持目录 + /// 序。行序与深度是纯树事实;折叠集合由调用方按自己的持久化策略持有。 + func visibleRows(collapsed: Set) -> [CatalogTreeRow] { + var rows: [CatalogTreeRow] = [] + func walk(_ nodes: [CatalogTreeNode], depth: Int) { + for node in nodes { + rows.append(CatalogTreeRow(node: node, depth: depth)) + if node.isGroup, !collapsed.contains(node.id) { + walk(node.childNodes, depth: depth + 1) + } + } + } + walk(roots, depth: 0) + return rows + } + + /// 全树服务器叶子数(工作区侧栏角标)。 + var serverLeafCount: Int { + func leaves(_ nodes: [CatalogTreeNode]) -> Int { + nodes.reduce(0) { $0 + ($1.isGroup ? leaves($1.childNodes) : 1) } + } + return leaves(roots) + } +} + extension CatalogTreeSnapshot { /// 从已提交目录构建 projection(module 内部推导;测试同 target 可直接调用)。 /// 每个服务器叶子求值一次激活校验(与既有口径一致,读凭据存储)。 diff --git a/ShadowsocksX-NG2/App/HomeServerListState.swift b/ShadowsocksX-NG2/App/HomeServerListState.swift index a4f37eab..0d8f2228 100644 --- a/ShadowsocksX-NG2/App/HomeServerListState.swift +++ b/ShadowsocksX-NG2/App/HomeServerListState.swift @@ -1,12 +1,6 @@ import Combine import Foundation -struct HomeServerTreeRow: Identifiable { - let node: CatalogTreeNode - let depth: Int - var id: NodeID { node.id } -} - /// 首页独立的浏览状态。选择与折叠不改变目录或运行时活动目标。 @MainActor final class HomeServerListState: ObservableObject { @@ -34,18 +28,9 @@ final class HomeServerListState: ObservableObject { } } - var visibleRows: [HomeServerTreeRow] { - var rows: [HomeServerTreeRow] = [] - func walk(_ nodes: [CatalogTreeNode], depth: Int) { - for node in nodes { - rows.append(HomeServerTreeRow(node: node, depth: depth)) - if node.isGroup, !collapsedGroupIDs.contains(node.id) { - walk(node.childNodes, depth: depth + 1) - } - } - } - walk(tree.roots, depth: 0) - return rows + /// 可见行投影委托树快照;折叠集合由本状态的持久化策略持有。 + var visibleRows: [CatalogTreeRow] { + tree.visibleRows(collapsed: collapsedGroupIDs) } func update(tree: CatalogTreeSnapshot, activeTargetID: NodeID?) { diff --git a/ShadowsocksX-NG2/App/HomeServerListView.swift b/ShadowsocksX-NG2/App/HomeServerListView.swift index 0d79f08f..0c3922ec 100644 --- a/ShadowsocksX-NG2/App/HomeServerListView.swift +++ b/ShadowsocksX-NG2/App/HomeServerListView.swift @@ -192,7 +192,7 @@ struct TargetTreeCard: View { private struct HomeServerListRow: View { @Environment(\.controlActiveState) private var controlActiveState - let row: HomeServerTreeRow + let row: CatalogTreeRow let eligibility: ActivationEligibility? let isSelected: Bool let isActive: Bool diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 9f907356..5c0a96a0 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -340,10 +340,7 @@ extension MainWindowView { } private var serverLeafCount: Int { - func leaves(_ nodes: [CatalogTreeNode]) -> Int { - nodes.reduce(0) { $0 + ($1.isGroup ? leaves($1.childNodes) : 1) } - } - return leaves(workflow.tree.roots) + workflow.tree.serverLeafCount } // MARK: - 底部代理状态卡 diff --git a/ShadowsocksX-NG2/App/ServersView.swift b/ShadowsocksX-NG2/App/ServersView.swift index a9892b4f..588f7a74 100644 --- a/ShadowsocksX-NG2/App/ServersView.swift +++ b/ShadowsocksX-NG2/App/ServersView.swift @@ -91,7 +91,7 @@ struct ServersView: View { private var serverSidebar: some View { List(selection: $selection) { - ForEach(visibleRows) { row in + ForEach(workflow.tree.visibleRows(collapsed: expansion.collapsedGroupIDs)) { row in treeRow(row) } } @@ -113,21 +113,6 @@ struct ServersView: View { .toolbar { toolbarContent } } - /// 折叠投影后的可见行(深度优先):收起分组的子树不出现,其余保持目录序。 - private var visibleRows: [CatalogTreeRow] { - var rows: [CatalogTreeRow] = [] - func walk(_ nodes: [CatalogTreeNode], depth: Int) { - for node in nodes { - rows.append(CatalogTreeRow(node: node, depth: depth)) - if node.isGroup, !expansion.isCollapsed(node.id) { - walk(node.childNodes, depth: depth + 1) - } - } - } - walk(workflow.tree.roots, depth: 0) - return rows - } - private func treeRow(_ row: CatalogTreeRow) -> some View { let node = row.node return HStack(spacing: 4) { @@ -354,11 +339,3 @@ private struct MoveContext: Identifiable { let nodeID: NodeID var id: NodeID { nodeID } } - -/// 侧栏树的可见行投影:节点 + 呈现深度;身份即节点身份(折叠只影响可见集合, -/// 不影响行身份与选中)。 -private struct CatalogTreeRow: Identifiable { - let node: CatalogTreeNode - let depth: Int - var id: NodeID { node.id } -} diff --git a/ShadowsocksX-NG2/Tests/CatalogTreeProjectionTests.swift b/ShadowsocksX-NG2/Tests/CatalogTreeProjectionTests.swift new file mode 100644 index 00000000..363c00fa --- /dev/null +++ b/ShadowsocksX-NG2/Tests/CatalogTreeProjectionTests.swift @@ -0,0 +1,71 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// CatalogTreeSnapshot 树投影的纯单元测试:折叠可见行与服务器叶子计数。 +/// 直接构造 snapshot(memberwise init),不经工作流。 +final class CatalogTreeProjectionTests: XCTestCase { + private func server(_ id: String) -> CatalogTreeNode { + CatalogTreeNode( + id: NodeID(rawValue: id), name: id, isGroup: false, source: .manual, parentID: nil, + invalidReasons: [], childCount: 0, subtreeNodeCount: 0, invalidDescendantCount: 0, + children: nil) + } + + private func group(_ id: String, children: [CatalogTreeNode]) -> CatalogTreeNode { + CatalogTreeNode( + id: NodeID(rawValue: id), name: id, isGroup: true, source: .manual, parentID: nil, + invalidReasons: [], childCount: children.count, + subtreeNodeCount: children.reduce(0) { $0 + 1 + $1.subtreeNodeCount }, + invalidDescendantCount: 0, children: children) + } + + /// 两层嵌套 + 根叶子 + 空组:覆盖深度、子树跳过与空组语义。 + private func nestedTree() -> CatalogTreeSnapshot { + CatalogTreeSnapshot(roots: [ + server("root-server"), + group( + "outer", + children: [ + group("inner", children: [server("deep-leaf")]), + server("outer-leaf"), + ]), + group("empty", children: []), + ]) + } + + // MARK: - 可见行折叠投影 + + func testVisibleRowsWalkDepthFirstWithDepth() { + let rows = nestedTree().visibleRows(collapsed: []) + XCTAssertEqual( + rows.map(\.id.rawValue), + ["root-server", "outer", "inner", "deep-leaf", "outer-leaf", "empty"]) + XCTAssertEqual(rows.map(\.depth), [0, 0, 1, 2, 1, 0]) + XCTAssertEqual(rows.map(\.node.name), rows.map(\.id.rawValue), "行身份即节点身份") + } + + func testVisibleRowsSkipCollapsedSubtrees() { + let tree = nestedTree() + XCTAssertEqual( + tree.visibleRows(collapsed: [NodeID(rawValue: "outer")]).map(\.id.rawValue), + ["root-server", "outer", "empty"], "收起组的子树不出现,组行自身保留可再展开") + XCTAssertEqual( + tree.visibleRows(collapsed: [NodeID(rawValue: "inner")]).map(\.id.rawValue), + ["root-server", "outer", "inner", "outer-leaf", "empty"]) + } + + func testVisibleRowsTreatUnknownAndLeafIDsAsInert() { + let collapsed: Set = [NodeID(rawValue: "nope"), NodeID(rawValue: "root-server")] + XCTAssertEqual( + nestedTree().visibleRows(collapsed: collapsed).map(\.id.rawValue), + ["root-server", "outer", "inner", "deep-leaf", "outer-leaf", "empty"]) + } + + // MARK: - 服务器叶子计数 + + func testServerLeafCountCountsLeavesAcrossNesting() { + XCTAssertEqual(nestedTree().serverLeafCount, 3) + XCTAssertEqual(CatalogTreeSnapshot(roots: []).serverLeafCount, 0) + } +}