mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-13 17:43:45 +00:00
200 lines
8.3 KiB
Swift
200 lines
8.3 KiB
Swift
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
/// 目录政策 seam 的 interface 测试:删除确认档位、
|
|
/// 拖放/移动资格、激活资格与激活命令经假 adapter 的目标传递。只观察结构化
|
|
/// fact 与 typed command outcome,不锁定 SwiftUI。
|
|
@MainActor
|
|
final class CatalogWorkflowPolicyTests: XCTestCase {
|
|
private var workDir: URL!
|
|
private var fileURL: URL!
|
|
private var runtime: FakeCatalogRuntime!
|
|
private var activator: FakeActivator!
|
|
private var workflow: CatalogWorkflow!
|
|
|
|
override func setUp() async throws {
|
|
try await super.setUp()
|
|
workDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("catalog-policy-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
|
fileURL = workDir.appendingPathComponent("catalog.json")
|
|
|
|
runtime = FakeCatalogRuntime()
|
|
activator = FakeActivator()
|
|
workflow = makeWorkflow()
|
|
}
|
|
|
|
override func tearDown() async throws {
|
|
try? FileManager.default.removeItem(at: workDir)
|
|
try await super.tearDown()
|
|
}
|
|
|
|
private func makeWorkflow() -> CatalogWorkflow {
|
|
runtime.hasActiveTarget = true
|
|
let coordinator = CatalogCommitCoordinator(
|
|
fileStore: makeTestCatalogStore(fileURL: fileURL), runtime: runtime)
|
|
return makeCatalogWorkflow(
|
|
coordinator: coordinator,
|
|
plugins: NoManagedPluginProvider(),
|
|
activator: activator)
|
|
}
|
|
|
|
private func importServer(into parent: NodeID? = nil) async throws -> NodeID {
|
|
let before = Set(workflow.tree.roots.flatMap(\.subtreeIDs))
|
|
_ = await workflow.importServers(
|
|
from: [.clipboardText("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388")],
|
|
into: parent)
|
|
if let parent {
|
|
let node = try XCTUnwrap(workflow.tree.node(withID: parent))
|
|
return try XCTUnwrap(node.childNodes.last?.id)
|
|
}
|
|
let added = workflow.tree.roots.map(\.id).filter { !before.contains($0) }
|
|
return try XCTUnwrap(added.last)
|
|
}
|
|
|
|
// MARK: - 删除确认档位
|
|
|
|
func testDeleteFactsLeafIsEmptySingleConfirm() async throws {
|
|
let serverID = try await importServer()
|
|
XCTAssertEqual(workflow.deleteFacts(for: serverID), .leaf)
|
|
}
|
|
|
|
func testDeleteFactsEmptyManualGroupIsSingleConfirm() async throws {
|
|
let groupID = try await workflow.createGroup(named: "空组", into: nil)
|
|
XCTAssertEqual(workflow.deleteFacts(for: groupID), .emptyGroup)
|
|
}
|
|
|
|
func testDeleteFactsNonEmptyGroupNamesSubtreeAndCredentials() async throws {
|
|
let groupID = try await workflow.createGroup(named: "组", into: nil)
|
|
_ = await workflow.importServers(
|
|
from: [.clipboardText("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388")],
|
|
into: groupID)
|
|
let nestedID = try await workflow.createGroup(named: "嵌套", into: groupID)
|
|
_ = await workflow.importServers(
|
|
from: [.clipboardText("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.8:8388")],
|
|
into: nestedID)
|
|
// 组内:服务器 + 嵌套组 + 嵌套服务器 = 3 个后代节点(不含自身)。
|
|
XCTAssertEqual(
|
|
workflow.deleteFacts(for: groupID),
|
|
.subtree(count: 3, includesCredentials: true))
|
|
}
|
|
|
|
// MARK: - 删除资格
|
|
|
|
func testCanDeleteManualLeafAndGroupButNotSubscriptionOrMissing() async throws {
|
|
let serverID = try await importServer()
|
|
let groupID = try await workflow.createGroup(named: "组", into: nil)
|
|
XCTAssertTrue(workflow.canDelete(serverID), "手动叶子可删")
|
|
XCTAssertTrue(workflow.canDelete(groupID), "手动分组可删(空组同理)")
|
|
|
|
let fixture = try CatalogFixtures.makeSubscriptionFixture()
|
|
try makeTestCatalogStore(fileURL: fileURL).save(CatalogDocument(catalog: fixture.catalog))
|
|
workflow = makeWorkflow()
|
|
XCTAssertFalse(workflow.canDelete(fixture.serverIDs[0]), "订阅成员结构只读")
|
|
XCTAssertFalse(workflow.canDelete(fixture.groupID), "订阅分组结构只读")
|
|
XCTAssertFalse(workflow.canDelete(NodeID(rawValue: "ghost")), "节点不存在")
|
|
}
|
|
|
|
// MARK: - 移动/拖放资格
|
|
|
|
func testCanMoveRejectsSelfCycleAndSubscriptionTarget() async throws {
|
|
let groupID = try await workflow.createGroup(named: "组", into: nil)
|
|
let nestedID = try await workflow.createGroup(named: "嵌套", into: groupID)
|
|
let serverID = try await importServer(into: groupID)
|
|
|
|
XCTAssertFalse(workflow.canMove(groupID, to: groupID), "非自身")
|
|
XCTAssertFalse(workflow.canMove(groupID, to: nil), "根层节点不得同父重排")
|
|
XCTAssertFalse(workflow.canMove(groupID, to: nestedID), "目标在被拖子树内")
|
|
XCTAssertTrue(workflow.canMove(nestedID, to: nil))
|
|
XCTAssertFalse(workflow.canMove(nestedID, to: nestedID))
|
|
XCTAssertTrue(workflow.canMove(serverID, to: nestedID))
|
|
XCTAssertFalse(workflow.canMove(serverID, to: groupID), "已有父组不得同父重排")
|
|
|
|
let fixture = try CatalogFixtures.makeSubscriptionFixture()
|
|
try makeTestCatalogStore(fileURL: fileURL).save(CatalogDocument(catalog: fixture.catalog))
|
|
workflow = makeWorkflow()
|
|
XCTAssertFalse(workflow.canMove(fixture.serverIDs[0], to: nil), "订阅节点不可拖")
|
|
XCTAssertFalse(workflow.canMove(serverID, to: fixture.groupID), "目标非手动组")
|
|
XCTAssertFalse(workflow.canMove(NodeID(rawValue: "ghost"), to: nil), "不存在")
|
|
}
|
|
|
|
func testDragPayloadManualHasIDSubscriptionNil() async throws {
|
|
let serverID = try await importServer()
|
|
XCTAssertEqual(workflow.dragPayload(for: serverID), serverID.rawValue)
|
|
|
|
let fixture = try CatalogFixtures.makeSubscriptionFixture()
|
|
try makeTestCatalogStore(fileURL: fileURL).save(CatalogDocument(catalog: fixture.catalog))
|
|
workflow = makeWorkflow()
|
|
XCTAssertNil(workflow.dragPayload(for: fixture.serverIDs[0]))
|
|
XCTAssertNil(workflow.dragPayload(for: fixture.groupID))
|
|
}
|
|
|
|
// MARK: - 激活资格
|
|
|
|
func testActivationEligibilityEmptyGroup() async throws {
|
|
let groupID = try await workflow.createGroup(named: "空组", into: nil)
|
|
XCTAssertEqual(
|
|
workflow.activationEligibility(for: groupID),
|
|
ActivationEligibility(
|
|
canActivate: false, candidateCount: 0, skippedInvalidCount: 0,
|
|
ineligibility: .emptyGroup))
|
|
}
|
|
|
|
func testActivationEligibilityCountsCandidatesAndSkips() async throws {
|
|
let groupID = try await workflow.createGroup(named: "组", into: nil)
|
|
_ = await workflow.importServers(
|
|
from: [.clipboardText("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388")],
|
|
into: groupID)
|
|
// 无效叶子(不支持的加密方法)。
|
|
_ = await workflow.importServers(
|
|
from: [.clipboardText("ss://ZnV0dXJlLWNpcGhlcjp4eHg=@203.0.113.9:8388")],
|
|
into: groupID)
|
|
let eligibility = try XCTUnwrap(workflow.activationEligibility(for: groupID))
|
|
XCTAssertTrue(eligibility.canActivate)
|
|
XCTAssertEqual(eligibility.candidateCount, 1)
|
|
XCTAssertEqual(eligibility.skippedInvalidCount, 1)
|
|
XCTAssertNil(eligibility.ineligibility)
|
|
}
|
|
|
|
func testActivationEligibilityAllInvalidIsNoCandidates() async throws {
|
|
_ = await workflow.importServers(
|
|
from: [.clipboardText("ss://ZnV0dXJlLWNpcGhlcjp4eHg=@203.0.113.9:8388")],
|
|
into: nil)
|
|
let serverID = try XCTUnwrap(workflow.tree.roots.first?.id)
|
|
let eligibility = try XCTUnwrap(workflow.activationEligibility(for: serverID))
|
|
XCTAssertEqual(eligibility.ineligibility, .noCandidates)
|
|
XCTAssertFalse(eligibility.canActivate)
|
|
XCTAssertEqual(eligibility.skippedInvalidCount, 1)
|
|
}
|
|
|
|
// MARK: - 激活命令经假 adapter
|
|
|
|
func testActivateForwardsTargetThroughActivatingPort() async throws {
|
|
let serverID = try await importServer()
|
|
activator.result = .activated(skippedInvalid: 2)
|
|
let outcome = try await workflow.activate(serverID)
|
|
XCTAssertEqual(outcome, .activated(skippedInvalid: 2))
|
|
XCTAssertEqual(activator.activated, [serverID])
|
|
}
|
|
|
|
func testActivateRejectedOutcomeIsNotThrown() async throws {
|
|
let serverID = try await importServer()
|
|
activator.result = .rejectedActivation(.targetNotFound(serverID))
|
|
let outcome = try await workflow.activate(serverID)
|
|
XCTAssertEqual(outcome, .rejectedActivation(.targetNotFound(serverID)))
|
|
}
|
|
}
|
|
|
|
private final class FakeActivator: Activating {
|
|
var activated: [NodeID] = []
|
|
var result: ActivationCommandOutcome = .activated(skippedInvalid: 0)
|
|
var error: Error?
|
|
|
|
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome {
|
|
activated.append(target)
|
|
if let error { throw error }
|
|
return result
|
|
}
|
|
}
|