Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift
T
enricobuehlerandClaude Opus 5 f071467cbb 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>
2026-07-29 12:06:41 +02:00

547 lines
27 KiB
Swift

// PunktfunkShared is a wire contract between the app (writer) and the widget extension +
// deep-link senders (readers). These pin the formats that cross that boundary:
// • the `StoredHost` JSON codec — the widget decodes the exact bytes the app persisted, and
// older saved JSON (missing `mgmtPort` / `macAddresses` / `profileID`) must still decode;
// • the `punktfunk://` deep-link grammar — driven by `clients/shared/deeplink-vectors.json`,
// the SAME file the Rust suite runs, so the two parsers cannot drift into two security
// postures;
// • the settings-profile catalog — including the don't-clobber rule that keeps an older build
// from erasing a newer one's overlay fields just by opening a profile.
import SwiftUI
import XCTest
@testable import PunktfunkKit
import PunktfunkShared
final class SharedFoundationTests: XCTestCase {
// MARK: - StoredHost JSON codec
func testStoredHostRoundTrips() throws {
let host = StoredHost(
id: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!,
name: "Tower", address: "192.168.1.173", port: 9777,
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"],
addedAt: Date(timeIntervalSince1970: 1_600_000_000))
let data = try JSONEncoder().encode(host)
let decoded = try JSONDecoder().decode(StoredHost.self, from: data)
XCTAssertEqual(decoded, host)
}
/// Older saved hosts predate `mgmtPort`/`macAddresses` — and now `profileID`/
/// `pinnedProfileIDs` too. A missing key must decode to nil, not throw (synthesized Decodable
/// treats a missing Optional as nil). This is the forward-compat guarantee the widget depends
/// on when reading a store written by any prior build.
func testStoredHostDecodesLegacyJSONWithoutOptionalKeys() throws {
let json = """
{"id":"11111111-2222-3333-4444-555555555555","name":"Old","address":"10.0.0.5","port":9777}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode(StoredHost.self, from: json)
XCTAssertEqual(decoded.name, "Old")
XCTAssertNil(decoded.mgmtPort)
XCTAssertNil(decoded.macAddresses)
XCTAssertNil(decoded.pinnedSHA256)
XCTAssertNil(decoded.lastConnected)
XCTAssertNil(decoded.clipboardSync)
XCTAssertNil(decoded.profileID)
XCTAssertNil(decoded.pinnedProfileIDs)
XCTAssertNil(decoded.addedAt)
// Resolvers fall back cleanly.
XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort)
XCTAssertEqual(decoded.wakeMacs, [])
XCTAssertEqual(decoded.displayName, "Old")
}
func testStoredHostDisplayNameFallsBackToAddress() {
let host = StoredHost(name: "", address: "10.0.0.9")
XCTAssertEqual(host.displayName, "10.0.0.9")
}
// MARK: - DeepLink grammar (the cross-language vector file)
private struct VectorFile: Decodable {
struct Case: Decodable {
let name: String
let url: String
let error: String?
let emit: String?
let expect: Expect?
}
struct Expect: Decodable {
let route: String
// swiftlint:disable:next identifier_name
let host_ref: String
let fp: String?
let launch: String?
let profile: String?
let name: String?
let host_addr: String?
let host_port: Int?
}
let cases: [Case]
}
/// `clients/shared/deeplink-vectors.json`, read from the source tree rather than copied into
/// the test bundle — a copy is a second file, and a second file drifts. Resolved from
/// `#filePath` (this file sits at `clients/apple/Tests/PunktfunkKitTests/`).
private static var vectorFileURL: URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // PunktfunkKitTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // apple
.deletingLastPathComponent() // clients
.appendingPathComponent("shared/deeplink-vectors.json")
}
/// Every case in the shared vector file — the same 44 the Rust suite's `shared_vectors` test
/// consumes. Refusal CODES are part of the contract, not just the happy path: a parser that
/// rejects the right inputs for the wrong reason has already drifted from the other one.
func testDeepLinkSharedVectors() throws {
let url = Self.vectorFileURL
XCTAssertTrue(
FileManager.default.fileExists(atPath: url.path),
"the cross-language vector file must be readable at \(url.path)")
let file = try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url))
XCTAssertGreaterThan(file.cases.count, 20, "the vector file is the contract; keep it rich")
for testCase in file.cases {
if let code = testCase.error {
do {
let link = try DeepLink.parse(testCase.url)
XCTFail("\(testCase.name): expected \(code), parsed \(link)")
} catch let error as DeepLinkError {
XCTAssertEqual(error.code, code, testCase.name)
}
continue
}
let want = try XCTUnwrap(testCase.expect, testCase.name)
let link = try DeepLink.parse(testCase.url)
XCTAssertEqual(link.route.rawValue, want.route, testCase.name)
XCTAssertEqual(link.hostRef, want.host_ref, testCase.name)
XCTAssertEqual(link.fp, want.fp, "\(testCase.name) fp")
XCTAssertEqual(link.launch, want.launch, "\(testCase.name) launch")
XCTAssertEqual(link.profile, want.profile, "\(testCase.name) profile")
XCTAssertEqual(link.name, want.name, "\(testCase.name) name")
XCTAssertEqual(link.host?.address, want.host_addr, "\(testCase.name) host_addr")
XCTAssertEqual(
link.host.map { Int($0.port) }, want.host_port, "\(testCase.name) host_port")
if let emit = testCase.emit {
XCTAssertEqual(link.urlString, emit, "\(testCase.name) emit")
}
}
}
/// The widget's and the Connect intent's emitter — a bare UUID path, which is the grammar the
/// shipped links use. Backward compatibility is not optional here: a widget on the Home Screen
/// keeps sending yesterday's URL.
func testDeepLinkConnectRoundTrips() throws {
let id = UUID()
let link = DeepLink.connect(host: id)
XCTAssertEqual(try DeepLink(url: link.url), link)
XCTAssertEqual(link.hostRef, id.uuidString)
XCTAssertNil(link.launch)
let launched = DeepLink.connect(host: id, launchID: "steam:570")
XCTAssertEqual(try DeepLink(url: launched.url).launch, "steam:570")
let profiled = DeepLink.connect(host: id, launchID: nil, profile: "a1b2c3d4e5f6")
XCTAssertEqual(try DeepLink(url: profiled.url).profile, "a1b2c3d4e5f6")
}
/// Self-emitted links ("Copy link", a shortcut) carry all three references, so they survive
/// both a re-addressed host and a wiped store.
func testDeepLinkForHostCarriesIDAddressAndPin() throws {
var host = StoredHost(
id: UUID(uuidString: "11111111-2222-4333-8444-555555555555")!,
name: "Desk", address: "192.168.1.50", port: 7777)
host.pinnedSHA256 = Data(repeating: 0xCC, count: 32)
let link = DeepLink.forHost(host, launch: "steam:570", profile: "aaaaaaaaaaaa")
XCTAssertEqual(
link.urlString,
"punktfunk://connect/11111111-2222-4333-8444-555555555555"
+ "?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ "&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa")
XCTAssertEqual(try DeepLink.parse(link.urlString), link)
}
/// Resolution order — id beats a unique name beats an address — plus the two refusals a
/// front-end must surface rather than guess through.
func testDeepLinkHostResolution() throws {
let desk = StoredHost(
id: UUID(uuidString: "11111111-2222-4333-8444-555555555555")!,
name: "Desk", address: "192.168.1.50",
pinnedSHA256: Data(repeating: 0xAA, count: 32))
let couchA = StoredHost(name: "Couch", address: "192.168.1.60")
let couchB = StoredHost(name: "Couch", address: "192.168.1.61")
let hosts = [desk, couchA, couchB]
func resolve(_ url: String) throws -> DeepLink.HostResolution {
try DeepLink.parse(url).resolveHost(in: hosts)
}
XCTAssertEqual(
try resolve("punktfunk://connect/11111111-2222-4333-8444-555555555555"), .known(desk))
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .known(desk))
XCTAssertEqual(try resolve("punktfunk://connect/couch"), .ambiguous)
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .known(desk))
// A stale id with the recovery parameter: the address finds the record anyway.
XCTAssertEqual(
try resolve(
"punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
.known(desk))
// Nothing local matches: the sheet gets the address, the claimed name and the pin — which
// is what makes a first connect verified rather than blind trust-on-first-use.
XCTAssertEqual(
try resolve("punktfunk://connect/10.0.0.9:7000?name=Studio"),
.unknown(address: "10.0.0.9", port: 7000, name: "Studio", fp: nil))
// But a stale record id is not a hostname: without `host=` there is nothing to dial.
XCTAssertEqual(
try resolve("punktfunk://connect/00000000-0000-4000-8000-000000000000"), .unresolvable)
XCTAssertEqual(try resolve("punktfunk://connect/Basement%20PC"), .unresolvable)
// A pin that contradicts the stored one is the link lying — the caller hard-refuses.
let lying = try DeepLink.parse("punktfunk://connect/desk?fp=\(String(repeating: "b", count: 64))")
XCTAssertTrue(lying.pinConflict(with: desk))
let honest = try DeepLink.parse("punktfunk://connect/desk?fp=\(String(repeating: "a", count: 64))")
XCTAssertFalse(honest.pinConflict(with: desk))
// No pin stored → nothing to contradict; the trust flow runs as usual.
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
/// value — including a value that happens to equal the base (an explicit pin).
func testOverlayAppliesOnlyWhatItOverrides() {
var base = EffectiveSettings()
base.width = 1920
base.height = 1080
base.bitrateKbps = 20_000
base.codec = "hevc"
XCTAssertTrue(SettingsOverlay().isEmpty)
XCTAssertEqual(base.applying(SettingsOverlay()), base)
var overlay = SettingsOverlay()
overlay.width = 3840
overlay.height = 2160
overlay.refreshHz = 120
overlay.codec = "av1"
overlay.enable444 = true
overlay.modifierLayout = "windows"
XCTAssertFalse(overlay.isEmpty)
let out = base.applying(overlay)
XCTAssertEqual([out.width, out.height, out.refreshHz], [3840, 2160, 120])
XCTAssertEqual(out.codec, "av1")
XCTAssertTrue(out.enable444)
XCTAssertEqual(out.modifierLayout, "windows")
// Untouched fields keep following the base.
XCTAssertEqual(out.bitrateKbps, 20_000)
// Tier-G endpoints are not in the overlay at all — no profile can move this device's
// speaker or microphone.
XCTAssertEqual(out.speakerUID, base.speakerUID)
// An overlay carrying a value equal to the base is still an override: the profile PINS it,
// so a later change to the global doesn't move it.
var pin = SettingsOverlay()
pin.bitrateKbps = 20_000
XCTAssertFalse(pin.isEmpty)
var moved = base
moved.bitrateKbps = 50_000
XCTAssertEqual(moved.applying(pin).bitrateKbps, 20_000)
}
/// `clear` is the explicit way back to inheriting, including the resolution tri-state one row
/// drives.
func testOverlayClearDropsOneOverride() {
var overlay = SettingsOverlay()
overlay.width = 3840
overlay.height = 2160
overlay.matchWindow = false
overlay.codec = "av1"
XCTAssertTrue(OverlayField.isOverridden("resolution", in: overlay))
XCTAssertTrue(OverlayField.clear("codec", in: &overlay))
XCTAssertNil(overlay.codec)
XCTAssertTrue(OverlayField.clear(OverlayField.resolution, in: &overlay))
XCTAssertNil(overlay.width)
XCTAssertNil(overlay.height)
XCTAssertNil(overlay.matchWindow)
XCTAssertTrue(overlay.isEmpty)
XCTAssertFalse(OverlayField.clear("no_such_field", in: &overlay))
}
/// A catalog round-trips, and what this build can't represent survives it: an unknown overlay
/// KEY is carried through untouched rather than erased — the don't-clobber rule, which is what
/// keeps an older build from silently gutting a profile a newer one wrote.
func testCatalogRoundTripsAndPreservesUnknownKeys() throws {
let stored = """
{
"version": 1,
"profiles": [
{
"id": "a1b2c3d4e5f6", "name": "Game", "accent": "#ff8800",
"overrides": {
"width": 3840, "height": 2160, "refresh_hz": 120,
"codec": "vvc-from-the-future", "stats_verbosity": "compact",
"some_new_axis": {"nested": true}
},
"future_profile_key": 7
},
{ "id": "0f0f0f0f0f0f", "name": "Work" }
]
}
""".data(using: .utf8)!
let catalog = try JSONDecoder().decode(ProfileCatalog.self, from: stored)
XCTAssertEqual(catalog.profiles.count, 2)
let game = try XCTUnwrap(catalog.profile(id: "a1b2c3d4e5f6"))
XCTAssertEqual(game.accent, "#ff8800")
XCTAssertEqual(game.overrides.codec, "vvc-from-the-future")
XCTAssertEqual(game.overrides.statsVerbosity, "compact")
// A profile with no `overrides` key at all is the empty (inherit-everything) one.
XCTAssertTrue(try XCTUnwrap(catalog.profile(id: "0f0f0f0f0f0f")).overrides.isEmpty)
let text = try XCTUnwrap(String(data: JSONEncoder().encode(catalog), encoding: .utf8))
XCTAssertTrue(text.contains("some_new_axis"))
XCTAssertTrue(text.contains("future_profile_key"))
// Absent overrides serialize away entirely — "not overridden" has one representation.
XCTAssertFalse(text.contains("null"))
let round = try JSONDecoder().decode(ProfileCatalog.self, from: Data(text.utf8))
XCTAssertEqual(round.profile(id: "a1b2c3d4e5f6")?.overrides.width, 3840)
XCTAssertEqual(round.profile(id: "a1b2c3d4e5f6")?.overrides.extra.count, 1)
XCTAssertEqual(round.profile(id: "a1b2c3d4e5f6")?.extra.count, 1)
}
/// Reference resolution: id first, then a unique case-insensitive name; two profiles sharing a
/// name resolve to `.ambiguous` (the caller refuses) rather than to whichever came first.
func testCatalogResolvesIDsFirstAndRefusesAmbiguity() {
let catalog = ProfileCatalog(profiles: [
StreamProfile(name: "Work", id: "111111111111"),
StreamProfile(name: "work", id: "222222222222"),
StreamProfile(name: "Game", id: "333333333333"),
])
XCTAssertEqual(catalog.resolve("111111111111").1, .found)
XCTAssertEqual(catalog.resolve("Work").1, .ambiguous)
XCTAssertEqual(catalog.resolve("game").1, .found)
XCTAssertEqual(catalog.resolve("GAME").0?.id, "333333333333")
XCTAssertEqual(catalog.resolve("nope").1, .notFound)
XCTAssertTrue(catalog.nameTaken("GAME"))
XCTAssertFalse(catalog.nameTaken("GAME", except: "333333333333"))
XCTAssertTrue(catalog.nameTaken("GAME", except: "111111111111"))
XCTAssertFalse(catalog.nameTaken("Travel"))
}
/// Bindings and pins live ON the host record, and both degrade rather than error: a deleted
/// profile means "Default settings" for a binding and a vanished card for a pin.
func testBindingsAndPinsDropDanglingIDs() {
let catalog = ProfileCatalog(profiles: [
StreamProfile(name: "Game", id: "111111111111"),
StreamProfile(name: "Work", id: "222222222222"),
])
var host = StoredHost(name: "Desk", address: "10.0.0.1")
XCTAssertNil(catalog.binding(for: host))
host.profileID = "111111111111"
XCTAssertEqual(catalog.binding(for: host)?.name, "Game")
host.profileID = "deadbeefdead"
XCTAssertNil(catalog.binding(for: host), "a deleted profile is Default settings, not an error")
host.pinnedProfileIDs = ["222222222222", "deadbeefdead", "222222222222", "111111111111"]
XCTAssertEqual(catalog.pinned(for: host).map(\.id), ["222222222222", "111111111111"])
}
/// The accent is a plain `#RRGGBB` string in the catalog — the palette is what this client
/// OFFERS, not what it accepts, so a colour another platform wrote still renders and a
/// malformed one falls back to the brand tint rather than to black.
func testProfileAccentParsing() {
XCTAssertNotNil(Color(hex: "#ff8800"))
XCTAssertNil(Color(hex: "ff8800"), "a missing # is not a colour")
XCTAssertNil(Color(hex: "#ff88"), "a short value is not a colour")
XCTAssertNil(Color(hex: "#gggggg"), "non-hex is not a colour")
XCTAssertNil(Color(hex: ""))
// Every offered swatch parses, and each is distinguishable by name and value.
XCTAssertEqual(Set(ProfileAccent.palette.map(\.hex)).count, ProfileAccent.palette.count)
for accent in ProfileAccent.palette {
XCTAssertNotNil(Color(hex: accent.hex), accent.name)
XCTAssertEqual(ProfileAccent.named(accent.hex.uppercased())?.name, accent.name)
}
XCTAssertNil(ProfileAccent.named(nil))
XCTAssertNil(ProfileAccent.named("#123456"), "an unlisted colour has no palette name")
}
/// The whole per-connect resolution, in the precedence every client shares:
/// one-off pick ?? host binding ?? none.
func testEffectiveSettingsResolutionPrecedence() {
let defaults = UserDefaults(suiteName: "io.unom.punktfunk.tests.effective")!
defaults.removePersistentDomain(forName: "io.unom.punktfunk.tests.effective")
defaults.set(2_560, forKey: DefaultsKey.streamWidth)
defaults.set(30_000, forKey: DefaultsKey.bitrateKbps)
var gameOverrides = SettingsOverlay()
gameOverrides.bitrateKbps = 80_000
var workOverrides = SettingsOverlay()
workOverrides.bitrateKbps = 8_000
let catalog = ProfileCatalog(profiles: [
StreamProfile(
name: "Game", id: "111111111111", accent: "#ff8800", overrides: gameOverrides),
StreamProfile(name: "Work", id: "222222222222", overrides: workOverrides),
])
var host = StoredHost(name: "Desk", address: "10.0.0.1")
host.profileID = "111111111111"
let unbound = EffectiveSettings.resolve(
host: StoredHost(name: "Plain", address: "10.0.0.2"),
catalog: catalog, defaults: defaults)
XCTAssertEqual(unbound.bitrateKbps, 30_000)
XCTAssertNil(unbound.profileID)
XCTAssertEqual(unbound.width, 2_560, "globals still flow through in every case")
let bound = EffectiveSettings.resolve(host: host, catalog: catalog, defaults: defaults)
XCTAssertEqual(bound.bitrateKbps, 80_000)
XCTAssertEqual(bound.profileName, "Game")
// The chip colour rides along, so the HUD can name the session in the same colour the
// card that launched it wore.
XCTAssertEqual(bound.profileAccent, "#ff8800")
// A one-off pick wins over the binding — and does not rebind anything.
let oneOff = EffectiveSettings.resolve(
host: host, selection: .profile("222222222222"),
catalog: catalog, defaults: defaults)
XCTAssertEqual(oneOff.bitrateKbps, 8_000)
XCTAssertEqual(host.profileID, "111111111111")
// "Connect with ▸ Default settings" on a BOUND host forces the globals — the case that
// makes this a three-way selection rather than an optional profile.
XCTAssertEqual(
EffectiveSettings.resolve(host: host, selection: .defaults, catalog: catalog,
defaults: defaults).bitrateKbps,
30_000)
// A one-off naming a profile that no longer exists degrades to the globals rather than
// erroring — same rule as a dangling binding.
XCTAssertEqual(
EffectiveSettings.resolve(host: host, selection: .profile("gone"), catalog: catalog,
defaults: defaults).bitrateKbps,
30_000)
defaults.removePersistentDomain(forName: "io.unom.punktfunk.tests.effective")
}
}