Zum Inhalt

apple/PhysarumApp/Tests/AmbientHostApplyTests.swift

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