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:
chick
2026-09-13 00:46:26 +08:00
parent 409345cba3
commit 2ff23d1fb0
23 changed files with 3600 additions and 269 deletions
@@ -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 {