package com.doublesymmetry.trackplayer import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.exoplayer.ExoPlayer import org.junit.After import org.junit.Before import org.junit.Test import org.junit.Assert.* import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config // --------------------------------------------------------------------------- // SleepTimerIntegrationTest // // Tests the full sleep-timer logic against a real ExoPlayer instance driven // by Robolectric, using the production SleepTimerController directly. // // Three bugs caught during manual testing are explicitly guarded: // 1. Timer not firing (RunLoop issue) — tick/pause assertions. // 2. Volume not restored after fade — testVolumeRestoredAfterFadeCompletes. // 3. Pause overridden during media-item transition — // testMediaItemTimerPausesAfterTargetIndex. // --------------------------------------------------------------------------- @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) class SleepTimerIntegrationTest { private lateinit var player: ExoPlayer private lateinit var controller: SleepTimerController private val triggeredTypes = mutableListOf() @Before fun setUp() { val context = RuntimeEnvironment.getApplication() player = ExoPlayer.Builder(context).build() player.playWhenReady = true controller = SleepTimerController(player) controller.onTriggered = { triggeredTypes.add(it) } } @After fun tearDown() { player.release() } // ---- helpers ----------------------------------------------------------- private fun buildMediaItem(id: String, url: String = "https://example.com/$id.mp3"): MediaItem = MediaItem.Builder() .setMediaId(id) .setUri(url) .setMediaMetadata(MediaMetadata.Builder().setTitle(id).build()) .build() private fun buildQueue(count: Int): List = (1..count).map { buildMediaItem("track-$it") } // ======================================================================== // Time-Based: Basic Countdown // ======================================================================== @Test fun `timer pauses player when countdown reaches zero`() { controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 0.0) controller.tick() // 2 remaining assertTrue("should not be paused before countdown ends", player.playWhenReady) controller.tick() // 1 remaining assertTrue("should not be paused before countdown ends", player.playWhenReady) controller.tick() // 0 remaining — fires assertFalse("player must be paused when timer reaches 0", player.playWhenReady) assertEquals("time", triggeredTypes.lastOrNull()) } @Test fun `getState returns null after timer fires`() { controller.sleepAfterTime(seconds = 2.0, fadeOutSeconds = 0.0) controller.tick() controller.tick() assertNull("getState must return null after firing", controller.getState()) } // ======================================================================== // Time-Based: Zero-Second Immediate Pause // ======================================================================== @Test fun `zero second timer pauses immediately without ticks`() { controller.sleepAfterTime(seconds = 0.0, fadeOutSeconds = 0.0) assertFalse("0-second timer must pause immediately", player.playWhenReady) assertEquals("time", triggeredTypes.lastOrNull()) assertNull(controller.getState()) } // ======================================================================== // Time-Based: Fade-Out // ======================================================================== @Test fun `fade out reduces volume linearly then pauses`() { player.volume = 1.0f controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 5.0) val preFadeVolume = 1.0f val volumes = mutableListOf(player.volume) repeat(5) { controller.tick() volumes.add(player.volume) } // Volume should have decreased monotonically during the fade for (i in 1 until volumes.size - 1) { assertTrue( "volume at step $i (${volumes[i]}) should be less than step ${i-1} (${volumes[i-1]})", volumes[i] < volumes[i - 1] ) } // After the timer fires, volume is restored (bug #2) assertEquals("volume must be restored after timer fires", preFadeVolume, player.volume, 0.01f) assertFalse("player must be paused", player.playWhenReady) } @Test fun `volume is restored after fade completes`() { // Explicitly guards bug #2: volume not restoring after fade. player.volume = 0.8f controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 3.0) repeat(3) { controller.tick() } assertEquals( "pre-fade volume must be restored after timer fires so next playback is not muted", 0.8f, player.volume, 0.01f ) } @Test fun `each tick during fade decreases volume`() { player.volume = 1.0f controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 5.0) var previousVolume = player.volume repeat(4) { controller.tick() assertTrue("volume should decrease each tick", player.volume < previousVolume) previousVolume = player.volume } } @Test fun `fade uses pre-fade volume as ceiling when less than 1`() { player.volume = 0.6f controller.sleepAfterTime(seconds = 4.0, fadeOutSeconds = 4.0) controller.tick() // 3 remaining, progress = 3/4 → expected = 0.6 * 0.75 = 0.45 assertEquals("fade must scale from pre-fade volume, not 1.0", 0.6f * 0.75f, player.volume, 0.01f) } @Test fun `no fade when fadeOutSeconds is zero`() { player.volume = 1.0f controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 0.0) controller.tick() controller.tick() assertEquals("volume must not change when fadeOutSeconds is 0", 1.0f, player.volume, 0.001f) controller.tick() // fires assertFalse("must pause when countdown ends", player.playWhenReady) } // ======================================================================== // Time-Based: Cancel Mid-Fade // ======================================================================== @Test fun `cancel mid-fade restores volume`() { player.volume = 1.0f controller.sleepAfterTime(seconds = 10.0, fadeOutSeconds = 10.0) controller.tick() controller.tick() val volumeDuringFade = player.volume assertTrue("volume should have decreased before cancel", volumeDuringFade < 1.0f) controller.cancel() assertEquals("cancelling mid-fade must restore pre-fade volume", 1.0f, player.volume, 0.01f) assertNull(controller.getState()) assertTrue("cancel must not pause", player.playWhenReady) } @Test fun `cancel without fade does not pause`() { controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 0.0) controller.tick() controller.cancel() assertTrue("cancel must not pause the player", player.playWhenReady) assertNull(controller.getState()) } // ======================================================================== // Time-Based: FadeOutSeconds > Seconds Clamping // ======================================================================== @Test fun `fadeOutSeconds is clamped to timer duration`() { controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 10.0) assertEquals("fadeOutSeconds must be clamped to timer duration", 3.0, controller.sleepTimerFadeOutSeconds, 0.001) } @Test fun `clamped fade still produces monotonic volume decrease`() { player.volume = 1.0f controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 10.0) controller.tick() // 2 remaining val v1 = player.volume controller.tick() // 1 remaining val v2 = player.volume controller.tick() // 0 remaining — fires assertTrue("volume must start decreasing after clamp", v1 < 1.0f) assertTrue("volume must keep decreasing", v2 < v1) assertFalse("timer must still fire after clamped fade", player.playWhenReady) } // ======================================================================== // Time-Based: getState State // ======================================================================== @Test fun `getState returns correct time state`() { controller.sleepAfterTime(seconds = 30.0, fadeOutSeconds = 10.0) val state = controller.getState() assertNotNull(state) assertEquals("time", state!!["type"]) assertEquals(30.0, state["remainingSeconds"] as Double, 0.001) assertEquals(10.0, state["fadeOutSeconds"] as Double, 0.001) } @Test fun `remaining seconds decrease after tick`() { controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 0.0) controller.tick() assertEquals(4.0, controller.sleepTimerRemainingSeconds, 0.001) } @Test fun `getState returns null after cancel`() { controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 0.0) controller.cancel() assertNull(controller.getState()) } // ======================================================================== // MediaItem-Based: Forward Transition // ======================================================================== @Test fun `mediaItem timer pauses after target index`() { // Guards bug #3: pause being overridden during media-item transitions. player.setMediaItems(buildQueue(3)) // Target is index 1; we are currently at index 1 (just finished it) controller.sleepAfterMediaItemAtIndex(index = 1) // Manually set previousIndex to simulate we are sitting at index 1 // The controller reads player.currentMediaItemIndex in sleepAfterMediaItemAtIndex, // so we need the player to be at index 1. Since we can't easily seek in Robolectric // without media loaded, we use handleItemTransition to simulate the transition flow. // First simulate arriving at index 1 (so previousIndex becomes 1) controller.handleItemTransition(1) val fired = controller.handleItemTransition(2) assertTrue("sleep timer should fire when transitioning past target index", fired) assertEquals("mediaItem", triggeredTypes.lastOrNull()) assertNull("timer must be cleared after firing", controller.getState()) } @Test fun `mediaItem timer does not fire when arriving at target`() { player.setMediaItems(buildQueue(3)) // Set up: target=1, previousIndex=0 (starts at 0 by default from player) controller.sleepAfterMediaItemAtIndex(index = 1) // Transition from 0 → 1 (arriving at target, not leaving it) val fired = controller.handleItemTransition(1) assertFalse("arriving at target index must not fire the timer", fired) assertTrue("player must not be paused", player.playWhenReady) } @Test fun `mediaItem timer does not fire before reaching target`() { player.setMediaItems(buildQueue(4)) // player starts at index 0, so sleepTimerPreviousIndex = 0, target = 2 controller.sleepAfterMediaItemAtIndex(index = 2) // 0 → 1 (pre-target) val firedAt1 = controller.handleItemTransition(1) assertFalse("must not fire when previous (0) ≠ target (2)", firedAt1) assertTrue(player.playWhenReady) // 1 → 2 (arriving at target) val firedAt2 = controller.handleItemTransition(2) assertFalse("must not fire when arriving at target", firedAt2) assertTrue(player.playWhenReady) // 2 → 3 (leaving target — fires) val firedAt3 = controller.handleItemTransition(3) assertTrue("must fire when leaving target index", firedAt3) } @Test fun `mediaItem timer fires on departure regardless of direction`() { // Production code fires when previousIndex == targetIndex && newIndex != targetIndex. // A skip-backward that departs the target index also fires. player.setMediaItems(buildQueue(3)) // player starts at index 0; set target=2, then simulate arriving at 2 first controller.sleepAfterMediaItemAtIndex(index = 2) controller.handleItemTransition(2) // arrive at target → previousIndex = 2 val fired = controller.handleItemTransition(1) // skip backward from target assertTrue("departing target (backward skip) fires the timer per production logic", fired) } // ======================================================================== // MediaItem-Based: Cancel // ======================================================================== @Test fun `cancelling mediaItem timer prevents subsequent pause`() { player.setMediaItems(buildQueue(2)) controller.sleepAfterMediaItemAtIndex(index = 0) controller.cancel() val fired = controller.handleItemTransition(1) assertFalse("cancelled timer must not fire on transition", fired) assertTrue("player must not be paused", player.playWhenReady) } // ======================================================================== // MediaItem-Based: getState State // ======================================================================== @Test fun `getState returns correct mediaItem state`() { player.setMediaItems(buildQueue(4)) controller.sleepAfterMediaItemAtIndex(index = 3) val state = controller.getState() assertNotNull(state) assertEquals("mediaItem", state!!["type"]) assertEquals(3, state["index"]) } @Test fun `getState returns null after mediaItem timer fires`() { player.setMediaItems(buildQueue(2)) controller.sleepAfterMediaItemAtIndex(index = 0) controller.handleItemTransition(1) // fires assertNull(controller.getState()) } // ======================================================================== // Interaction: Last-One-Wins // ======================================================================== @Test fun `setting time timer cancels existing mediaItem timer`() { controller.sleepAfterMediaItemAtIndex(index = 1) assertEquals("mediaItem", controller.getState()!!["type"]) controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 0.0) assertEquals("setting time timer must replace active mediaItem timer", "time", controller.getState()!!["type"]) } @Test fun `setting mediaItem timer cancels existing time timer`() { controller.sleepAfterTime(seconds = 10.0, fadeOutSeconds = 0.0) assertEquals("time", controller.getState()!!["type"]) controller.sleepAfterMediaItemAtIndex(index = 2) assertEquals("setting mediaItem timer must replace active time timer", "mediaItem", controller.getState()!!["type"]) } @Test fun `replacing fading time timer with new timer restores volume`() { player.volume = 1.0f controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 5.0) controller.tick() controller.tick() assertTrue("volume must have decreased during fade", player.volume < 1.0f) // Replace with a new timer controller.sleepAfterTime(seconds = 10.0, fadeOutSeconds = 0.0) assertEquals( "replacing a fading timer must restore volume before starting the new one", 1.0f, player.volume, 0.01f ) } // ======================================================================== // State Invariants // ======================================================================== @Test fun `getState returns null initially`() { assertNull(controller.getState()) } @Test fun `extra ticks after timer fires are no-ops`() { controller.sleepAfterTime(seconds = 1.0, fadeOutSeconds = 0.0) controller.tick() // fires — timer is now cancelled // Extra ticks after firing are no-ops (timer type is null) controller.tick() controller.tick() // Verify the controller recorded exactly one triggered event, not multiple. assertEquals(1, triggeredTypes.size) assertEquals("time", triggeredTypes[0]) // And sleepTimer state is fully cleared assertNull(controller.getState()) assertEquals(0.0, controller.sleepTimerRemainingSeconds, 0.001) } @Test fun `volume defaults to 1 on a fresh ExoPlayer`() { assertEquals("ExoPlayer default volume should be 1.0", 1.0f, player.volume, 0.001f) } @Test fun `volume changes are reflected on ExoPlayer`() { player.volume = 0.5f assertEquals(0.5f, player.volume, 0.001f) player.volume = 0.0f assertEquals(0.0f, player.volume, 0.001f) player.volume = 1.0f assertEquals(1.0f, player.volume, 0.001f) } }