From fd7f9f8ab3f680ccb52541bb868f427a5fc8f7c0 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 11 Nov 2025 14:54:48 +0100 Subject: [PATCH] Refactor PlayPause logic and fix behavior for SinglePods (e.g. AirPods Max) Fix edge case where removing a pod while both were in ears didn't trigger pause in one-pod mode. The aggregate boolean (isEitherPodInEar) stayed true, masking the individual pod removal. See thread by `ZV`: https://discord.com/channels/548521543039189022/1437499888068726814 --- .../reaction/core/playpause/PlayPause.kt | 182 ++++- .../core/playpause/PlayPauseLogicTest.kt | 653 ++++++++++++++++++ 2 files changed, 813 insertions(+), 22 deletions(-) create mode 100644 app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt diff --git a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt index e000debc..3e603ab6 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt @@ -57,46 +57,184 @@ class PlayPause @Inject constructor( .onEach { (previous, current) -> log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" } - val previousWorn: Boolean? - val currentWorn: Boolean? + // Convert to EarDetectionState based on device capabilities + val prevState: EarDetectionState + val currState: EarDetectionState when { - reactionSettings.onePodMode.value && previous is HasEarDetectionDual && current is HasEarDetectionDual -> { - previousWorn = previous.isEitherPodInEar - currentWorn = current.isEitherPodInEar - log(TAG, VERBOSE) { "previous: left=${previous.isLeftPodInEar}, right=${previous.isRightPodInEar}" } - log(TAG, VERBOSE) { "current: left${current.isLeftPodInEar}, right=${current.isRightPodInEar}" } + previous is HasEarDetectionDual && current is HasEarDetectionDual -> { + // Dual pod devices (AirPods, AirPods Pro, etc.) + log(TAG, VERBOSE) { + "Dual-pod device: left=${current.isLeftPodInEar}, right=${current.isRightPodInEar}" + } + prevState = EarDetectionState.fromDualPod(previous) + currState = EarDetectionState.fromDualPod(current) } + previous is HasEarDetection && current is HasEarDetection -> { - previousWorn = previous.isBeingWorn - currentWorn = current.isBeingWorn - log(TAG, VERBOSE) { "prev.isBeingWorn=${previousWorn}, cur.isBeingWorn=${currentWorn}" } + // Single pod devices (AirPods Max, etc.) + log(TAG, VERBOSE) { "Single-pod device: worn=${current.isBeingWorn}" } + prevState = EarDetectionState.fromSinglePod(previous) + currState = EarDetectionState.fromSinglePod(current) } + else -> { - log(TAG, VERBOSE) { "Current devices don't support ear detection." } - previousWorn = null - currentWorn = null + log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" } + return@onEach } } - if (previousWorn == false && currentWorn == true && !mediaControl.isPlaying) { - if (reactionSettings.autoPlay.value) { - log(TAG) { "autoPlay is triggered, sendPlay()" } + // Evaluate what action to take + val decision = evaluatePlayPauseAction( + previous = prevState, + current = currState, + onePodMode = reactionSettings.onePodMode.value, + isCurrentlyPlaying = mediaControl.isPlaying + ) + + log(TAG, VERBOSE) { "Decision: ${decision.reason}" } + + // Execute the decision + when { + decision.shouldPlay && reactionSettings.autoPlay.value -> { + log(TAG) { "autoPlay is triggered, sendPlay() - ${decision.reason}" } mediaControl.sendPlay() - } else { + } + + decision.shouldPlay && !reactionSettings.autoPlay.value -> { log(TAG, VERBOSE) { "autoPlay is disabled" } } - } else if (previousWorn == true && currentWorn == false && mediaControl.isPlaying) { - if (reactionSettings.autoPause.value) { - log(TAG) { "autoPause is triggered, sendPause()" } + + decision.shouldPause && reactionSettings.autoPause.value -> { + log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" } mediaControl.sendPause() - } else { - log(TAG) { "autoPause is disabled" } + } + + decision.shouldPause && !reactionSettings.autoPause.value -> { + log(TAG, VERBOSE) { "autoPause is disabled" } } } } .setupCommonEventHandlers(TAG) { "monitor" } + internal fun evaluatePlayPauseAction( + previous: EarDetectionState, + current: EarDetectionState, + onePodMode: Boolean, + isCurrentlyPlaying: Boolean + ): PlayPauseDecision = if (onePodMode) { + evaluateOnePodMode(previous, current, isCurrentlyPlaying) + } else { + evaluateNormalMode(previous, current, isCurrentlyPlaying) + } + + private fun evaluateOnePodMode( + previous: EarDetectionState, + current: EarDetectionState, + isCurrentlyPlaying: Boolean + ): PlayPauseDecision { + val netChange = current.podCount - previous.podCount + + return when { + // Net decrease: pod(s) removed → pause + netChange < 0 && isCurrentlyPlaying -> PlayPauseDecision( + shouldPlay = false, + shouldPause = true, + reason = "One-pod mode: pod(s) removed (net change: $netChange)" + ) + + // Net increase: pod(s) inserted → play + netChange > 0 && !isCurrentlyPlaying -> PlayPauseDecision( + shouldPlay = true, + shouldPause = false, + reason = "One-pod mode: pod(s) inserted (net change: +$netChange)" + ) + + // No net change, or action not appropriate for current playing state + else -> PlayPauseDecision( + shouldPlay = false, + shouldPause = false, + reason = "One-pod mode: no action (net change: $netChange, playing: $isCurrentlyPlaying)" + ) + } + } + + private fun evaluateNormalMode( + previous: EarDetectionState, + current: EarDetectionState, + isCurrentlyPlaying: Boolean + ): PlayPauseDecision { + val wasWorn = previous.bothInEar + val isWorn = current.bothInEar + + return when { + // Transition: not worn → worn, and not playing → play + !wasWorn && isWorn && !isCurrentlyPlaying -> PlayPauseDecision( + shouldPlay = true, + shouldPause = false, + reason = "Normal mode: both pods in ear" + ) + + // Transition: worn → not worn, and playing → pause + wasWorn && !isWorn && isCurrentlyPlaying -> PlayPauseDecision( + shouldPlay = false, + shouldPause = true, + reason = "Normal mode: not both pods in ear" + ) + + // No action needed + else -> PlayPauseDecision( + shouldPlay = false, + shouldPause = false, + reason = "Normal mode: no action (wasWorn: $wasWorn, isWorn: $isWorn, playing: $isCurrentlyPlaying)" + ) + } + } + + data class EarDetectionState( + val leftInEar: Boolean?, // null for single pod devices + val rightInEar: Boolean?, // null for single pod devices + val isWorn: Boolean // Always populated + ) { + val isDualPod: Boolean get() = leftInEar != null && rightInEar != null + val isSinglePod: Boolean get() = !isDualPod + val eitherInEar: Boolean get() = if (isDualPod) leftInEar!! || rightInEar!! else isWorn + val bothInEar: Boolean get() = if (isDualPod) leftInEar!! && rightInEar!! else isWorn + val podCount: Int + get() = if (isDualPod) { + (if (leftInEar!!) 1 else 0) + (if (rightInEar!!) 1 else 0) + } else { + if (isWorn) 1 else 0 + } + + companion object { + fun fromDualPod(device: HasEarDetectionDual) = fromDualPod( + left = device.isLeftPodInEar, + right = device.isRightPodInEar, + ) + + fun fromDualPod(left: Boolean, right: Boolean) = EarDetectionState( + leftInEar = left, + rightInEar = right, + isWorn = left && right + ) + + fun fromSinglePod(device: HasEarDetection) = fromSinglePod(worn = device.isBeingWorn) + + fun fromSinglePod(worn: Boolean) = EarDetectionState( + leftInEar = null, + rightInEar = null, + isWorn = worn + ) + } + } + + data class PlayPauseDecision( + val shouldPlay: Boolean, + val shouldPause: Boolean, + val reason: String + ) + companion object { private val TAG = logTag("Reaction", "PlayPause") } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt new file mode 100644 index 00000000..30d85d9c --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt @@ -0,0 +1,653 @@ +package eu.darken.capod.reaction.core.playpause + +import eu.darken.capod.reaction.core.playpause.PlayPause.EarDetectionState +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class PlayPauseLogicTest : BaseTest() { + + private lateinit var playPause: PlayPause + + @BeforeEach + fun setup() { + // Create PlayPause instance with mocked dependencies (relaxed so we don't need to stub everything) + playPause = PlayPause( + podMonitor = mockk(relaxed = true), + bluetoothManager = mockk(relaxed = true), + reactionSettings = mockk(relaxed = true), + mediaControl = mockk(relaxed = true) + ) + } + + @Nested + inner class NormalModeTests { + + @Test + fun `both in to one out - should pause if playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = true) + val current = EarDetectionState.fromDualPod(left = true, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe true + } + + @Test + fun `both in to one out - no action if not playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = true) + val current = EarDetectionState.fromDualPod(left = true, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `one in to both in - should play if not playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = false) + val current = EarDetectionState.fromDualPod(left = true, right = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe true + decision.shouldPause shouldBe false + } + + @Test + fun `one in to both in - no action if already playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = false) + val current = EarDetectionState.fromDualPod(left = true, right = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `none in to one in - no action (need both)`() { + val previous = EarDetectionState.fromDualPod(left = false, right = false) + val current = EarDetectionState.fromDualPod(left = true, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `none in to both in - should play if not playing`() { + val previous = EarDetectionState.fromDualPod(left = false, right = false) + val current = EarDetectionState.fromDualPod(left = true, right = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe true + decision.shouldPause shouldBe false + } + + @Test + fun `both in to both out - should pause if playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = true) + val current = EarDetectionState.fromDualPod(left = false, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe true + } + + @Test + fun `one in to none in - no action (wasn't fully worn)`() { + val previous = EarDetectionState.fromDualPod(left = true, right = false) + val current = EarDetectionState.fromDualPod(left = false, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `both out to one in - no action (need both)`() { + val previous = EarDetectionState.fromDualPod(left = false, right = false) + val current = EarDetectionState.fromDualPod(left = false, right = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + } + + @Nested + inner class OnePodModeTests { + + @Test + fun `one in to none in - should pause if playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = false) + val current = EarDetectionState.fromDualPod(left = false, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe true + } + + @Test + fun `one in to none in - no action if not playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = false) + val current = EarDetectionState.fromDualPod(left = false, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `none in to one in - should play if not playing`() { + val previous = EarDetectionState.fromDualPod(left = false, right = false) + val current = EarDetectionState.fromDualPod(left = true, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe true + decision.shouldPause shouldBe false + } + + @Test + fun `none in to one in - no action if already playing`() { + val previous = EarDetectionState.fromDualPod(left = false, right = false) + val current = EarDetectionState.fromDualPod(left = true, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `one in to both in - should play if paused`() { + val previous = EarDetectionState.fromDualPod(left = true, right = false) + val current = EarDetectionState.fromDualPod(left = true, right = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = false + ) + + // NEW BEHAVIOR: In one-pod mode, inserting a pod triggers play + // Net change: +1 pod (right inserted) + decision.shouldPlay shouldBe true + decision.shouldPause shouldBe false + } + + @Test + fun `one in to both in - no action if already playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = false) + val current = EarDetectionState.fromDualPod(left = true, right = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = true + ) + + // If already playing, inserting another pod shouldn't do anything + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `both in to one in - should pause if playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = true) + val current = EarDetectionState.fromDualPod(left = true, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = true + ) + + // NEW BEHAVIOR: In one-pod mode, removing a pod triggers pause + // Net change: -1 pod (right removed) + // This fixes the edge case! + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe true + } + + @Test + fun `both in to one in - no action if not playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = true) + val current = EarDetectionState.fromDualPod(left = true, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = false + ) + + // If not playing, removing a pod shouldn't do anything + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `switch pods (left to right) - no action`() { + val previous = EarDetectionState.fromDualPod(left = true, right = false) + val current = EarDetectionState.fromDualPod(left = false, right = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = true + ) + + // Switching pods shouldn't affect playback + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `both in to none in - should pause if playing`() { + val previous = EarDetectionState.fromDualPod(left = true, right = true) + val current = EarDetectionState.fromDualPod(left = false, right = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe true + } + + @Test + fun `none in to both in - should play if not playing`() { + val previous = EarDetectionState.fromDualPod(left = false, right = false) + val current = EarDetectionState.fromDualPod(left = true, right = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe true + decision.shouldPause shouldBe false + } + + /** + * THE EDGE CASE: This test documents the bug in the current implementation. + * + * User scenario: + * 1. Both pods in ears, music playing + * 2. Remove right pod → should pause + * 3. Reinsert right pod → should play + * + * Expected behavior: + * - Step 1→2: shouldPause = true + * - Step 2→3: shouldPlay = true + * + * Current behavior (BUG): + * - Step 1→2: No action (eitherInEar stays true) + * - Step 2→3: No action (eitherInEar stays true) + * + * This test verifies the EXPECTED behavior and will FAIL until we implement the fix. + */ + @Test + fun `EDGE CASE - both in, remove one, reinsert - should pause then play`() { + // Step 1→2: Both in → Right removed (should pause) + val step1 = EarDetectionState.fromDualPod(left = true, right = true) + val step2 = EarDetectionState.fromDualPod(left = true, right = false) + + val decision1to2 = playPause.evaluatePlayPauseAction( + previous = step1, + current = step2, + onePodMode = true, + isCurrentlyPlaying = true + ) + + // Expected: should pause when a pod is removed, even if another is still in + decision1to2.shouldPlay shouldBe false + decision1to2.shouldPause shouldBe true // THIS WILL FAIL with current implementation + + // Step 2→3: Right reinserted (should play) + val step3 = EarDetectionState.fromDualPod(left = true, right = true) + + val decision2to3 = playPause.evaluatePlayPauseAction( + previous = step2, + current = step3, + onePodMode = true, + isCurrentlyPlaying = false // Music is now paused from step 2 + ) + + // Expected: should play when a pod is reinserted + decision2to3.shouldPlay shouldBe true // THIS WILL FAIL with current implementation + decision2to3.shouldPause shouldBe false + } + + @Test + fun `rapid transitions - none to one to none - should play then pause`() { + // Step 1: None → One in (should play) + val step1 = EarDetectionState.fromDualPod(left = false, right = false) + val step2 = EarDetectionState.fromDualPod(left = true, right = false) + + val decision1to2 = playPause.evaluatePlayPauseAction( + previous = step1, + current = step2, + onePodMode = true, + isCurrentlyPlaying = false + ) + + decision1to2.shouldPlay shouldBe true + decision1to2.shouldPause shouldBe false + + // Step 2: One → None (should pause) + val step3 = EarDetectionState.fromDualPod(left = false, right = false) + + val decision2to3 = playPause.evaluatePlayPauseAction( + previous = step2, + current = step3, + onePodMode = true, + isCurrentlyPlaying = true + ) + + decision2to3.shouldPlay shouldBe false + decision2to3.shouldPause shouldBe true + } + } + + @Nested + inner class EarDetectionStateTests { + + @Test + fun `eitherInEar - both in`() { + val state = EarDetectionState.fromDualPod(left = true, right = true) + state.eitherInEar shouldBe true + } + + @Test + fun `eitherInEar - left only`() { + val state = EarDetectionState.fromDualPod(left = true, right = false) + state.eitherInEar shouldBe true + } + + @Test + fun `eitherInEar - right only`() { + val state = EarDetectionState.fromDualPod(left = false, right = true) + state.eitherInEar shouldBe true + } + + @Test + fun `eitherInEar - none in`() { + val state = EarDetectionState.fromDualPod(left = false, right = false) + state.eitherInEar shouldBe false + } + + @Test + fun `bothInEar - both in`() { + val state = EarDetectionState.fromDualPod(left = true, right = true) + state.bothInEar shouldBe true + } + + @Test + fun `bothInEar - left only`() { + val state = EarDetectionState.fromDualPod(left = true, right = false) + state.bothInEar shouldBe false + } + + @Test + fun `bothInEar - none in`() { + val state = EarDetectionState.fromDualPod(left = false, right = false) + state.bothInEar shouldBe false + } + + @Test + fun `podCount - both in`() { + val state = EarDetectionState.fromDualPod(left = true, right = true) + state.podCount shouldBe 2 + } + + @Test + fun `podCount - left only`() { + val state = EarDetectionState.fromDualPod(left = true, right = false) + state.podCount shouldBe 1 + } + + @Test + fun `podCount - none in`() { + val state = EarDetectionState.fromDualPod(left = false, right = false) + state.podCount shouldBe 0 + } + } + + @Nested + inner class SinglePodDeviceTests { + + @Test + fun `single pod - not worn to worn - should play if not playing`() { + val previous = EarDetectionState.fromSinglePod(worn = false) + val current = EarDetectionState.fromSinglePod(worn = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, // Irrelevant for single pods + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe true + decision.shouldPause shouldBe false + } + + @Test + fun `single pod - not worn to worn - no action if already playing`() { + val previous = EarDetectionState.fromSinglePod(worn = false) + val current = EarDetectionState.fromSinglePod(worn = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `single pod - worn to not worn - should pause if playing`() { + val previous = EarDetectionState.fromSinglePod(worn = true) + val current = EarDetectionState.fromSinglePod(worn = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe true + } + + @Test + fun `single pod - worn to not worn - no action if not playing`() { + val previous = EarDetectionState.fromSinglePod(worn = true) + val current = EarDetectionState.fromSinglePod(worn = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `single pod - worn stays worn - no action`() { + val previous = EarDetectionState.fromSinglePod(worn = true) + val current = EarDetectionState.fromSinglePod(worn = true) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = true + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `single pod - not worn stays not worn - no action`() { + val previous = EarDetectionState.fromSinglePod(worn = false) + val current = EarDetectionState.fromSinglePod(worn = false) + + val decision = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false + ) + + decision.shouldPlay shouldBe false + decision.shouldPause shouldBe false + } + + @Test + fun `single pod - one-pod mode enabled - behaves same as normal mode`() { + // For single-pod devices, one-pod mode doesn't change behavior + val previous = EarDetectionState.fromSinglePod(worn = false) + val current = EarDetectionState.fromSinglePod(worn = true) + + val decisionNormal = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false + ) + + val decisionOnePod = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = true, // Should behave the same + isCurrentlyPlaying = false + ) + + // Both should trigger play + decisionNormal.shouldPlay shouldBe true + decisionOnePod.shouldPlay shouldBe true + } + + @Test + fun `single pod - podCount is 0 or 1`() { + val notWorn = EarDetectionState.fromSinglePod(worn = false) + val worn = EarDetectionState.fromSinglePod(worn = true) + + notWorn.podCount shouldBe 0 + worn.podCount shouldBe 1 + } + + @Test + fun `single pod - isSinglePod is true`() { + val state = EarDetectionState.fromSinglePod(worn = true) + state.isSinglePod shouldBe true + state.isDualPod shouldBe false + } + + @Test + fun `single pod - leftInEar and rightInEar are null`() { + val state = EarDetectionState.fromSinglePod(worn = true) + state.leftInEar shouldBe null + state.rightInEar shouldBe null + } + } +}