diff --git a/CONTEXT.md b/CONTEXT.md index ffe41e70..0c1d6e8f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,6 +118,6 @@ - The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available. - The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model. - Runtime file deployment prepares contract bytes once and still reads the actual disk contract before choosing actions. ACL digest-manifest hits avoid reading the variant body; a miss reads the existing variant once and reuses those bytes if rollback is needed. An unchanged active-link target is preserved. The synchronous transaction records only completed mutations and attempts every recorded restoration on failure, reporting any restoration failure explicitly. A decoded contract with no ACL body can restore only an existing regular variant file; a missing or unsafe variant fails before publishing its link or contract. Digest metadata is updated only after contract persistence succeeds. -- The Proxy Rules destination provides browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots, and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; browsing preserves stable source input order with no user sorting; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. The page also tests domains, IP literals, and URL hosts offline against that complete saved collection, independently of browsing filters, runtime state, and the unmatched default action. Results retain all deciding identities and source memberships plus other target-specific hits; domain tests explicitly leave IP rules untested without DNS. Invalid input and incomplete collections are operation errors. Editing the target or retrying clears prior results, and intent tickets reject late tasks. The page supports individual and batch enablement through the Workflow. A schema-2 user document atomically stores custom entries and source-independent disabled identities; schema 1 reads with no disabled records. Disabled identities survive source disappearance and are visible under all sources with the disabled filter, where enabling removes orphan rows. Filtering excludes disabled identities before current coverage analysis and offline matching; runtime analysis separately uses the existing default-action source subset and actual ACL skeleton. Commits finish when the validated rule document is atomically saved and return the saved document. Only persistence blocks repeated edits; runtime application and browsing analysis never prolong a rule operation. Saved enablement, source memberships, custom entries, counts, and document version become available immediately. Coverage explanations retain their previous values while background analysis catches up, with no additional waiting or invalidation UI. Browsing analysis and runtime application independently coalesce nearby saves and consume the latest saved document; stale analysis never replaces newer saved facts. An unchanged document retains the collection. There is no ordinary toolbar reload or post-commit file reread. Initial loading shares bundled source facts with runtime preparation; only failure actions explicitly retry failed sources. Success feedback counts actual saved changes and expires after three seconds; validation and persistence failures remain dismissible with details and contextual retry actions. Runtime application failures belong to proxy status, independently of rule-operation success. A failed source-loading preparation retains cached rows and blocks edits until an explicit successful retry. Runtime projection and ACL compilation use immutable captured inputs off the UI actor, reject superseded intent before deployment, and reuse the compiled document for convergence checks and deployment. Rule deployments run serially and only changed ACL identities restart sslocal. A save during deployment queues convergence to the latest document after that deployment settles. Deployment failure attempts to restore the previous runtime without restoring old saved rules; recovery failures remain explicit runtime errors. Failed rule application is not retried in a loop; another saved change or explicit agent-on retry requests convergence again. The window toolbar adds a single custom rule and custom memberships expose per-row editing through a native sheet. Sheet-local versioned drafts support IPv4/IPv6 IP literals and CIDRs, ASCII/Punycode exact domains and suffixes (including the existing national suffix), normalized previews, and enabled-collection coverage explanations. Previews run outside the UI actor and prepare only the prospective row; explicit saves revalidate the saved version and reuse the same document transaction and feedback path as enablement. Duplicate custom identities and fixed-local proxy conflicts block saving; builtin equivalence and explanatory absorption or shadowing do not. Editing preserves the custom UUID and source metadata, moves only the custom membership, inherits disablement onto the new identity, and retains old disabled records. Additions do not clear an existing disabled identity; edits that would inherit disablement onto an immutable fixed identity are rejected. Cancellation and navigation discard the draft; in-flight commits block repeated saves and interactive dismissal. The operation area counts selected custom memberships and offers single or batch deletion. Workflow preparation captures an immutable custom UUID set and collection version for one confirmation; filtering clears hidden selections, and later selection changes cannot expand the captured set. Confirmation rejects stale or incomplete facts and uses the same whole-document transaction, retaining builtin memberships and all disabled identities, including orphan records. Saved deletion updates source counts and selection immediately, recomputes relationships in the background, and invalidates the address test; unchanged ACLs do not restart runtime. Cancellation only discards the confirmation. +- The Proxy Rules destination provides browsing through `RulesWorkflow`: it prepares a versioned collection off the UI actor from fixed local policy, all three built-in snapshots, and the custom-rule document. Equivalent identities retain all source memberships and custom UUIDs. Search, source, and action filters intersect; browsing preserves stable source input order with no user sorting; hidden selections are cleared and navigation preserves session query/selection. Source metadata and original conversion reports remain distinct from current coverage explanations. Missing/corrupt inputs explicitly mark the collection incomplete while retaining available sources. The page also tests domains, IP literals, and URL hosts offline against that complete saved collection, independently of browsing filters, runtime state, and the unmatched default action. Results retain all deciding identities and source memberships plus other target-specific hits; domain tests explicitly leave IP rules untested without DNS. Invalid input and incomplete collections are operation errors. Editing the target or retrying clears prior results, and intent tickets reject late tasks. The page supports individual and batch enablement through the Workflow. A schema-2 user document atomically stores custom entries and source-independent disabled identities; schema 1 reads with no disabled records. Disabled identities survive source disappearance and are visible under all sources with the disabled filter, where enabling removes orphan rows. Filtering excludes disabled identities before current coverage analysis and offline matching; runtime analysis separately uses the existing default-action source subset and actual ACL skeleton. Commits finish when the validated rule document is atomically saved and return the saved document. Only persistence blocks repeated edits; runtime application and browsing analysis never prolong a rule operation. Saved enablement, source memberships, custom entries, counts, and document version become available immediately. Coverage explanations retain their previous values while background analysis catches up, with no additional waiting or invalidation UI. Browsing analysis and runtime application independently coalesce nearby saves and consume the latest saved document; stale analysis never replaces newer saved facts. An unchanged document retains the collection. There is no ordinary toolbar reload or post-commit file reread. Initial loading shares bundled source facts with runtime preparation; only failure actions explicitly retry failed sources. Success feedback counts actual saved changes and expires after three seconds; validation and persistence failures remain dismissible with details and contextual retry actions. Runtime application failures belong to proxy status, independently of rule-operation success. A failed source-loading preparation retains cached rows and blocks edits until an explicit successful retry. Runtime projection and ACL compilation use immutable captured inputs off the UI actor, reject superseded intent before deployment, and reuse the compiled document for convergence checks and deployment. Rule deployments run serially and only changed ACL identities restart sslocal. A save during deployment queues convergence to the latest document after that deployment settles. Deployment failure attempts to restore the previous runtime without restoring old saved rules; recovery failures remain explicit runtime errors. Failed rule application is not retried in a loop; another saved change or explicit agent-on retry requests convergence again. The window toolbar adds a single custom rule and custom memberships expose per-row editing through a native sheet. Sheet-local versioned drafts support IPv4/IPv6 IP literals and CIDRs, ASCII/Punycode exact domains and suffixes (including the existing national suffix), normalized previews, and enabled-collection coverage explanations. Previews run outside the UI actor and prepare only the prospective row; explicit saves revalidate the saved version and reuse the same document transaction and feedback path as enablement. Duplicate custom identities and fixed-local proxy conflicts block saving; builtin equivalence and explanatory absorption or shadowing do not. Editing preserves the custom UUID and source metadata, moves only the custom membership, inherits disablement onto the new identity, and retains old disabled records. Additions do not clear an existing disabled identity; edits that would inherit disablement onto an immutable fixed identity are rejected. Cancellation and navigation discard the draft; in-flight commits block repeated saves and interactive dismissal. The operation area counts selected custom memberships and offers single or batch deletion. Workflow preparation captures an immutable custom UUID set and collection version for one confirmation; filtering clears hidden selections, and later selection changes cannot expand the captured set. Confirmation rejects stale or incomplete facts through the same feedback surface as commit failures and uses the same whole-document transaction, retaining builtin memberships and all disabled identities, including orphan records. Saved deletion updates source counts and selection immediately, recomputes relationships in the background, and invalidates the address test; unchanged ACLs do not restart runtime. Cancellation only discards the confirmation. - The app presents in the adaptive app form: menu-bar-only after the last workspace or independent rule-report window closes, and with a Dock icon and app-switcher presence while either is open; hiding the app is not closing it and does not change the form; the GUI process keeps running with the status menu available regardless of the workspace window. - At launch the GUI presents the workspace window unless silent launch is on; after the workspace window is closed, the status menu is the only way to reopen it. Silent launch changes only window presentation: proxy runtime restoration happens at every launch regardless of it. diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift index 945219b1..217ad11c 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift @@ -3,6 +3,7 @@ import Foundation // MARK: - 自定义规则更新结果 /// The result of accepting and saving a rule document, independent of runtime. +/// `busy`/`superseded` are transaction-gate rejections: no save was attempted. enum CustomRuleUpdateOutcome: Equatable, Sendable { /// 规则已保存;运行规则模式时应用。 case saved @@ -11,6 +12,8 @@ enum CustomRuleUpdateOutcome: Equatable, Sendable { /// 持久化失败:旧规则保持不变。 case persistenceFailed case busy + /// 事务门拒绝:意图捕获后事实已前进(集合版本/完整性变化),本次更新未尝试保存。 + case superseded case invalidDocument(detail: String) var isSuccess: Bool { @@ -55,6 +58,7 @@ extension CustomRuleUpdateOutcome { case .saved: RulesCopy.text("已保存") case .persistenceFailed: RulesCopy.text("保存失败,规则未更改") case .busy: RulesCopy.text("正在更新规则…") + case .superseded: RulesCopy.text("规则集合已变化,操作未执行。") case .invalidDocument, .rejected: RulesCopy.text("无法更新规则") } } @@ -69,7 +73,7 @@ extension CustomRuleUpdateOutcome { var nextStep: String? { switch self { - case .persistenceFailed, .busy: "请重试规则操作。" + case .persistenceFailed, .busy, .superseded: "请重试规则操作。" case .invalidDocument, .rejected: "请检查规则数据后再操作。" default: nil } diff --git a/ShadowsocksX-NG2/App/RulesDeletionButton.swift b/ShadowsocksX-NG2/App/RulesDeletionButton.swift index a449066d..5afb40a3 100644 --- a/ShadowsocksX-NG2/App/RulesDeletionButton.swift +++ b/ShadowsocksX-NG2/App/RulesDeletionButton.swift @@ -1,10 +1,11 @@ import SwiftUI /// A single confirmation captures custom UUIDs before starting the shared transaction. +/// Gate rejections and commit failures surface through the workflow's commit +/// feedback, not a local alert. struct RulesDeletionButton: View { @ObservedObject var workflow: RulesWorkflow @State private var confirmation: CustomRuleDeletion? - @State private var failure: CustomRuleDeletionResult.Failure? var body: some View { Button { @@ -24,35 +25,15 @@ struct RulesDeletionButton: View { ) { captured in Button(RulesCopy.text("删除"), role: .destructive) { confirmation = nil - Task { - if case .unavailable(let reason) = await workflow.deleteCustomRules(captured) { - failure = reason - } - } + Task { await workflow.deleteCustomRules(captured) } } Button(RulesCopy.text("取消"), role: .cancel) { confirmation = nil } } message: { _ in Text(RulesCopy.text("只删除所选自定义条目。其他来源的规则和已有禁用记录将保留。")) } - .alert( - RulesCopy.text("无法更新规则"), - isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } }) - ) { - Button(RulesCopy.text("关闭")) { failure = nil } - } message: { - Text(RulesCopy.text(failureMessage)) - } } private func countText(_ key: String, _ count: Int) -> String { String.localizedStringWithFormat(RulesCopy.text(key), Int64(count)) } - - private var failureMessage: String { - switch failure { - case .staleConfirmation: "规则集合已变化,请重新确认删除。" - case .incompleteCollection: "集合不完整" - case .busy, nil: "正在更新规则…" - } - } } diff --git a/ShadowsocksX-NG2/App/RulesWorkflow.swift b/ShadowsocksX-NG2/App/RulesWorkflow.swift index ff48eb81..2399beeb 100644 --- a/ShadowsocksX-NG2/App/RulesWorkflow.swift +++ b/ShadowsocksX-NG2/App/RulesWorkflow.swift @@ -361,20 +361,26 @@ extension RulesWorkflow { return CustomRuleDeletion(customIDs: deletableSelection, version: ticket.version) } - func deleteCustomRules(_ confirmation: CustomRuleDeletion) async -> CustomRuleDeletionResult { - guard !snapshot.isCommitting else { return .unavailable(.busy) } - guard let ticket = intentTicket(), let old = ticket.document - else { return .unavailable(.incompleteCollection) } - guard confirmation.version == ticket.version, + /// 删除的事务门拒绝(忙碌/超期)与提交失败走同一条 commitFeedback 呈现, + /// 不另设返回词汇:确认手势的所有失败口径一致。 + func deleteCustomRules(_ confirmation: CustomRuleDeletion) async { + guard !snapshot.isCommitting else { + publishFeedback(.busy, operation: .delete, changedCount: 0, page: snapshot) + return + } + guard let ticket = intentTicket(), let old = ticket.document, !confirmation.customIDs.isEmpty, + confirmation.version == ticket.version, confirmation.customIDs.isSubset(of: Set(old.rules.map(\.id))) - else { return .unavailable(.staleConfirmation) } + else { + publishFeedback(.superseded, operation: .delete, changedCount: 0, page: snapshot) + return + } let document = CustomRuleDocument( rules: old.rules.filter { !confirmation.customIDs.contains($0.id) }, disabledIdentities: old.disabledIdentities) - return .committed( - await commit( - document, operation: .delete, changedCount: confirmation.customIDs.count)) + _ = await commit( + document, operation: .delete, changedCount: confirmation.customIDs.count) } } diff --git a/ShadowsocksX-NG2/Domain/CustomRuleDeletion.swift b/ShadowsocksX-NG2/Domain/CustomRuleDeletion.swift deleted file mode 100644 index 76bb920f..00000000 --- a/ShadowsocksX-NG2/Domain/CustomRuleDeletion.swift +++ /dev/null @@ -1,5 +0,0 @@ -enum CustomRuleDeletionResult: Equatable, Sendable { - enum Failure: Equatable, Sendable { case busy, incompleteCollection, staleConfirmation } - case unavailable(Failure) - case committed(CustomRuleUpdateOutcome) -} diff --git a/ShadowsocksX-NG2/Tests/CustomRuleDeletionControllerTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleDeletionControllerTests.swift index 5b2b43d4..65f84359 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleDeletionControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleDeletionControllerTests.swift @@ -31,8 +31,8 @@ final class CustomRuleDeletionControllerTests: ProxyRuntimeControllerTests { await workflow.refresh() workflow.select(Set(workflow.snapshot.rows.map(\.id))) let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) - let result = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(result, .committed(.saved)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitOutcome, .saved) await controller.ruleApplicationTask?.value XCTAssertEqual( try store.loadDocument(), @@ -71,8 +71,8 @@ final class CustomRuleDeletionControllerTests: ProxyRuntimeControllerTests { workflow.select(Set(workflow.snapshot.rows.filter { !$0.customIDs.isEmpty }.map(\.id))) let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) let registrations = agent.registerCount - let result = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(result, .committed(.saved)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitOutcome, .saved) await controller.ruleApplicationTask?.value XCTAssertEqual(agent.registerCount, registrations + 1) XCTAssertTrue(try store.load().isEmpty) @@ -101,8 +101,8 @@ final class CustomRuleDeletionControllerTests: ProxyRuntimeControllerTests { workflow.select(Set(workflow.snapshot.rows.filter { !$0.customIDs.isEmpty }.map(\.id))) let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) let registrations = agent.registerCount - let result = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(result, .committed(.saved)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitOutcome, .saved) await controller.ruleApplicationTask?.value XCTAssertEqual(agent.registerCount, registrations) XCTAssertTrue(try store.load().isEmpty) @@ -135,8 +135,8 @@ final class CustomRuleDeletionControllerTests: ProxyRuntimeControllerTests { try? runtimeStore.writeRuntimeReceipt( for: isRecovery ? requested : previousRuntime, processID: 42) } - let result = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(result, .committed(.saved)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitOutcome, .saved) let saved = CustomRuleDocument(rules: [], disabledIdentities: [orphan]) XCTAssertEqual(try store.loadDocument(), saved) await controller.ruleApplicationTask?.value @@ -172,8 +172,8 @@ final class CustomRuleDeletionControllerTests: ProxyRuntimeControllerTests { try FileManager.default.moveItem(at: directory, to: backupDirectory) try Data("blocks parent directory".utf8).write(to: directory) let backup = backupDirectory.appendingPathComponent(store.fileURL.lastPathComponent) - let result = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(result, .committed(.persistenceFailed)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitOutcome, .persistenceFailed) XCTAssertEqual(controller.ruleDocuments.current, original) XCTAssertEqual(try Data(contentsOf: backup), originalData) XCTAssertEqual(workflow.snapshot.version, confirmation.version) diff --git a/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift index a7b7aed8..f6ca4fe9 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleDeletionWorkflowTests.swift @@ -29,8 +29,8 @@ final class CustomRuleDeletionWorkflowTests: XCTestCase { XCTAssertEqual(confirmation.customIDs, [custom.id, other.id]) XCTAssertEqual(commits, 0, "Preparing and discarding a confirmation has no side effects") let oldVersion = workflow.snapshot.version - let result = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(result, .committed(.saved)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitOutcome, .saved) XCTAssertEqual(commits, 1) XCTAssertTrue(saved.rules.isEmpty) XCTAssertEqual(saved.disabledIdentities, [custom.identity, orphan]) @@ -68,11 +68,11 @@ extension CustomRuleDeletionWorkflowTests { query.search = "" workflow.query(query) workflow.select(Set(workflow.snapshot.rows.map(\.id))) - let deleted = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(deleted, .committed(.saved)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitOutcome, .saved) XCTAssertEqual(saved.rules.map(\.id), [hidden.id], "New selection cannot expand captured UUIDs") - let stale = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(stale, .unavailable(.staleConfirmation)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitFeedback?.outcome, .superseded) XCTAssertEqual(commits, 1) workflow.select([]) XCTAssertNil(workflow.prepareCustomRuleDeletion()) @@ -91,8 +91,8 @@ extension CustomRuleDeletionWorkflowTests { workflow.select(Set(workflow.snapshot.rows.map(\.id))) let confirmation = try XCTUnwrap(workflow.prepareCustomRuleDeletion()) await workflow.setEnabled(false, identities: [rule.identity]) - let result = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(result, .unavailable(.staleConfirmation)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitFeedback?.outcome, .superseded) XCTAssertEqual(commits, 1) XCTAssertEqual(workflow.deletableSelection, [rule.id]) } @@ -121,11 +121,11 @@ extension CustomRuleDeletionWorkflowTests { let pending = Task { await workflow.deleteCustomRules(confirmation) } await fulfillment(of: [started], timeout: 3) XCTAssertNil(workflow.prepareCustomRuleDeletion()) - let repeated = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(repeated, .unavailable(.busy)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitFeedback?.outcome, .busy) continuation?.resume() - let result = await pending.value - XCTAssertEqual(result, .committed(.persistenceFailed)) + await pending.value + XCTAssertEqual(workflow.snapshot.commitOutcome, .persistenceFailed) XCTAssertEqual(commits, 1) XCTAssertEqual(workflow.snapshot.version, confirmation.version) XCTAssertEqual(workflow.prepareCustomRuleDeletion(), confirmation) @@ -150,8 +150,8 @@ extension CustomRuleDeletionWorkflowTests { failed = true await workflow.refresh() XCTAssertNil(workflow.prepareCustomRuleDeletion()) - let result = await workflow.deleteCustomRules(confirmation) - XCTAssertEqual(result, .unavailable(.incompleteCollection)) + await workflow.deleteCustomRules(confirmation) + XCTAssertEqual(workflow.snapshot.commitFeedback?.outcome, .superseded) XCTAssertEqual(commits, 0) } } diff --git a/ShadowsocksX-NG2/Tests/RulesPresentationTests.swift b/ShadowsocksX-NG2/Tests/RulesPresentationTests.swift index 73e071db..9bc18d14 100644 --- a/ShadowsocksX-NG2/Tests/RulesPresentationTests.swift +++ b/ShadowsocksX-NG2/Tests/RulesPresentationTests.swift @@ -66,6 +66,10 @@ final class RulesPresentationTests: XCTestCase { CustomRuleUpdateOutcome.persistenceFailed.nextStep, RulesCopy.text("请重试规则操作。")) XCTAssertNil(CustomRuleUpdateOutcome.persistenceFailed.failureDetail) XCTAssertEqual(CustomRuleUpdateOutcome.busy.rulesMessage, RulesCopy.text("正在更新规则…")) + XCTAssertEqual( + CustomRuleUpdateOutcome.superseded.rulesMessage, RulesCopy.text("规则集合已变化,操作未执行。")) + XCTAssertEqual(CustomRuleUpdateOutcome.superseded.nextStep, RulesCopy.text("请重试规则操作。")) + XCTAssertNil(CustomRuleUpdateOutcome.superseded.failureDetail) let invalid = CustomRuleUpdateOutcome.invalidDocument(detail: "detail-1") XCTAssertEqual(invalid.rulesMessage, RulesCopy.text("无法更新规则")) XCTAssertEqual(invalid.failureDetail, "detail-1")