diff --git a/ShadowsocksX-NG2/App/CatalogCommitCoordinator.swift b/ShadowsocksX-NG2/App/CatalogCommitCoordinator.swift new file mode 100644 index 00000000..479bd392 --- /dev/null +++ b/ShadowsocksX-NG2/App/CatalogCommitCoordinator.swift @@ -0,0 +1,139 @@ +import Combine +import Foundation + +/// 已提交目录的内存快照(issue #40):协调器与运行时缝之间的传值。运行时 +/// 收敛以此为唯一目标,不回读磁盘;磁盘仍是重启与跨进程恢复的权威来源。 +struct CommittedCatalogSnapshot: Equatable, Sendable { + let catalog: ConfigurationCatalog + let subscriptions: [SubscriptionRecord] +} + +/// 目录提交后一次运行时收敛的结构化结果(issue #40)。协调器不生成本地化 +/// 文案:失败细节原样携带平台运行时给出的点名事实,由上层 presentation 决定 +/// 如何呈现。 +enum RuntimeSyncOutcome: Equatable, Sendable { + /// 代理未开启:活动目标已按提交快照重校验,未部署。 + case revalidated + /// 运行时已向提交快照收敛(部署已执行且启动健康通过)。 + case converged(skippedServers: [SkippedServer]) + /// 部署已执行但运行时未收敛;细节为平台运行时的点名事实(nil 表示等待 + /// 用户在系统设置批准等无额外细节的终态)。 + case failed(detail: String?) + /// 活动目标失效:目标已清除且代理已停止(无静默回退)。 + case clearedAndStopped(ActivationFailure) +} + +/// 运行时收敛的可观察阶段(issue #40):目录已提交 → 运行时同步中 → 结束。 +/// generation 为提交代次;被更新提交取代的旧代次不写最终状态。 +enum RuntimeSyncStatus: Equatable { + /// 无进行中或已完成的同步(无活动目标时提交保持此状态)。 + case idle + /// 正在向该代次提交快照收敛。 + case syncing(generation: Int) + /// 该代次收敛已结束。 + case finished(generation: Int, outcome: RuntimeSyncOutcome) +} + +/// 运行时同步缝(issue #40):目录提交协调器与代理运行时之间的窄接口。 +/// 生产实现适配现有代理运行时控制器;测试注入确定性 fake。 +@MainActor +protocol CatalogRuntimeSyncing: AnyObject { + /// 当前是否存在活动目标(无目标时目录提交不驱动运行时)。 + var hasActiveTarget: Bool { get } + /// 让运行时向刚提交的目录快照收敛。健康检查、LaunchAgent 与端点启动的 + /// 耗时属于本调用的异步收敛阶段。 + func converge(to snapshot: CommittedCatalogSnapshot) async -> RuntimeSyncOutcome +} + +/// 目录提交—运行时同步协调器(issue #40):普通配置变更的唯一提交入口。 +/// 管线为「副本变更 → 校验并原子持久化 → 发布已提交状态 → 异步运行时收敛」; +/// 目录提交在持久化成功后立即完成,不因运行时失败回滚目录,也不被健康检查 +/// 或启动耗时阻塞。每次提交递增代次:旧代次自然结束,但不得覆盖最新代次的 +/// 最终状态;没有自动重试,后续提交、显式激活或启动代理会发起新的同步尝试。 +@MainActor +final class CatalogCommitCoordinator: ObservableObject { + /// 最新提交的运行时收敛阶段(结构化;呈现由上层决定)。 + @Published private(set) var syncStatus: RuntimeSyncStatus = .idle + + let fileStore: CatalogFileStore + private let runtime: CatalogRuntimeSyncing + /// 提交代次:每次提交递增,用于丢弃被更新提交取代的旧同步结果。 + private var generation = 0 + + /// 已提交状态(协调器持有的内存快照;`commit` 成功后整体替换)。 + private(set) var committedCatalog: ConfigurationCatalog + private(set) var committedSubscriptions: [SubscriptionRecord] + + init(fileStore: CatalogFileStore, runtime: CatalogRuntimeSyncing) { + self.fileStore = fileStore + self.runtime = runtime + (committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore) + } + + /// 普通目录变更的唯一提交管线。任一同步步骤失败则已提交状态不动并原样 + /// 上抛(凭据等外部副作用由调用方回滚);运行时收敛在持久化成功后异步开始。 + func commit( + _ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T + ) throws -> T { + var workingCatalog = committedCatalog + var workingSubscriptions = committedSubscriptions + let result = try mutate(&workingCatalog, &workingSubscriptions) + try fileStore.save( + CatalogDocument(catalog: workingCatalog, subscriptions: workingSubscriptions)) + committedCatalog = workingCatalog + committedSubscriptions = workingSubscriptions + scheduleRuntimeSync( + CommittedCatalogSnapshot(catalog: workingCatalog, subscriptions: workingSubscriptions)) + return result + } + + /// Legacy 导入等独立路径直接落盘后对齐已提交状态;不触发运行时收敛 + /// (导入的运行时边界由独立路径自行驱动,不经普通提交管线)。 + func reloadCommittedStateFromStore() { + (committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore) + } + + /// 异步收敛调度:无活动目标不调用运行时;同步开始前与结束后都以代次 + /// 校验——被更新提交取代的旧同步直接结束或丢弃结果,不覆盖最新状态。 + private func scheduleRuntimeSync(_ snapshot: CommittedCatalogSnapshot) { + generation += 1 + let generation = generation + guard runtime.hasActiveTarget else { + syncStatus = .idle + return + } + syncStatus = .syncing(generation: generation) + Task { @MainActor [weak self] in + guard let self, self.generation == generation else { return } + let outcome = await self.runtime.converge(to: snapshot) + guard !Task.isCancelled, self.generation == generation else { return } + self.syncStatus = .finished(generation: generation, outcome: outcome) + } + } + + /// 磁盘读取的容错口径与首次加载一致:损坏文档回退全新空文档,等待 + /// 用户经由导入/设置路径修复。 + private static func loadCommitted( + from fileStore: CatalogFileStore + ) -> (catalog: ConfigurationCatalog, subscriptions: [SubscriptionRecord]) { + let loaded = (try? fileStore.load()) ?? CatalogDocument() + return (loaded.catalog, loaded.subscriptions) + } +} + +/// 生产运行时适配器(issue #40):协调器与现有代理运行时控制器之间的唯一 +/// 生产接线,由应用组合根建立。 +@MainActor +final class ProxyRuntimeSyncAdapter: CatalogRuntimeSyncing { + private let controller: ProxyRuntimeController + + init(controller: ProxyRuntimeController) { + self.controller = controller + } + + var hasActiveTarget: Bool { controller.isActiveTargetPresent } + + func converge(to snapshot: CommittedCatalogSnapshot) async -> RuntimeSyncOutcome { + await controller.catalogDidCommit(snapshot: snapshot.catalog) + } +} diff --git a/ShadowsocksX-NG2/App/CatalogViewModel+Subscriptions.swift b/ShadowsocksX-NG2/App/CatalogViewModel+Subscriptions.swift index 785ba972..a9a0f22f 100644 --- a/ShadowsocksX-NG2/App/CatalogViewModel+Subscriptions.swift +++ b/ShadowsocksX-NG2/App/CatalogViewModel+Subscriptions.swift @@ -3,8 +3,8 @@ import Foundation /// 订阅生命周期(spec #21 D4,issue #35):粘贴 HTTPS URL 创建、单个/全部 /// 立即更新、编辑 URL(保留身份)、删除(递归清除)。刷新 = 完整获取、解析、 /// 校验后同文档原子提交快照与刷新状态;任何失败保留最后成功快照、活动目标与 -/// 运行状态,仅标记来源失败。每次提交都触发 postCommit -/// 重展开(订阅子树变更 → 活动目标原子跟随或清除停止)。 +/// 运行状态,仅标记来源失败。每次提交都经目录提交协调器异步触发运行时收敛 +/// (issue #40;订阅子树变更 → 活动目标原子跟随或清除停止)。 extension CatalogViewModel { /// 创建订阅:校验 HTTPS URL → 落订阅记录与空固定分组 → 立即首次刷新。 /// 首次刷新失败留空分组加错误态(状态在刷新内标记,创建本身总是成功)。 @@ -17,7 +17,7 @@ extension CatalogViewModel { urlRef: .fresh(), status: .never) try credentials.save(url.absoluteString, for: record.urlRef) - try await commitDocument { catalog, subscriptions in + try commitDocument { catalog, subscriptions in // 固定分组挂在目录根(CONTEXT.md「Subscription group」);名称以 host // 兜底,首次成功刷新后跟随远端。 try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID) @@ -73,7 +73,7 @@ extension CatalogViewModel { throw SubscriptionFormError.notFound } var removedEntries: [CatalogEntry] = [] - try await commitDocument { catalog, subscriptions in + try commitDocument { catalog, subscriptions in removedEntries = try catalog.removeSubscriptionSubtree(of: record.groupID) subscriptions.removeAll { $0.id == id } } @@ -148,7 +148,7 @@ extension CatalogViewModel { var removedServers: [CatalogEntry] = [] var credentialJournal = CredentialWriteJournal(credentials: credentials) do { - try await commitDocument { catalog, subscriptions in + try commitDocument { catalog, subscriptions in // 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用; // journal 保证快照提交失败时恢复旧秘密。 var reusedRefs: [NodeID: ServerCredentialRefs] = [:] @@ -182,7 +182,7 @@ extension CatalogViewModel { } private func markRefreshFailed(subscriptionID: NodeID, reason: String) async { - try? await commitDocument { _, subscriptions in + try? commitDocument { _, 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/CatalogViewModel.swift b/ShadowsocksX-NG2/App/CatalogViewModel.swift index 538a1404..846f72bf 100644 --- a/ShadowsocksX-NG2/App/CatalogViewModel.swift +++ b/ShadowsocksX-NG2/App/CatalogViewModel.swift @@ -3,8 +3,9 @@ import SwiftUI /// 主窗口视图模型(issue #32/#35):把 ConfigurationCatalog 领域语义接到树 /// 操作、详情表单、添加三入口、分享与订阅生命周期。所有变更走「副本变更 → -/// 落盘 → 发布 → postCommit 重展开」,失败即整体不变更;凭据读写只在模型层 -/// 出现。 +/// 落盘 → 发布」,失败即整体不变更;凭据读写只在模型层出现。提交管线、 +/// 运行时收敛与并发代次由注入的 `CatalogCommitCoordinator` 负责(issue #40), +/// 本类在迁移期间保留为兼容 facade,向现有 SwiftUI 调用方提供已发布状态。 @MainActor final class CatalogViewModel: ObservableObject { @Published private(set) var catalog: ConfigurationCatalog @@ -21,29 +22,32 @@ final class CatalogViewModel: ObservableObject { @Published private(set) var legacyImportReport: LegacyImportReport? private var discoveredLegacySnapshot: LegacySnapshot? + /// 提交管线与运行时收敛的唯一入口(组合根注入,issue #40)。 + let coordinator: CatalogCommitCoordinator private let fileStore: CatalogFileStore /// 订阅扩展(CatalogViewModel+Subscriptions)同样经此读写凭据。 let credentials: CredentialStoring private let plugins: ManagedPluginProviding /// 订阅获取缝(默认 URLSession 实现;测试注入夹具,issue #35)。 var subscriptionFetcher: SubscriptionFetching - /// 目录提交后的运行时重展开(生产接线 `ProxyRuntimeController.catalogDidCommit`)。 - var postCommit: (() async -> Void)? - /// Legacy 导入提交后的运行时边界(生产接线为不写系统代理的停止/重载)。 - var postLegacyImport: ((LegacyImportOutcome) async -> Void)? + /// Legacy 导入提交后的运行时边界(组合根一次性接线;独立于普通提交管线)。 + private let postLegacyImport: ((LegacyImportOutcome) async -> Void)? private let legacyImportService: LegacyImportService init( - fileStore: CatalogFileStore = CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()), + coordinator: CatalogCommitCoordinator, credentials: CredentialStoring = KeychainCredentialStore(), plugins: ManagedPluginProviding = BundleManagedPluginProvider(), subscriptionFetcher: SubscriptionFetching = HTTPSSubscriptionFetcher(), - legacyImportService: LegacyImportService? = nil + legacyImportService: LegacyImportService? = nil, + postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil ) { - self.fileStore = fileStore + self.coordinator = coordinator + fileStore = coordinator.fileStore self.credentials = credentials self.plugins = plugins self.subscriptionFetcher = subscriptionFetcher + self.postLegacyImport = postLegacyImport self.legacyImportService = legacyImportService ?? LegacyImportService( @@ -52,9 +56,8 @@ final class CatalogViewModel: ObservableObject { activationStore: ActivationStateFileStore( fileURL: ActivationStateFileStore.defaultFileURL()), credentials: credentials) - let loaded = (try? fileStore.load()) ?? CatalogDocument() - catalog = loaded.catalog - subscriptions = loaded.subscriptions + catalog = coordinator.committedCatalog + subscriptions = coordinator.committedSubscriptions refreshLegacyImportState() } @@ -87,9 +90,9 @@ final class CatalogViewModel: ObservableObject { snapshot = current } let outcome = try legacyImportService.importSnapshot(snapshot, reimport: reimport) - let loaded = try fileStore.load() - catalog = loaded.catalog - subscriptions = loaded.subscriptions + coordinator.reloadCommittedStateFromStore() + catalog = coordinator.committedCatalog + subscriptions = coordinator.committedSubscriptions legacyImportReport = outcome.report refreshLegacyImportState() await postLegacyImport?(outcome) @@ -140,7 +143,7 @@ final class CatalogViewModel: ObservableObject { } guard !prepared.isEmpty else { return (0, failures) } do { - try await commit { catalog in + try commit { catalog in for item in prepared { try catalog.addServer(item.fields, to: parent) } @@ -158,7 +161,7 @@ final class CatalogViewModel: ObservableObject { func addGroup(named name: String, into parent: NodeID?) async throws -> NodeID { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { throw ServerFormError.emptyName } - return try await commit { catalog in + return try commit { catalog in try catalog.addGroup(trimmed, to: parent) } } @@ -166,21 +169,21 @@ final class CatalogViewModel: ObservableObject { func renameGroup(_ id: NodeID, to name: String) async throws { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { throw ServerFormError.emptyName } - try await commit { catalog in + try commit { catalog in try catalog.renameGroup(id, to: trimmed) } } /// 拖拽/移动菜单落点。跨来源、成环、移动进自身由领域拒绝,原样上抛呈现。 func move(_ id: NodeID, to parent: NodeID?) async throws { - try await commit { catalog in + try commit { catalog in try catalog.move(id, to: parent) } } /// 删除(手动分组递归删整棵子树),并尽力清理被删节点的 Keychain 秘密。 func remove(_ id: NodeID) async throws { - let removed: [CatalogEntry] = try await commit { catalog in + let removed: [CatalogEntry] = try commit { catalog in try catalog.remove(id) } if let selection = selectedNodeID, removed.contains(where: { $0.id == selection }) { @@ -215,7 +218,7 @@ final class CatalogViewModel: ObservableObject { throw ServerFormError.unsupportedEncryptionMethod(trimmedMethod) } guard !password.isEmpty else { throw ServerFormError.invalidPassword } - try await commit { catalog in + try commit { catalog in guard let entry = catalog.entry(for: id), case .server(var fields) = entry.kind else { throw CatalogError.notAServer(id) } @@ -312,28 +315,23 @@ final class CatalogViewModel: ObservableObject { // MARK: - 提交管线 - /// 副本变更 → 落盘 → 发布 → postCommit;任一步失败则已发布状态不动。 + /// 仅目录变更的提交便捷入口(订阅扩展使用双参 `commitDocument`)。 private func commit( _ mutate: (inout ConfigurationCatalog) throws -> T - ) async throws -> T { - try await commitDocument { catalog, _ in + ) throws -> T { + try commitDocument { catalog, _ in try mutate(&catalog) } } - /// 目录 + 订阅记录同文档提交(订阅刷新/创建/删除共用):任一步失败则已 - /// 发布状态不动,成功才依次发布并触发运行时重展开。 + /// 副本变更 → 协调器落盘 → 发布已提交状态;任一步失败则已发布状态不动。 + /// 运行时收敛由协调器异步调度(issue #40),不阻塞也不回滚本提交。 func commitDocument( _ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T - ) async throws -> T { - var workingCatalog = catalog - var workingSubscriptions = subscriptions - let result = try mutate(&workingCatalog, &workingSubscriptions) - try fileStore.save( - CatalogDocument(catalog: workingCatalog, subscriptions: workingSubscriptions)) - catalog = workingCatalog - subscriptions = workingSubscriptions - await postCommit?() + ) throws -> T { + let result = try coordinator.commit(mutate) + catalog = coordinator.committedCatalog + subscriptions = coordinator.committedSubscriptions return result } diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 06249566..ec9e9433 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -4,20 +4,32 @@ import SwiftUI struct ShadowsocksXNG2App: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var proxyController: ProxyRuntimeController - @StateObject private var catalogViewModel = CatalogViewModel() + @StateObject private var catalogViewModel: CatalogViewModel @StateObject private var loginController: LaunchAtLoginController init() { let settingsStore = ProxySettingsFileStore() let restoredSettings = ProxySettingsFileStore.restored(store: settingsStore) - _proxyController = StateObject( - wrappedValue: ProxyRuntimeController( - listenRestore: RestoredListenSettings( - settings: restoredSettings.settings.listen, unreadableError: nil), - settingsStore: settingsStore, - settingsRestore: restoredSettings)) + let controller = ProxyRuntimeController( + listenRestore: RestoredListenSettings( + settings: restoredSettings.settings.listen, unreadableError: nil), + settingsStore: settingsStore, + settingsRestore: restoredSettings) + _proxyController = StateObject(wrappedValue: controller) let loginController = LaunchAtLoginController() _loginController = StateObject(wrappedValue: loginController) + // 组合根(issue #40):目录提交协调器与生产运行时适配器只在此接线一次, + // 各 scene 不再重复设置提交回调;Legacy 导入边界同为一次性注入。 + let coordinator = CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()), + runtime: ProxyRuntimeSyncAdapter(controller: controller)) + _catalogViewModel = StateObject( + wrappedValue: CatalogViewModel( + coordinator: coordinator, + postLegacyImport: { outcome in + await controller.legacyImportDidCommit() + loginController.applyImportedValue(outcome.loginAtLogin) + })) Task { @MainActor in loginController.syncAtLaunch() } @@ -30,17 +42,8 @@ struct ShadowsocksXNG2App: App { MenuBarExtra("ShadowsocksX-NG 2.0", systemImage: "network") { ProxyStatusMenu(controller: proxyController, catalogViewModel: catalogViewModel) .task { - // postCommit 在应用启动即接线(不只主窗口打开时):菜单栏「立即更新 - // 全部订阅」(issue #35)等未开窗路径的目录提交同样立即重展开运行时。 - let controller = proxyController - catalogViewModel.postCommit = { await controller.catalogDidCommit() } - let loginController = loginController - catalogViewModel.postLegacyImport = { outcome in - await controller.legacyImportDidCommit() - loginController.applyImportedValue(outcome.loginAtLogin) - } // 全局快捷键(issue #31):开关代理、切换模式,与菜单项同一入口。 - GlobalShortcuts.wire(controller: controller) + GlobalShortcuts.wire(controller: proxyController) await proxyController.resyncOnLaunch() } } @@ -51,16 +54,6 @@ struct ShadowsocksXNG2App: App { viewModel: catalogViewModel, proxyController: proxyController, eventStore: .shared ) - .task { - // 目录提交 → 代理运行时立即重展开(spec #21 D3「已提交编辑立即跟随」)。 - let controller = proxyController - catalogViewModel.postCommit = { await controller.catalogDidCommit() } - let loginController = loginController - catalogViewModel.postLegacyImport = { outcome in - await controller.legacyImportDidCommit() - loginController.applyImportedValue(outcome.loginAtLogin) - } - } } .defaultLaunchBehavior( catalogViewModel.shouldOfferLegacyImport ? .automatic : .suppressed) diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index cdde5f39..cad4180a 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -231,22 +231,6 @@ final class ProxyRuntimeController: ObservableObject { await presentLaunchHealth(document) } - /// 目录已提交变更后的立即重展开(D3/D5):有效非空且代理开启 → 原子更新 - /// 运行时;目标失效 → 清除目标并停止代理。目录以磁盘为事实来源重载。 - func catalogDidCommit() async { - reloadCatalog() - switch reexpand() { - case .deployed(let configuration): - if state != .off { - await deploy(configuration.document) - } - case .clearedAndStopped(let failure): - await handleCleared(failure) - case nil: - break - } - } - /// GUI 启动重同步(D5「GUI 下次启动重新校验同步」):注册态是代理意图的 /// 事实来源——注册过即视为开启并重校验;随后与磁盘契约对齐(相同内容跳 /// 过写入)。GUI 崩溃期间 agent 与 wrapper 均不受影响。 @@ -433,6 +417,44 @@ final class ProxyRuntimeController: ObservableObject { } extension ProxyRuntimeController { + /// 目录提交协调器的生产适配入口(issue #40):以刚提交的内存快照重展开, + /// 不回读磁盘(磁盘仍是重启与跨进程恢复的权威来源)。有效非空且代理开启 + /// → 原子更新运行时;目标失效 → 清除目标并停止代理。返回结构化收敛结果, + /// 健康检查耗时属于本调用的异步收敛阶段,不改变「目录已提交」的事实。 + func catalogDidCommit(snapshot catalog: ConfigurationCatalog) async -> RuntimeSyncOutcome { + self.catalog = catalog + switch reexpand() { + case .deployed(let configuration): + guard state != .off else { + return .revalidated + } + await deploy(configuration.document) + return Self.syncOutcome(for: state, skippedServers: configuration.skippedServers) + case .clearedAndStopped(let failure): + await handleCleared(failure) + return .clearedAndStopped(failure) + case nil: + return .revalidated + } + } + + /// 部署后的控制器状态 → 结构化收敛结果:健康通过(含防火墙受阻的运行态) + /// 视为已收敛;启动、服务、系统代理与激活拒绝都视为未收敛并携带点名细节。 + private static func syncOutcome( + for state: ProxyState, skippedServers: [SkippedServer] + ) -> RuntimeSyncOutcome { + switch state { + case .running, .firewallBlocked: + .converged(skippedServers: skippedServers) + case .launchFailed(let detail), .serviceFailed(let detail), .systemProxyFailed(let detail): + .failed(detail: detail) + case .activationFailed(let reason): + .failed(detail: reason) + case .requiresApproval, .off, .starting: + .failed(detail: nil) + } + } + /// Persists a fully validated settings snapshot and, when the proxy is /// active, re-derives the same runtime path with the new snapshot. func updateSettings(_ next: ProxySettings) async throws { diff --git a/ShadowsocksX-NG2/Tests/CatalogCommitCoordinatorTests.swift b/ShadowsocksX-NG2/Tests/CatalogCommitCoordinatorTests.swift new file mode 100644 index 00000000..d860cf08 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/CatalogCommitCoordinatorTests.swift @@ -0,0 +1,287 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 确定性运行时同步替身(issue #40 测试缝):记录 converge 快照与次序, +/// 可编程每次结果;门闩让同步在收敛中挂起,用于验证提交代次语义。 +@MainActor +final class FakeCatalogRuntime: CatalogRuntimeSyncing { + var hasActiveTarget = false + /// 未编程结果时的默认返回。 + var defaultOutcome: RuntimeSyncOutcome = .converged(skippedServers: []) + private var outcomeQueue: [RuntimeSyncOutcome] = [] + private(set) var convergeSnapshots: [CommittedCatalogSnapshot] = [] + + /// 门闩:武装后接下来的 converge 在记录调用后挂起,直到 openGate。 + private var armedGateCount = 0 + private var gateWaiters: [CheckedContinuation] = [] + + var convergeCount: Int { convergeSnapshots.count } + + /// 预编程按次序返回的结果(耗尽后回退 `defaultOutcome`)。 + func enqueueOutcomes(_ outcomes: [RuntimeSyncOutcome]) { + outcomeQueue.append(contentsOf: outcomes) + } + + func armGate(count: Int = 1) { + armedGateCount = count + } + + func openGate() { + armedGateCount = 0 + gateWaiters.forEach { $0.resume() } + gateWaiters.removeAll() + } + + func converge(to snapshot: CommittedCatalogSnapshot) async -> RuntimeSyncOutcome { + convergeSnapshots.append(snapshot) + let result = outcomeQueue.isEmpty ? defaultOutcome : outcomeQueue.removeFirst() + if armedGateCount > 0 { + armedGateCount -= 1 + await withCheckedContinuation { gateWaiters.append($0) } + } + return result + } +} + +/// 让出 MainActor 直到条件成立(被测同步任务在同一线程上排队执行)。供 +/// 协调器与视图模型测试共享:提交后的运行时收敛不再内联等待(issue #40)。 +@MainActor +func waitUntilRuntimeSettles( + _ condition: @autoclosure () -> Bool, + timeout: TimeInterval = 2, + file: StaticString = #filePath, + line: UInt = #line +) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + await Task.yield() + } + XCTAssertTrue(condition(), "等待条件超时", file: file, line: line) +} + +/// 目录提交—运行时同步协调器(issue #40):只断言外部行为——持久化与发布、 +/// 运行时是否被调用及使用的快照、阶段与失败结果、并发提交的最终赢家、无效 +/// 活动目标的停止语义。全部走临时目录文件存储,不触碰真实运行时。 +@MainActor +final class CatalogCommitCoordinatorTests: XCTestCase { + private var workDir: URL! + private var fileURL: URL! + private var runtime: FakeCatalogRuntime! + private var coordinator: CatalogCommitCoordinator! + + override func setUp() async throws { + try await super.setUp() + // 专用工作目录:写入器会把父目录强制 0700(不得指向临时根)。 + workDir = FileManager.default.temporaryDirectory + .appendingPathComponent("catalog-commit-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + fileURL = workDir.appendingPathComponent("catalog.json") + runtime = FakeCatalogRuntime() + coordinator = makeCoordinator() + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: workDir) + try await super.tearDown() + } + + private func makeCoordinator() -> CatalogCommitCoordinator { + CatalogCommitCoordinator(fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime) + } + + /// 预置目录后重建协调器(模拟已落盘的既有状态)。 + private func seedCatalog(_ mutate: (inout ConfigurationCatalog) throws -> Void) throws { + var catalog = ConfigurationCatalog() + try mutate(&catalog) + try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: catalog)) + coordinator = makeCoordinator() + } + + private func persistedCatalog() throws -> ConfigurationCatalog { + try CatalogFileStore(fileURL: fileURL).load().catalog + } + + // MARK: - 无活动目标 + + func testCommitWithoutActiveTargetPersistsAndPublishesWithoutRuntimeCall() throws { + runtime.hasActiveTarget = false + + let added = try coordinator.commit { catalog, _ in + try catalog.addTestServer("香港 01") + } + + XCTAssertTrue(coordinator.committedCatalog.contains(added), "提交后已发布新状态") + XCTAssertEqual(try persistedCatalog().rootChildren, [added], "变更已原子持久化") + XCTAssertEqual(runtime.convergeCount, 0, "无活动目标不驱动运行时") + XCTAssertEqual(coordinator.syncStatus, .idle) + } + + // MARK: - 活动目标提交 + + func testCommitWithActiveServerTargetConvergesToCommittedSnapshot() async throws { + try seedCatalog { try $0.addTestServer("已有节点") } + runtime.hasActiveTarget = true + + let added = try coordinator.commit { catalog, _ in + try catalog.addTestServer("新节点") + } + + await waitUntilRuntimeSettles(runtime.convergeCount == 1) + XCTAssertEqual(runtime.convergeCount, 1, "一次提交恰好一次同步") + let snapshot = try XCTUnwrap(runtime.convergeSnapshots.first) + XCTAssertTrue(snapshot.catalog.contains(added), "同步使用刚提交的内存快照") + XCTAssertEqual(snapshot.subscriptions, []) + XCTAssertEqual( + coordinator.syncStatus, + .finished(generation: 1, outcome: .converged(skippedServers: []))) + } + + func testCommitWithActiveGroupTargetUsesSamePipeline() async throws { + try seedCatalog { catalog in + let group = try catalog.addGroup("手动组") + _ = try catalog.addTestServer("成员", to: group) + } + runtime.hasActiveTarget = true + let groupID = try XCTUnwrap(coordinator.committedCatalog.rootChildren.first) + + _ = try coordinator.commit { catalog, _ in + try catalog.addTestServer("新成员", to: groupID) + } + + await waitUntilRuntimeSettles(runtime.convergeCount == 1) + XCTAssertEqual(runtime.convergeCount, 1, "分组目标与服务器目标共用同一提交语义") + let snapshot = try XCTUnwrap(runtime.convergeSnapshots.first) + let group = try XCTUnwrap(snapshot.catalog.entry(for: groupID)) + guard case .group(let fields) = group.kind else { + return XCTFail("快照中的目标应为分组") + } + XCTAssertEqual(fields.children.count, 2, "同步快照携带刚提交的新子树") + } + + func testMoveRemoveAndRenameShareCommitSemantics() async throws { + try seedCatalog { catalog in + let group = try catalog.addGroup("组") + _ = try catalog.addTestServer("成员", to: group) + } + runtime.hasActiveTarget = true + let groupID = try XCTUnwrap(coordinator.committedCatalog.rootChildren.first) + let serverID = try XCTUnwrap(coordinator.committedCatalog.children(of: groupID).first) + + try coordinator.commit { catalog, _ in try catalog.move(serverID, to: nil) } + await waitUntilRuntimeSettles(runtime.convergeCount == 1) + try coordinator.commit { catalog, _ in try catalog.renameGroup(groupID, to: "改名") } + await waitUntilRuntimeSettles(runtime.convergeCount == 2) + try coordinator.commit { catalog, _ in try catalog.remove(serverID) } + await waitUntilRuntimeSettles(runtime.convergeCount == 3) + + XCTAssertEqual(runtime.convergeCount, 3, "移动、改名、删除各触发一次同步") + XCTAssertEqual( + coordinator.syncStatus, + .finished(generation: 3, outcome: .converged(skippedServers: [])), + "最新代次的收敛结果是最终状态") + } + + // MARK: - 无效活动目标 + + func testRemovedActiveTargetClearsAndStopsWithoutSilentFallback() async throws { + try seedCatalog { try $0.addTestServer("活动节点") } + let targetID = try XCTUnwrap(coordinator.committedCatalog.rootChildren.first) + runtime.hasActiveTarget = true + runtime.enqueueOutcomes([.clearedAndStopped(.targetNotFound(targetID))]) + + try coordinator.commit { catalog, _ in try catalog.remove(targetID) } + + await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 1)) + XCTAssertEqual(runtime.convergeCount, 1) + XCTAssertEqual( + coordinator.syncStatus, + .finished(generation: 1, outcome: .clearedAndStopped(.targetNotFound(targetID))), + "清除停止以结构化结果呈现") + XCTAssertFalse(try persistedCatalog().contains(targetID), "运行时失败不回滚目录") + } + + // MARK: - 目录与运行时结果区分 + + func testRuntimeFailureKeepsCommittedCatalogAndDistinguishesResult() async throws { + runtime.hasActiveTarget = true + runtime.defaultOutcome = .failed(detail: "agent register failed") + + let added = try coordinator.commit { catalog, _ in + try catalog.addTestServer("香港 01") + } + + await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 1)) + XCTAssertTrue(try persistedCatalog().contains(added), "运行时失败不回滚已提交目录") + XCTAssertEqual( + coordinator.syncStatus, + .finished(generation: 1, outcome: .failed(detail: "agent register failed")), + "结果明确区分目录已提交与运行时未收敛") + } + + // MARK: - 提交代次 + + func testSupersededSyncBeforeStartNeverCallsRuntime() async throws { + runtime.hasActiveTarget = true + + try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") } + try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") } + // 同一 MainActor 回合内的两次提交:旧代次同步尚未启动即被取代。 + await waitUntilRuntimeSettles(runtime.convergeCount == 1) + + XCTAssertEqual(runtime.convergeCount, 1, "被取代的旧代次不触碰运行时") + let snapshot = try XCTUnwrap(runtime.convergeSnapshots.first) + XCTAssertEqual(snapshot.catalog.rootChildren.count, 2, "最终同步采用最新提交快照") + XCTAssertEqual( + coordinator.syncStatus, + .finished(generation: 2, outcome: .converged(skippedServers: []))) + } + + func testDelayedSyncCannotOverrideLatestSnapshotOrResult() async throws { + runtime.hasActiveTarget = true + runtime.enqueueOutcomes([ + .converged(skippedServers: []), + .failed(detail: "最新代次的失败结果"), + ]) + runtime.armGate() + + try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") } + await waitUntilRuntimeSettles(runtime.convergeCount == 1) + + try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") } + runtime.openGate() + await waitUntilRuntimeSettles(runtime.convergeCount == 2) + await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 2)) + + XCTAssertEqual(runtime.convergeCount, 2) + XCTAssertEqual( + runtime.convergeSnapshots[1].catalog.rootChildren.count, 2, "最新代次使用最新快照") + XCTAssertEqual( + coordinator.syncStatus, + .finished(generation: 2, outcome: .failed(detail: "最新代次的失败结果")), + "迟到的旧代次结果不得覆盖最新代次") + } + + // MARK: - 独立路径对齐 + + func testReloadCommittedStateAdoptsExternalWritesWithoutRuntimeCall() throws { + runtime.hasActiveTarget = true + + // Legacy 导入等独立路径绕过协调器直接落盘(订阅固定分组须在目录内)。 + var external = ConfigurationCatalog() + let groupID = NodeID(rawValue: "import:group") + try external.addGroup("导入订阅组", source: .subscription, id: groupID) + try CatalogFileStore(fileURL: fileURL).save( + CatalogDocument( + catalog: external, + subscriptions: [ + SubscriptionRecord(id: .fresh(), groupID: groupID, urlRef: .fresh(), status: .never) + ])) + + coordinator.reloadCommittedStateFromStore() + + XCTAssertEqual(coordinator.committedCatalog.rootChildren, [groupID], "已提交状态对齐磁盘") + XCTAssertEqual(coordinator.committedSubscriptions.count, 1) + XCTAssertEqual(runtime.convergeCount, 0, "对齐不触发运行时收敛") + } +} diff --git a/ShadowsocksX-NG2/Tests/CatalogViewModelPluginTests.swift b/ShadowsocksX-NG2/Tests/CatalogViewModelPluginTests.swift index 5c5b9889..2f8059e3 100644 --- a/ShadowsocksX-NG2/Tests/CatalogViewModelPluginTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogViewModelPluginTests.swift @@ -43,12 +43,11 @@ final class CatalogViewModelPluginTests: XCTestCase { } private func makeViewModel() -> CatalogViewModel { - let model = CatalogViewModel( - fileStore: CatalogFileStore(fileURL: fileURL), + CatalogViewModel( + coordinator: CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()), credentials: credentials, plugins: BundleManagedPluginProvider(bundleURL: bundleRoot)) - model.postCommit = {} - return model } /// 直建目录(不经 URI 导入)后重载视图模型,返回该服务器身份。 diff --git a/ShadowsocksX-NG2/Tests/CatalogViewModelSubscriptionTests.swift b/ShadowsocksX-NG2/Tests/CatalogViewModelSubscriptionTests.swift index ccf47f29..6c61e154 100644 --- a/ShadowsocksX-NG2/Tests/CatalogViewModelSubscriptionTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogViewModelSubscriptionTests.swift @@ -3,16 +3,17 @@ import XCTest @testable import ShadowsocksX_NG2 /// 订阅端到端语义(issue #35 验收):创建门禁、刷新失败保留快照、overlay 与 -/// 身份连续性、URL 编辑保身份、删除递归清除、UI 数据面与 postCommit 接线。 -/// 获取走 FakeSubscriptionFetcher(传输侧契约在 SubscriptionFetcherTests)。 +/// 身份连续性、URL 编辑保身份、删除递归清除、UI 数据面与提交接线。 +/// 获取走 FakeSubscriptionFetcher(传输侧契约在 SubscriptionFetcherTests); +/// 提交后的运行时收敛以确定性 fake 计数(issue #40)。 @MainActor final class CatalogViewModelSubscriptionTests: XCTestCase { private var workDir: URL! private var fileURL: URL! private var credentials: InMemoryCredentialStore! private var fetcher: FakeSubscriptionFetcher! + private var runtime: FakeCatalogRuntime! private var viewModel: CatalogViewModel! - private var commitCounter: CommitCounter! override func setUp() async throws { try await super.setUp() @@ -21,7 +22,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) fileURL = workDir.appendingPathComponent("catalog.json") credentials = InMemoryCredentialStore() - commitCounter = CommitCounter() + runtime = FakeCatalogRuntime() fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 1))) viewModel = makeViewModel() } @@ -32,13 +33,15 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { } private func makeViewModel() -> CatalogViewModel { + // 计数即提交计数:提交后的运行时收敛由协调器异步调度(issue #40)。 + runtime.hasActiveTarget = true + let coordinator = CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime) let model = CatalogViewModel( - fileStore: CatalogFileStore(fileURL: fileURL), + coordinator: coordinator, credentials: credentials, plugins: NoManagedPluginProvider()) model.subscriptionFetcher = fetcher! - let counter = commitCounter! - model.postCommit = { await counter.increment() } return model } @@ -123,7 +126,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { fetcher = FakeSubscriptionFetcher(behavior: .success(treeDoc)) viewModel = makeViewModel() let record = try await viewModel.createSubscription(urlString: "https://p.example.com/s.json") - let commitsBefore = await commitCounter.count + let convergesBefore = runtime.convergeCount fetcher = FakeSubscriptionFetcher(behavior: .failure(.httpStatus(code: 503))) viewModel.subscriptionFetcher = fetcher @@ -139,8 +142,8 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { } XCTAssertTrue(reason.contains("503")) // 仅状态提交了一次(快照未动)。 - let commitsAfter = await commitCounter.count - XCTAssertEqual(commitsAfter - commitsBefore, 1) + await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 1) + XCTAssertEqual(runtime.convergeCount - convergesBefore, 1) } func testDecodeAndSchemaFailuresMarkFailedWithoutTouchingSnapshot() async throws { @@ -301,7 +304,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { let record = try await seedSuccessfulSubscription() viewModel.selectedNodeID = serverAID let passwordRef = try XCTUnwrap(serverFields(of: serverAID)).passwordRef - let commitsBefore = await commitCounter.count + let convergesBefore = runtime.convergeCount try await viewModel.removeSubscription(record.id) @@ -312,8 +315,8 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { XCTAssertNil(viewModel.selectedNodeID, "选中节点随子树清除") XCTAssertNil(try credentials.secret(for: passwordRef), "远端成员凭据一并清理") XCTAssertNil(try credentials.secret(for: record.urlRef), "订阅 URL 凭据一并清理") - let commitsAfter = await commitCounter.count - XCTAssertEqual(commitsAfter - commitsBefore, 1, "删除经 postCommit 触发活动目标重展开接线") + await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 1) + XCTAssertEqual(runtime.convergeCount - convergesBefore, 1, "删除经提交协调器触发活动目标重展开") } // MARK: 批量刷新与持久化 @@ -322,7 +325,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { _ = try await seedSuccessfulSubscription() _ = try await createOnly(urlString: "https://q.example.com/t.json") let requestsBefore = fetcher.requestCount - let commitsBefore = await commitCounter.count + let convergesBefore = runtime.convergeCount await viewModel.refreshAllSubscriptions() @@ -332,15 +335,16 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { return XCTFail("全部订阅都应记成功态:\(record.id)") } } - let commitsAfter = await commitCounter.count - XCTAssertEqual(commitsAfter - commitsBefore, 2) + await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 2) + XCTAssertEqual(runtime.convergeCount - convergesBefore, 2) } func testSubscriptionsPersistAcrossViewModelReload() async throws { let record = try await seedSuccessfulSubscription() let reloaded = CatalogViewModel( - fileStore: CatalogFileStore(fileURL: fileURL), + coordinator: CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()), credentials: credentials, plugins: NoManagedPluginProvider()) XCTAssertEqual(reloaded.subscriptions.count, 1) diff --git a/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift b/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift index cd21f33e..8318049a 100644 --- a/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift @@ -3,14 +3,15 @@ import XCTest @testable import ShadowsocksX_NG2 /// 主窗口视图模型(issue #32):三入口导入、凭据生命周期、订阅子树只读夹具、 -/// 分享互逆与 postCommit 接线。全部走内存凭据存储与临时目录文件存储。 +/// 分享互逆与提交接线。全部走内存凭据存储与临时目录文件存储;提交后的运行 +/// 时收敛以确定性 fake 计数(issue #40)。 @MainActor final class CatalogViewModelTests: XCTestCase { private var workDir: URL! private var fileURL: URL! private var credentials: InMemoryCredentialStore! + private var runtime: FakeCatalogRuntime! private var viewModel: CatalogViewModel! - private var commitCounter: CommitCounter! override func setUp() async throws { try await super.setUp() @@ -20,7 +21,7 @@ final class CatalogViewModelTests: XCTestCase { try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) fileURL = workDir.appendingPathComponent("catalog.json") credentials = InMemoryCredentialStore() - commitCounter = CommitCounter() + runtime = FakeCatalogRuntime() viewModel = makeViewModel() } @@ -30,13 +31,14 @@ final class CatalogViewModelTests: XCTestCase { } private func makeViewModel() -> CatalogViewModel { - let model = CatalogViewModel( - fileStore: CatalogFileStore(fileURL: fileURL), + // 计数即提交计数:提交后的运行时收敛由协调器异步调度(issue #40)。 + runtime.hasActiveTarget = true + let coordinator = CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime) + return CatalogViewModel( + coordinator: coordinator, credentials: credentials, plugins: NoManagedPluginProvider()) - let counter = commitCounter! - model.postCommit = { await counter.increment() } - return model } private func makeSubscriptionCatalog() throws { @@ -137,8 +139,14 @@ final class CatalogViewModelTests: XCTestCase { XCTAssertEqual(fields.remark, "改过的") XCTAssertEqual(fields.passwordRef, oldRef, "同一引用始终对应最新秘密") XCTAssertEqual(try credentials.secret(for: oldRef), "新密码") - let commitCount = await commitCounter.count - XCTAssertEqual(commitCount, 2, "导入 + 更新各一次提交") + // 连续提交合并为最新代次的收敛(issue #40):旧代次在启动前被取代, + // 最终结果必为最新提交快照。 + await waitUntilRuntimeSettles(viewModel.coordinator.syncStatus != .syncing(generation: 2)) + XCTAssertEqual( + viewModel.coordinator.syncStatus, + .finished(generation: 2, outcome: .converged(skippedServers: []))) + XCTAssertFalse(runtime.convergeSnapshots.isEmpty) + XCTAssertEqual(runtime.convergeSnapshots.last?.catalog, viewModel.catalog) } func testUpdateServerRejectsInvalidAddressAndPort() async throws { @@ -251,12 +259,6 @@ final class CatalogViewModelTests: XCTestCase { } } -/// postCommit 触发计数。 -actor CommitCounter { - private(set) var count = 0 - func increment() { count += 1 } -} - /// `XCTAssertThrowsError` 的 async 版本(表达式在 await 之后才能检查)。 func expectThrowsAsync( _ expression: () async throws -> Void, diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift index 4054f1e5..192a3632 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift @@ -336,7 +336,9 @@ final class ProxyRuntimeControllerTests: XCTestCase { try catalog.updateServer(seeded.server, with: fields) try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog)) - await controller.catalogDidCommit() + // 协调器生产适配入口(issue #40):以刚提交的内存快照重展开。 + let committed = try CatalogFileStore(fileURL: catalogFileURL).load().catalog + await controller.catalogDidCommit(snapshot: committed) XCTAssertEqual(controller.state, .running) let expectedRemark = "新加坡 01" @@ -359,7 +361,9 @@ final class ProxyRuntimeControllerTests: XCTestCase { try catalog.remove(seeded.server) try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog)) - await controller.catalogDidCommit() + // 协调器生产适配入口(issue #40):以刚提交的内存快照重展开。 + let committed = try CatalogFileStore(fileURL: catalogFileURL).load().catalog + await controller.catalogDidCommit(snapshot: committed) guard case .activationFailed = controller.state else { XCTFail("应呈现点名原因,实际 \(controller.state)")