From cdf1c8ab8f531fcc14a2bb59c4bea42fa5f028c4 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Tue, 22 Sep 2026 20:41:18 +0800 Subject: [PATCH] refactor(ng2): deepen catalog policy into workflow seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New UI must bind projections and send typed commands only. Views were re-deriving delete confirm, move destinations, activation eligibility, and drag payload from domain state. Empty manual-group delete is no longer instant — always one confirmation. --- CONTEXT.md | 4 +- .../CatalogWorkflow+Policy.swift | 118 +++++++++++ .../CatalogWorkflow+Subscriptions.swift | 8 +- .../App/CatalogWorkflow/CatalogWorkflow.swift | 34 ++- .../CatalogWorkflowModels.swift | 82 ++++++-- ShadowsocksX-NG2/App/GroupDetailView.swift | 32 +-- ShadowsocksX-NG2/App/ImportSheets.swift | 19 +- ShadowsocksX-NG2/App/MainApp.swift | 3 +- ShadowsocksX-NG2/App/MainWindowView.swift | 52 ++--- .../App/ProxyRuntimeController.swift | 13 +- ShadowsocksX-NG2/App/ServerDetailView.swift | 7 +- ShadowsocksX-NG2/App/SidebarRow.swift | 39 ++-- .../Tests/CatalogWorkflowPolicyTests.swift | 196 ++++++++++++++++++ .../Tests/CatalogWorkflowTests.swift | 8 +- .../Tests/ProxyRuntimeControllerTests.swift | 30 +-- .../Tests/SettingsWorkflowTests.swift | 2 +- .../Tests/UnreadableListenSettingsTests.swift | 2 +- 17 files changed, 514 insertions(+), 135 deletions(-) create mode 100644 ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift create mode 100644 ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index dee63c27..107d327c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,7 +5,7 @@ - **Configuration catalog**: The non-user-visible root of the 2.0 configuration tree. Its ordered top-level children may be server configurations or configuration groups. - **Server configuration**: The user-visible connection data for one Shadowsocks server. It is a leaf with an opaque, persistent UUID and a source owner. - **Configuration group**: A named, ordered container that may contain server configurations and nested configuration groups. A node has at most one parent, and group membership forms an acyclic tree. -- **Manual group**: A configuration group owned by the user. It may contain only manually owned nodes; deleting it recursively deletes its descendants, with a second confirmation when it is non-empty. +- **Manual group**: A configuration group owned by the user. It may contain only manually owned nodes; deleting it recursively deletes its descendants. Deletion is always user-confirmed: one confirmation for an empty manual group, and a second confirmation naming the recursive subtree scale when it is non-empty. - **Subscription**: A remote configuration source with a persistent opaque UUID and a fixed subscription-group identity. Its URL is a mutable endpoint/credential reference; editing the URL keeps the source identity, while deleting and creating a subscription establishes a new source identity. - **Subscription group**: The fixed, source-owned configuration subtree provided by one subscription. Users cannot move or structurally edit its nodes. A subscription node's user-visible name/remark is one remote-derived value from `remarks`, falling back to the server address when absent; it has no local alias or note override. - **Active target**: The persisted server configuration or configuration group selected for proxying. When a group is active, the group UUID remains the target even when `sslocal` chooses an individual descendant server. @@ -46,7 +46,7 @@ - The active target remains the selected node ID and is not silently replaced by a descendant chosen by `sslocal`. If it is deleted, has no activation candidate, or is otherwise invalid, the target is cleared and proxying stops rather than falling back silently. - After a committed edit to the active target's subtree, the target is re-expanded immediately: a non-empty set of activation candidates updates the runtime atomically; no activation candidate clears the target and stops proxying without fallback. - Manual nodes may move between the catalog root and manual groups, carrying their subtree and child order without changing identity or ownership; cross-source moves, shared parents, and cycles are rejected. -- Empty groups may persist and remain editable, but cannot be activated. Deleting a non-empty manual group recursively deletes its descendants and requires a second confirmation. +- Empty groups may persist and remain editable, but cannot be activated. Deleting a server configuration or an empty manual group takes one user confirmation. Deleting a non-empty manual group recursively deletes its descendants and takes a second confirmation that names the subtree scale and that the subtree's server credentials are removed. - A subscription owns its subscription group and its refreshed members; manual groups remain user-owned. Remote connection fields, membership, structure, order, and `remarks` are authoritative; subscription nodes have no local eligibility overlay. - Subscription server and nested-group identities use provider-supplied stable IDs scoped to that subscription. When a remote record has no stable ID, continuity is allowed only for an exactly matching canonical record; endpoint, name, or path heuristics must not merge identities. - Subscription-owned nodes and nested groups permit no local connection-field, name/remark, membership, parent, order, or eligibility override. diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift new file mode 100644 index 00000000..664ce3e0 --- /dev/null +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift @@ -0,0 +1,118 @@ +import Foundation + +/// 目录政策查询与激活命令(issue #41):删除确认档位、移动目的地、拖放资格 +/// 与激活资格/命令的唯一 UI-facing 出口。视图不得重推 ownership、删除确认或 +/// ADR-0001 激活预检;领域拒绝仍是不变量防线。 +extension CatalogWorkflow { + // MARK: - 删除确认事实 + + /// 删除确认档位(CONTEXT.md:空手动组/服务器单次;非空手动组二次并点名规模)。 + /// 节点不存在时为 `nil`。 + func deleteFacts(for id: NodeID) -> DeleteConfirmKind? { + guard let node = tree.node(withID: id) else { return nil } + guard node.isGroup, node.isManual else { return .leaf } + if node.childNodes.isEmpty { return .emptyGroup } + return .subtree( + count: node.subtreeNodeCount, + includesCredentials: subtreeHasServers(node)) + } + + private func subtreeHasServers(_ node: CatalogTreeNode) -> Bool { + if !node.isGroup { return true } + return node.childNodes.contains { subtreeHasServers($0) } + } + + // MARK: - 移动目的地与拖放资格 + + /// 「移动到」目的地:目录根 + 除自身子树外的全部手动组(depth 供展示缩进)。 + func moveDestinations(for id: NodeID) -> [MoveDestination] { + var result = [MoveDestination(id: nil, name: "目录根", depth: 0)] + let excluded = tree.node(withID: id)?.subtreeIDs ?? [id] + func walk(_ nodes: [CatalogTreeNode], depth: Int) { + for node in nodes { + guard node.isGroup, node.isManual else { continue } + guard !excluded.contains(node.id) else { continue } + result.append(MoveDestination(id: node.id, name: node.name, depth: depth)) + walk(node.childNodes, depth: depth + 1) + } + } + walk(tree.roots, depth: 0) + return result + } + + /// 移动/落点资格:存在性、非自身、被拖节点可拖、目标为根或不在被拖子树内 + /// 的手动组。跨来源与成环仍由领域拒绝(不变量防线,不在此复制)。 + func canMove(_ id: NodeID, to parent: NodeID?) -> Bool { + guard let node = tree.node(withID: id) else { return false } + guard id != parent else { return false } + guard node.isManual else { return false } + guard let parent else { return true } + guard let parentNode = tree.node(withID: parent), parentNode.isGroup, parentNode.isManual else { + return false + } + return !node.subtreeIDs.contains(parent) + } + + /// 拖拽载荷:手动节点携带身份;订阅节点结构只读,返回 `nil`(空载荷)。 + func dragPayload(for id: NodeID) -> String? { + guard let node = tree.node(withID: id), node.isManual else { return nil } + return id.rawValue + } + + // MARK: - 激活资格与命令 + + /// 激活资格(点前门禁,ADR-0001):空组与无 candidate 原子不可激活; + /// 组内已知无效叶子计入跳过数。 + func activationEligibility(for id: NodeID) -> ActivationEligibility? { + guard let node = tree.node(withID: id) else { return nil } + if node.isGroup { + if node.childNodes.isEmpty { + return ActivationEligibility( + canActivate: false, candidateCount: 0, skippedInvalidCount: 0, + ineligibility: .emptyGroup) + } + var candidateCount = 0 + var skipped = 0 + countEligibility(of: node, candidates: &candidateCount, skipped: &skipped) + if candidateCount == 0 { + return ActivationEligibility( + canActivate: false, candidateCount: 0, skippedInvalidCount: skipped, + ineligibility: .noCandidates) + } + return ActivationEligibility( + canActivate: true, candidateCount: candidateCount, skippedInvalidCount: skipped, + ineligibility: nil) + } + if node.isInvalid { + return ActivationEligibility( + canActivate: false, candidateCount: 0, skippedInvalidCount: 1, + ineligibility: .noCandidates) + } + return ActivationEligibility( + canActivate: true, candidateCount: 1, skippedInvalidCount: 0, ineligibility: nil) + } + + private func countEligibility( + of node: CatalogTreeNode, + candidates: inout Int, + skipped: inout Int + ) { + if !node.isGroup { + if node.isInvalid { + skipped += 1 + } else { + candidates += 1 + } + return + } + for child in node.childNodes { + countEligibility(of: child, candidates: &candidates, skipped: &skipped) + } + } + + /// 激活命令(typed command surface):经 `Activating` port 发出;编排与 + /// 运行时收敛留在 port 背后。意外错误 throws。 + func activate(_ id: NodeID) async throws -> ActivationCommandOutcome { + try await activator.activate(id) + } +} diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift index a1f1704a..fff84040 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift @@ -19,7 +19,7 @@ extension CatalogWorkflow { status: .never) try credentials.save(url.absoluteString, for: record.urlRef) do { - try commitDocument { catalog, subscriptions in + try commitSubscriptionDocument { catalog, subscriptions in // 固定分组挂在目录根(CONTEXT.md「Subscription group」);名称以 host // 兜底,首次成功刷新后跟随远端。 try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID) @@ -81,7 +81,7 @@ extension CatalogWorkflow { throw SubscriptionFormError.notFound } var removedEntries: [CatalogEntry] = [] - try commitDocument { catalog, subscriptions in + try commitSubscriptionDocument { catalog, subscriptions in removedEntries = try catalog.removeSubscriptionSubtree(of: record.groupID) subscriptions.removeAll { $0.id == id } } @@ -126,7 +126,7 @@ extension CatalogWorkflow { var removedServers: [CatalogEntry] = [] var credentialJournal = CredentialWriteJournal(credentials: credentials) do { - try commitDocument { [self] catalog, subscriptions in + try commitSubscriptionDocument { [self] catalog, subscriptions in // 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用; // journal 保证快照提交失败时恢复旧秘密(story 29)。 var reusedRefs: [NodeID: ServerCredentialRefs] = [:] @@ -160,7 +160,7 @@ extension CatalogWorkflow { /// 不含订阅 URL),不是弹窗文案;其文案化沿用在同一 presentation 边缘的 /// `Error.presentableMessage`,独立编译 target 拆分时随该边缘外移。 private func markRefreshFailed(subscriptionID: NodeID, reason: String) async { - try? commitDocument { _, subscriptions in + try? commitSubscriptionDocument { _, subscriptions in if let index = subscriptions.firstIndex(where: { $0.id == subscriptionID }) { subscriptions[index].status = .failed(at: Date(), reason: reason) } diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift index f8692080..b53389d9 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift @@ -44,6 +44,8 @@ final class CatalogWorkflow: ObservableObject { /// Legacy 导入提交后的运行时边界(组合根一次性接线;独立于普通提交管线)。 let postLegacyImport: ((LegacyImportOutcome) async -> Void)? let legacyImportService: LegacyImportService + /// 激活缝(issue #41):生产 adapter 为 `ProxyRuntimeController`,测试注入假 adapter。 + let activator: Activating init( coordinator: CatalogCommitCoordinator, @@ -51,7 +53,8 @@ final class CatalogWorkflow: ObservableObject { plugins: ManagedPluginProviding = BundleManagedPluginProvider(), subscriptionFetcher: SubscriptionFetching = HTTPSSubscriptionFetcher(), legacyImportService: LegacyImportService? = nil, - postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil + postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil, + activator: Activating = RejectingActivator() ) { self.coordinator = coordinator fileStore = coordinator.fileStore @@ -59,6 +62,7 @@ final class CatalogWorkflow: ObservableObject { self.plugins = plugins self.subscriptionFetcher = subscriptionFetcher self.postLegacyImport = postLegacyImport + self.activator = activator self.legacyImportService = legacyImportService ?? LegacyImportService( @@ -81,9 +85,9 @@ final class CatalogWorkflow: ObservableObject { coordinator.committedCatalog.entry(for: id)?.displayName ?? "" } - /// 服务器激活校验结果(非敏感点名原因;编辑面「激活状态」区)。 - func serverValidation(for id: NodeID) -> ServerValidation? { - tree.node(withID: id)?.validation + /// 服务器叶子的已知阻塞原因(typed;编辑面「激活状态」区)。 + func serverInvalidReasons(for id: NodeID) -> [LeafInvalidationReason] { + tree.node(withID: id)?.invalidReasons ?? [] } /// 服务器编辑面(显式命令,story 11):解析密码与受管插件参数明文。 @@ -288,10 +292,10 @@ final class CatalogWorkflow: ObservableObject { ).encode() } - // MARK: - 提交管线(module 内部) + // MARK: - 提交管线(module 内部;UI 不得调用,拆独立 target 前靠约定) - /// 仅目录变更的提交便捷入口(订阅扩展使用双参 `commitDocument`)。 - func commit( + /// 仅目录变更的提交便捷入口(订阅扩展经 `commitSubscriptionDocument`)。 + private func commit( _ mutate: (inout ConfigurationCatalog) throws -> T ) throws -> T { try commitDocument { catalog, _ in @@ -301,7 +305,7 @@ final class CatalogWorkflow: ObservableObject { /// 副本变更 → 协调器落盘 → 重新发布 projection;任一步失败则已发布状态 /// 不动。运行时收敛由协调器异步调度(issue #40),不阻塞也不回滚本提交。 - func commitDocument( + private func commitDocument( _ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T ) throws -> T { let result = try coordinator.commit(mutate) @@ -309,6 +313,13 @@ final class CatalogWorkflow: ObservableObject { return result } + /// 订阅扩展的唯一写路径(module 内部窄缝,替代直接摸 `commitDocument`)。 + func commitSubscriptionDocument( + _ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T + ) throws -> T { + try commitDocument(mutate) + } + /// 提交成功后的 projection 重建(树 + 订阅卡片)。 func republishCommittedState() { tree = .build( @@ -431,6 +442,13 @@ final class CatalogWorkflow: ObservableObject { } } +/// 默认激活 adapter:未接线时原子拒绝(测试不触激活的场景)。 +final class RejectingActivator: Activating { + func activate(_ target: NodeID) async throws -> ActivationCommandOutcome { + .rejectedActivation + } +} + /// 凭据写入日志:目录文件只有在全部凭据写入成功后才替换;如果任何凭据或 /// 目录写入失败,则恢复改动前的每个引用值,避免旧秘密被覆盖或丢失 /// (story 13/29)。 diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift index 02921506..c1def909 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift @@ -3,8 +3,10 @@ import Foundation // MARK: - 目录树 projection(非敏感) /// 目录树节点快照(目录工作流 module 的 UI-facing projection,issue #41): -/// 名称、来源、形态、校验事实与子树计数;不含凭据引用、密码、插件参数等 +/// 名称、来源、形态、渲染原子与子树计数;不含凭据引用、密码、插件参数等 /// 秘密值。身份为不透明 `NodeID`,重命名、移动或刷新后保持稳定(story 3)。 +/// 门禁/关系型事实(删除档位、移动资格、激活资格)走 module 查询方法, +/// 不在节点上堆政策字段。 struct CatalogTreeNode: Identifiable, Equatable { let id: NodeID let name: String @@ -12,21 +14,18 @@ struct CatalogTreeNode: Identifiable, Equatable { let source: NodeSource /// 父节点身份;目录根层节点为 `nil`。 let parentID: NodeID? - /// 服务器叶子的 app 可知校验结果;分组为 `nil`。 - let validation: ServerValidation? + /// 服务器叶子的已知阻塞原因(typed,无成句文案);分组为空。 + let invalidReasons: [LeafInvalidationReason] /// 直接子节点数(分组)。 let childCount: Int /// 子树全部后代节点数(分组;不含自身;删除确认的递归规模)。 let subtreeNodeCount: Int - /// 子树中已知无效的服务器数量(不含当前叶子自身)。 + /// 子树中已知无效的服务器数量(不含当前叶子自身;仅供 tooltip 展示)。 let invalidDescendantCount: Int - /// 子树中服务器叶子总数(叶子为 1)。 - let serverCount: Int /// 分组持有子树快照;服务器叶子为 `nil`。 let children: [CatalogTreeNode]? - var isInvalid: Bool { validation?.isValid == false } - var invalidServerCount: Int { (isInvalid ? 1 : 0) + invalidDescendantCount } + var isInvalid: Bool { !invalidReasons.isEmpty } var isManual: Bool { source == .manual } /// 子树快照;服务器叶子为空(与 `children` 的 nil 区分叶子语义并存)。 var childNodes: [CatalogTreeNode] { children ?? [] } @@ -48,6 +47,11 @@ extension CatalogTreeNode { for child in children ?? [] { ids.formUnion(child.subtreeIDs) } return ids } + + /// 子树内已知无效服务器总数(含自身叶子)。 + var subtreeInvalidServerCount: Int { + (isInvalid ? 1 : 0) + invalidDescendantCount + } } /// 目录树快照:侧栏、活动目标级联、移动目的地与删除确认共用的同一份 @@ -69,7 +73,9 @@ struct CatalogTreeSnapshot: Equatable { func containsNode(_ id: NodeID) -> Bool { node(withID: id) != nil } /// 全树已知无效服务器总数(诊断计数)。 - var invalidServerCount: Int { roots.reduce(0) { $0 + $1.invalidServerCount } } + var invalidServerCount: Int { + roots.reduce(0) { $0 + $1.subtreeInvalidServerCount } + } } extension CatalogTreeSnapshot { @@ -93,24 +99,24 @@ extension CatalogTreeSnapshot { isGroup: true, source: entry.source, parentID: parentID, - validation: nil, + invalidReasons: [], childCount: children.count, subtreeNodeCount: children.reduce(0) { $0 + 1 + $1.subtreeNodeCount }, - invalidDescendantCount: children.reduce(0) { $0 + $1.invalidServerCount }, - serverCount: children.reduce(0) { $0 + $1.serverCount }, + invalidDescendantCount: children.reduce(0) { $0 + $1.subtreeInvalidServerCount }, children: children) case .server(let fields): + let validation = ServerValidation.evaluate( + fields, credentials: credentials, plugins: plugins) return CatalogTreeNode( id: id, name: entry.displayName, isGroup: false, source: entry.source, parentID: parentID, - validation: ServerValidation.evaluate(fields, credentials: credentials, plugins: plugins), + invalidReasons: validation.issues, childCount: 0, subtreeNodeCount: 0, invalidDescendantCount: 0, - serverCount: 1, children: nil) } } @@ -122,6 +128,54 @@ extension CatalogTreeSnapshot { } } +// MARK: - 目录政策事实(UI 写句子;本 module 不产成句文案) + +/// 删除确认档位(CONTEXT.md 删除不变量的结构化事实)。 +enum DeleteConfirmKind: Equatable, Sendable { + /// 服务器叶子:单次确认。 + case leaf + /// 空手动分组:单次确认。 + case emptyGroup + /// 非空手动分组:二次确认,点名子树规模与凭据移除。 + case subtree(count: Int, includesCredentials: Bool) +} + +/// 「移动到」目的地(目录根 + 除自身子树外的全部手动组)。 +struct MoveDestination: Identifiable, Equatable, Sendable { + /// `nil` = 目录根。 + let id: NodeID? + let name: String + /// 展示缩进深度(根为 0)。 + let depth: Int +} + +/// 激活资格事实(点前门禁;与 `ActivationCommandOutcome` 的点后结果区分)。 +struct ActivationEligibility: Equatable, Sendable { + enum Ineligibility: Equatable, Sendable { + case emptyGroup + case noCandidates + } + + let canActivate: Bool + let candidateCount: Int + let skippedInvalidCount: Int + let ineligibility: Ineligibility? +} + +/// 激活命令的结构化结果(意外错误仍 throws)。 +enum ActivationCommandOutcome: Equatable, Sendable { + /// 成功;组展开时跳过的已知无效叶子数。 + case activated(skippedInvalid: Int) + /// 原子拒绝(无 activation candidate / 目标失效);状态完全不动。 + case rejectedActivation +} + +/// 激活缝(issue #41):目录命令面经此发出激活意图。生产 adapter 为 +/// `ProxyRuntimeController`;测试注入假 adapter 观察目标传递。 +protocol Activating: AnyObject { + func activate(_ target: NodeID) async throws -> ActivationCommandOutcome +} + // MARK: - 服务器详情与编辑 /// 服务器编辑面状态:用户打开编辑器时经显式命令解析,凭据明文只在此出现 diff --git a/ShadowsocksX-NG2/App/GroupDetailView.swift b/ShadowsocksX-NG2/App/GroupDetailView.swift index dbadb188..33ca7222 100644 --- a/ShadowsocksX-NG2/App/GroupDetailView.swift +++ b/ShadowsocksX-NG2/App/GroupDetailView.swift @@ -1,12 +1,11 @@ import SwiftUI /// 分组详情(issue #32/#41):名称编辑(手动组)、直接子节点数与激活入口。 -/// 数据来自目录工作流 module 的树 projection;订阅固定分组名称由远端管理, -/// 只读。空组保留可编辑(可先搭结构再填内容)。 +/// 数据来自目录工作流 module 的树 projection 与激活资格查询;订阅固定分组 +/// 名称由远端管理,只读。空组保留可编辑(可先搭结构再填内容)。 struct GroupDetailView: View { let workflow: CatalogWorkflow let groupID: NodeID - let proxyController: ProxyRuntimeController let errors: ErrorAlertPresenter @State private var name = "" @@ -14,6 +13,10 @@ struct GroupDetailView: View { private var node: CatalogTreeNode? { workflow.tree.node(withID: groupID) } private var isManual: Bool { node?.isManual ?? false } + private var eligibility: ActivationEligibility? { + workflow.activationEligibility(for: groupID) + } + var body: some View { Form { Section("分组") { @@ -25,26 +28,32 @@ struct GroupDetailView: View { } Section { LabeledContent("直接子节点", value: "\(directChildCount)") - if invalidServerCount > 0 { - LabeledContent("已知无效服务器", value: "\(invalidServerCount)") + if let eligibility, eligibility.skippedInvalidCount > 0 { + LabeledContent("已知无效服务器", value: "\(eligibility.skippedInvalidCount)") } LabeledContent("来源", value: isManual ? "手动" : "订阅") Button { - Task { await proxyController.activate(groupID) } + Task { + do { + _ = try await workflow.activate(groupID) + } catch { + errors.present(error) + } + } } label: { Label("激活此分组", systemImage: "bolt.fill") } - .disabled(validServerCount == 0) + .disabled(!(eligibility?.canActivate ?? false)) if directChildCount == 0 { Text("空分组保留可编辑,但不能激活。") .font(.footnote) .foregroundStyle(.secondary) - } else if validServerCount == 0 { + } else if eligibility?.canActivate == false { Text("分组中没有可激活的有效服务器。") .font(.footnote) .foregroundStyle(.secondary) - } else if invalidServerCount > 0 { - Text("激活时将跳过 \(invalidServerCount) 个存在已知阻塞问题的服务器。") + } else if let eligibility, eligibility.skippedInvalidCount > 0 { + Text("激活时将跳过 \(eligibility.skippedInvalidCount) 个存在已知阻塞问题的服务器。") .font(.footnote) .foregroundStyle(.secondary) } @@ -57,9 +66,6 @@ struct GroupDetailView: View { } private var directChildCount: Int { node?.childCount ?? 0 } - private var invalidServerCount: Int { node?.invalidServerCount ?? 0 } - private var serverCount: Int { node?.serverCount ?? 0 } - private var validServerCount: Int { serverCount - invalidServerCount } private func loadName() { name = workflow.displayName(for: groupID) diff --git a/ShadowsocksX-NG2/App/ImportSheets.swift b/ShadowsocksX-NG2/App/ImportSheets.swift index 9d32df2f..507d6198 100644 --- a/ShadowsocksX-NG2/App/ImportSheets.swift +++ b/ShadowsocksX-NG2/App/ImportSheets.swift @@ -178,8 +178,8 @@ struct QRImportSheet: View { } } -/// 「移动到」表单(issue #41):目的地由树 projection 派生——目录根与除自身 -/// 子树外的全部手动组;跨来源与成环由领域层最终拒绝。 +/// 「移动到」表单(issue #41):目的地由 seam 的 `moveDestinations` 提供—— +/// 目录根与除自身子树外的全部手动组;跨来源与成环由领域层最终拒绝。 struct MoveNodeSheet: View { let workflow: CatalogWorkflow let errors: ErrorAlertPresenter @@ -194,19 +194,10 @@ struct MoveNodeSheet: View { } private var destinations: [Destination] { - var result = [Destination(id: nil, title: "目录根")] - let excluded = workflow.tree.node(withID: nodeID)?.subtreeIDs ?? [nodeID] - func walk(_ nodes: [CatalogTreeNode], depth: Int) { - for node in nodes { - guard node.isGroup, node.isManual else { continue } - guard !excluded.contains(node.id) else { continue } - let indent = String(repeating: " ", count: depth) - result.append(Destination(id: node.id, title: indent + node.name)) - walk(node.children ?? [], depth: depth + 1) - } + workflow.moveDestinations(for: nodeID).map { item in + let indent = String(repeating: " ", count: item.depth) + return Destination(id: item.id, title: indent + item.name) } - walk(workflow.tree.roots, depth: 0) - return result } var body: some View { diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 52a652c1..7e1cbe16 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -29,7 +29,8 @@ struct ShadowsocksXNG2App: App { coordinator: coordinator, postLegacyImport: { _ in await controller.legacyImportDidCommit() - }) + }, + activator: controller) _catalogWorkflow = StateObject(wrappedValue: catalogWorkflow) // 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次。 _settingsWorkflow = StateObject(wrappedValue: SettingsWorkflow(controller: controller)) diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 84987bc3..6ef958c4 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -15,6 +15,7 @@ struct MainWindowView: View { } @ObservedObject var workflow: CatalogWorkflow + /// 运行时事实来源:活动目标标记;激活命令经 `workflow.activate`。 @ObservedObject var proxyController: ProxyRuntimeController /// 诊断工作流 module(issue #43):诊断侧栏与详情共用的唯一 seam。 @ObservedObject var diagnostics: DiagnosticsWorkflow @@ -158,7 +159,7 @@ struct MainWindowView: View { SidebarRow( node: node, workflow: workflow, - proxyController: proxyController, + activeTargetID: proxyController.activeTargetID, errors: errors, onRename: { id in renameTarget = id @@ -169,14 +170,13 @@ struct MainWindowView: View { newGroupName = "" }, onMove: { moveTarget = $0 }, - onDelete: { deleteTarget = $0 }, - onRemoved: { clearSelectionIfInvalidated($0) } + onDelete: { deleteTarget = $0 } ) .onDrag { - // 订阅节点结构只读:携带空负载,落点校验节点存在性后自动忽略。 - node.isManual - ? NSItemProvider(object: node.id.rawValue as NSString) - : NSItemProvider() + guard let payload = workflow.dragPayload(for: node.id) else { + return NSItemProvider() + } + return NSItemProvider(object: payload as NSString) } .dropDestination(for: String.self) { payload, _ in handleDrop(payload, onto: node.id) @@ -211,11 +211,11 @@ struct MainWindowView: View { } /// 落点语义:拖到分组行 = 移入该组(仅手动组接受),拖到列表空白/根 = 移到根。 - /// 跨来源由视图过滤 + 领域拒绝双重保证;无效负载不接收。 + /// 资格事实由 seam 提供;跨来源/成环仍由领域拒绝(不变量防线)。 private func handleDrop(_ payload: [String], onto target: NodeID?) -> Bool { guard let raw = payload.first else { return false } let dragged = NodeID(rawValue: raw) - guard dragged != target, workflow.tree.containsNode(dragged) else { return false } + guard workflow.canMove(dragged, to: target) else { return false } Task { do { try await workflow.move(dragged, to: target) @@ -241,11 +241,11 @@ struct MainWindowView: View { if let id = selection, let node = workflow.tree.node(withID: id) { if node.isGroup { GroupDetailView( - workflow: workflow, groupID: id, proxyController: proxyController, + workflow: workflow, groupID: id, errors: errors) } else { ServerDetailView( - workflow: workflow, serverID: id, proxyController: proxyController, + workflow: workflow, serverID: id, errors: errors) } } else { @@ -332,27 +332,29 @@ struct MainWindowView: View { } } - private var deleteTargetNode: CatalogTreeNode? { - deleteTarget.flatMap { workflow.tree.node(withID: $0) } - } - - /// 删除语义:非空手动组点名子树规模(二次确认),空组与服务器单次确认。 + /// 删除确认文案:档位与规模事实来自 seam;句子由 UI 拼(Q3-A)。 private var deleteTitle: String { - guard let node = deleteTargetNode, node.isGroup, node.isManual else { - return "删除「\(deleteTargetName)」?" + guard let id = deleteTarget else { return "" } + let name = workflow.displayName(for: id) + switch workflow.deleteFacts(for: id) { + case .subtree: + return "删除分组「\(name)」及其整棵子树?" + case .leaf, .emptyGroup, nil: + return "删除「\(name)」?" } - return "删除分组「\(deleteTargetName)」及其整棵子树?" } private var deleteMessage: String { - guard let node = deleteTargetNode, node.isGroup, node.isManual else { + guard let id = deleteTarget else { return "" } + switch workflow.deleteFacts(for: id) { + case .subtree(let count, let includesCredentials): + if includesCredentials { + return "将递归删除 \(count) 个节点(含其中的服务器与凭据),此操作不可撤销。" + } + return "将递归删除 \(count) 个节点,此操作不可撤销。" + case .leaf, .emptyGroup, nil: return "此操作不可撤销。" } - return "将递归删除 \(node.subtreeNodeCount) 个节点(含其中的服务器与凭据),此操作不可撤销。" - } - - private var deleteTargetName: String { - deleteTarget.map { workflow.displayName(for: $0) } ?? "" } private func commitDelete() { diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index e297cb3a..3a8c5482 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -156,8 +156,10 @@ final class ProxyRuntimeController: ObservableObject { // MARK: - 用户意图 /// 激活一个服务器或分组目标(目录 UI 工单复用入口):持久化目标;代理 - /// 开启时立即把新档推到运行时。激活原子失败时状态完全不动(D3)。 - func activate(_ target: NodeID) async { + /// 开启时立即把新档推到运行时。激活原子失败时状态完全不动(D3),返回 + /// `.rejectedActivation`;意外错误 throws 并进入 `serviceFailed`。 + @discardableResult + func activate(_ target: NodeID) async throws -> ActivationCommandOutcome { reloadCatalog() do { let configuration = try machine.activate( @@ -170,15 +172,18 @@ final class ProxyRuntimeController: ObservableObject { try activationFileStore.save(activeTargetID: target) } catch { state = .serviceFailed(detail: String(describing: error)) - return + throw error } if state != .off { await deploy(configuration.document) } + return .activated(skippedInvalid: configuration.skippedServers.count) } catch let failure as ActivationFailure { state = .activationFailed(reason: failure.presentedReason) + return .rejectedActivation } catch { state = .serviceFailed(detail: String(describing: error)) + throw error } } @@ -442,6 +447,8 @@ extension ProxyRuntimeController.ProxyState { } } +extension ProxyRuntimeController: Activating {} + extension ProxyRuntimeController { /// 目录提交协调器的生产适配入口(issue #40):以刚提交的内存快照重展开, /// 不回读磁盘(磁盘仍是重启与跨进程恢复的权威来源)。有效非空且代理开启 diff --git a/ShadowsocksX-NG2/App/ServerDetailView.swift b/ShadowsocksX-NG2/App/ServerDetailView.swift index af258931..1d568ea4 100644 --- a/ShadowsocksX-NG2/App/ServerDetailView.swift +++ b/ShadowsocksX-NG2/App/ServerDetailView.swift @@ -8,7 +8,6 @@ import SwiftUI struct ServerDetailView: View { let workflow: CatalogWorkflow let serverID: NodeID - let proxyController: ProxyRuntimeController let errors: ErrorAlertPresenter @State private var address = "" @@ -74,10 +73,10 @@ struct ServerDetailView: View { .disabled(!isEditable) } - if let validation = node?.validation, !validation.isValid { + if let node, node.isInvalid { Section("激活状态") { - ForEach(Array(validation.issues.enumerated()), id: \.offset) { _, issue in - Label(issue.presentedReason, systemImage: "exclamationmark.triangle.fill") + ForEach(Array(node.invalidReasons.enumerated()), id: \.offset) { _, reason in + Label(reason.presentedReason, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } } diff --git a/ShadowsocksX-NG2/App/SidebarRow.swift b/ShadowsocksX-NG2/App/SidebarRow.swift index 8993b043..136591c8 100644 --- a/ShadowsocksX-NG2/App/SidebarRow.swift +++ b/ShadowsocksX-NG2/App/SidebarRow.swift @@ -1,19 +1,18 @@ import SwiftUI /// 侧栏树行(issue #41):来源标识、有效性提示、活动目标标记与右键菜单。 -/// 数据来自目录工作流 module 的树 projection;服务器/分组不再拥有独立的启 -/// 用/停用状态,激活时由状态机按有效性展开。 +/// 数据来自目录工作流 module 的树 projection;激活经 seam 的 typed command, +/// 活动目标标记由父视图传入(运行时事实,不进目录 projection)。删除一律 +/// 交父视图确认弹窗(空手动组单次、非空二次,CONTEXT.md)。 struct SidebarRow: View { let node: CatalogTreeNode let workflow: CatalogWorkflow - let proxyController: ProxyRuntimeController + let activeTargetID: NodeID? let errors: ErrorAlertPresenter let onRename: (NodeID) -> Void let onNewGroup: (NodeID?) -> Void let onMove: (NodeID) -> Void let onDelete: (NodeID) -> Void - /// 直接删除完成后的回调(携带被删身份集合;UI 据此清除失效选择)。 - let onRemoved: (Set) -> Void private var isSubscription: Bool { node.source == .subscription } @@ -26,7 +25,7 @@ struct SidebarRow: View { if node.isInvalid { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) - .help(node.validation?.issues.first?.presentedReason ?? "服务器存在已知阻塞问题") + .help(node.invalidReasons.first?.presentedReason ?? "服务器存在已知阻塞问题") } else if node.invalidDescendantCount > 0 { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.orange) @@ -37,7 +36,7 @@ struct SidebarRow: View { .foregroundStyle(.tertiary) .help("订阅节点:由远端管理") } - if proxyController.activeTargetID == node.id { + if activeTargetID == node.id { Image(systemName: "bolt.fill") .foregroundStyle(.orange) .help("活动目标") @@ -46,15 +45,16 @@ struct SidebarRow: View { .contextMenu { contextMenu } } - /// 空手动组直接删除;服务器与非空手动组走确认弹窗(issue #32)。 - private var isEmptyManualGroup: Bool { - node.isGroup && node.isManual && node.childNodes.isEmpty - } - @ViewBuilder private var contextMenu: some View { Button("激活") { - Task { await proxyController.activate(node.id) } + Task { + do { + _ = try await workflow.activate(node.id) + } catch { + errors.present(error) + } + } } if !isSubscription { Divider() @@ -68,18 +68,7 @@ struct SidebarRow: View { Button("移动到…") { onMove(node.id) } Divider() Button("删除…", role: .destructive) { - if isEmptyManualGroup { - Task { - do { - let outcome = try await workflow.remove(node.id) - onRemoved(outcome.removedNodeIDs) - } catch { - errors.present(error) - } - } - } else { - onDelete(node.id) - } + onDelete(node.id) } } } diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift new file mode 100644 index 00000000..494edae3 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift @@ -0,0 +1,196 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 目录政策 seam 的 interface 测试(issue #41):删除确认档位、移动目的地、 +/// 拖放/移动资格、激活资格与激活命令经假 adapter 的目标传递。只观察结构化 +/// fact 与 typed command outcome,不锁定 SwiftUI。 +@MainActor +final class CatalogWorkflowPolicyTests: XCTestCase { + private var workDir: URL! + private var fileURL: URL! + private var credentials: InMemoryCredentialStore! + private var runtime: FakeCatalogRuntime! + private var activator: FakeActivator! + private var workflow: CatalogWorkflow! + + override func setUp() async throws { + try await super.setUp() + workDir = FileManager.default.temporaryDirectory + .appendingPathComponent("catalog-policy-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + fileURL = workDir.appendingPathComponent("catalog.json") + credentials = InMemoryCredentialStore() + runtime = FakeCatalogRuntime() + activator = FakeActivator() + workflow = makeWorkflow() + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: workDir) + try await super.tearDown() + } + + private func makeWorkflow() -> CatalogWorkflow { + runtime.hasActiveTarget = true + let coordinator = CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime) + return CatalogWorkflow( + coordinator: coordinator, + credentials: credentials, + plugins: NoManagedPluginProvider(), + activator: activator) + } + + private func importServer(into parent: NodeID? = nil) async throws -> NodeID { + let before = Set(workflow.tree.roots.flatMap(\.subtreeIDs)) + _ = try await workflow.createServers( + fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: parent) + if let parent { + let node = try XCTUnwrap(workflow.tree.node(withID: parent)) + return try XCTUnwrap(node.childNodes.last?.id) + } + let added = workflow.tree.roots.map(\.id).filter { !before.contains($0) } + return try XCTUnwrap(added.last) + } + + // MARK: - 删除确认档位 + + func testDeleteFactsLeafIsEmptySingleConfirm() async throws { + let serverID = try await importServer() + XCTAssertEqual(workflow.deleteFacts(for: serverID), .leaf) + } + + func testDeleteFactsEmptyManualGroupIsSingleConfirm() async throws { + let groupID = try await workflow.createGroup(named: "空组", into: nil) + XCTAssertEqual(workflow.deleteFacts(for: groupID), .emptyGroup) + } + + func testDeleteFactsNonEmptyGroupNamesSubtreeAndCredentials() async throws { + let groupID = try await workflow.createGroup(named: "组", into: nil) + _ = try await workflow.createServers( + fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: groupID) + let nestedID = try await workflow.createGroup(named: "嵌套", into: groupID) + _ = try await workflow.createServers( + fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.8:8388", into: nestedID) + // 组内:服务器 + 嵌套组 + 嵌套服务器 = 3 个后代节点(不含自身)。 + XCTAssertEqual( + workflow.deleteFacts(for: groupID), + .subtree(count: 3, includesCredentials: true)) + } + + // MARK: - 移动目的地 + + func testMoveDestinationsAreRootAndManualGroupsExcludingOwnSubtree() async throws { + let outer = try await workflow.createGroup(named: "外层", into: nil) + let inner = try await workflow.createGroup(named: "内层", into: outer) + let sibling = try await workflow.createGroup(named: "兄弟", into: nil) + let serverID = try await importServer() + + let destinations = workflow.moveDestinations(for: outer) + XCTAssertEqual(destinations.map(\.id), [nil, sibling], "排除自身子树(内层)") + XCTAssertEqual(destinations.map(\.depth), [0, 0]) + XCTAssertEqual(destinations.map(\.name), ["目录根", "兄弟"]) + + let forServer = workflow.moveDestinations(for: serverID) + XCTAssertTrue(forServer.map(\.id).contains(outer), "服务器可移入手动组") + XCTAssertTrue(forServer.map(\.id).contains(inner), "含嵌套手动组") + } + + // MARK: - 移动/拖放资格 + + func testCanMoveRejectsSelfCycleAndSubscriptionTarget() async throws { + let groupID = try await workflow.createGroup(named: "组", into: nil) + let nestedID = try await workflow.createGroup(named: "嵌套", into: groupID) + let serverID = try await importServer(into: groupID) + + XCTAssertFalse(workflow.canMove(groupID, to: groupID), "非自身") + XCTAssertTrue(workflow.canMove(groupID, to: nil), "可回根") + XCTAssertFalse(workflow.canMove(groupID, to: nestedID), "目标在被拖子树内") + XCTAssertTrue(workflow.canMove(nestedID, to: nil)) + XCTAssertFalse(workflow.canMove(nestedID, to: nestedID)) + XCTAssertTrue(workflow.canMove(serverID, to: nestedID)) + + let fixture = try CatalogFixtures.makeSubscriptionFixture() + try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: fixture.catalog)) + workflow = makeWorkflow() + XCTAssertFalse(workflow.canMove(fixture.serverIDs[0], to: nil), "订阅节点不可拖") + XCTAssertFalse(workflow.canMove(serverID, to: fixture.groupID), "目标非手动组") + XCTAssertFalse(workflow.canMove(NodeID(rawValue: "ghost"), to: nil), "不存在") + } + + func testDragPayloadManualHasIDSubscriptionNil() async throws { + let serverID = try await importServer() + XCTAssertEqual(workflow.dragPayload(for: serverID), serverID.rawValue) + + let fixture = try CatalogFixtures.makeSubscriptionFixture() + try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: fixture.catalog)) + workflow = makeWorkflow() + XCTAssertNil(workflow.dragPayload(for: fixture.serverIDs[0])) + XCTAssertNil(workflow.dragPayload(for: fixture.groupID)) + } + + // MARK: - 激活资格 + + func testActivationEligibilityEmptyGroup() async throws { + let groupID = try await workflow.createGroup(named: "空组", into: nil) + XCTAssertEqual( + workflow.activationEligibility(for: groupID), + ActivationEligibility( + canActivate: false, candidateCount: 0, skippedInvalidCount: 0, + ineligibility: .emptyGroup)) + } + + func testActivationEligibilityCountsCandidatesAndSkips() async throws { + let groupID = try await workflow.createGroup(named: "组", into: nil) + _ = try await workflow.createServers( + fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: groupID) + // 无效叶子(不支持的加密方法)。 + _ = try await workflow.createServers( + fromURIs: "ss://ZnV0dXJlLWNpcGhlcjp4eHg=@203.0.113.9:8388", into: groupID) + let eligibility = try XCTUnwrap(workflow.activationEligibility(for: groupID)) + XCTAssertTrue(eligibility.canActivate) + XCTAssertEqual(eligibility.candidateCount, 1) + XCTAssertEqual(eligibility.skippedInvalidCount, 1) + XCTAssertNil(eligibility.ineligibility) + } + + func testActivationEligibilityAllInvalidIsNoCandidates() async throws { + _ = try await workflow.createServers( + fromURIs: "ss://ZnV0dXJlLWNpcGhlcjp4eHg=@203.0.113.9:8388", into: nil) + let serverID = try XCTUnwrap(workflow.tree.roots.first?.id) + let eligibility = try XCTUnwrap(workflow.activationEligibility(for: serverID)) + XCTAssertEqual(eligibility.ineligibility, .noCandidates) + XCTAssertFalse(eligibility.canActivate) + XCTAssertEqual(eligibility.skippedInvalidCount, 1) + } + + // MARK: - 激活命令经假 adapter + + func testActivateForwardsTargetThroughActivatingPort() async throws { + let serverID = try await importServer() + activator.result = .activated(skippedInvalid: 2) + let outcome = try await workflow.activate(serverID) + XCTAssertEqual(outcome, .activated(skippedInvalid: 2)) + XCTAssertEqual(activator.activated, [serverID]) + } + + func testActivateRejectedOutcomeIsNotThrown() async throws { + let serverID = try await importServer() + activator.result = .rejectedActivation + let outcome = try await workflow.activate(serverID) + XCTAssertEqual(outcome, .rejectedActivation) + } +} + +private final class FakeActivator: Activating { + var activated: [NodeID] = [] + var result: ActivationCommandOutcome = .activated(skippedInvalid: 0) + var error: Error? + + func activate(_ target: NodeID) async throws -> ActivationCommandOutcome { + activated.append(target) + if let error { throw error } + return result + } +} diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift index 47ae25f9..77d58865 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift @@ -65,7 +65,7 @@ final class CatalogWorkflowTests: XCTestCase { XCTAssertEqual(roots[0].name, "香港 01", "备注优先的显示名") XCTAssertEqual( workflow.serverEditForm(for: roots[0].id)?.password, "password123", "密码入凭据存储") - XCTAssertTrue(roots[0].validation?.isValid ?? false) + XCTAssertTrue(roots[0].invalidReasons.isEmpty) // 两个身份均为全新 UUID 形态(不按内容去重)。 XCTAssertNotEqual(roots[0].id, roots[1].id) } @@ -94,7 +94,7 @@ final class CatalogWorkflowTests: XCTestCase { XCTAssertEqual(outcome.addedCount, 1) let node = try XCTUnwrap(workflow.tree.roots.first) XCTAssertEqual( - node.validation?.issues, [.unsupportedEncryptionMethod("future-cipher")], + node.invalidReasons, [.unsupportedEncryptionMethod("future-cipher")], "不支持的方法保留并明确标记为无效(story 9)") } @@ -134,14 +134,12 @@ final class CatalogWorkflowTests: XCTestCase { XCTAssertEqual(group.parentID, nil) XCTAssertEqual(group.childCount, 1) XCTAssertEqual(group.subtreeNodeCount, 2, "嵌套分组 + 服务器叶子") - XCTAssertEqual(group.serverCount, 1) - XCTAssertEqual(group.invalidServerCount, 0) let nested = try XCTUnwrap(group.children?.first) XCTAssertEqual(nested.id, nestedID) XCTAssertEqual(nested.parentID, groupID) let rootServer = try XCTUnwrap(workflow.tree.roots.last) XCTAssertFalse(rootServer.isGroup) - XCTAssertEqual(rootServer.serverCount, 1) + XCTAssertFalse(rootServer.isInvalid) XCTAssertNil(rootServer.children) XCTAssertTrue(workflow.tree.containsNode(groupID)) } diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift index a9d8f453..3a719b58 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift @@ -121,7 +121,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { let cancellable = controller.$activeTargetID.dropFirst().sink { observed = $0 } defer { cancellable.cancel() } - await controller.activate(seeded.server) + try await controller.activate(seeded.server) XCTAssertEqual(controller.activeTargetID, seeded.server) XCTAssertEqual(observed, seeded.server, "代理关闭路径的激活也要发布目标变更") @@ -133,7 +133,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { let probe = ProxyRuntimeFixture.FakeProbe.reachable() let controller = makeController(probe: probe) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) XCTAssertEqual(controller.state, .running) @@ -166,7 +166,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { pacProbe: pacProbe, proxyMode: .externalPAC(externalURL)) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) guard case .systemProxyFailed(let detail) = controller.state else { @@ -187,7 +187,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { let controller = makeController( probe: ProxyRuntimeFixture.FakeProbe.reachable(), proxyMode: .global) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) XCTAssertEqual( systemProxy.applied, @@ -220,7 +220,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { let controller = makeController( probe: ProxyRuntimeFixture.FakeProbe.refusing(), agentStatus: .notRegistered) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) guard case .launchFailed(let detail) = controller.state else { @@ -236,7 +236,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { func testDisableUnregistersAndCleansRuntimeFiles() async throws { let seeded = try makeSeededCatalog() let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable()) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) await controller.setProxyEnabled(false) @@ -253,7 +253,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { let controller = makeController( probe: ProxyRuntimeFixture.FakeProbe.reachable(), firewallChecker: firewall) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) XCTAssertEqual(controller.state, .running) @@ -275,7 +275,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { listen: listen, firewallChecker: firewall) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) guard case .firewallBlocked(let detail) = controller.state else { @@ -297,7 +297,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { listen: listen, firewallChecker: firewall) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) let deadline = Date().addingTimeInterval(1) @@ -317,7 +317,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { func testCatalogEditOfActiveTargetRewritesAndSignalsRunningWrapper() async throws { let seeded = try makeSeededCatalog() let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable()) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) // 模拟 wrapper 在跑:pid 指向本测试进程(kill(pid, 0) 判活通过)。 try Data("\(ProcessInfo.processInfo.processIdentifier)".utf8).write(to: runtime.pidFile) @@ -353,7 +353,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { let seeded = try makeSeededCatalog() try Data("\(ProcessInfo.processInfo.processIdentifier)".utf8).write(to: runtime.pidFile) let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable()) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) // 删除活动目标 → 重展开失败 → 清除并停止。 @@ -381,7 +381,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { let seeded = try makeSeededCatalog() let controller = makeController( probe: ProxyRuntimeFixture.FakeProbe.reachable(), agentStatus: .notRegistered) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) XCTAssertTrue(agent.registerCount >= 1) let contractMtime = @@ -414,7 +414,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { try Data("99999".utf8).write(to: runtime.pidFile) let controller = makeController( probe: ProxyRuntimeFixture.FakeProbe.reachable(), agentStatus: .notRegistered) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) try ActivationStateFileStore(fileURL: activationFileURL).save(activeTargetID: seeded.server) await controller.resyncOnLaunch() @@ -499,7 +499,7 @@ extension ProxyRuntimeControllerTests { settingsStore: settingsStore, systemProxy: systemProxy) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) XCTAssertEqual(controller.state, .running) XCTAssertEqual(systemProxy.applied.count, 1) @@ -539,7 +539,7 @@ extension ProxyRuntimeControllerTests { let controller = makeController( probe: ProxyRuntimeFixture.FakeProbe.reachable(), settingsStore: settingsStore) - await controller.activate(seeded.server) + try await controller.activate(seeded.server) await controller.setProxyEnabled(true) var next = controller.settings diff --git a/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift b/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift index 30ebac10..1a33db9a 100644 --- a/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift @@ -186,7 +186,7 @@ final class SettingsWorkflowTests: XCTestCase { "香港 01", in: &catalog, credentials: credentials) try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog)) let pair = makePair() - await pair.controller.activate(server) + try await pair.controller.activate(server) await pair.controller.setProxyEnabled(true) await waitUntil(pair.controller.state == .running) return pair diff --git a/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift b/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift index 855631c4..c3ce7a91 100644 --- a/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift +++ b/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift @@ -64,7 +64,7 @@ final class UnreadableListenSettingsTests: XCTestCase { .duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 1086) ])) - await controller.activate(server) + try await controller.activate(server) await controller.setProxyEnabled(true) guard case .launchFailed(let detail) = controller.state else {