tv-anarchy/Tests/TVAnarchyCoreTests/DeviceConfigDecodeTests.swift

244 lines
11 KiB
Swift
Raw Permalink Normal View History

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)
}
}