fix(ios): improve fleet overview and app identity
- Show stable group health in a fixed 2x2 metric grid - Add link-chain state to fleet cards and parse hardware/control/SIM/cellular - Replace the wrapped icon with a full-bleed app icon - Bump the IPA build number
This commit is contained in:
@@ -22,6 +22,28 @@ final class DeviceServiceTests: XCTestCase {
|
||||
XCTAssertEqual(snapshot.sections[5].values["state"], "connected")
|
||||
}
|
||||
|
||||
func testBuildsRunningSummaryFromUpstreamSections() async throws {
|
||||
let store = InstanceSessionStore()
|
||||
let client = DeviceTestClient()
|
||||
let service = DeviceService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
|
||||
let summary = try await service.summary(instance)
|
||||
|
||||
XCTAssertEqual(summary.deviceName, "SIMBox 8")
|
||||
XCTAssertEqual(summary.phoneNumber, "13800138000")
|
||||
XCTAssertEqual(summary.operatorName, "中国移动")
|
||||
XCTAssertEqual(summary.connectivity, "connected")
|
||||
XCTAssertEqual(summary.firmwareVersion, "2.4.1")
|
||||
XCTAssertEqual(summary.totalStorageBytes, 1024)
|
||||
XCTAssertEqual(summary.availableStorageBytes, 512)
|
||||
XCTAssertEqual(summary.downloadRateBytesPerSecond, 128)
|
||||
XCTAssertEqual(summary.uploadRateBytesPerSecond, 64)
|
||||
XCTAssertEqual(summary.messagesToday, 12)
|
||||
XCTAssertEqual(summary.uptimeSeconds, 86400)
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
@@ -43,12 +65,12 @@ private final class DeviceTestClient: SimAdminClientProtocol, @unchecked Sendabl
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
let payload: [UpstreamRoute: String] = [
|
||||
.device: #"{"data":{"model":"SIMBox 8","online":true}}"#,
|
||||
.device: #"{"data":{"model":"SIMBox 8","online":true,"firmware_version":"2.4.1"}}"#,
|
||||
.sim: #"{"data":{"phone_numbers":["13800138000"],"iccid":"123"}}"#,
|
||||
.network: #"{"data":{"operator_name":"中国移动","registration_status":"registered"}}"#,
|
||||
.stats: #"{"data":{"uptime":{"uptime_seconds":86400}}}"#,
|
||||
.stats: #"{"data":{"uptime":{"uptime_seconds":86400},"disk_total_bytes":1024,"disk_available_bytes":512}}"#,
|
||||
.statsCpu: #"{"data":{"core_count":4,"model_name":"ARM CPU"}}"#,
|
||||
.connectivity: #"{"data":{"state":"connected","latency_ms":21}}"#
|
||||
.connectivity: #"{"data":{"state":"connected","latency_ms":21,"download":128,"upload":64,"messages_today":12}}"#
|
||||
]
|
||||
guard let json = payload[route] else {
|
||||
throw UpstreamError.notSupported
|
||||
|
||||
@@ -28,6 +28,117 @@ final class FleetViewModelTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"])
|
||||
XCTAssertEqual(model.rows.map(\.status), [.online, .online])
|
||||
XCTAssertTrue(model.rows.allSatisfy { $0.latencyMilliseconds != nil })
|
||||
}
|
||||
|
||||
func testVisibleRowsAndGroupModesUseFilteredResults() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore()
|
||||
)
|
||||
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"])
|
||||
XCTAssertEqual(model.groupedRows.flatMap(\.rows).map(\.instance.name), ["Home"])
|
||||
|
||||
model.searchText = ""
|
||||
model.groupMode = .tags
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["home", "未打标签"])
|
||||
XCTAssertEqual(model.groupedRows.map(\.rows.count), [1, 1])
|
||||
|
||||
model.groupMode = .status
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["在线"])
|
||||
}
|
||||
|
||||
func testGroupModeKeepsUngroupedInstancesVisible() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore()
|
||||
)
|
||||
try await model.add(InstanceInput(name: "Ungrouped", origin: URL(string: "http://192.168.1.72:3000")!))
|
||||
|
||||
await model.refresh()
|
||||
model.groupMode = .groups
|
||||
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["未分组"])
|
||||
XCTAssertEqual(model.groupedRows.first?.rows.map(\.instance.name), ["Ungrouped"])
|
||||
}
|
||||
|
||||
func testStoredGroupsResolveNamesAndCanUpdateInstance() async throws {
|
||||
let location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathComponent("groups.json")
|
||||
let groupStore = FileInstanceGroupStore(location: location)
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
groupStore: groupStore
|
||||
)
|
||||
|
||||
let createdGroup = try await groupStore.create(InstanceGroupInput(name: "外场"))
|
||||
try await model.add(InstanceInput(
|
||||
name: "Van",
|
||||
origin: URL(string: "http://192.168.1.80:3000")!,
|
||||
groupId: createdGroup.id
|
||||
))
|
||||
|
||||
await model.refresh()
|
||||
model.groupMode = .groups
|
||||
|
||||
XCTAssertEqual(model.groups.map(\.name), ["外场"])
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["外场"])
|
||||
XCTAssertEqual(model.groupedRows.first?.rows.map(\.instance.name), ["Van"])
|
||||
|
||||
let row = try XCTUnwrap(model.rows.first)
|
||||
try await model.update(row.id, InstanceInput(
|
||||
name: "Van Pro",
|
||||
origin: row.instance.origin,
|
||||
tags: ["field"],
|
||||
groupId: createdGroup.id
|
||||
))
|
||||
|
||||
XCTAssertEqual(model.rows.first?.instance.name, "Van Pro")
|
||||
XCTAssertEqual(model.rows.first?.instance.tags, ["field"])
|
||||
}
|
||||
|
||||
func testAssignMovesSelectedInstancesIntoAndOutOfGroup() async throws {
|
||||
let location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathComponent("groups.json")
|
||||
let groupStore = FileInstanceGroupStore(location: location)
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
groupStore: groupStore
|
||||
)
|
||||
let first = try await model.add(InstanceInput(
|
||||
name: "First",
|
||||
origin: URL(string: "http://192.168.1.90:3000")!
|
||||
))
|
||||
let second = try await model.add(InstanceInput(
|
||||
name: "Second",
|
||||
origin: URL(string: "http://192.168.1.91:3000")!
|
||||
))
|
||||
let group = try await model.createGroup(named: "外场")
|
||||
|
||||
try await model.assign(groupID: group.id, to: [first.id, second.id])
|
||||
XCTAssertEqual(model.rows.map(\.instance.groupId), [group.id, group.id])
|
||||
|
||||
try await model.assign(groupID: nil, to: [first.id])
|
||||
XCTAssertEqual(model.rows.first?.instance.groupId, nil)
|
||||
XCTAssertEqual(model.rows.last?.instance.groupId, group.id)
|
||||
}
|
||||
|
||||
func testAddValidatesPasswordAndDeleteRemovesCredential() async throws {
|
||||
@@ -153,8 +264,25 @@ final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
|
||||
return instance
|
||||
}
|
||||
|
||||
func update(_: UUID, expectedRevision: Int, _: InstancePatch) async throws -> Instance {
|
||||
throw RepositoryError.notFound
|
||||
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 patch.groupId != nil || instance.groupId != nil { instance.groupId = patch.groupId }
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user