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>
547 lines
27 KiB
Swift
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")
|
|
}
|
|
}
|