Zum Inhalt

apple/PhysarumApp/Tests/AmbientControllerTests.swift

import XCTest
import PhysarumEngine
@testable import Physarum

final class AmbientControllerTests: XCTestCase {
    private func preset(_ name: String) -> Preset {
        var p = PresetCatalog.fallbackPreset
        p.name = name; p.builtIn = false
        // Distinct params per name so `paramsEqual(edited, presets[i].params)` genuinely discriminates
        // (proves the full target structure is adopted at black — not a tautology when every seeded
        // slot shares the fallback params).
        p.params.bloomIntensity = Float(name.first?.asciiValue ?? 65) * 0.01
        return p
    }
    /// An AppState seeded with N known slots; the controller derives its rotation from those slots
    /// (nothing pinned → all filled), so `presets` here are the slot-id'd (`slot-NN`) filled slots.
    private func make(_ names: [String] = ["A", "B", "C", "D"]) -> (AppState, [Preset], AmbientController) {
        let factory = PresetSlots.Factory(presets: names.map { preset($0) }, version: 1)
        let state = AppState(slotStore: SlotStore(backend: InMemorySlotBackend(), factory: factory))
        state.select(id: state.presets[0].id)
        let controller = AmbientController(appState: state, fadeDuration: 0.5, blackHold: 0.2, dwell: 2.0)
        return (state, state.presets, controller)
    }

    func testTogglePlaylistFlipsCycling() {
        let (_, _, c) = make()
        XCTAssertTrue(c.isCycling)
        c.apply(.togglePlaylist)
        XCTAssertFalse(c.isCycling)
    }

    // MARK: - Crossfade-to-black transition (Inc 12c phase 2)

    func testTransitionDimCurve() {
        let (_, _, c) = make()                  // fade 0.5, hold 0.2
        XCTAssertEqual(c.transitionDim, 0, accuracy: 1e-9)
        c.apply(.next)
        c.tick(0.25); XCTAssertEqual(c.transitionDim, 0.5, accuracy: 0.05)   // mid fade-out
        c.tick(0.25); XCTAssertEqual(c.transitionDim, 1, accuracy: 1e-9)     // full black
        c.tick(0.2);  XCTAssertEqual(c.transitionDim, 1, accuracy: 1e-9)     // hold black
        c.tick(0.5);  XCTAssertEqual(c.transitionDim, 0, accuracy: 1e-9)     // faded back in / cleared
    }

    func testReseedFiresOnceAtBlackPeak() {
        let (state, presets, c) = make()
        c.apply(.next)
        c.tick(0.49); XCTAssertEqual(state.selectedID, presets[0].id)        // pre-black: still old
        c.tick(0.02); XCTAssertEqual(state.selectedID, presets[1].id)        // crossed black → re-seeded
        XCTAssertTrue(paramsEqual(state.edited, presets[1].params))          // full structure adopted
        c.tick(0.5)                                                          // fade-in; no second select
        XCTAssertEqual(state.selectedID, presets[1].id)
    }

    func testAutoCycleAdvancesViaFade() {
        let (state, presets, c) = make()        // dwell 2.0
        c.tick(2.0)                             // dwell reached → begins transition
        c.tick(0.5 + 0.2 + 0.5)                 // fade-out + hold + fade-in
        XCTAssertEqual(state.selectedID, presets[1].id)
        XCTAssertEqual(c.transitionDim, 0, accuracy: 1e-9)
    }

    func testOverlayStaysOpenUntilDismissed() {
        let (_, _, c) = make()
        c.apply(.showOverlay)
        XCTAssertTrue(c.overlayVisible)
        c.tick(30)                           // no auto-dismiss anymore
        XCTAssertTrue(c.overlayVisible)
        c.apply(.dismissOverlay)             // only Menu/dismiss closes it
        XCTAssertFalse(c.overlayVisible)
    }

    // MARK: - Feedback HUD + startup hint (Inc 12b)

    func testTogglePlaylistShowsCycleHUD() {
        let (_, _, c) = make()
        XCTAssertNil(c.hud)
        c.apply(.togglePlaylist)                 // cycling true → false
        XCTAssertEqual(c.hud, .cycle(false))
        c.apply(.togglePlaylist)                 // → true
        XCTAssertEqual(c.hud, .cycle(true))
    }

    func testManualNextShowsPresetNameHUD() {
        let (_, presets, c) = make()
        c.apply(.next)
        XCTAssertEqual(c.hud, .preset(presets[1].name))
    }

    func testManualPreviousWrapsAndShowsPresetNameHUD() {
        let (_, presets, c) = make()
        c.apply(.previous)
        XCTAssertEqual(c.hud, .preset(presets[presets.count - 1].name))
    }

    func testJumpToShowsPresetNameHUD() {
        let (_, presets, c) = make()
        let target = presets[2]
        c.apply(.jumpTo(target.id))
        XCTAssertEqual(c.hud, .preset(target.name))
    }

    // A phone-remote can select a filled-but-UNPINNED slot. Even when a pinned subset makes the
    // rotation small, jumpTo must still crossfade + re-seed to that slot (WYSIWYG), because jumps
    // address the full filled set, not just the rotation.
    func testJumpToUnpinnedSlotTransitionsEvenWithPinnedRotation() {
        let (state, presets, c) = make()
        state.togglePin(presets[0].id)                       // rotation shrinks to [slot-00]
        XCTAssertEqual(state.slotStore.slots.rotation.count, 1)
        c.apply(.jumpTo(presets[2].id))                      // jump to an unpinned filled slot
        XCTAssertEqual(c.hud, .preset(presets[2].name))      // HUD raised ⇒ a real transition began
        c.tick(0.5 + 0.2 + 0.5)                              // complete the crossfade
        XCTAssertEqual(state.selectedID, presets[2].id)      // re-seeded to the unpinned slot
    }

