Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
243 lines
11 KiB
Swift
243 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|