feat(client/apple): sort and group the host grid
The grid had no ordering story — hosts sat in the order they were added and that was the whole of it. A toolbar menu now offers Sort by (Date Added, Name, Last Connected) and Group by (None, Profile, Status), both per device: this is a window on a list, not something about how a host streams. The control stays out of the toolbar until there are two hosts to arrange. Grouping by profile is the one the profiles made possible: bands in catalog order, each header wearing its profile's colour, then the unbound hosts. Pinned cards stay with their host — a pin is presentation of that host, not a host of its own — and a dangling binding lands in "No Profile" rather than in a band named after a profile that no longer exists. Empty bands aren't drawn. The ordering is pure and tested, which earned its keep immediately: the test caught that undated hosts were sorting first, and made me work out whether that was a bug. It isn't — no date means saved before `addedAt` existed, which means older, and in a real store those are a prefix, so the default sort leaves an upgraded grid exactly as it was. Two other traps are pinned by tests: `sorted` is not stable in Swift, so every comparison tie-breaks on the stored index or equal rows swap on redraw; and a never-connected host sorts LAST under Last Connected, not first as `.distantPast` would have it. `StoredHost` gains `addedAt` for the date the sort actually needs — optional and appended last, so the widget contract holds and hosts saved before it keep working. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,10 @@ struct HomeView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||
@State private var editTarget: StoredHost?
|
||||
// How this device shows its own list. `.added` is the default because it is what the grid
|
||||
// did before it could sort at all — an update should not rearrange anyone's hosts.
|
||||
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
|
||||
@AppStorage(DefaultsKey.hostGrouping) private var groupingRaw = HostGrouping.none.rawValue
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -53,11 +57,18 @@ struct HomeView: View {
|
||||
} else {
|
||||
ScrollView {
|
||||
if !store.hosts.isEmpty {
|
||||
LazyVStack(alignment: .leading, spacing: gridSpacing) {
|
||||
ForEach(hostGroups) { group in
|
||||
if let title = group.title {
|
||||
groupHeader(title, accent: group.accent)
|
||||
}
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(gridEntries) { entry in
|
||||
ForEach(entries(in: group)) { entry in
|
||||
hostCard(entry.host, pinned: entry.profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
// Mirror of the action row's focusSection below: an UPWARD move from
|
||||
// the centered buttons must land back in the grid even when no card
|
||||
@@ -137,8 +148,17 @@ struct HomeView: View {
|
||||
#if os(iOS)
|
||||
// Adjacent trailing items share one glass pill (the system default).
|
||||
ToolbarItem(placement: .topBarTrailing) { settingsButton }
|
||||
if showsArrangeMenu {
|
||||
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
#else
|
||||
if showsArrangeMenu {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
arrangeMenu
|
||||
.help("Sort and group the host list")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
addHostButton
|
||||
.help("Add a host")
|
||||
@@ -199,22 +219,52 @@ struct HomeView: View {
|
||||
var id: String { "\(host.id.uuidString)#\(profile?.id ?? "")" }
|
||||
}
|
||||
|
||||
/// Saved hosts, each immediately followed by its pinned cards — so a pin reads as belonging to
|
||||
/// its host rather than as a stray tile somewhere in the grid.
|
||||
private var gridEntries: [HostGridEntry] {
|
||||
store.hosts.flatMap { host in
|
||||
/// The grid's bands, ordered and divided per this device's preference (`HostArrangement`).
|
||||
private var hostGroups: [HostGroup] {
|
||||
HostArrangement.groups(
|
||||
hosts: store.hosts, catalog: profiles.catalog,
|
||||
online: Set(store.hosts.filter(isOnline).map(\.id)),
|
||||
sort: HostSort(rawValue: sortRaw) ?? .added,
|
||||
grouping: HostGrouping(rawValue: groupingRaw) ?? .none)
|
||||
}
|
||||
|
||||
/// A band's hosts, each immediately followed by its pinned cards — so a pin reads as belonging
|
||||
/// to its host rather than as a stray tile somewhere in the grid.
|
||||
private func entries(in group: HostGroup) -> [HostGridEntry] {
|
||||
group.hosts.flatMap { host in
|
||||
[HostGridEntry(host: host, profile: nil)]
|
||||
+ profiles.pinned(for: host).map { HostGridEntry(host: host, profile: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Online = advertising on mDNS OR answered the reachability probe (a routed/VPN host never
|
||||
/// advertises). One definition, used by the cards and by the Status grouping alike.
|
||||
private func isOnline(_ host: StoredHost) -> Bool {
|
||||
discovery.advertises(host) || store.probedOnline.contains(host.id)
|
||||
}
|
||||
|
||||
private func groupHeader(_ title: String, accent: String?) -> some View {
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(Color(hex: accent ?? "") ?? Color.secondary.opacity(0.5))
|
||||
.frame(width: 7, height: 7)
|
||||
.accessibilityHidden(true) // the title says it
|
||||
Text(title)
|
||||
.font(.geist(13, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.6)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View {
|
||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
||||
// A pinned card connects with ITS profile; the primary card follows the binding.
|
||||
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
|
||||
return HostCardView(
|
||||
host: host,
|
||||
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||
isOnline: isOnline(host),
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
// The accent ring marks the most recent HOST; pinned cards stay quiet so the grid
|
||||
// doesn't grow several "most recent" bars for one machine.
|
||||
@@ -311,6 +361,34 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One host has no order and nothing to divide, so the control stays out of the way until
|
||||
/// there is a list to arrange.
|
||||
private var showsArrangeMenu: Bool { store.hosts.count > 1 }
|
||||
|
||||
/// Sort and group, as two inline pickers in one menu — both are about the same list, and
|
||||
/// splitting them across two toolbar items would say otherwise.
|
||||
private var arrangeMenu: some View {
|
||||
Menu {
|
||||
Picker("Sort By", selection: $sortRaw) {
|
||||
ForEach(HostSort.allCases) { option in
|
||||
Label(option.label, systemImage: option.symbol).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
Divider()
|
||||
Picker("Group By", selection: $groupingRaw) {
|
||||
ForEach(HostGrouping.allCases) { option in
|
||||
Label(option.label, systemImage: option.symbol).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
} label: {
|
||||
Label("Sort and Group", systemImage: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(macOS)
|
||||
private var settingsButton: some View {
|
||||
Button {
|
||||
|
||||
@@ -97,6 +97,11 @@ final class HostStore: ObservableObject {
|
||||
}
|
||||
|
||||
func add(_ host: StoredHost) {
|
||||
var host = host
|
||||
// Stamped here rather than in the initializer: a `StoredHost` is also built to describe a
|
||||
// host we are only dialing (the dev auto-connect hook, a deep link's confirmation), and
|
||||
// those were never added to anything.
|
||||
host.addedAt = host.addedAt ?? Date()
|
||||
hosts.append(host)
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,11 @@ public enum DefaultsKey {
|
||||
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
|
||||
public static let enable444 = "punktfunk.enable444"
|
||||
public static let hosts = "punktfunk.hosts"
|
||||
/// How the host grid is ordered (a `HostSort` raw value) and what it's divided by (a
|
||||
/// `HostGrouping`). Per device, never per profile: it is this device's window on its own
|
||||
/// list, not something about how a host is streamed.
|
||||
public static let hostSort = "punktfunk.hostSort"
|
||||
public static let hostGrouping = "punktfunk.hostGrouping"
|
||||
/// The settings-profile catalog (`ProfileCatalog`, one JSON blob) — design
|
||||
/// client-settings-profiles.md §4.2. Lives in the APP GROUP suite with `hosts`, not with the
|
||||
/// settings: bindings and pins are fields on the host record, and an extension that can read
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// How the host grid is ordered and grouped.
|
||||
//
|
||||
// Pure value logic, deliberately: which host lands where is the kind of thing that reads as
|
||||
// obviously right and behaves subtly wrong (an unstable sort reshuffling equal rows on every
|
||||
// redraw, a never-connected host sorting as if it were connected in 1970), so it lives apart from
|
||||
// the view and is tested. It sits in PunktfunkShared because everything it touches — `StoredHost`,
|
||||
// `ProfileCatalog` — already does.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The order hosts appear in.
|
||||
public enum HostSort: String, CaseIterable, Identifiable, Sendable {
|
||||
/// Oldest first — the order they were added, and what the grid did before it could sort at
|
||||
/// all. The default for exactly that reason: an update shouldn't rearrange anyone's grid.
|
||||
case added
|
||||
case name
|
||||
case lastConnected
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .added: return "Date Added"
|
||||
case .name: return "Name"
|
||||
case .lastConnected: return "Last Connected"
|
||||
}
|
||||
}
|
||||
|
||||
public var symbol: String {
|
||||
switch self {
|
||||
case .added: return "calendar"
|
||||
case .name: return "textformat.abc"
|
||||
case .lastConnected: return "clock.arrow.circlepath"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the grid is divided by.
|
||||
public enum HostGrouping: String, CaseIterable, Identifiable, Sendable {
|
||||
case none
|
||||
/// The host's DEFAULT profile — the one a plain tap uses. Pinned cards stay with their host:
|
||||
/// a pin is presentation of that host, not a host of its own.
|
||||
case profile
|
||||
case status
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .none: return "None"
|
||||
case .profile: return "Profile"
|
||||
case .status: return "Status"
|
||||
}
|
||||
}
|
||||
|
||||
public var symbol: String {
|
||||
switch self {
|
||||
case .none: return "square.grid.2x2"
|
||||
case .profile: return "slider.horizontal.3"
|
||||
case .status: return "wifi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One rendered band of the grid. `title` is nil for the ungrouped case, which is what tells the
|
||||
/// view to draw no header at all rather than an empty one.
|
||||
public struct HostGroup: Identifiable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let title: String?
|
||||
/// `#RRGGBB` when the band is a profile — its chip colour, so the header matches the cards.
|
||||
public let accent: String?
|
||||
public let hosts: [StoredHost]
|
||||
|
||||
public init(id: String, title: String?, accent: String? = nil, hosts: [StoredHost]) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.accent = accent
|
||||
self.hosts = hosts
|
||||
}
|
||||
}
|
||||
|
||||
public enum HostArrangement {
|
||||
/// Group, then sort within each group. `online` is the set of host ids currently reachable —
|
||||
/// the caller owns that (mDNS plus the reachability probe), this only reads it.
|
||||
public static func groups(
|
||||
hosts: [StoredHost], catalog: ProfileCatalog, online: Set<UUID>,
|
||||
sort: HostSort, grouping: HostGrouping
|
||||
) -> [HostGroup] {
|
||||
// The original index is the tie-break for every sort below. `Array.sorted` is NOT stable
|
||||
// in Swift, so without it two hosts that compare equal — same name, both never connected —
|
||||
// could swap places between redraws.
|
||||
let ranked = hosts.enumerated().map { (index: $0.offset, host: $0.element) }
|
||||
|
||||
func ordered(_ slice: [(index: Int, host: StoredHost)]) -> [StoredHost] {
|
||||
slice.sorted { lhs, rhs in
|
||||
switch sort {
|
||||
case .added:
|
||||
// Undated hosts first: no date means saved before the field existed, which
|
||||
// means older than anything carrying one. In a real store those are a PREFIX
|
||||
// (everything from before the upgrade), so they hold their stored order and
|
||||
// the grid looks exactly as it did — which is why this is the default sort.
|
||||
let a = lhs.host.addedAt, b = rhs.host.addedAt
|
||||
if let a, let b, a != b { return a < b }
|
||||
if (a == nil) != (b == nil) { return a == nil }
|
||||
case .name:
|
||||
let comparison = lhs.host.displayName.localizedStandardCompare(
|
||||
rhs.host.displayName)
|
||||
if comparison != .orderedSame { return comparison == .orderedAscending }
|
||||
case .lastConnected:
|
||||
// Never-connected hosts go last, not first — `.distantPast` would put a host
|
||||
// you have never used at the top of "Last Connected".
|
||||
switch (lhs.host.lastConnected, rhs.host.lastConnected) {
|
||||
case let (a?, b?) where a != b: return a > b
|
||||
case (nil, _?): return false
|
||||
case (_?, nil): return true
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return lhs.index < rhs.index
|
||||
}.map(\.host)
|
||||
}
|
||||
|
||||
switch grouping {
|
||||
case .none:
|
||||
return [HostGroup(id: "all", title: nil, hosts: ordered(ranked))]
|
||||
|
||||
case .profile:
|
||||
// Catalog order, so the bands sit in the same order the scope menu lists them, then
|
||||
// the unbound hosts. A band with nothing in it isn't drawn.
|
||||
var groups: [HostGroup] = catalog.profiles.compactMap { profile in
|
||||
let members = ranked.filter { catalog.binding(for: $0.host)?.id == profile.id }
|
||||
guard !members.isEmpty else { return nil }
|
||||
return HostGroup(
|
||||
id: "profile-\(profile.id)", title: profile.name, accent: profile.accent,
|
||||
hosts: ordered(members))
|
||||
}
|
||||
// A dangling binding resolves as none (§4.4), so it lands here rather than in a band
|
||||
// named after a profile that no longer exists.
|
||||
let unbound = ranked.filter { catalog.binding(for: $0.host) == nil }
|
||||
if !unbound.isEmpty {
|
||||
groups.append(
|
||||
HostGroup(id: "profile-none", title: "No Profile", hosts: ordered(unbound)))
|
||||
}
|
||||
return groups
|
||||
|
||||
case .status:
|
||||
var groups: [HostGroup] = []
|
||||
let up = ranked.filter { online.contains($0.host.id) }
|
||||
let down = ranked.filter { !online.contains($0.host.id) }
|
||||
if !up.isEmpty {
|
||||
groups.append(HostGroup(id: "status-online", title: "Online", hosts: ordered(up)))
|
||||
}
|
||||
if !down.isEmpty {
|
||||
groups.append(
|
||||
HostGroup(id: "status-offline", title: "Offline", hosts: ordered(down)))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,16 @@ public struct StoredHost: Identifiable, Codable, Hashable, Sendable {
|
||||
/// default — that is `profileID`; a pin is presentation only, and duplicates and dangling ids
|
||||
/// are dropped when the cards are built. Optional for the same forward-compat reason.
|
||||
public var pinnedProfileIDs: [String]?
|
||||
/// When this host was saved — what the grid's "Date Added" sort orders by. Optional and
|
||||
/// appended last for the same widget-contract reason as the rest; hosts saved before it
|
||||
/// existed have none, and keep their stored order, which IS the order they were added in.
|
||||
public var addedAt: Date?
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777,
|
||||
pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil,
|
||||
macAddresses: [String]? = nil, clipboardSync: Bool? = nil,
|
||||
profileID: String? = nil, pinnedProfileIDs: [String]? = nil
|
||||
profileID: String? = nil, pinnedProfileIDs: [String]? = nil, addedAt: Date? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -67,6 +71,7 @@ public struct StoredHost: Identifiable, Codable, Hashable, Sendable {
|
||||
self.clipboardSync = clipboardSync
|
||||
self.profileID = profileID
|
||||
self.pinnedProfileIDs = pinnedProfileIDs
|
||||
self.addedAt = addedAt
|
||||
}
|
||||
|
||||
public var displayName: String { name.isEmpty ? address : name }
|
||||
|
||||
@@ -24,7 +24,8 @@ final class SharedFoundationTests: XCTestCase {
|
||||
pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]),
|
||||
lastConnected: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"], clipboardSync: true,
|
||||
profileID: "a1b2c3d4e5f6", pinnedProfileIDs: ["0f0f0f0f0f0f"])
|
||||
profileID: "a1b2c3d4e5f6", pinnedProfileIDs: ["0f0f0f0f0f0f"],
|
||||
addedAt: Date(timeIntervalSince1970: 1_600_000_000))
|
||||
|
||||
let data = try JSONEncoder().encode(host)
|
||||
let decoded = try JSONDecoder().decode(StoredHost.self, from: data)
|
||||
@@ -49,6 +50,7 @@ final class SharedFoundationTests: XCTestCase {
|
||||
XCTAssertNil(decoded.clipboardSync)
|
||||
XCTAssertNil(decoded.profileID)
|
||||
XCTAssertNil(decoded.pinnedProfileIDs)
|
||||
XCTAssertNil(decoded.addedAt)
|
||||
// Resolvers fall back cleanly.
|
||||
XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort)
|
||||
XCTAssertEqual(decoded.wakeMacs, [])
|
||||
@@ -212,6 +214,116 @@ final class SharedFoundationTests: XCTestCase {
|
||||
XCTAssertFalse(lying.pinConflict(with: couchA))
|
||||
}
|
||||
|
||||
// MARK: - Host grid arrangement
|
||||
|
||||
private func arrangementFixture() -> (hosts: [StoredHost], catalog: ProfileCatalog) {
|
||||
let catalog = ProfileCatalog(profiles: [
|
||||
StreamProfile(name: "Game", id: "111111111111", accent: "#ff8800"),
|
||||
StreamProfile(name: "Work", id: "222222222222"),
|
||||
])
|
||||
// Stored order is the order they were added. Basement has no `addedAt` at all — it was
|
||||
// saved before the field existed, which is why it sits at the FRONT of the store: undated
|
||||
// hosts are always the oldest ones, never scattered through the middle.
|
||||
let basement = StoredHost(name: "Basement", address: "10.0.0.3")
|
||||
var desk = StoredHost(name: "Desk", address: "10.0.0.1",
|
||||
addedAt: Date(timeIntervalSince1970: 100))
|
||||
desk.profileID = "111111111111"
|
||||
desk.lastConnected = Date(timeIntervalSince1970: 5_000)
|
||||
var attic = StoredHost(name: "attic", address: "10.0.0.2",
|
||||
addedAt: Date(timeIntervalSince1970: 200))
|
||||
attic.profileID = "222222222222"
|
||||
var couch = StoredHost(name: "Couch", address: "10.0.0.4",
|
||||
addedAt: Date(timeIntervalSince1970: 300))
|
||||
couch.lastConnected = Date(timeIntervalSince1970: 9_000)
|
||||
return ([basement, desk, attic, couch], catalog)
|
||||
}
|
||||
|
||||
private func arranged(
|
||||
_ sort: HostSort, _ grouping: HostGrouping = .none, online: Set<UUID> = []
|
||||
) -> [HostGroup] {
|
||||
let (hosts, catalog) = arrangementFixture()
|
||||
return HostArrangement.groups(
|
||||
hosts: hosts, catalog: catalog, online: online, sort: sort, grouping: grouping)
|
||||
}
|
||||
|
||||
/// The default is the order the grid had before it could sort at all — an update must not
|
||||
/// rearrange anyone's hosts.
|
||||
///
|
||||
/// Undated hosts sort FIRST, because having no date means predating the field means being
|
||||
/// older. In a real store they are a prefix (everything saved before the upgrade), so the
|
||||
/// result is the stored order exactly — which is the point.
|
||||
func testHostSortByDateAddedKeepsTheStoredOrder() {
|
||||
let (hosts, _) = arrangementFixture()
|
||||
let group = arranged(.added)[0]
|
||||
XCTAssertNil(group.title, "ungrouped draws no header")
|
||||
XCTAssertEqual(group.hosts.map(\.name), hosts.map(\.name))
|
||||
XCTAssertEqual(group.hosts.map(\.name), ["Basement", "Desk", "attic", "Couch"])
|
||||
}
|
||||
|
||||
/// Case- and locale-insensitive, so "attic" doesn't sort after "Desk" the way a raw `<` on
|
||||
/// Strings would put every lowercase name below every uppercase one.
|
||||
func testHostSortByNameIgnoresCase() {
|
||||
XCTAssertEqual(
|
||||
arranged(.name)[0].hosts.map(\.name), ["attic", "Basement", "Couch", "Desk"])
|
||||
}
|
||||
|
||||
/// Most recent first — and a host you have NEVER connected to goes last, not first. Treating
|
||||
/// "never" as `.distantPast` would sort it as if it had been connected in 1970.
|
||||
func testHostSortByLastConnectedPutsNeverConnectedLast() {
|
||||
let hosts = arranged(.lastConnected)[0].hosts
|
||||
XCTAssertEqual(hosts.prefix(2).map(\.name), ["Couch", "Desk"])
|
||||
// The two never-connected ones tie, so they keep their stored order — `sorted` is not
|
||||
// stable in Swift, and equal rows swapping between redraws is a visible bug.
|
||||
XCTAssertEqual(hosts.suffix(2).map(\.name), ["Basement", "attic"])
|
||||
}
|
||||
|
||||
/// Bands in catalog order, then the unbound ones. An empty band isn't drawn at all.
|
||||
func testHostGroupingByProfile() {
|
||||
let groups = arranged(.name, .profile)
|
||||
XCTAssertEqual(groups.map(\.title), ["Game", "Work", "No Profile"])
|
||||
XCTAssertEqual(groups[0].accent, "#ff8800", "the header matches its cards")
|
||||
XCTAssertEqual(groups[0].hosts.map(\.name), ["Desk"])
|
||||
XCTAssertEqual(groups[2].hosts.map(\.name), ["Basement", "Couch"])
|
||||
|
||||
// A profile nobody uses gets no band.
|
||||
let unused = ProfileCatalog(profiles: [StreamProfile(name: "Travel", id: "333333333333")])
|
||||
let (hosts, _) = arrangementFixture()
|
||||
let none = HostArrangement.groups(
|
||||
hosts: hosts, catalog: unused, online: [], sort: .name, grouping: .profile)
|
||||
XCTAssertEqual(none.map(\.title), ["No Profile"], "every host is unbound in this catalog")
|
||||
}
|
||||
|
||||
/// A dangling binding resolves as no profile (§4.4), so it lands in the unbound band rather
|
||||
/// than in one named after a profile that no longer exists.
|
||||
func testHostGroupingDropsDanglingBindings() {
|
||||
var host = StoredHost(name: "Desk", address: "10.0.0.1")
|
||||
host.profileID = "deadbeefdead"
|
||||
let groups = HostArrangement.groups(
|
||||
hosts: [host], catalog: ProfileCatalog(), online: [], sort: .name, grouping: .profile)
|
||||
XCTAssertEqual(groups.map(\.title), ["No Profile"])
|
||||
}
|
||||
|
||||
func testHostGroupingByStatus() {
|
||||
// One fixture, reused: each call mints fresh UUIDs, so an `online` set taken from a
|
||||
// second fixture would name hosts this one has never heard of.
|
||||
let (hosts, catalog) = arrangementFixture()
|
||||
func groups(online: Set<UUID>) -> [HostGroup] {
|
||||
HostArrangement.groups(
|
||||
hosts: hosts, catalog: catalog, online: online, sort: .name, grouping: .status)
|
||||
}
|
||||
|
||||
// By name, not by index: the fixture's order is a fact about `.added`, not about this.
|
||||
let up = hosts.filter { ["Desk", "Couch"].contains($0.name) }.map(\.id)
|
||||
let some = groups(online: Set(up))
|
||||
XCTAssertEqual(some.map(\.title), ["Online", "Offline"])
|
||||
XCTAssertEqual(some[0].hosts.map(\.name), ["Couch", "Desk"])
|
||||
XCTAssertEqual(some[1].hosts.map(\.name), ["attic", "Basement"])
|
||||
|
||||
// All offline: no empty "Online" band.
|
||||
XCTAssertEqual(groups(online: []).map(\.title), ["Offline"])
|
||||
XCTAssertEqual(groups(online: Set(hosts.map(\.id))).map(\.title), ["Online"])
|
||||
}
|
||||
|
||||
// MARK: - Settings profiles
|
||||
|
||||
/// The overlay applies field by field: a value wins, an absent one keeps the base's live
|
||||
|
||||
Reference in New Issue
Block a user