    func testHUDAutoClearsAfterDuration() {
        let (_, _, c) = make()                   // hudDuration default 2.0
        c.apply(.togglePlaylist)
        XCTAssertNotNil(c.hud)
        c.tick(2.1)
        XCTAssertNil(c.hud)
    }

    func testAutoAdvanceRaisesNoHUD() {
        let (state, presets, c) = make()         // dwell 2.0
        c.tick(2.0)                              // dwell reached → auto-advance transition (not via apply)
        XCTAssertNil(c.hud)
        c.tick(1.1)                              // crosses black → re-seeds
        XCTAssertEqual(state.selectedID, presets[1].id)
        XCTAssertNil(c.hud)
    }

    func testHintVisibleInitiallyThenTimesOut() {
        let (_, _, c) = make()                   // hintDuration default 5.0
        XCTAssertTrue(c.hintVisible)
        c.tick(5.1)
        XCTAssertFalse(c.hintVisible)
    }

    func testHintClearsOnFirstIntent() {
        let (_, _, c) = make()
        XCTAssertTrue(c.hintVisible)
        c.apply(.showOverlay)
        XCTAssertFalse(c.hintVisible)
    }

    // MARK: - Truthful feedback (no-op switches start nothing)

    func testJumpToCurrentRaisesNoHUDOrTransition() {
        let (state, _, c) = make()
        c.apply(.jumpTo(state.selectedID))       // already here → no-op
        XCTAssertNil(c.hud)
        XCTAssertEqual(c.transitionDim, 0, accuracy: 1e-9)
    }

    func testManualSwitchOnSinglePresetRaisesNoHUD() {
        let (_, _, c) = make(["Solo"])           // one filled slot → rotation of size 1
        c.apply(.next)
        XCTAssertNil(c.hud)
        c.apply(.previous)
        XCTAssertNil(c.hud)
    }

    func testNewHUDResetsTimerAndReplacesContent() {
        let (_, presets, c) = make()             // hudDuration default 2.0
        c.apply(.togglePlaylist)
        c.tick(1.5)                              // 1.5s into the cycle HUD
        c.apply(.next)                           // second HUD before expiry
        XCTAssertEqual(c.hud, .preset(presets[1].name))  // replaced, not stacked
        c.tick(1.0)                              // 1.0s < 2.0s: visible only if timer reset
        XCTAssertEqual(c.hud, .preset(presets[1].name))
        c.tick(1.1)                              // 2.1s total on the second HUD
        XCTAssertNil(c.hud)
    }

    func testOverlayIntentsRaiseNoHUD() {
        let (_, _, c) = make()
        c.apply(.showOverlay)
        XCTAssertNil(c.hud)
        c.apply(.dismissOverlay)
        XCTAssertNil(c.hud)
    }

    func testManualIntentsClearHint() {
        for intent in [ControlIntent.next, .previous, .togglePlaylist] {
            let (_, _, c) = make()
            XCTAssertTrue(c.hintVisible)
            c.apply(intent)
            XCTAssertFalse(c.hintVisible, "hint should clear on \(intent)")
        }
        let (_, presets, c) = make()
        XCTAssertTrue(c.hintVisible)
        c.apply(.jumpTo(presets[2].id))
        XCTAssertFalse(c.hintVisible)
    }

    // MARK: - Mid-fade re-trigger (Phase 2 review fixes)

    func testMidFadeRetargetResumesFromDarknessAndSkipsIntermediate() {
        let (state, presets, c) = make()         // fade 0.5, hold 0.2
        c.apply(.next)                           // A → B
        c.tick(0.25)                             // mid fade-out, not reseeded
        let dimBefore = c.transitionDim
        c.apply(.next)                           // retarget B → C before black
        XCTAssertGreaterThanOrEqual(c.transitionDim, dimBefore - 1e-9)   // continues darkening, no flash
        c.tick(0.5 + 0.2 + 0.5)                  // complete
        XCTAssertEqual(state.selectedID, presets[2].id)                  // landed on C; B skipped
        XCTAssertTrue(paramsEqual(state.edited, presets[2].params))
    }

    func testReRequestInFlightTargetIsNoOp() {
        let (_, presets, c) = make()
        c.apply(.next)                           // A → B (toID = presets[1])
        c.tick(0.1)
        let before = c.transitionDim
        c.apply(.jumpTo(presets[1].id))          // re-request the in-flight target → no-op
        XCTAssertEqual(c.transitionDim, before, accuracy: 1e-9)          // no restart, no snap to 0
    }

    func testSwipeBackBeforeBlackCancelsWithoutDropoutOrReseed() {
        let (state, presets, c) = make()
        c.apply(.next)                           // A → B fade-out
        c.tick(0.2)                              // mid fade-out, selectedID still A
        c.apply(.previous)                       // swipe back to A (source) → cancel
        c.tick(0.5 + 0.2 + 0.5)                  // fade back in
        XCTAssertEqual(state.selectedID, presets[0].id)                  // stayed on A
        XCTAssertEqual(c.transitionDim, 0, accuracy: 1e-9)
    }
}