- 删除分组实体:分组视图/编辑器/仓储与 groupId 全部移除,升级首启清理 instance-groups.json 与 fleet.groupMode - 新增批量标签表:三态覆盖度(未贴 / N/M / 全部),整批加减与新建标签 - 配色重构,全应用零蓝色:石墨墨色承担主操作与选中态,颜色只留给状态语义; 浅色改中性冷灰,深色改低明度石墨三层 - 选中态改实心主色 + onPrimary,替换不可读的低透明度色雾(主题块、图例胶囊) - 应用图标重做为扁平石墨底 + 黄铜 SIM 面板,参数化光栅化脚本与参考稿 - 运行概览:检测进度并入副标题,读数栅格固定八格两行并保留不可用占位 - 实例详情:仪表改两列发丝线栅格,缺数据留轨道,修掉头部高度回弹; 补齐设备名称/存储总量/可用存储/使用率四个字段名 - 版本号显示去掉括号,设置页只显示 0.2.2 - 文档:新增 docs/ios-native-0.2.2.md,同步 ios-native.md 与两处 README
356 lines
13 KiB
Swift
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",
|
|
origin: row.instance.origin,
|
|
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,
|
|
origin: input.origin,
|
|
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 origin = patch.origin { instance.origin = origin }
|
|
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 }
|
|
}
|
|
}
|