Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/SettingsWorkflowInterfaceTests.swift
T

271 lines
11 KiB
Swift

import XCTest
@testable import ShadowsocksX_NG2
/// 设置项编辑器的 UI-facing interface 测试;写入缝与占用探测均注入替身,
/// 不触碰真实偏好文件、钥匙串与系统端口。
@MainActor
final class SettingsWorkflowInterfaceTests: XCTestCase {
var committing: FakeSettingsCommitter!
var probe: FakeOccupancyProbe!
override func setUp() async throws {
try await super.setUp()
committing = FakeSettingsCommitter()
probe = FakeOccupancyProbe()
}
func makeWorkflow() -> SettingsWorkflow {
SettingsWorkflow(committing: committing, occupancyProbe: probe)
}
func testPortEditorMapsValidationIssuesToTheAffectedFields() {
let workflow = makeWorkflow()
let invalid = SettingsPortDraft(socksPort: 0, httpPort: 11_087)
XCTAssertEqual(
workflow.portFieldState(for: .socks, editorDraft: invalid).issues,
[.port(.socks, error: .portOutOfRange(endpoint: .socks, port: 0))])
XCTAssertTrue(workflow.portFieldState(for: .http, editorDraft: invalid).issues.isEmpty)
let duplicate = SettingsPortDraft(socksPort: 11_086, httpPort: 11_086)
let duplicateIssue = SettingsFieldIssue.port(
.socks, error: .duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 11_086))
XCTAssertEqual(
workflow.portFieldState(for: .socks, editorDraft: duplicate).issues,
[
duplicateIssue
])
XCTAssertEqual(
workflow.portFieldState(for: .http, editorDraft: duplicate).issues,
[.port(.http, error: .duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 11_086))])
}
func testPortEditorReportsFreeOccupiedAndUnknownFacts() async {
probe = FakeOccupancyProbe(occupiedPorts: [11_087])
let workflow = makeWorkflow()
let draft = workflow.beginPortSettingsEditing()
workflow.refreshPortEditorOccupancy(for: draft)
await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft).occupancy != nil)
XCTAssertEqual(workflow.portFieldState(for: .socks, editorDraft: draft).occupancy, .free)
XCTAssertEqual(
workflow.portFieldState(for: .http, editorDraft: draft).occupancy,
.occupied(occupier: "other-app"))
probe = FakeOccupancyProbe(unknownPorts: [11_086])
let unknownWorkflow = makeWorkflow()
let unknownDraft = unknownWorkflow.beginPortSettingsEditing()
unknownWorkflow.refreshPortEditorOccupancy(for: unknownDraft)
await waitUntil(
unknownWorkflow.portFieldState(for: .socks, editorDraft: unknownDraft).occupancy != nil)
XCTAssertEqual(
unknownWorkflow.portFieldState(for: .socks, editorDraft: unknownDraft).occupancy,
.unknown(detail: "无法判定"))
}
func testPortEditorProbeUsesTheCompleteCommittedListenIdentity() async {
committing.committedSettings.listen.listenerMode = .allIPv4Interfaces
let workflow = makeWorkflow()
let draft = workflow.beginPortSettingsEditing()
workflow.refreshPortEditorOccupancy(for: draft)
let expected = RuntimeListenFacts(
listenerMode: .allIPv4Interfaces,
socksPort: draft.socksPort,
httpPort: draft.httpPort)
await waitUntil(probe.requests.contains { $0.listen == expected })
XCTAssertEqual(probe.requests.first(where: { $0.listen == expected })?.endpoint, .socks)
XCTAssertEqual(probe.requests.first(where: { $0.listen == expected })?.bindAddress, "0.0.0.0")
}
func testRuntimePortExceptionRequiresTheCompleteUnchangedListenIdentity() async {
probe = FakeOccupancyProbe(occupiedPorts: [11_086])
committing.isProxyRunning = true
let workflow = makeWorkflow()
let draft = workflow.beginPortSettingsEditing()
workflow.refreshPortEditorOccupancy(for: draft)
await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft).occupancy != nil)
XCTAssertTrue(workflow.portFieldState(for: .socks, editorDraft: draft).isRuntimePortException)
XCTAssertFalse(workflow.portFieldState(for: .socks, editorDraft: draft).canSuggestFreePort)
XCTAssertTrue(workflow.canSavePortSettings(draft))
committing.runtimeListenFacts = RuntimeListenFacts(
listenerMode: .allIPv4Interfaces, socksPort: 11_086, httpPort: 11_087)
workflow.refreshPortEditorOccupancy(for: draft)
await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft).occupancy != nil)
XCTAssertFalse(workflow.portFieldState(for: .socks, editorDraft: draft).isRuntimePortException)
XCTAssertFalse(workflow.canSavePortSettings(draft))
}
func testChangedPortWithKnownExternalOccupancyBlocksPortSave() async {
probe = FakeOccupancyProbe(occupiedPorts: [12_086])
let workflow = makeWorkflow()
let draft = SettingsPortDraft(socksPort: 12_086, httpPort: 12_087)
workflow.refreshPortEditorOccupancy(for: draft)
await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft).occupancy != nil)
XCTAssertTrue(workflow.portFieldState(for: .socks, editorDraft: draft).canSuggestFreePort)
XCTAssertFalse(workflow.canSavePortSettings(draft))
let outcome = await workflow.savePortSettings(draft)
XCTAssertEqual(outcome, .rejected(.occupied([.socks])))
XCTAssertTrue(committing.updateCalls.isEmpty)
}
func testUnknownPortOccupancyAllowsPortSave() async {
probe = FakeOccupancyProbe(unknownPorts: [12_086])
let workflow = makeWorkflow()
let draft = SettingsPortDraft(socksPort: 12_086, httpPort: 12_087)
workflow.refreshPortEditorOccupancy(for: draft)
await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft).occupancy != nil)
XCTAssertTrue(workflow.canSavePortSettings(draft))
let outcome = await workflow.savePortSettings(draft)
XCTAssertEqual(outcome, .persisted)
XCTAssertEqual(committing.committedSettings.listen.socksPort, 12_086)
}
func testPortSuggestionReturnsACandidateWithoutSavingIt() async {
probe = FakeOccupancyProbe(occupiedPorts: [12_086])
let workflow = makeWorkflow()
let draft = SettingsPortDraft(socksPort: 12_086, httpPort: 12_087)
workflow.refreshPortEditorOccupancy(for: draft)
await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft).occupancy != nil)
let outcome = await workflow.suggestFreePort(for: .socks, from: draft)
XCTAssertEqual(outcome, .suggestedPort(port: .socks, value: 32_768))
XCTAssertTrue(committing.updateCalls.isEmpty)
XCTAssertEqual(committing.committedSettings.listen.socksPort, 11_086)
}
func testPortSettingsSaveChangesOnlyTheCommittedPorts() async {
let original = committedSettingsForItemSaveTests()
committing.committedSettings = original
let workflow = makeWorkflow()
let edited = SettingsPortDraft(socksPort: 12_096, httpPort: 12_097)
workflow.refreshPortEditorOccupancy(for: edited)
await waitUntil(workflow.canSavePortSettings(edited))
let outcome = await workflow.savePortSettings(edited)
var expected = original
expected.listen.socksPort = edited.socksPort
expected.listen.httpPort = edited.httpPort
XCTAssertEqual(outcome, .persisted)
XCTAssertEqual(committing.updateCalls, [expected])
XCTAssertEqual(committing.committedSettings, expected)
}
func testPortSaveRejectsInvalidPortsWithoutStartingACommit() async {
let workflow = makeWorkflow()
let invalid = SettingsPortDraft(socksPort: 0, httpPort: 11_087)
let outcome = await workflow.savePortSettings(invalid)
XCTAssertEqual(
outcome,
.rejected(
.validation([
.port(.socks, error: .portOutOfRange(endpoint: .socks, port: 0))
])))
XCTAssertFalse(workflow.isCommitting)
XCTAssertTrue(committing.updateCalls.isEmpty)
}
func testProxyExceptionsSaveChangesOnlyTheCommittedExceptions() async {
let original = committedSettingsForItemSaveTests()
committing.committedSettings = original
let workflow = makeWorkflow()
let edited = "localhost\nexample.com"
let outcome = await workflow.saveProxyExceptions(edited)
var expected = original
expected.proxyExceptions = edited
XCTAssertEqual(outcome, .persisted)
XCTAssertEqual(committing.updateCalls, [expected])
XCTAssertEqual(committing.committedSettings, expected)
XCTAssertNil(workflow.lastFailure)
}
func testProxyExceptionsPersistenceFailurePreservesCommittedSnapshot() async {
let original = committedSettingsForItemSaveTests()
committing.committedSettings = original
committing.updateError = ProxySettingsStoreError.ioFailure(detail: "disk full")
let workflow = makeWorkflow()
let outcome = await workflow.saveProxyExceptions("localhost")
XCTAssertEqual(
outcome,
.persistenceFailed(.store(.ioFailure(detail: "disk full"))))
XCTAssertEqual(workflow.lastFailure, .store(.ioFailure(detail: "disk full")))
XCTAssertEqual(committing.committedSettings, original)
XCTAssertTrue(committing.updateCalls.isEmpty)
XCTAssertFalse(workflow.isCommitting)
}
func testItemSavesSerializeWhilePersistenceIsInProgress() async {
committing.updateGate = AsyncGate()
let workflow = makeWorkflow()
let first = Task { await workflow.saveProxyExceptions("localhost") }
await waitUntil(workflow.isCommitting)
let second = await workflow.savePortSettings(
SettingsPortDraft(socksPort: 11_086, httpPort: 11_087))
committing.updateGate?.release()
let firstOutcome = await first.value
XCTAssertEqual(second, .rejected(.inProgress))
XCTAssertEqual(firstOutcome, .persisted)
XCTAssertEqual(committing.updateCalls.count, 1)
}
func testEqualProxyExceptionsDoNotWrite() async {
let workflow = makeWorkflow()
committing.committedSettings.proxyExceptions = "localhost"
let outcome = await workflow.saveProxyExceptions("localhost")
XCTAssertEqual(outcome, .persisted)
XCTAssertTrue(committing.updateCalls.isEmpty)
}
func testStalePortOccupancyCannotOverrideTheNewerEditorDraft() async {
let gated = GatedOccupancyProbe(
gatedAnswer: .occupied(
PortOccupancyFacts(
occupier: "stale", occupiedFamilies: [.ipv4], verifiedFamilies: [.ipv4])),
passThroughAnswer: .free)
let workflow = SettingsWorkflow(committing: committing, occupancyProbe: gated)
let firstDraft = workflow.beginPortSettingsEditing()
workflow.refreshPortEditorOccupancy(for: firstDraft)
await waitUntil(gated.entered > 0)
let laterDraft = SettingsPortDraft(socksPort: 30_086, httpPort: 11_087)
gated.passThrough(.free)
workflow.refreshPortEditorOccupancy(for: laterDraft)
await waitUntil(
workflow.portFieldState(for: .socks, editorDraft: laterDraft).occupancy == .free)
gated.releaseGatedCalls()
try? await Task.sleep(nanoseconds: 50_000_000)
XCTAssertEqual(
workflow.portFieldState(for: .socks, editorDraft: laterDraft).occupancy, .free)
}
func committedSettingsForItemSaveTests() -> ProxySettings {
var settings = ProxySettings()
settings.listen.listenerMode = .allIPv6Interfaces
settings.listen.socksPort = 12_086
settings.listen.httpPort = 12_087
settings.proxyExceptions = "existing.example"
settings.preferredMode = .global
settings.agentEnabled = true
settings.systemProxyEnabled = true
return settings
}
}