Move diagnostics into settings, remove the app-local notification module, unify the global theme switcher, refine privacy copy and the instance detail experience, and rebuild the app icon.
207 lines
7.0 KiB
Swift
207 lines
7.0 KiB
Swift
import XCTest
|
|
|
|
@testable import MultiSimAdmin
|
|
|
|
final class LogCenterTests: XCTestCase {
|
|
func testAggregatesDeliveryLogsConnectionsAndDiagnostics() async throws {
|
|
let store = MemoryLogStore()
|
|
let service = LogCenterService(
|
|
client: LogCenterTestClient(),
|
|
sessions: InstanceSessionStore(),
|
|
store: store
|
|
)
|
|
let instance = Self.instance()
|
|
|
|
let snapshot = try await service.refresh([instance])
|
|
|
|
XCTAssertEqual(snapshot.runtime.count, 0)
|
|
XCTAssertEqual(snapshot.counts[.delivery], 0)
|
|
XCTAssertEqual(store.runtimeStorage.count, 0)
|
|
|
|
XCTAssertEqual(snapshot.connections.count, 1)
|
|
XCTAssertEqual(snapshot.connections.first?.outcome, .success)
|
|
XCTAssertEqual(snapshot.connections.first?.state, "online")
|
|
XCTAssertEqual(store.connectionStorage.count, 1)
|
|
|
|
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
|
|
XCTAssertEqual(diagnostic.id, instance.id)
|
|
XCTAssertEqual(diagnostic.state, .online)
|
|
XCTAssertEqual(diagnostic.featureStatus.map(\.state), [
|
|
.ready,
|
|
.ready,
|
|
.ready
|
|
])
|
|
XCTAssertEqual(diagnostic.connection?.total, 1)
|
|
XCTAssertEqual(diagnostic.connection?.availabilityPercent, 100)
|
|
}
|
|
|
|
func testRecordsUnauthorizedDeliveryLogsWithoutLosingConnectionHistory() async throws {
|
|
let store = MemoryLogStore()
|
|
let service = LogCenterService(
|
|
client: LogCenterTestClient(
|
|
),
|
|
sessions: InstanceSessionStore(),
|
|
store: store
|
|
)
|
|
let instance = Self.instance()
|
|
|
|
let snapshot = try await service.refresh([instance])
|
|
|
|
XCTAssertEqual(snapshot.runtime.count, 0)
|
|
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
|
|
XCTAssertEqual(diagnostic.state, .online)
|
|
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 3)
|
|
XCTAssertEqual(store.runtimeStorage.count, 0)
|
|
XCTAssertEqual(store.connectionStorage.count, 1)
|
|
}
|
|
|
|
func testOfflineProbeBecomesFailedConnectionAndErrorLog() async throws {
|
|
let store = MemoryLogStore()
|
|
let service = LogCenterService(
|
|
client: LogCenterTestClient(connectionUnavailable: true),
|
|
sessions: InstanceSessionStore(),
|
|
store: store
|
|
)
|
|
|
|
let snapshot = try await service.refresh([Self.instance()])
|
|
|
|
XCTAssertEqual(snapshot.connections.first?.outcome, .failed)
|
|
XCTAssertEqual(snapshot.connections.first?.errorCode, "TIMEOUT")
|
|
XCTAssertTrue(snapshot.runtime.contains { $0.level == .error && $0.code == "TIMEOUT" })
|
|
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
|
|
XCTAssertEqual(diagnostic.state, .offline)
|
|
XCTAssertEqual(diagnostic.healthStatus, "超时")
|
|
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 2)
|
|
}
|
|
|
|
@MainActor
|
|
func testViewModelFiltersRuntimeLogsAndPrunesHistory() async throws {
|
|
let repository = MemoryInstanceRepository()
|
|
let logStore = MemoryLogStore()
|
|
let service = LogCenterService(
|
|
client: LogCenterTestClient(),
|
|
sessions: InstanceSessionStore(),
|
|
store: logStore
|
|
)
|
|
let model = LogsViewModel(
|
|
repository: repository,
|
|
service: service,
|
|
store: logStore
|
|
)
|
|
let instance = try await repository.create(InstanceInput(
|
|
name: "Log Node",
|
|
origin: URL(string: "http://192.168.1.20:3000")!,
|
|
password: nil
|
|
))
|
|
|
|
await model.refresh()
|
|
|
|
XCTAssertNotNil(model.snapshot)
|
|
XCTAssertEqual(model.filteredRuntime.count, 0)
|
|
XCTAssertEqual(model.filteredDiagnostics.count, 1)
|
|
XCTAssertEqual(model.offlineCount, 0)
|
|
|
|
model.selectedInstanceID = instance.id
|
|
model.searchText = "offline"
|
|
model.selectedLevel = .error
|
|
|
|
XCTAssertEqual(model.filteredRuntime.count, 0)
|
|
XCTAssertNil(model.filteredRuntime.first)
|
|
|
|
let removed = await model.pruneConnections(olderThanDays: 7)
|
|
XCTAssertEqual(removed, 0)
|
|
}
|
|
|
|
private static func instance() -> Instance {
|
|
Instance(
|
|
id: UUID(),
|
|
name: "Log Node",
|
|
origin: URL(string: "http://192.168.1.20:3000")!,
|
|
configRevision: 1,
|
|
createdAt: Date(),
|
|
updatedAt: Date()
|
|
)
|
|
}
|
|
}
|
|
|
|
private final class MemoryLogStore: LogStore, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var runtimeItems: [RuntimeLogEntry] = []
|
|
private var connectionItems: [ConnectionLogEntry] = []
|
|
|
|
var runtimeStorage: [RuntimeLogEntry] {
|
|
lock.withLock { runtimeItems }
|
|
}
|
|
|
|
var connectionStorage: [ConnectionLogEntry] {
|
|
lock.withLock { connectionItems }
|
|
}
|
|
|
|
func runtimeLogs() async throws -> [RuntimeLogEntry] {
|
|
runtimeStorage
|
|
}
|
|
|
|
func connectionLogs() async throws -> [ConnectionLogEntry] {
|
|
connectionStorage
|
|
}
|
|
|
|
func append(
|
|
runtime: [RuntimeLogEntry],
|
|
connections: [ConnectionLogEntry]
|
|
) async throws {
|
|
lock.withLock {
|
|
var mergedRuntime = runtime
|
|
for existing in runtimeItems where !runtime.contains(where: { $0.id == existing.id }) {
|
|
mergedRuntime.append(existing)
|
|
}
|
|
runtimeItems = mergedRuntime.sorted { $0.occurredAt > $1.occurredAt }
|
|
|
|
var mergedConnections = connections
|
|
for existing in connectionItems
|
|
where !connections.contains(where: { $0.id == existing.id }) {
|
|
mergedConnections.append(existing)
|
|
}
|
|
connectionItems = mergedConnections.sorted { $0.observedAt > $1.observedAt }
|
|
}
|
|
}
|
|
|
|
func pruneConnections(before _: Date) async throws -> Int {
|
|
0
|
|
}
|
|
|
|
func clearRuntimeLogs() async throws -> Int {
|
|
0
|
|
}
|
|
}
|
|
|
|
private struct LogCenterTestClient: SimAdminClientProtocol {
|
|
var failingRoutes: Set<UpstreamRoute> = []
|
|
var connectionUnavailable = false
|
|
|
|
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
|
if connectionUnavailable {
|
|
throw UpstreamError.timeout
|
|
}
|
|
if failingRoutes.contains(route) {
|
|
throw UpstreamError.unauthorized
|
|
}
|
|
|
|
switch route {
|
|
case .health:
|
|
return Self.response(#"{"status":"UP","version":"1.2.3","platform":"linux"}"#)
|
|
case .device:
|
|
return Self.response(
|
|
#"{"status":"ok","data":{"model":"UFI-003","battery":82,"temperature":42}}"#
|
|
)
|
|
case .connectivity:
|
|
return Self.response(#"{"status":"ok","data":{"connected":true}}"#)
|
|
default:
|
|
return UpstreamResponse(status: 404, data: Data("{}".utf8))
|
|
}
|
|
}
|
|
|
|
private static func response(_ json: String) -> UpstreamResponse {
|
|
UpstreamResponse(status: 200, data: Data(json.utf8))
|
|
}
|
|
}
|