mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 10:44:50 +00:00
Add Mainland China proxy routing with independent rule workspaces. Preserve existing routing choices through equivalent preset mappings. BREAKING CHANGE: existing custom rules and enablement settings are not migrated. Recreate them in the appropriate preset.
177 lines
8.0 KiB
Swift
177 lines
8.0 KiB
Swift
import Combine
|
|
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
@MainActor
|
|
final class RuleStatusWorkflowTests: XCTestCase {
|
|
func testUnavailableFinalDocumentKeepsRowsAndRequiresExplicitRetry() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
|
|
let rule = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "example.net"))
|
|
try store.save([rule])
|
|
let workflow = RulesWorkflow(
|
|
loadDocument: { try store.loadDocument() },
|
|
commitDocument: { document in
|
|
_ = saveFixture(document, to: store, outcome: .saved)
|
|
return RuleDocumentCommit(outcome: .saved, document: nil)
|
|
},
|
|
feedbackDelay: { XCTFail("An incomplete collection must not start the success timer") },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
await workflow.refresh()
|
|
let rows = workflow.snapshot.rows
|
|
await workflow.setEnabled(false, identities: [rule.identity])
|
|
XCTAssertEqual(workflow.snapshot.operationStatus, .collectionIncomplete)
|
|
XCTAssertEqual(workflow.snapshot.rows, rows)
|
|
XCTAssertFalse(workflow.snapshot.isComplete)
|
|
await workflow.refresh()
|
|
XCTAssertEqual(workflow.snapshot.operationStatus, .idle)
|
|
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
|
|
}
|
|
|
|
func testSuccessCountsOnlyActualChangesAndExpiresAfterCollectionUpdate() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
|
|
let disabled = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "disabled.example"))
|
|
let enabled = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "enabled.example"))
|
|
try store.saveDocument(
|
|
CustomRuleDocument(rules: [disabled, enabled], disabledIdentities: [disabled.identity]))
|
|
let timerStarted = expectation(description: "success timer started")
|
|
let clock = RuleFeedbackClock(started: timerStarted)
|
|
let workflow = RulesWorkflow(
|
|
loadDocument: { try store.loadDocument() },
|
|
commitDocument: { document in
|
|
saveFixture(document, to: store, outcome: .saved)
|
|
},
|
|
feedbackDelay: { await clock.wait() },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
await workflow.refresh()
|
|
workflow.query(RulesQuery(source: .fixed))
|
|
let fixed = try XCTUnwrap(workflow.snapshot.rows.first { $0.isFixed }?.identity)
|
|
workflow.query(RulesQuery(source: .custom))
|
|
await workflow.setEnabled(true, identities: [disabled.identity, enabled.identity, fixed])
|
|
let feedback = try XCTUnwrap(workflow.snapshot.commitFeedback)
|
|
XCTAssertEqual(feedback.changedCount, 1)
|
|
XCTAssertEqual(feedback.operation, .enablement(true))
|
|
XCTAssertEqual(feedback.outcome, .saved)
|
|
XCTAssertTrue(workflow.snapshot.rows.first { $0.identity == disabled.identity }!.isEnabled)
|
|
await fulfillment(of: [timerStarted], timeout: 3)
|
|
let expired = expectation(description: "success dismissed")
|
|
let observation = workflow.$snapshot.filter { $0.commitFeedback == nil }.sink { _ in
|
|
expired.fulfill()
|
|
}
|
|
await clock.resume()
|
|
await fulfillment(of: [expired], timeout: 3)
|
|
XCTAssertEqual(workflow.snapshot.operationStatus, .idle)
|
|
withExtendedLifetime(observation) {}
|
|
}
|
|
|
|
func testCommitRemainsOneBusyOperationAndAllowsBrowsing() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
|
|
let rule = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "example.net"))
|
|
try store.save([rule])
|
|
let reloadStarted = expectation(description: "collection reload paused")
|
|
let gate = RuleFeedbackClock(started: reloadStarted)
|
|
let workflow = RulesWorkflow(
|
|
loadDocument: { try store.loadDocument() },
|
|
commitDocument: { document in
|
|
await gate.wait()
|
|
return saveFixture(document, to: store, outcome: .saved)
|
|
},
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
await workflow.refresh()
|
|
let pending = Task { await workflow.setEnabled(false, identities: [rule.identity]) }
|
|
await fulfillment(of: [reloadStarted], timeout: 3)
|
|
XCTAssertFalse(workflow.snapshot.isLoading)
|
|
XCTAssertEqual(workflow.snapshot.operationStatus, .updating)
|
|
XCTAssertNil(workflow.snapshot.commitFeedback)
|
|
XCTAssertTrue(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
|
|
let query = RulesQuery(search: "example", source: .custom)
|
|
workflow.query(query)
|
|
let row = try XCTUnwrap(workflow.snapshot.rows.first)
|
|
workflow.select([row.id])
|
|
await gate.resume()
|
|
await pending.value
|
|
XCTAssertEqual(workflow.snapshot.query, query)
|
|
XCTAssertEqual(workflow.snapshot.selection, [row.id])
|
|
XCTAssertFalse(workflow.snapshot.rows.first!.isEnabled)
|
|
XCTAssertEqual(workflow.snapshot.commitOutcome, .saved)
|
|
}
|
|
|
|
func testOldSuccessTimerCannotDismissNewFailureAndClosingDoesNotChangeRules() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
|
|
let rule = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "example.net"))
|
|
try store.save([rule])
|
|
let timerStarted = expectation(description: "success timer started")
|
|
let timerReturned = expectation(description: "old timer returned")
|
|
let clock = RuleFeedbackClock(started: timerStarted)
|
|
let commitState = RuleCommitState()
|
|
let workflow = RulesWorkflow(
|
|
loadDocument: { try store.loadDocument() },
|
|
commitDocument: { document in
|
|
commitState.outcome.isSuccess
|
|
? saveFixture(document, to: store, outcome: commitState.outcome)
|
|
: RuleDocumentCommit(outcome: commitState.outcome, document: try? store.loadDocument())
|
|
},
|
|
feedbackDelay: {
|
|
await clock.wait()
|
|
timerReturned.fulfill()
|
|
},
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
await workflow.refresh()
|
|
await workflow.setEnabled(false, identities: [rule.identity])
|
|
await fulfillment(of: [timerStarted], timeout: 3)
|
|
commitState.outcome = .persistenceFailed
|
|
await workflow.setEnabled(true, identities: [rule.identity])
|
|
await clock.resume()
|
|
await fulfillment(of: [timerReturned], timeout: 3)
|
|
XCTAssertEqual(workflow.snapshot.commitOutcome, .persistenceFailed)
|
|
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
|
|
workflow.dismissFeedback()
|
|
XCTAssertEqual(workflow.snapshot.operationStatus, .idle)
|
|
XCTAssertFalse(workflow.snapshot.rows.first { $0.identity == rule.identity }!.isEnabled)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class RuleCommitState {
|
|
var outcome: CustomRuleUpdateOutcome = .saved
|
|
}
|
|
|
|
private actor RuleFeedbackClock {
|
|
let started: XCTestExpectation
|
|
private var continuation: CheckedContinuation<Void, Never>?
|
|
|
|
init(started: XCTestExpectation) { self.started = started }
|
|
|
|
func wait() async {
|
|
await withCheckedContinuation {
|
|
continuation = $0
|
|
started.fulfill()
|
|
}
|
|
}
|
|
|
|
func resume() {
|
|
continuation?.resume()
|
|
continuation = nil
|
|
}
|
|
}
|
|
|
|
private func saveFixture(
|
|
_ document: CustomRuleDocument, to store: CustomRuleStore, outcome: CustomRuleUpdateOutcome
|
|
) -> RuleDocumentCommit {
|
|
do {
|
|
try store.saveDocument(document)
|
|
return RuleDocumentCommit(outcome: outcome, document: document)
|
|
} catch {
|
|
XCTFail("Fixture save failed: \(error)")
|
|
return RuleDocumentCommit(outcome: .persistenceFailed, document: try? store.loadDocument())
|
|
}
|
|
}
|