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 } } }