import XCTest
import PhysarumEngine
@testable import Physarum
/// The ATV host maps decoded RemoteMessages onto the same AppState + AmbientController the Siri
/// Remote drives. These tests pin the apply mapping (the network transport is device-empirical).
final class AmbientHostApplyTests: XCTestCase {
private func preset(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset
p.name = name; p.builtIn = false
// Distinct baseline params per name so the adopt/retarget assertions compare meaningfully.
p.params.bloomIntensity = Float(name.first?.asciiValue ?? 65) * 0.01
return p
}
private func make() -> (AppState, [Preset], AmbientController) {
let factory = PresetSlots.Factory(presets: ["A", "B", "C", "D"].map { preset($0) }, version: 1)
let s = AppState(slotStore: SlotStore(backend: InMemorySlotBackend(), factory: factory))
s.select(id: s.presets[0].id)
let c = AmbientController(appState: s, fadeDuration: 0.5, blackHold: 0.2, dwell: 2.0)
return (s, s.presets, c)
}
/// Advance past a full crossfade (fade-out + black-hold + fade-in) so the reseed has landed.
private func runCrossfade(_ c: AmbientController) { c.tick(0.5 + 0.2 + 0.5) }
func testHelloAdoptsSelectionAndCycle() {
let (s, presets, c) = make()
AmbientHost.apply(.hello(.init(selectedID: presets[2].id, params: presets[2].params, cycling: false)),
to: c)
runCrossfade(c)
XCTAssertEqual(s.selectedID, presets[2].id) // crossfade landed on the target preset
XCTAssertFalse(c.isCycling) // adopted the remote's cycle state
}
func testHelloWithLiveEditsSurvivesCrossfade() {
let (s, presets, c) = make()
var edited = presets[2].params
edited.bloomIntensity += 0.5 // a live edit on top of preset 2's baseline
AmbientHost.apply(.hello(.init(selectedID: presets[2].id, params: edited, cycling: false)),
to: c)
runCrossfade(c)
XCTAssertEqual(s.selectedID, presets[2].id)
// The reseed at black resets edited to the preset baseline; the adopted live params must
// be re-applied afterward so the ATV mirrors exactly what the phone is showing (WYSIWYG).
XCTAssertEqual(s.edited.bloomIntensity, edited.bloomIntensity, accuracy: 1e-6)
}
func testParamsMessageAppliesLiveEdit() {
let (s, _, c) = make()
var edited = s.edited
edited.bloomIntensity += 0.3
AmbientHost.apply(.params(edited), to: c)
XCTAssertEqual(s.edited.bloomIntensity, edited.bloomIntensity, accuracy: 1e-6)
}
func testIntentMessageDrivesController() {
let (s, presets, c) = make()
AmbientHost.apply(.intent(.jumpTo(presets[3].id)), to: c)
runCrossfade(c)
XCTAssertEqual(s.selectedID, presets[3].id)
}
func testDisconnectResumesCycling() {
let (_, _, c) = make()
c.apply(.togglePlaylist) // cycling off
XCTAssertFalse(c.isCycling)
AmbientHost.handleDisconnect(c)
XCTAssertTrue(c.isCycling) // T1 autonomous cycle resumes
}
func testAudioFactorsMessageSetsRemoteAudio() {
let (_, _, c) = make()
var f = AudioFactors(); f[.bloomIntensity] = 1.7
let frame = AudioFactorFrame(f)
AmbientHost.apply(.audioFactors(frame), to: c)
XCTAssertEqual(c.remoteAudio, frame)
}
func testDisconnectWhileCyclingIsNoOp() {
let (_, _, c) = make()
XCTAssertTrue(c.isCycling) // starts cycling
AmbientHost.handleDisconnect(c)
XCTAssertTrue(c.isCycling) // stays cycling, no double-toggle
}
// A live param edit that arrives WHILE the adopt-crossfade is still fading (before the reseed)
// must win over the hello's params — the reseed re-applies the latest, not the stale payload.
func testParamsDuringCrossfadeSupersedesAdoptedHello() {
let (s, presets, c) = make() // on preset 0
var helloParams = presets[2].params
helloParams.bloomIntensity = 0.10
AmbientHost.apply(.hello(.init(selectedID: presets[2].id, params: helloParams, cycling: false)),
to: c)
c.tick(0.1) // into the fade-out, before the reseed (fade 0.5)
var liveParams = presets[2].params
liveParams.bloomIntensity = 0.90
AmbientHost.apply(.params(liveParams), to: c) // live edit mid-crossfade
runCrossfade(c) // reseed lands here
XCTAssertEqual(s.selectedID, presets[2].id)
XCTAssertEqual(s.edited.bloomIntensity, 0.90, accuracy: 1e-6) // live edit wins, not stale 0.10
}
// A local intent that retargets the fade mid-flight starts a fresh transition; the abandoned
// adoption's params must NOT bleed onto the new target preset.
func testLocalRetargetDuringAdoptUsesTargetBaseline() {
let (s, presets, c) = make() // on preset 0
var helloParams = presets[2].params
helloParams.bloomIntensity = 0.10
AmbientHost.apply(.hello(.init(selectedID: presets[2].id, params: helloParams, cycling: true)),
to: c)
c.tick(0.1) // mid fade-out, before reseed
c.apply(.jumpTo(presets[3].id)) // local retarget to preset 3
runCrossfade(c)
XCTAssertEqual(s.selectedID, presets[3].id)
XCTAssertEqual(s.edited.bloomIntensity, presets[3].params.bloomIntensity, accuracy: 1e-6) // P3 baseline
}
// A swipe-back cancel during an adoption (jump back to the not-yet-left preset) must not strand
// the adopted params and leak them onto a later, unrelated transition.
func testSwipeBackDuringAdoptDoesNotLeakParamsToLaterPreset() {
let (s, presets, c) = make() // on preset 0
var adopted = presets[2].params
adopted.bloomIntensity = 0.123 // distinctive sentinel
c.adoptRemote(selectedID: presets[2].id, params: adopted, cycling: true) // start fade 0→2
c.apply(.jumpTo(presets[0].id)) // swipe back to 0 (not yet left) — cancels, no reseed
runCrossfade(c)
c.apply(.next) // unrelated switch 0 → 1
runCrossfade(c)
XCTAssertEqual(s.selectedID, presets[1].id)
XCTAssertEqual(s.edited.bloomIntensity, presets[1].params.bloomIntensity, accuracy: 1e-6) // P1 baseline, not 0.123
}
}