Files
multi-simadmin/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift
T
chick 3f6090081f feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct,
  写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>)
- RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器,
  因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写)
- NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter;
  DirectTransport 是薄适配,直通路径行为不变
- RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃,
  四类以上失败各有独立 UpstreamError 分支与用户文案
- DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet
- 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成
- scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫
- 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、
  传输往返与各类拒绝路径
2026-09-16 18:57:41 +08:00

356 lines
13 KiB
Swift

import XCTest
@testable import MultiSimAdmin
@MainActor
final class FleetViewModelTests: XCTestCase {
private func transientPreferences() -> UserDefaults {
let suite = "FleetViewModelTests.\(UUID().uuidString)"
return UserDefaults(suiteName: suite)!
}
func testRefreshSortsByNameAndReportsStatus() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Zulu",
origin: URL(string: "http://192.168.1.30:3000")!,
tags: ["lab"],
password: nil
))
try await model.add(InstanceInput(
name: "Alpha",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
await model.refresh()
XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"])
XCTAssertEqual(model.rows.map(\.status), [.online, .online])
XCTAssertTrue(model.rows.allSatisfy { $0.latencyMilliseconds != nil })
}
func testVisibleRowsFollowSearchAndTagFilters() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(name: "Home", origin: URL(string: "http://192.168.1.70:3000")!, tags: ["home"]))
try await model.add(InstanceInput(name: "Lab", origin: URL(string: "http://192.168.1.71:3000")!, tags: []))
await model.refresh()
model.searchText = "home"
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
model.searchText = ""
model.clearTagFilters()
model.toggleTagFilter("Home")
XCTAssertEqual(model.allTags, ["home"])
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
XCTAssertEqual(model.countForTagFilter("home"), 1)
model.clearTagFilters()
XCTAssertEqual(model.visibleRows.count, 2, "没有筛选时全部节点都要出现")
}
func testVisibleRowsPutProblemNodesFirst() async throws {
let model = FleetViewModel(
repository: MemoryInstanceRepository(),
probe: ConnectionProbe(client: UnreachableForClient(host: "192.168.1.99")),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(name: "Healthy", origin: URL(string: "http://192.168.1.98:3000")!))
try await model.add(InstanceInput(name: "Broken", origin: URL(string: "http://192.168.1.99:3000")!))
await model.refresh()
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Broken", "Healthy"])
}
func testTagFiltersCombineWithAndSemanticsAndPruneStaleSelections() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Both",
origin: URL(string: "http://192.168.1.90:3000")!,
tags: ["primary", "field"]
))
try await model.add(InstanceInput(
name: "Single",
origin: URL(string: "http://192.168.1.91:3000")!,
tags: ["primary"]
))
await model.refresh()
model.toggleTagFilter("primary")
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both", "Single"])
model.toggleTagFilter("FIELD")
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both"])
model.toggleTagFilter("field")
model.toggleTagFilter("primary")
XCTAssertTrue(model.selectedTagFilters.isEmpty)
model.toggleTagFilter("vanished")
XCTAssertTrue(model.isTagFilterActive("vanished"))
await model.refresh()
XCTAssertTrue(model.selectedTagFilters.isEmpty, "已不存在的标签筛选应当在刷新后自动清理")
}
func testTagFilterSelectionSurvivesAcrossInstances() async throws {
let suite = "FleetTagFilterTests.\(UUID().uuidString)"
let repository = MemoryInstanceRepository()
let first = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: UserDefaults(suiteName: suite)!
)
try await first.add(InstanceInput(
name: "Relay",
origin: URL(string: "http://192.168.1.92:3000")!,
tags: ["relay"]
))
await first.refresh()
first.toggleTagFilter("relay")
let second = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: UserDefaults(suiteName: suite)!
)
XCTAssertTrue(second.selectedTagFilters.contains("relay"))
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
}
func testUpdateRewritesNameAndTags() async throws {
let model = FleetViewModel(
repository: MemoryInstanceRepository(),
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Van",
origin: URL(string: "http://192.168.1.80:3000")!,
tags: ["old"]
))
await model.refresh()
let row = try XCTUnwrap(model.rows.first)
try await model.update(row.id, InstanceInput(
name: "Van Pro",
endpoint: row.instance.endpoint,
tags: ["field"]
))
XCTAssertEqual(model.rows.first?.instance.name, "Van Pro")
XCTAssertEqual(model.rows.first?.instance.tags, ["field"])
}
func testApplyTagAddsAndRemovesAcrossTheWholeSelection() async throws {
let model = FleetViewModel(
repository: MemoryInstanceRepository(),
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
let first = try await model.add(InstanceInput(
name: "First",
origin: URL(string: "http://192.168.1.90:3000")!,
tags: ["keep"]
))
let second = try await model.add(InstanceInput(
name: "Second",
origin: URL(string: "http://192.168.1.91:3000")!
))
await model.refresh()
let ids = [first.id, second.id]
await model.applyTag("外场", to: ids)
XCTAssertEqual(model.rows.map(\.instance.tags).map { $0.contains("外场") }, [true, true])
XCTAssertEqual(model.tagCoverage("外场", in: ids), 2)
// 再点一次只撕掉这一个标签,其他标签必须留下。
await model.applyTag("外场", to: [first.id], removing: true)
XCTAssertEqual(model.rows.first?.instance.tags, ["keep"])
XCTAssertEqual(model.tagCoverage("外场", in: ids), 1)
}
func testAddValidatesPasswordAndDeleteRemovesCredential() async throws {
let repository = MemoryInstanceRepository()
let credentials = NoopCredentialStore()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: credentials
)
try await model.add(InstanceInput(
name: "Secret",
origin: URL(string: "http://192.168.1.40:3000")!,
password: "pass"
))
let row = try XCTUnwrap(model.rows.first)
XCTAssertTrue(row.instance.credentialConfigured)
XCTAssertEqual(credentials.saved[row.instance.id], "pass")
await model.delete([row.instance.id])
XCTAssertTrue(model.rows.isEmpty)
XCTAssertNil(credentials.saved[row.instance.id])
}
func testDeleteSelectedClearsSelectionAndCredential() async throws {
let repository = MemoryInstanceRepository()
let credentials = NoopCredentialStore()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: credentials
)
try await model.add(InstanceInput(
name: "Selected",
origin: URL(string: "http://192.168.1.50:3000")!,
password: "pass"
))
try await model.add(InstanceInput(
name: "Other",
origin: URL(string: "http://192.168.1.60:3000")!,
password: nil
))
let selectedID = model.rows[0].id
model.selectionMode = true
model.selectedIDs = [selectedID]
await model.deleteSelected()
XCTAssertEqual(model.rows.map(\.instance.name), ["Selected"])
XCTAssertEqual(credentials.removed, [selectedID])
XCTAssertFalse(model.selectionMode)
XCTAssertTrue(model.selectedIDs.isEmpty)
}
}
struct AlwaysOnlineClient: SimAdminClientProtocol {
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data("{}".utf8))
}
}
/// 指定主机一律不可达,用来验证"问题节点排在前面"。
struct UnreachableForClient: SimAdminClientProtocol {
let host: String
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
if baseURL.host == host {
throw UpstreamError.transportFailed
}
return UpstreamResponse(status: 200, data: Data("{}".utf8))
}
}
final class NoopCredentialStore: @unchecked Sendable, CredentialStore {
private let lock = NSLock()
private var savedStorage: [UUID: String] = [:]
private var removedStorage: [UUID] = []
var saved: [UUID: String] {
lock.withLock { savedStorage }
}
var removed: [UUID] {
lock.withLock { removedStorage }
}
func get(instanceID: UUID) throws -> String? {
saved[instanceID]
}
func set(instanceID: UUID, password: String) throws {
lock.withLock { savedStorage[instanceID] = password }
}
func remove(instanceID: UUID) throws {
lock.withLock {
savedStorage[instanceID] = nil
removedStorage.append(instanceID)
}
}
}
final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
private let lock = NSLock()
private var itemsStorage: [Instance] = []
private var items: [Instance] {
get { lock.withLock { itemsStorage } }
set { lock.withLock { itemsStorage = newValue } }
}
func list(_: InstanceQuery) async throws -> [Instance] {
items.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
func get(_ id: UUID) async throws -> Instance? {
items.first { $0.id == id }
}
func create(_ input: InstanceInput) async throws -> Instance {
let now = Date()
let instance = Instance(
id: UUID(),
name: input.name,
endpoint: input.endpoint,
tags: input.tags,
credentialConfigured: !(input.password ?? "").isEmpty,
configRevision: 1,
createdAt: now,
updatedAt: now
)
items.append(instance)
return instance
}
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
guard let index = items.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard items[index].configRevision == expectedRevision else {
throw RepositoryError.revisionConflict
}
var instance = items[index]
if let name = patch.name { instance.name = name }
if let endpoint = patch.endpoint { instance.endpoint = endpoint }
if let tags = patch.tags { instance.tags = tags }
if let password = patch.password {
instance.credentialConfigured = !password.isEmpty
}
instance.updatedAt = Date()
instance.configRevision += 1
items[index] = instance
return instance
}
func delete(_ id: UUID, expectedRevision: Int) async throws {
items.removeAll { $0.id == id }
}
}