import XCTest @testable import TVAnarchyCore /// GAP-5 regression: the schema must stay strictly additive, because /// `loadOrSeed()` OVERWRITES hosts.json on any decode failure. Old configs must /// still decode; new ones decode with defaults; round-trips preserve fields. final class DeviceConfigDecodeTests: XCTestCase { func testLegacyConfigStillDecodes() throws { let json = #""" {"hosts":[ {"id":"plum-vlc","name":"Plum VLC","kind":"vlc","vlc":{"host":"127.0.0.1","port":8080}}, {"id":"black","name":"Black TV","kind":"blacktv", "ssh":{"endpoints":["lilith@10.0.0.11","lilith@10.9.0.4"],"bin":"/usr/local/bin/black-tv"}} ]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) XCTAssertEqual(cfg.devices.count, 2) XCTAssertEqual(cfg.devices[0].kind, .vlc) XCTAssertEqual(cfg.devices[1].kind, .blacktv) XCTAssertEqual(cfg.devices[1].ssh?.bin, "/usr/local/bin/black-tv") } func testMpvIPCConfigDecodesWithDefaults() throws { // Minimal mpv block — socket/sudo/socat/volumeScale should default. let json = #""" {"hosts":[ {"id":"black","name":"Black TV","kind":"mpv-ipc", "mpv":{"endpoints":["lilith@10.9.0.4"]}, "commands":{"launchShow":["btv","play-show","{query}"],"stop":["btv","stop"]}} ]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) let h = cfg.devices[0] XCTAssertEqual(h.kind, .mpvIPC) XCTAssertEqual(h.mpv?.socket, "/tmp/mpv.sock") XCTAssertEqual(h.mpv?.sudo, true) XCTAssertEqual(h.mpv?.socat, "socat") XCTAssertEqual(h.mpv?.volumeScale, 130) XCTAssertEqual(h.commands?.stop, ["btv", "stop"]) XCTAssertNil(h.commands?.releases) // unspecified capability → nil // pre-`restart` config with canonical `[helper, "stop"]` teardown → // restart inferred onto the same helper (no migration step) XCTAssertEqual(h.commands?.restart, ["btv", "restart"]) } /// The restart inference is limited to the canonical `[helper, "stop"]` /// shape — a bespoke teardown command must NOT grow a guessed restart. func testRestartNotInferredFromBespokeStopCommand() throws { let json = #""" {"devices":[ {"id":"x","name":"X","kind":"mpv-ipc","mpv":{"endpoints":["a@b"]}, "commands":{"stop":["ssh-helper","teardown","--force"]}} ]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) XCTAssertNil(cfg.devices[0].commands?.restart) } @MainActor func testLegacyBlacktvMigratesToMpvTarget() { // An old `blacktv` host must still build a working target — the generic // MpvTarget, with delegated commands derived from the old `bin`. let h = DeviceConfig(id: "black", name: "Black", kind: .blacktv, ssh: SSHConn(endpoints: ["lilith@10.9.0.4"], bin: "/usr/local/bin/black-tv")) let t = PlayerController.makeTarget(h) XCTAssertNotNil(t) XCTAssertEqual(t?.kind, .mpvIPC) XCTAssertTrue(t is MpvTarget) } func testOfflinePolicyDecodesWithDefaults() throws { let json = #""" {"devices":[{"id":"plum-vlc","name":"Plum","kind":"vlc","type":"laptop", "services":{"stream":true,"offlineCache":true}, "offlinePolicy":{"episodesAhead":2,"budgetPercent":10}}]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) let p = cfg.devices[0].resolvedOfflinePolicy() XCTAssertEqual(p.episodesAhead, 2) XCTAssertEqual(p.budgetPercent, 10) XCTAssertEqual(p.shows, 5) XCTAssertTrue(p.warmupEnabled) } func testSeedRoundTrips() throws { let seed = DevicesConfig.seeded() let data = try JSONEncoder().encode(seed) let back = try JSONDecoder().decode(DevicesConfig.self, from: data) let local = back.localDevice XCTAssertNotNil(local) XCTAssertEqual(local?.kind, .vlc) XCTAssertEqual(local?.type, .laptop) XCTAssertEqual(local?.id, DeviceHostname.localPlayerId(kind: .vlc)) XCTAssertEqual(local?.name, DeviceHostname.localPlayerName(kind: .vlc)) XCTAssertNil(back.storageDevice) // storage is opt-in via TV_ANARCHY_STORAGE_HOST } func testHostnameDerivedEndpointsWhenUnset() throws { let json = #""" {"devices":[{"id":"black","name":"Black","kind":"mpv-ipc","hostname":"black","mpv":{}}]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) XCTAssertEqual(cfg.devices[0].resolvedSSHEndpoints(), ["lilith@black.lan", "lilith@black.wg"]) } func testExplicitEndpointsOverrideHostname() throws { let json = #""" {"devices":[{"id":"black","name":"Black","kind":"mpv-ipc","hostname":"black", "mpv":{"endpoints":["lilith@10.0.0.11"]}}]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) XCTAssertEqual(cfg.devices[0].resolvedSSHEndpoints(), ["lilith@10.0.0.11"]) } func testLocalDeviceHostnameDefaultsToSystemName() { let d = DeviceConfig(id: "laptop-vlc", name: "Laptop VLC", kind: .vlc) XCTAssertEqual(d.resolvedHostname(), DeviceHostname.systemShortName()) } func testRemoteDeviceHostnameDefaultsToId() { let d = DeviceConfig(id: "storage-tv", name: "Storage TV", kind: .mpvIPC) XCTAssertEqual(d.resolvedHostname(), "storage-tv") } func testStorageDevicePrefersTypedStorageOverMpvId() throws { let json = #""" {"devices":[ {"id":"other-mpv","name":"Other","kind":"mpv-ipc","type":"laptop","mpv":{}}, {"id":"media-server","name":"Media Server","kind":"mpv-ipc","type":"storage","hostname":"srv","mpv":{}} ]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) XCTAssertEqual(cfg.storageDevice?.id, "media-server") } func testLegacyIPEndpointsMigrateToHostname() throws { var cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(#""" {"devices":[{"id":"black","name":"Black","kind":"mpv-ipc", "mpv":{"endpoints":["lilith@10.0.0.11","lilith@10.9.0.4"]}}]} """#.utf8)) XCTAssertTrue(cfg.migrateLegacyIPEndpoints()) let black = cfg.devices[0] XCTAssertEqual(black.hostname, "black") XCTAssertTrue(black.mpv?.endpoints.isEmpty ?? false) XCTAssertEqual(black.resolvedSSHEndpoints(), ["lilith@black.lan", "lilith@black.wg"]) } /// The Devices-tab restart action keys off the configured command: a host /// with a `restart` template can restart; one without reports it can't. @MainActor func testRestartCapabilityFollowsConfiguredCommand() { let migrated = PlayerController.makeTarget( DeviceConfig(id: "black", name: "Black", kind: .blacktv, ssh: SSHConn(endpoints: ["lilith@10.9.0.4"], bin: "/usr/local/bin/black-tv"))) XCTAssertEqual((migrated as? ServiceRestartable)?.canRestartService, true) let bare = MpvTarget(id: "m", name: "M", mpv: MpvConn(endpoints: ["x@y"]), commands: nil) XCTAssertFalse(bare.canRestartService) } // MARK: device type + services (Part B) /// A pre-`type` config must infer each device's type from its player backend, /// NOT silently default to the first enum case: a local VLC → laptop, an /// mpv/blacktv (black) → storage. Services follow the inferred type's preset. func testLegacyConfigInfersDeviceType() throws { let json = #""" {"hosts":[ {"id":"plum-vlc","name":"Plum VLC","kind":"vlc","vlc":{"host":"127.0.0.1","port":8080}}, {"id":"black","name":"Black TV","kind":"mpv-ipc","mpv":{"endpoints":["lilith@10.0.0.11"]}} ]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) XCTAssertEqual(cfg.devices[0].type, .laptop) // local player XCTAssertEqual(cfg.devices[1].type, .storage) // mpv-over-ssh server XCTAssertTrue(cfg.devices[0].services.stream) // laptop streams XCTAssertTrue(cfg.devices[1].services.stream) // storage ALSO streams (black) XCTAssertTrue(cfg.devices[1].services.custody) // …and holds copies } func testDeviceTypePresets() { XCTAssertEqual(DeviceType.cellphone.defaultServices, DeviceServices(stream: true, offlineCache: true)) XCTAssertEqual(DeviceType.laptop.defaultServices, DeviceServices(stream: true, offlineCache: true, ttlSeed: true)) // storage + seed: custody and stream are independent, not either/or. XCTAssertTrue(DeviceType.storage.defaultServices.stream) XCTAssertTrue(DeviceType.storage.defaultServices.custody) XCTAssertFalse(DeviceType.seed.defaultServices.stream) XCTAssertTrue(DeviceType.seed.defaultServices.publicSwarmFace) // broadcast = the mesh anchor: registry + relay + swarm face, no streaming. let b = DeviceType.broadcast.defaultServices XCTAssertTrue(b.meshAnchor && b.f2fRelay && b.publicSwarmFace) XCTAssertFalse(b.stream) XCTAssertEqual(DeviceType.broadcast.fleetClass, "broadcast") } // MARK: registry-only kind (phones/tablets — no player backend) /// `"kind": "none"` decodes to the registry-only backend and round-trips. /// Schema stays additive: GAP-5 still holds. func testRegistryKindDecodesAndRoundTrips() throws { let json = #""" {"devices":[ {"id":"strawberry","name":"Quinn's iPhone","kind":"none","type":"cellphone", "services":{"stream":false,"offlineCache":true}} ]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) let d = cfg.devices[0] XCTAssertEqual(d.kind, .registry) XCTAssertEqual(d.type, .cellphone) XCTAssertFalse(d.services.stream) let back = try JSONDecoder().decode(DevicesConfig.self, from: JSONEncoder().encode(cfg)) XCTAssertEqual(back.devices[0].kind, .registry) } /// A registry-only device never builds a player target — it's bookkeeping, /// not something the app connects to. @MainActor func testRegistryKindBuildsNoTarget() { let d = DeviceConfig(id: "strawberry", name: "Quinn's iPhone", kind: .registry, type: .cellphone) XCTAssertNil(PlayerController.makeTarget(d)) } /// An explicit, overridden services set is preserved (not re-derived from type). func testExplicitServicesOverrideSurvivesDecode() throws { let json = #""" {"devices":[ {"id":"phone","name":"Phone","kind":"quicktime","type":"cellphone", "services":{"stream":true,"offlineCache":false,"ttlSeed":true}} ]} """# let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) let s = cfg.devices[0].services XCTAssertEqual(cfg.devices[0].type, .cellphone) XCTAssertFalse(s.offlineCache) // override beat the preset's `true` XCTAssertTrue(s.ttlSeed) } }