Grouping by profile asked each HOST which profile it was bound to, then handed the answer to that host's every card. So a card visibly wearing a "Gaming" chip — a pin, on a host bound to nothing — was filed under "No Profile" along with the rest. Every pinned card was, which is most of what the grouping exists to separate. Pins are not bindings. The arrangement works on CARDS now: a host's own card follows its binding, a pinned card follows its pin, and both can land in the same band when a host pinned the profile it is also bound to. The expansion moved out of the view and into the arrangement, because it is exactly what the grouping has to see. The regression is pinned by a test whose fixture is the shape that broke: a host bound to nothing, carrying two pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
566 lines
28 KiB
Swift
566 lines
28 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)
|
|
// Couch is bound to nothing but has PINNED both profiles as their own cards.
|
|
couch.pinnedProfileIDs = ["111111111111", "222222222222"]
|
|
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)
|
|
}
|
|
|
|
/// Card labels: the host, plus the pinned profile when the card carries one.
|
|
private func labels(_ group: HostGroup) -> [String] {
|
|
group.cards.map { card in
|
|
card.pinned.map { "\(card.host.name)·\($0.name)" } ?? card.host.name
|
|
}
|
|
}
|
|
|
|
/// The default is the order the grid had before it could sort at all — an update must not
|
|
/// rearrange anyone's hosts. Each host is followed by its own pinned cards.
|
|
///
|
|
/// 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 group = arranged(.added)[0]
|
|
XCTAssertNil(group.title, "ungrouped draws no header")
|
|
XCTAssertEqual(
|
|
labels(group), ["Basement", "Desk", "attic", "Couch", "Couch·Game", "Couch·Work"])
|
|
}
|
|
|
|
/// 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].cards.map(\.host.name),
|
|
["attic", "Basement", "Couch", "Couch", "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 names = arranged(.lastConnected)[0].cards.map(\.host.name)
|
|
XCTAssertEqual(names.prefix(4).map { $0 }, ["Couch", "Couch", "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(names.suffix(2).map { $0 }, ["Basement", "attic"])
|
|
}
|
|
|
|
/// ⭐ The regression this was written for: a PINNED card belongs to the profile it connects
|
|
/// with, not to whatever its host is bound to. Grouping on the binding alone filed every
|
|
/// pinned card under "No Profile" — including the ones visibly wearing a profile chip.
|
|
func testHostGroupingFilesPinnedCardsUnderTheirOwnProfile() {
|
|
let groups = arranged(.name, .profile)
|
|
XCTAssertEqual(groups.map(\.title), ["Game", "Work", "No Profile"])
|
|
// Desk is BOUND to Game; Couch merely pinned it. Both belong in the Game band.
|
|
XCTAssertEqual(labels(groups[0]), ["Couch·Game", "Desk"])
|
|
XCTAssertEqual(labels(groups[1]), ["attic", "Couch·Work"])
|
|
// Couch's OWN card streams with the defaults, so that one is unbound.
|
|
XCTAssertEqual(labels(groups[2]), ["Basement", "Couch"])
|
|
XCTAssertEqual(groups[0].accent, "#ff8800", "the header matches its cards")
|
|
}
|
|
|
|
/// A profile nobody uses gets no band at all.
|
|
func testHostGroupingSkipsEmptyBands() {
|
|
let (hosts, _) = arrangementFixture()
|
|
let unused = ProfileCatalog(profiles: [StreamProfile(name: "Travel", id: "333333333333")])
|
|
let groups = HostArrangement.groups(
|
|
hosts: hosts, catalog: unused, online: [], sort: .name, grouping: .profile)
|
|
XCTAssertEqual(groups.map(\.title), ["No Profile"])
|
|
// The pins point at profiles this catalog doesn't have, so they produce no cards either.
|
|
XCTAssertEqual(labels(groups[0]), ["attic", "Basement", "Couch", "Desk"])
|
|
}
|
|
|
|
/// 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"])
|
|
// A pinned card follows its host's status — same record, same reachability.
|
|
XCTAssertEqual(labels(some[0]), ["Couch", "Couch·Game", "Couch·Work", "Desk"])
|
|
XCTAssertEqual(labels(some[1]), ["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")
|
|
}
|
|
}
|