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