diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Diagnostics.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Diagnostics.swift index 8f2bc422..7768182d 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Diagnostics.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Diagnostics.swift @@ -7,7 +7,7 @@ extension CatalogWorkflow { /// 当前目录的聚合诊断事实(服务器/分组/来源/插件计数与已知无效数)。 var diagnosticCatalogFacts: DiagnosticCatalogFacts { DiagnosticCatalogFacts( - counts: DiagnosticReportBuilder.counts(in: coordinator.committedCatalog), + counts: DiagnosticReportBuilder.counts(in: dependencies.coordinator.committedCatalog), knownInvalidServerCount: tree.invalidServerCount) } } diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Legacy.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Legacy.swift index bd16cf5a..87a3bd7f 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Legacy.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Legacy.swift @@ -12,33 +12,34 @@ extension CatalogWorkflow { func importLegacy(reimport: Bool = false) async throws -> LegacyImportReport { let snapshot: LegacySnapshot if reimport { - guard let current = try legacyImportService.readSnapshot() else { + guard let current = try dependencies.legacyImportService.readSnapshot() else { throw LegacyImportError.noSnapshot } snapshot = current } else if let discoveredLegacySnapshot { snapshot = discoveredLegacySnapshot } else { - guard let current = try legacyImportService.readSnapshot() else { + guard let current = try dependencies.legacyImportService.readSnapshot() else { throw LegacyImportError.noSnapshot } snapshot = current } - let outcome = try legacyImportService.importSnapshot(snapshot, reimport: reimport) + let outcome = try dependencies.legacyImportService.importSnapshot( + snapshot, reimport: reimport) // 导入服务独立落盘各 store 后对齐内存已提交状态;不经普通提交管线, // 因此不触发运行时收敛。 - coordinator.reloadCommittedStateFromStore() + dependencies.coordinator.reloadCommittedStateFromStore() republishCommittedState() publishLegacyImportReport(outcome.report) refreshLegacyImportState() - await postLegacyImport?(outcome) + await dependencies.postLegacyImport?(outcome) return outcome.report } /// 重读磁盘发现状态,不写任何标记或 Legacy 数据。 func refreshLegacyImportState() { - let completed = (try? legacyImportService.isCompleted()) ?? false - discoveredLegacySnapshot = try? legacyImportService.readSnapshot() + let completed = (try? dependencies.legacyImportService.isCompleted()) ?? false + discoveredLegacySnapshot = try? dependencies.legacyImportService.readSnapshot() publishLegacyImportState( LegacyImportAvailability( snapshotFound: discoveredLegacySnapshot != nil, completed: completed)) diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift index 664ce3e0..f3eca381 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Policy.swift @@ -113,6 +113,6 @@ extension CatalogWorkflow { /// 激活命令(typed command surface):经 `Activating` port 发出;编排与 /// 运行时收敛留在 port 背后。意外错误 throws。 func activate(_ id: NodeID) async throws -> ActivationCommandOutcome { - try await activator.activate(id) + try await dependencies.activator.activate(id) } } diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift index 15c15073..4bda3210 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift @@ -22,7 +22,7 @@ extension CatalogWorkflow { groupID: .fresh(), urlRef: .fresh(), status: .never) - try credentials.save(url.absoluteString, for: record.urlRef) + try dependencies.credentials.save(url.absoluteString, for: record.urlRef) do { try commitSubscriptionDocument { catalog, subscriptions in // 固定分组挂在目录根(CONTEXT.md「Subscription group」);名称以 host @@ -31,7 +31,7 @@ extension CatalogWorkflow { subscriptions.append(record) } } catch { - try? credentials.delete(record.urlRef) + try? dependencies.credentials.delete(record.urlRef) throw error } await refreshSubscription(record.id) @@ -77,7 +77,7 @@ extension CatalogWorkflow { let url = try Self.validatedSubscriptionURL(urlString) // 覆盖写同一引用;URL 编辑不落目录文档(引用与身份不变),新地址刷新 // 成功才有新快照——写入失败不触碰目录状态。 - try credentials.save(url.absoluteString, for: record.urlRef) + try dependencies.credentials.save(url.absoluteString, for: record.urlRef) await refreshSubscription(record.id) } @@ -94,7 +94,7 @@ extension CatalogWorkflow { subscriptions.removeAll { $0.id == id } } for ref in [record.urlRef] + Self.credentialRefs(of: removedEntries) { - try? credentials.delete(ref) + try? dependencies.credentials.delete(ref) } return RemovalOutcome(removedNodeIDs: Set(removedEntries.map(\.id))) } @@ -104,7 +104,7 @@ extension CatalogWorkflow { guard let record = subscriptionRecord(withID: id) else { throw SubscriptionFormError.notFound } - guard let stored = try credentials.secret(for: record.urlRef) else { + guard let stored = try dependencies.credentials.secret(for: record.urlRef) else { throw CredentialStoreError.keychainStatus(errSecItemNotFound) } return stored @@ -118,7 +118,7 @@ extension CatalogWorkflow { // 缺凭据是命名失败(区别于地址格式无效),不静默当作空 URL。 let urlString = try subscriptionURL(for: record.id) let url = try Self.validatedSubscriptionURL(urlString) - let data = try await subscriptionFetcher.fetch(url) + let data = try await dependencies.subscriptionFetcher.fetch(url) let snapshot = try SubscriptionDocumentParser.parse(data, subscriptionID: record.id) let summary = subscriptions.first(where: { $0.id == record.id }) @@ -132,7 +132,7 @@ extension CatalogWorkflow { _ snapshot: SubscriptionSnapshot, summary: SubscriptionSummary, fallbackName: String ) async throws { var removedServers: [CatalogEntry] = [] - var credentialJournal = CredentialWriteJournal(credentials: credentials) + var credentialJournal = CredentialWriteJournal(credentials: dependencies.credentials) do { try commitSubscriptionDocument { catalog, subscriptions in // 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用; @@ -164,7 +164,7 @@ extension CatalogWorkflow { } // 被移除节点的凭据引用不会被新树复用(身份已不在),提交成功后清理。 for ref in Self.credentialRefs(of: removedServers) { - try? credentials.delete(ref) + try? dependencies.credentials.delete(ref) } } @@ -194,7 +194,7 @@ extension CatalogWorkflow { /// 订阅原始记录查询(module 内部;投影不含凭据引用等原始形态)。 private func subscriptionRecord(withID id: NodeID) -> SubscriptionRecord? { - coordinator.committedSubscriptions.first { $0.id == id } + dependencies.coordinator.committedSubscriptions.first { $0.id == id } } /// 订阅卡片 projection 推导(module 内部):名称、URL host、结构化状态、 @@ -268,7 +268,7 @@ extension CatalogWorkflow { } /// 订阅地址门禁:必须是带主机的 HTTPS URL(宽松模式永不提供)。 - static func validatedSubscriptionURL(_ string: String) throws -> URL { + private static func validatedSubscriptionURL(_ string: String) throws -> URL { let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) guard let url = URL(string: trimmed), url.scheme?.lowercased() == "https", @@ -280,8 +280,8 @@ extension CatalogWorkflow { } } -/// 一台服务器叶子的凭据引用对(密码 + 可选插件参数)。 -struct ServerCredentialRefs: Sendable { +/// 一台服务器叶子的凭据引用对(密码 + 可选插件参数;仅本实现文件使用)。 +private struct ServerCredentialRefs: Sendable { var password: CredentialReference var options: CredentialReference? } diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift index 8d66e3fa..26de788d 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift @@ -1,17 +1,20 @@ import Combine import Foundation -/// 目录工作流 module(issue #41):主窗口与配置 UI 的唯一 UI-facing seam。 -/// 隐藏配置目录、凭据存储、订阅快照、Legacy 导入细节、持久化协调与运行时 -/// 收敛,向 UI 提供不含秘密值的树/详情/订阅 projection、opaque 节点身份查询、 -/// typed command、typed error 与 structured outcome。选择、pane、sheet、alert -/// 等窗口状态仍由 UI 持有;本类不生成用户可见文案。 +/// 目录工作流 module(issue #41,#49 深化为 application module):主窗口与 +/// 配置 UI 的唯一 UI-facing seam。隐藏配置目录、凭据存储、订阅快照、Legacy +/// 导入细节、持久化协调与运行时收敛,向 UI 提供不含秘密值的树/详情/订阅 +/// projection、opaque 节点身份查询、typed command、typed error 与 structured +/// outcome。选择、pane、sheet、alert 等窗口状态仍由 UI 持有;本类不生成 +/// 用户可见文案。 /// /// 内部沿用既有深 module:`ConfigurationCatalog` 的结构约束、 /// `ActivationStateMachine` 经 `CatalogCommitCoordinator` 的激活语义与目录 /// 持久化/runtime sync 协调,以及既有凭据、订阅获取与 Legacy 导入缝。 /// 「目录已提交」与「运行时已收敛」是分离的观察面(story 38/39):提交在 /// 持久化成功即完成,`runtimeSync` 单独呈现收敛阶段,失败不回滚目录。 +/// 实现 adapter 全部经组合根注入的 `CatalogWorkflowDependencies` 装配 +/// (issue #49),本类不自行创建生产默认实现。 @MainActor final class CatalogWorkflow: ObservableObject { /// 目录树 projection(侧栏、级联、移动目的地、删除确认共用)。 @@ -31,52 +34,25 @@ final class CatalogWorkflow: ObservableObject { snapshotFound: false, completed: false) /// 最近一次 Legacy 导入报告(story 31)。 @Published private(set) var legacyImportReport: LegacyImportReport? + + /// 组合根与测试装配的全部实现 adapter(issue #49):coordinator、凭据、 + /// 插件、订阅获取、Legacy 导入服务、导入后回调与激活缝只经此束持有, + /// 不再作为 workflow 属性暴露。仅供组合根与测试构造;视图不得访问—— + /// 独立 target 拆分前由 architecture deletion check 守护残余可见性。 + let dependencies: CatalogWorkflowDependencies /// 启动时发现的 Legacy 快照(导入编排用;秘密值不进 projection)。 var discoveredLegacySnapshot: LegacySnapshot? - // module 内部协作成员:UI-facing seam 之外的实现细节(issue #41)。在拆出 - // 独立编译 target 之前访问级别只到 internal,seam 纪律由约定与 deletion - // test 维护——视图不得直接触碰目录、凭据与协调器。 - /// 提交管线与运行时收敛的唯一入口(组合根注入,沿用 #40)。 - let coordinator: CatalogCommitCoordinator - private let fileStore: CatalogFileStore - /// 凭据存储只在 module 内出现(story 11);UI 不得持有。 - let credentials: CredentialStoring - let plugins: ManagedPluginProviding - /// 订阅获取缝(默认 URLSession 实现;测试注入夹具)。 - var subscriptionFetcher: SubscriptionFetching - /// Legacy 导入提交后的运行时边界(组合根一次性接线;独立于普通提交管线)。 - let postLegacyImport: ((LegacyImportOutcome) async -> Void)? - let legacyImportService: LegacyImportService - /// 激活缝(issue #41):生产 adapter 为 `ProxyRuntimeController`,测试注入假 adapter。 - let activator: Activating - - init( - coordinator: CatalogCommitCoordinator, - credentials: CredentialStoring = KeychainCredentialStore(), - plugins: ManagedPluginProviding = BundleManagedPluginProvider(), - subscriptionFetcher: SubscriptionFetching = HTTPSSubscriptionFetcher(), - legacyImportService: LegacyImportService? = nil, - postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil, - activator: Activating = RejectingActivator() - ) { - self.coordinator = coordinator - fileStore = coordinator.fileStore - self.credentials = credentials - self.plugins = plugins - self.subscriptionFetcher = subscriptionFetcher - self.postLegacyImport = postLegacyImport - self.activator = activator - self.legacyImportService = - legacyImportService - ?? LegacyImportService( - catalogStore: fileStore, - credentials: credentials) + /// 组合根注入依赖束;workflow 不自行创建任何生产 adapter(issue #49)。 + init(dependencies: CatalogWorkflowDependencies) { + self.dependencies = dependencies + let coordinator = dependencies.coordinator tree = .build( - from: coordinator.committedCatalog, credentials: credentials, plugins: plugins) + from: coordinator.committedCatalog, + credentials: dependencies.credentials, plugins: dependencies.plugins) subscriptions = Self.subscriptionSummaries( coordinator.committedSubscriptions, catalog: coordinator.committedCatalog, - credentials: credentials) + credentials: dependencies.credentials) runtimeSync = coordinator.syncStatus coordinator.$syncStatus.assign(to: &$runtimeSync) refreshLegacyImportState() @@ -86,7 +62,7 @@ final class CatalogWorkflow: ObservableObject { /// 行显示名(导航标题、重命名预填等);节点不存在为空串。 func displayName(for id: NodeID) -> String { - coordinator.committedCatalog.entry(for: id)?.displayName ?? "" + dependencies.coordinator.committedCatalog.entry(for: id)?.displayName ?? "" } /// 服务器叶子的已知阻塞原因(typed;编辑面「激活状态」区)。 @@ -97,10 +73,10 @@ final class CatalogWorkflow: ObservableObject { /// 服务器编辑面(显式命令,story 11):解析密码与受管插件参数明文。 /// 节点不存在或不是服务器叶子为 `nil`。 func serverEditForm(for id: NodeID) -> ServerEditForm? { - guard let entry = coordinator.committedCatalog.entry(for: id), + guard let entry = dependencies.coordinator.committedCatalog.entry(for: id), case .server(let fields) = entry.kind else { return nil } - let password = (try? credentials.secret(for: fields.passwordRef)) ?? "" + let password = (try? dependencies.credentials.secret(for: fields.passwordRef)) ?? "" return ServerEditForm( address: fields.address, port: fields.port, @@ -126,8 +102,10 @@ final class CatalogWorkflow: ObservableObject { var provided = false var options = "" if case .managed(let program) = selection { - provided = plugins.executablePath(forProgram: program) != nil - options = fields.pluginOptionsRef.flatMap { (try? credentials.secret(for: $0)) ?? "" } ?? "" + provided = dependencies.plugins.executablePath(forProgram: program) != nil + options = + fields.pluginOptionsRef.flatMap { (try? dependencies.credentials.secret(for: $0)) ?? "" } + ?? "" } return PluginSectionState( selection: selection, @@ -139,17 +117,14 @@ final class CatalogWorkflow: ObservableObject { /// 添加落点:选中手动分组 → 组内;选中服务器 → 其父组(仅手动);其余 → 根。 func importTargetParent(for selection: NodeID?) -> NodeID? { - guard let selection, let entry = coordinator.committedCatalog.entry(for: selection) else { - return nil - } + let catalog = dependencies.coordinator.committedCatalog + guard let selection, let entry = catalog.entry(for: selection) else { return nil } switch entry.kind { case .group: return entry.source == .manual ? selection : nil case .server: - guard let parent = (try? coordinator.committedCatalog.parentID(of: selection)) ?? nil else { - return nil - } - return coordinator.committedCatalog.entry(for: parent)?.source == .manual ? parent : nil + guard let parent = (try? catalog.parentID(of: selection)) ?? nil else { return nil } + return catalog.entry(for: parent)?.source == .manual ? parent : nil } } @@ -166,7 +141,7 @@ final class CatalogWorkflow: ObservableObject { for (index, line) in text.split(whereSeparator: \.isNewline).enumerated() { do { let uri = try SsUri.decode(String(line)) - let fields = try Self.serverFields(from: uri, credentials: credentials) + let fields = try Self.serverFields(from: uri, credentials: dependencies.credentials) prepared.append((uri: uri, fields: fields)) } catch { let reason: ImportLineFailureReason @@ -189,7 +164,7 @@ final class CatalogWorkflow: ObservableObject { } } catch { for item in prepared { - Self.deleteCredentialRefs(for: item.fields, credentials: credentials) + Self.deleteCredentialRefs(for: item.fields, credentials: dependencies.credentials) } throw error } @@ -230,7 +205,7 @@ final class CatalogWorkflow: ObservableObject { try catalog.remove(id) } for ref in Self.credentialRefs(of: removed) { - try? credentials.delete(ref) + try? dependencies.credentials.delete(ref) } return RemovalOutcome(removedNodeIDs: Set(removed.map(\.id))) } @@ -255,7 +230,7 @@ final class CatalogWorkflow: ObservableObject { { throw ServerFormError.pluginNotManaged(program) } - var journal = CredentialWriteJournal(credentials: credentials) + var journal = CredentialWriteJournal(credentials: dependencies.credentials) do { try commit { [self] catalog in guard let entry = catalog.entry(for: id), case .server(var fields) = entry.kind else { @@ -268,7 +243,7 @@ final class CatalogWorkflow: ObservableObject { fields.remark = draft.remark.trimmingCharacters(in: .whitespaces) try Self.applyPluginSelection( draft.plugin, options: draft.pluginOptions, to: &fields, - credentials: credentials, journal: &journal) + credentials: dependencies.credentials, journal: &journal) try catalog.updateServer(id, with: fields) } } catch { @@ -281,15 +256,15 @@ final class CatalogWorkflow: ObservableObject { /// 服务器 → ss://(SIP002,story 22):凭据从存储解析;无凭据即点名失败 /// (不分享空档)。仅在用户明确请求分享时调用。 func shareURI(for id: NodeID) throws -> String { - guard let entry = coordinator.committedCatalog.entry(for: id), + guard let entry = dependencies.coordinator.committedCatalog.entry(for: id), case .server(let fields) = entry.kind else { throw CatalogError.notAServer(id) } - guard let password = try credentials.secret(for: fields.passwordRef) else { + guard let password = try dependencies.credentials.secret(for: fields.passwordRef) else { throw CredentialStoreError.keychainStatus(errSecItemNotFound) } var pluginOptions: String? if let optionsRef = fields.pluginOptionsRef { - pluginOptions = try credentials.secret(for: optionsRef) ?? "" + pluginOptions = try dependencies.credentials.secret(for: optionsRef) ?? "" } return SsUri( method: fields.encryptionMethod, @@ -302,7 +277,7 @@ final class CatalogWorkflow: ObservableObject { ).encode() } - // MARK: - 提交管线(module 内部;UI 不得调用,拆独立 target 前靠约定) + // MARK: - 提交管线(module 内部;UI 不得调用,独立 target 拆分前靠 deletion check) /// 仅目录变更的提交便捷入口(订阅扩展经 `commitSubscriptionDocument`)。 private func commit( @@ -318,7 +293,7 @@ final class CatalogWorkflow: ObservableObject { private func commitDocument( _ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T ) throws -> T { - let result = try coordinator.commit(mutate) + let result = try dependencies.coordinator.commit(mutate) republishCommittedState() return result } @@ -332,11 +307,13 @@ final class CatalogWorkflow: ObservableObject { /// 提交成功后的 projection 重建(树 + 订阅卡片)。 func republishCommittedState() { + let coordinator = dependencies.coordinator tree = .build( - from: coordinator.committedCatalog, credentials: credentials, plugins: plugins) + from: coordinator.committedCatalog, + credentials: dependencies.credentials, plugins: dependencies.plugins) subscriptions = Self.subscriptionSummaries( coordinator.committedSubscriptions, catalog: coordinator.committedCatalog, - credentials: credentials) + credentials: dependencies.credentials) } // MARK: - module 内部发布缝(扩展文件经此更新只读投影,setter 保持 private) @@ -411,7 +388,7 @@ final class CatalogWorkflow: ObservableObject { } } - static func deleteCredentialRefs( + private static func deleteCredentialRefs( for fields: ServerFields, credentials: CredentialStoring ) { try? credentials.delete(fields.passwordRef) @@ -459,10 +436,3 @@ final class CatalogWorkflow: ObservableObject { } } } - -/// 默认激活 adapter:未接线时原子拒绝(测试不触激活的场景)。 -final class RejectingActivator: Activating { - func activate(_ target: NodeID) async throws -> ActivationCommandOutcome { - .rejectedActivation - } -} diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowDependencies.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowDependencies.swift new file mode 100644 index 00000000..6424a98d --- /dev/null +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowDependencies.swift @@ -0,0 +1,25 @@ +import Foundation + +/// 组合根专用依赖束(issue #49):目录工作流全部实现 adapter 的唯一装配缝。 +/// 生产 adapter 由组合根(MainApp)装配,测试注入 hermetic fake; +/// CatalogWorkflow 不自行创建钥匙串存储、网络获取器或提交协调器。 +/// 本类型不是 UI-facing facade:视图不得引用——独立 target 拆分前, +/// 同 target 的访问纪律由 architecture deletion check 守护。 +@MainActor +struct CatalogWorkflowDependencies { + /// 提交管线与运行时收敛的唯一入口;与代理运行时路径共享同一 + /// committed catalog source(组合根创建并注入)。 + let coordinator: CatalogCommitCoordinator + /// 凭据存储(story 11):只在 module 内出现,UI 不持有。 + let credentials: CredentialStoring + /// 受管插件提供缝(编辑面集内事实与可执行文件存在性)。 + let plugins: ManagedPluginProviding + /// 订阅获取缝。 + let subscriptionFetcher: SubscriptionFetching + /// Legacy 导入服务(快照发现、完成标记与原子导入)。 + let legacyImportService: LegacyImportService + /// Legacy 导入提交后的 2.0 运行时边界(组合根一次性接线;独立于普通提交管线)。 + let postLegacyImport: ((LegacyImportOutcome) async -> Void)? + /// 激活缝:生产 adapter 为 `ProxyRuntimeController`,测试注入假 adapter。 + let activator: Activating +} diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index f9b92de1..68a4a4c3 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -29,20 +29,25 @@ struct ShadowsocksXNG2App: App { _proxyController = StateObject(wrappedValue: controller) let loginController = LaunchAtLoginController(service: dependencies.loginService) _loginController = StateObject(wrappedValue: loginController) - // 组合根(issue #40/#41):目录提交协调器与生产运行时适配器只在此接线一次, - // 各 scene 不再重复设置提交回调;Legacy 导入后的 2.0 运行时边界同为一次性注入。 + // 组合根(issue #40/#41/#49):目录提交协调器与全部生产运行时适配器只在 + // 此接线一次,各 scene 不再重复设置提交回调;目录工作流的实现依赖经 + // CatalogWorkflowDependencies 显式装配(workflow 不自行创建生产默认实现); + // Legacy 导入后的 2.0 运行时边界同为一次性注入。 let coordinator = CatalogCommitCoordinator( fileStore: dependencies.catalogFileStore, runtime: ProxyRuntimeSyncAdapter(controller: controller), bootstrap: catalogBootstrap) let catalogWorkflow = CatalogWorkflow( - coordinator: coordinator, - credentials: dependencies.credentials, - legacyImportService: dependencies.legacyImportService, - postLegacyImport: { _ in - await controller.legacyImportDidCommit() - }, - activator: controller) + dependencies: CatalogWorkflowDependencies( + coordinator: coordinator, + credentials: dependencies.credentials, + plugins: BundleManagedPluginProvider(), + subscriptionFetcher: HTTPSSubscriptionFetcher(), + legacyImportService: dependencies.legacyImportService, + postLegacyImport: { _ in + await controller.legacyImportDidCommit() + }, + activator: controller)) _catalogWorkflow = StateObject(wrappedValue: catalogWorkflow) // 代理控制工作流 module(issue #47):状态菜单等 UI 表面的唯一代理控制 // seam,组合根接线一次。生产 runtime adapter 包装既有控制器(不复制运行 @@ -108,7 +113,7 @@ private struct ApplicationDependencies { let settingsStore: ProxySettingsFileStore let settingsRestore: RestoredProxySettings let listenRestore: RestoredListenSettings - let legacyImportService: LegacyImportService? + let legacyImportService: LegacyImportService let launchAgent: LaunchAgentControlling let loginService: LaunchAtLoginControlling @@ -119,25 +124,32 @@ private struct ApplicationDependencies { } static func make() -> ApplicationDependencies { - guard isUnitTesting else { - let credentials = KeychainCredentialStore() - let settingsStore = ProxySettingsFileStore(credentials: credentials) - let restoredSettings = ProxySettingsFileStore.restored(store: settingsStore) - return ApplicationDependencies( - credentials: credentials, - catalogFileStore: CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()), - activationFileStore: ActivationStateFileStore( - fileURL: ActivationStateFileStore.defaultFileURL()), - runtimeFileStore: RuntimeFileStore(), - settingsStore: settingsStore, - settingsRestore: restoredSettings, - listenRestore: RestoredListenSettings( - settings: restoredSettings.settings.listen, unreadableError: nil), - legacyImportService: nil, - launchAgent: SMAppLaunchAgentService(), - loginService: SMAppLaunchAtLoginService()) - } + guard isUnitTesting else { return makeProduction() } + return makeTesting() + } + private static func makeProduction() -> ApplicationDependencies { + let credentials = KeychainCredentialStore() + let catalogFileStore = CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()) + let settingsStore = ProxySettingsFileStore(credentials: credentials) + let restoredSettings = ProxySettingsFileStore.restored(store: settingsStore) + return ApplicationDependencies( + credentials: credentials, + catalogFileStore: catalogFileStore, + activationFileStore: ActivationStateFileStore( + fileURL: ActivationStateFileStore.defaultFileURL()), + runtimeFileStore: RuntimeFileStore(), + settingsStore: settingsStore, + settingsRestore: restoredSettings, + listenRestore: RestoredListenSettings( + settings: restoredSettings.settings.listen, unreadableError: nil), + legacyImportService: LegacyImportService( + catalogStore: catalogFileStore, credentials: credentials), + launchAgent: SMAppLaunchAgentService(), + loginService: SMAppLaunchAtLoginService()) + } + + private static func makeTesting() -> ApplicationDependencies { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("ShadowsocksX-NG2-test-host-\(UUID().uuidString)", isDirectory: true) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) diff --git a/ShadowsocksX-NG2/Tests/CatalogFixtures.swift b/ShadowsocksX-NG2/Tests/CatalogFixtures.swift index 09c86a95..ad77fc43 100644 --- a/ShadowsocksX-NG2/Tests/CatalogFixtures.swift +++ b/ShadowsocksX-NG2/Tests/CatalogFixtures.swift @@ -100,3 +100,70 @@ func expectThrowsAsync( errorHandler(error) } } + +// MARK: - 目录工作流测试夹具(issue #49) + +/// 中性激活替身:不关心激活语义的测试场景,激活命令原子拒绝。 +final class RejectingActivator: Activating { + func activate(_ target: NodeID) async throws -> ActivationCommandOutcome { + .rejectedActivation + } +} + +/// 永不提供快照的 Legacy 来源替身(测试不读用户 Legacy 偏好)。 +struct EmptyLegacySnapshotProvider: LegacySnapshotProviding { + func readSnapshot() throws -> LegacySnapshot? { nil } +} + +/// 固定快照提供缝替身(可中途改写快照,供重读场景断言)。 +final class FixedLegacySnapshotProvider: LegacySnapshotProviding { + var snapshot: LegacySnapshot? + + init(snapshot: LegacySnapshot?) { + self.snapshot = snapshot + } + + func readSnapshot() throws -> LegacySnapshot? { snapshot } +} + +/// 内存完成标记替身(测试不写 UserDefaults)。 +final class InMemoryLegacyImportMarker: LegacyImportMarkerStoring { + private(set) var completed = false + + func isCompleted() throws -> Bool { completed } + + func setCompleted(_ completed: Bool) throws { + self.completed = completed + } +} + +/// 目录工作流测试组装缝(issue #49):经依赖束显式装配全部 hermetic fake, +/// 不触碰生产 Keychain、UserDefaults、网络或激活边界。各测试按需覆盖任一 +/// adapter;未指定 Legacy 服务时用「空快照 + 内存标记」的组合(工作流 init +/// 的发现状态读取仍被观察,但只触替身)。 +@MainActor +func makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator, + credentials: CredentialStoring, + plugins: ManagedPluginProviding = NoManagedPluginProvider(), + subscriptionFetcher: SubscriptionFetching = FakeSubscriptionFetcher( + behavior: .success(Data())), + legacyImportService: LegacyImportService? = nil, + postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil, + activator: Activating = RejectingActivator() +) -> CatalogWorkflow { + CatalogWorkflow( + dependencies: CatalogWorkflowDependencies( + coordinator: coordinator, + credentials: credentials, + plugins: plugins, + subscriptionFetcher: subscriptionFetcher, + legacyImportService: legacyImportService + ?? LegacyImportService( + source: EmptyLegacySnapshotProvider(), + catalogStore: coordinator.fileStore, + credentials: credentials, + marker: InMemoryLegacyImportMarker()), + postLegacyImport: postLegacyImport, + activator: activator)) +} diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift index f1679fb5..a5dd9eb5 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift @@ -45,7 +45,7 @@ final class CatalogWorkflowPluginTests: XCTestCase { } private func makeWorkflow() -> CatalogWorkflow { - CatalogWorkflow( + makeCatalogWorkflow( coordinator: CatalogCommitCoordinator( fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()), credentials: credentials, diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift index 494edae3..9bcc5e29 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowPolicyTests.swift @@ -35,7 +35,7 @@ final class CatalogWorkflowPolicyTests: XCTestCase { runtime.hasActiveTarget = true let coordinator = CatalogCommitCoordinator( fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime) - return CatalogWorkflow( + return makeCatalogWorkflow( coordinator: coordinator, credentials: credentials, plugins: NoManagedPluginProvider(), diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift index 1bffa134..bf195f31 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift @@ -52,7 +52,7 @@ final class CatalogWorkflowRollbackTests: XCTestCase { bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore) let coordinator = CatalogCommitCoordinator( fileStore: fileStore, runtime: runtime, bootstrap: bootstrap) - return CatalogWorkflow( + return makeCatalogWorkflow( coordinator: coordinator, credentials: credentials, plugins: NoManagedPluginProvider(), @@ -288,25 +288,3 @@ final class CatalogWorkflowRollbackTests: XCTestCase { marker: InMemoryLegacyImportMarker()) } } - -/// 固定快照提供缝(与 LegacyImportTests 同型的最小替身)。 -private final class FixedLegacySnapshotProvider: LegacySnapshotProviding { - let snapshot: LegacySnapshot? - - init(snapshot: LegacySnapshot?) { - self.snapshot = snapshot - } - - func readSnapshot() throws -> LegacySnapshot? { snapshot } -} - -/// 内存完成标记替身。 -private final class InMemoryLegacyImportMarker: LegacyImportMarkerStoring { - private(set) var completed = false - - func isCompleted() throws -> Bool { completed } - - func setCompleted(_ completed: Bool) throws { - self.completed = completed - } -} diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowSubscriptionTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowSubscriptionTests.swift index 422089cd..cffa1e34 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowSubscriptionTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowSubscriptionTests.swift @@ -33,16 +33,16 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { try await super.tearDown() } - private func makeWorkflow() -> CatalogWorkflow { + private func makeWorkflow(fetcher override: SubscriptionFetching? = nil) -> CatalogWorkflow { // 计数即提交计数:提交后的运行时收敛由协调器异步调度(issue #40)。 runtime.hasActiveTarget = true let coordinator = CatalogCommitCoordinator( fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime) - return CatalogWorkflow( + return makeCatalogWorkflow( coordinator: coordinator, credentials: credentials, plugins: NoManagedPluginProvider(), - subscriptionFetcher: fetcher!) + subscriptionFetcher: override ?? fetcher!) } /// 文档稳定 ID 后缀在树 projection 中定位节点(订阅前缀随机)。 @@ -127,8 +127,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { let convergesBefore = runtime.convergeCount let treeBefore = workflow.tree - fetcher = FakeSubscriptionFetcher(behavior: .failure(.httpStatus(code: 503))) - workflow.subscriptionFetcher = fetcher + fetcher.setBehavior(.failure(.httpStatus(code: 503))) await workflow.refreshSubscription(record.id) // 快照原样保留。 @@ -148,8 +147,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { let record = try await seedSuccessfulSubscription() let treeBefore = workflow.tree - workflow.subscriptionFetcher = FakeSubscriptionFetcher( - behavior: .success(SubscriptionDocs.duplicateIDs())) + fetcher.setBehavior(.success(SubscriptionDocs.duplicateIDs())) await workflow.refreshSubscription(record.id) guard case .failed(_, let failure) = workflow.subscriptions[0].status else { @@ -163,8 +161,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { let record = try await seedSuccessfulSubscription() let treeBefore = workflow.tree - workflow.subscriptionFetcher = FakeSubscriptionFetcher( - behavior: .success(SubscriptionDocs.invalidRecord())) + fetcher.setBehavior(.success(SubscriptionDocs.invalidRecord())) await workflow.refreshSubscription(record.id) guard case .failed(_, let failure) = workflow.subscriptions[0].status else { @@ -179,8 +176,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { func testEmptySnapshotIsSuccessAndClearsSubtree() async throws { let record = try await seedSuccessfulSubscription() - workflow.subscriptionFetcher = FakeSubscriptionFetcher( - behavior: .success(SubscriptionDocs.flat(serverCount: 0))) + fetcher.setBehavior(.success(SubscriptionDocs.flat(serverCount: 0))) await workflow.refreshSubscription(record.id) let group = try XCTUnwrap(workflow.tree.node(withID: record.groupID)) @@ -194,8 +190,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { func testCycleDocumentFallsBackToFlatAndSucceeds() async throws { let record = try await seedSuccessfulSubscription() - workflow.subscriptionFetcher = FakeSubscriptionFetcher( - behavior: .success(SubscriptionDocs.cycleExtension())) + fetcher.setBehavior(.success(SubscriptionDocs.cycleExtension())) await workflow.refreshSubscription(record.id) let group = try XCTUnwrap(workflow.tree.node(withID: record.groupID)) @@ -212,8 +207,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { fetcher = FakeSubscriptionFetcher(behavior: .success(treeDoc)) workflow = makeWorkflow() let record = try await workflow.createSubscription(urlString: "https://p.example.com/s.json") - workflow.subscriptionFetcher = FakeSubscriptionFetcher( - behavior: .success(changedTreeDoc)) + fetcher.setBehavior(.success(changedTreeDoc)) await workflow.refreshSubscription(record.id) // 身份不变;名称/位置/顺序跟随远端。 @@ -235,8 +229,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { XCTAssertEqual(onlyServerChildID(ofGroup: record.groupID), serverID) // 端口变化 → 新身份:旧节点删除(无墓碑)。 - workflow.subscriptionFetcher = FakeSubscriptionFetcher( - behavior: .success(SubscriptionDocs.idLess(port: 9999))) + fetcher.setBehavior(.success(SubscriptionDocs.idLess(port: 9999))) await workflow.refreshSubscription(record.id) XCTAssertNil(workflow.tree.node(withID: serverID), "无稳定 ID 且记录变化即不同节点") let newID = try XCTUnwrap(onlyServerChildID(ofGroup: record.groupID)) @@ -251,20 +244,18 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { let originalGroupID = record.groupID // 新地址先失败:快照保留、身份不动。 - let failing = FakeSubscriptionFetcher(behavior: .failure(.httpStatus(code: 404))) - workflow.subscriptionFetcher = failing + fetcher.setBehavior(.failure(.httpStatus(code: 404))) try await workflow.editSubscriptionURL( record.id, urlString: "https://other.example.com/v2.json") XCTAssertEqual(workflow.subscriptions[0].id, originalRecordID, "订阅身份保留") guard case .failed = workflow.subscriptions[0].status else { return XCTFail("新地址失败应记失败态") } - XCTAssertEqual(failing.lastURL?.host, "other.example.com") + XCTAssertEqual(fetcher.lastURL?.host, "other.example.com") XCTAssertNotNil(findNode(withSuffix: "aaaaaaaa-0000-4000-8000-00000000000a"), "失败保留最后成功快照") // 新地址成功:同一身份命名空间下应用新内容。 - workflow.subscriptionFetcher = FakeSubscriptionFetcher( - behavior: .success(SubscriptionDocs.flat(serverCount: 1))) + fetcher.setBehavior(.success(SubscriptionDocs.flat(serverCount: 1))) await workflow.refreshSubscription(record.id) XCTAssertEqual(workflow.subscriptions[0].groupID, originalGroupID) guard case .succeeded = workflow.subscriptions[0].status else { @@ -327,11 +318,10 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { func testSubscriptionsPersistAcrossWorkflowReload() async throws { let record = try await seedSuccessfulSubscription() - let reloaded = CatalogWorkflow( + let reloaded = makeCatalogWorkflow( coordinator: CatalogCommitCoordinator( fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()), - credentials: credentials, - plugins: NoManagedPluginProvider()) + credentials: credentials) XCTAssertEqual(reloaded.subscriptions.count, 1) XCTAssertEqual(reloaded.subscriptions[0].id, record.id) guard case .succeeded = reloaded.subscriptions[0].status else { @@ -344,8 +334,9 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase { func testConcurrentRefreshOnSameSubscriptionDoesNotReenter() async throws { let record = try await seedSuccessfulSubscription() + // 用门控获取器重建工作流:刷新停在 fetch 内部,验证并发守卫。 let gated = GatedFetcher() - workflow.subscriptionFetcher = gated + workflow = makeWorkflow(fetcher: gated) async let first: Void = workflow.refreshSubscription(record.id) // 轮询而非阻塞等待:主线程阻塞会饿死继承 MainActor 的 async let 子任务。 @@ -410,7 +401,7 @@ extension CatalogWorkflowSubscriptionTests { ), ] { let treeBefore = workflow.tree - workflow.subscriptionFetcher = FakeSubscriptionFetcher(behavior: behavior) + fetcher.setBehavior(behavior) await workflow.refreshSubscription(record.id) guard case .failed(_, let failure) = workflow.subscriptions[0].status else { @@ -429,8 +420,7 @@ extension CatalogWorkflowSubscriptionTests { guard case .succeeded = healthy.status else { return XCTFail("应记成功态") } XCTAssertEqual(healthy.serverCount, 2) - workflow.subscriptionFetcher = FakeSubscriptionFetcher( - behavior: .failure(.transport(detail: "URLError.cannotConnectToHost"))) + fetcher.setBehavior(.failure(.transport(detail: "URLError.cannotConnectToHost"))) await workflow.refreshSubscription(healthy.id) let failed = try XCTUnwrap(workflow.subscriptions.first) @@ -446,11 +436,10 @@ extension CatalogWorkflowSubscriptionTests { func testPartialCredentialRollbackIsTransientAndPersistedCoarsely() async throws { let partialStore = SubscriptionRollbackCredentialStore() runtime.hasActiveTarget = true - let partialWorkflow = CatalogWorkflow( + let partialWorkflow = makeCatalogWorkflow( coordinator: CatalogCommitCoordinator( fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime), credentials: partialStore, - plugins: NoManagedPluginProvider(), subscriptionFetcher: FakeSubscriptionFetcher( behavior: .success(SubscriptionDocs.tree()))) let record = try await partialWorkflow.createSubscription( diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift index 5080fa8d..b1931b75 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift @@ -37,7 +37,7 @@ final class CatalogWorkflowTests: XCTestCase { runtime.hasActiveTarget = true let coordinator = CatalogCommitCoordinator( fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime) - return CatalogWorkflow( + return makeCatalogWorkflow( coordinator: coordinator, credentials: credentials, plugins: NoManagedPluginProvider()) @@ -327,3 +327,80 @@ final class CatalogWorkflowTests: XCTestCase { XCTAssertEqual(reloaded.displayName(for: groupID), "持久组") } } + +/// 组合根 fakes 装配覆盖(issue #49 story 11):工作流可以完全经依赖束以 +/// hermetic fake 组装——init 的发现状态、目录命令、激活命令与 Legacy 导入 +/// 全部命中注入替身,不触碰生产 Keychain、app 数据、LaunchAgent、代理进程 +/// 或 SystemConfiguration。 +@MainActor +final class CatalogWorkflowHermeticAssemblyTests: XCTestCase { + private var workDir: URL! + + override func setUp() async throws { + try await super.setUp() + workDir = FileManager.default.temporaryDirectory + .appendingPathComponent("catalog-assembly-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: workDir) + try await super.tearDown() + } + + func testWorkflowAssemblesFromFakesWithoutProductionSideEffects() async throws { + let fileURL = workDir.appendingPathComponent("catalog.json") + let credentials = InMemoryCredentialStore() + let runtime = FakeCatalogRuntime() + let marker = InMemoryLegacyImportMarker() + let dependencies = CatalogWorkflowDependencies( + coordinator: CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime), + credentials: credentials, + plugins: NoManagedPluginProvider(), + subscriptionFetcher: FakeSubscriptionFetcher(behavior: .success(Data())), + legacyImportService: LegacyImportService( + source: FixedLegacySnapshotProvider(snapshot: try makeLegacySnapshot()), + catalogStore: CatalogFileStore(fileURL: fileURL), + credentials: credentials, + marker: marker), + postLegacyImport: nil, + activator: RejectingActivator()) + + let workflow = CatalogWorkflow(dependencies: dependencies) + + // init 的发现状态来自注入的 Legacy 服务,而非生产缺省实现。 + XCTAssertEqual( + workflow.legacyImportState, + LegacyImportAvailability(snapshotFound: true, completed: false)) + + // 目录命令落在注入的内存凭据与文件存储上;激活命令命中注入替身。 + let groupID = try await workflow.createGroup(named: "组装组", into: nil) + XCTAssertTrue(workflow.tree.containsNode(groupID)) + let activation = try await workflow.activate(groupID) + XCTAssertEqual(activation, .rejectedActivation) + + // Legacy 导入走注入服务:报告、完成标记与凭据写入都落在注入的 fake 上; + // 不经提交管线触发运行时收敛。 + let report = try await workflow.importLegacy() + XCTAssertEqual(report.importedServerCount, 1) + XCTAssertEqual(try marker.isCompleted(), true) + XCTAssertEqual( + credentials.storageSnapshot.values.contains("legacy-pw"), true, + "导入凭据写入注入的内存存储,而非生产 Keychain") + XCTAssertEqual(runtime.convergeCount, 0, "导入不经提交管线触发运行时收敛") + } + + private func makeLegacySnapshot() throws -> LegacySnapshot { + let profile: [String: Any] = [ + "Id": "11111111-2222-4333-8444-555555555555", + "ServerHost": "203.0.113.9", + "ServerPort": 8388, + "Method": "aes-256-gcm", + "Password": "legacy-pw", + "Remark": "旧服务器", + ] + return try LegacySnapshot( + propertyList: ["ServerProfiles": [profile], "ShadowsocksRunningMode": "manual"]) + } +} diff --git a/ShadowsocksX-NG2/Tests/DiagnosticsWorkflowLifecycleTests.swift b/ShadowsocksX-NG2/Tests/DiagnosticsWorkflowLifecycleTests.swift index 679bbc05..527b3411 100644 --- a/ShadowsocksX-NG2/Tests/DiagnosticsWorkflowLifecycleTests.swift +++ b/ShadowsocksX-NG2/Tests/DiagnosticsWorkflowLifecycleTests.swift @@ -135,7 +135,7 @@ final class DiagnosticsWorkflowLifecycleTests: DiagnosticsWorkflowTestCase { let fileURL = workDir.appendingPathComponent("catalog.json") let coordinator = CatalogCommitCoordinator( fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()) - let catalogWorkflow = CatalogWorkflow( + let catalogWorkflow = makeCatalogWorkflow( coordinator: coordinator, credentials: InMemoryCredentialStore(), plugins: ActivationFixture.TestManagedPlugins(pathsByProgram: [:])) diff --git a/ShadowsocksX-NG2/Tests/LegacyImportTests.swift b/ShadowsocksX-NG2/Tests/LegacyImportTests.swift index ab9a8269..880e5ee7 100644 --- a/ShadowsocksX-NG2/Tests/LegacyImportTests.swift +++ b/ShadowsocksX-NG2/Tests/LegacyImportTests.swift @@ -245,16 +245,6 @@ extension LegacyImportTests { } } -private final class FixedLegacySnapshotProvider: LegacySnapshotProviding { - var snapshot: LegacySnapshot? - - init(snapshot: LegacySnapshot?) { - self.snapshot = snapshot - } - - func readSnapshot() throws -> LegacySnapshot? { snapshot } -} - private final class TestLegacyImportMarker: LegacyImportMarkerStoring { var completed = false var failWhenSettingCompleted = false diff --git a/ShadowsocksX-NG2/Tests/ProxyControlWorkflowIntegrationTests.swift b/ShadowsocksX-NG2/Tests/ProxyControlWorkflowIntegrationTests.swift index e766d553..10f0d3b2 100644 --- a/ShadowsocksX-NG2/Tests/ProxyControlWorkflowIntegrationTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyControlWorkflowIntegrationTests.swift @@ -71,7 +71,7 @@ final class ProxyControlWorkflowIntegrationTests: XCTestCase { sendSignal: { _, _ in 0 }) let fileStore = CatalogFileStore(fileURL: catalogFileURL) let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore) - let catalogWorkflow = CatalogWorkflow( + let catalogWorkflow = makeCatalogWorkflow( coordinator: CatalogCommitCoordinator( fileStore: fileStore, runtime: ProxyRuntimeSyncAdapter(controller: controller), diff --git a/ShadowsocksX-NG2/Tests/SkeletonTests.swift b/ShadowsocksX-NG2/Tests/SkeletonTests.swift index 2f4859e9..aeeab659 100644 --- a/ShadowsocksX-NG2/Tests/SkeletonTests.swift +++ b/ShadowsocksX-NG2/Tests/SkeletonTests.swift @@ -20,3 +20,108 @@ final class SkeletonTests: XCTestCase { XCTAssertEqual(Bundle.main.object(forInfoDictionaryKey: "LSUIElement") as? Bool, true) } } + +/// 架构 deletion check(issue #49):UI 源文件不得引用目录工作流的实现 +/// 协作者或原始目录/凭据存储类型。组合根(MainApp)与各 workflow module +/// 目录豁免;新增视图文件自动纳入扫描,新增运行时/系统适配器需显式登记 +/// 豁免名单。规则只点名实现缝,不锁定实现内部的文件划分或私有 helper。 +final class CatalogWorkflowArchitectureTests: XCTestCase { + func testUISourceFilesDoNotReferenceCatalogWorkflowImplementation() throws { + let appDirectory = Self.testTargetRoot.appendingPathComponent("App") + let uiFiles = try Self.uiSourceFiles(in: appDirectory) + XCTAssertFalse(uiFiles.isEmpty, "UI 源文件集合不应为空(App 目录缺失?)") + + var violations: [String] = [] + for file in uiFiles { + let content = try String(contentsOf: file, encoding: .utf8) + for (index, line) in content.components(separatedBy: .newlines).enumerated() { + for pattern in Self.forbiddenPatterns + where pattern.regex.firstMatch( + in: line, range: NSRange(line.startIndex..., in: line)) != nil + { + violations.append("\(file.lastPathComponent):\(index + 1) → \(pattern.label)") + } + } + } + XCTAssertTrue( + violations.isEmpty, + """ + UI 源文件不得引用目录工作流实现协作者或原始目录/凭据存储类型; + 经 CatalogWorkflow 的 UI-facing interface 表达(issue #49): + \(violations.joined(separator: "\n")) + """) + } + + // MARK: - 扫描范围与禁令表 + + private static let testTargetRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + + /// 豁免文件:组合根(生产 adapter 唯一装配点)、应用生命周期与运行时/ + /// 系统适配器(合法接触原始目录与凭据类型)。 + private static let exemptedFiles: Set = [ + "MainApp.swift", + "AppDelegate.swift", + "CatalogCommitCoordinator.swift", + "ProxyRuntimeController.swift", + "SystemConfigurationProxyController.swift", + "LaunchAgentService.swift", + "LoginAtLoginService.swift", + "FirewallStatusChecker.swift", + "PACHealthProbe.swift", + ] + + /// 豁免目录:workflow module 实现(含被测的目录工作流自身)。 + private static let exemptedDirectories: Set = [ + "CatalogWorkflow", "ProxyControlWorkflow", "SettingsWorkflow", "DiagnosticsWorkflow", + ] + + /// 禁令:实现协作者类型、原始目录/凭据存储类型与 workflow 内部成员缝。 + private static let forbiddenPatterns = makeForbiddenPatterns() + + private static func makeForbiddenPatterns() -> [(label: String, regex: NSRegularExpression)] { + let typeTokens = [ + "CatalogWorkflowDependencies", "CatalogCommitCoordinator", "CatalogFileStore", + "ConfigurationCatalog", "CatalogEntry", "CommittedCatalogSnapshot", + "CredentialStoring", "KeychainCredentialStore", "CredentialWriteJournal", + "CredentialReference", "ManagedPluginProviding", "BundleManagedPluginProvider", + "SubscriptionFetching", "HTTPSSubscriptionFetcher", "SubscriptionRecord", + "LegacyImportService", "LegacySnapshot", "Activating", "ServerFields", + ] + // workflow 内部成员缝(不作为可访问属性/方法暴露给 UI)。 + let memberTokens = [ + "\\.dependencies\\b", "\\bfileStore\\b", "discoveredLegacySnapshot", + "republishCommittedState", "commitSubscriptionDocument", "publishLegacyImportState", + "publishLegacyImportReport", "setRefreshInFlight", "setSubscriptionRefreshFailure", + "subscriptionSummaries", "credentialRefs", + ] + return (typeTokens + memberTokens).map { token in + do { + return (label: token, regex: try NSRegularExpression(pattern: token)) + } catch { + preconditionFailure("禁令正则编译失败:\(token)") + } + } + } + + /// UI 源文件 = App 下全部 Swift 文件,除豁免名单与 workflow module 目录。 + private static func uiSourceFiles(in directory: URL) throws -> [URL] { + guard + let enumerator = FileManager.default.enumerator( + at: directory, includingPropertiesForKeys: nil) + else { + XCTFail("无法枚举 App 源码目录:\(directory.path)") + return [] + } + var result: [URL] = [] + for case let url as URL in enumerator { + let name = url.lastPathComponent + guard name.hasSuffix(".swift"), !exemptedFiles.contains(name) else { continue } + let relativePath = url.path.replacingOccurrences(of: directory.path + "/", with: "") + guard !exemptedDirectories.contains(where: { relativePath.hasPrefix("\($0)/") }) + else { continue } + result.append(url) + } + return result + } +} diff --git a/ShadowsocksX-NG2/Tests/SubscriptionFetcherTests.swift b/ShadowsocksX-NG2/Tests/SubscriptionFetcherTests.swift index ab05c1c3..2275cdc9 100644 --- a/ShadowsocksX-NG2/Tests/SubscriptionFetcherTests.swift +++ b/ShadowsocksX-NG2/Tests/SubscriptionFetcherTests.swift @@ -149,6 +149,13 @@ final class FakeSubscriptionFetcher: SubscriptionFetching, @unchecked Sendable { self.behavior = behavior } + /// 中途切换预设结果(工作流不再暴露可替换的获取器属性,issue #49)。 + func setBehavior(_ behavior: Behavior) { + lock.lock() + defer { lock.unlock() } + self.behavior = behavior + } + var lastURL: URL? { lock.lock() defer { lock.unlock() }