From 2541a80b2b58bbf092c1e141a3fb6d6eb8ddc767 Mon Sep 17 00:00:00 2001 From: "antoni.castejon" Date: Fri, 24 Jul 2026 14:48:17 +0200 Subject: [PATCH 1/4] [MUON-2017] Simplify BpkVideoPlayer by removing VideoHandler seam VideoPlayerHandle/PlayerFactory/FakeVideoPlayerHandle were only added to unit-test BpkVideoPlayerController without a real ExoPlayer. The controller now owns an ExoPlayer directly, and play/pause/loop/mute/ended/dispose coverage moved to instrumentation tests driving a real bundled local video instead of a fake. Co-Authored-By: Claude Sonnet 5 --- .../compose/videoplayer/BpkVideoPlayerTest.kt | 264 ++++++++++++++++++ .../res/raw/bpk_video_player_test.mp4 | Bin 0 -> 7297 bytes .../videoplayer/BpkVideoPlayerController.kt | 54 ++-- .../videoplayer/internal/PlayerFactory.kt | 69 ----- .../videoplayer/internal/VideoPlayerHandle.kt | 43 --- .../BpkVideoPlayerControllerTest.kt | 199 ------------- .../videoplayer/FakeVideoPlayerHandle.kt | 96 ------- 7 files changed, 299 insertions(+), 426 deletions(-) create mode 100644 backpack-compose/src/androidTest/res/raw/bpk_video_player_test.mp4 delete mode 100644 backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/videoplayer/internal/PlayerFactory.kt delete mode 100644 backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/videoplayer/internal/VideoPlayerHandle.kt delete mode 100644 backpack-compose/src/test/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerControllerTest.kt delete mode 100644 backpack-compose/src/test/kotlin/net/skyscanner/backpack/compose/videoplayer/FakeVideoPlayerHandle.kt diff --git a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt index cf472a9170..47d6894708 100644 --- a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt +++ b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt @@ -18,10 +18,18 @@ package net.skyscanner.backpack.compose.videoplayer +import android.os.ParcelFileDescriptor.AutoCloseInputStream +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertContentDescriptionContains import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.media3.common.Player +import androidx.test.platform.app.InstrumentationRegistry import net.skyscanner.backpack.compose.theme.BpkTheme +import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule @@ -37,6 +45,61 @@ class BpkVideoPlayerTest { accessibilityLabel = "Test video", ) + private var originalAnimationScales: Pair? = null + + // BpkVideoPlayerController defaults to respectsReducedMotion = true and treats animator/transition + // scale 0 (a common test-emulator setting, used to speed up Espresso) as a reduced-motion signal, + // which suppresses autoplay and leaves the player stuck at ReadyToPlay. + private fun disableReducedMotionSignal() { + originalAnimationScales = + getGlobalSetting(ANIMATOR_DURATION_SCALE_KEY) to getGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY) + putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, "1") + putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, "1") + } + + @After + fun restoreReducedMotionSignalIfChanged() { + originalAnimationScales?.let { (animator, transition) -> + putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, animator ?: "1") + putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, transition ?: "1") + originalAnimationScales = null + } + } + + // android.provider.Settings.Global requires WRITE_SECURE_SETTINGS, which the app under test does + // not hold; UiAutomation shell commands run with shell identity, which does. + private fun getGlobalSetting(key: String): String? = + runShellCommand("settings get global $key") + .trim() + .takeUnless { it == "null" || it.isEmpty() } + + private fun putGlobalSetting(key: String, value: String) { + runShellCommand("settings put global $key $value") + } + + private fun runShellCommand(command: String): String = + AutoCloseInputStream(InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(command)) + .use { it.readBytes().decodeToString() } + + private fun bundledVideoUrl(): String { + val targetPackage = InstrumentationRegistry.getInstrumentation().targetContext.packageName + return "android.resource://$targetPackage/raw/bpk_video_player_test" + } + + private fun playableConfig( + loop: Boolean = false, + startsMuted: Boolean = true, + autoPlay: Boolean = true, + loadTimeoutMs: Long = 7_000L, + ) = BpkVideoPlayerConfig( + videoUrl = BpkVideoUrl(bundledVideoUrl()), + loop = loop, + startsMuted = startsMuted, + autoPlay = autoPlay, + loadTimeoutMs = loadTimeoutMs, + accessibilityLabel = "Test video", + ) + @Test fun accessibilityLabel_isAppliedToSemantics() { composeTestRule.setContent { @@ -120,4 +183,205 @@ class BpkVideoPlayerTest { composeTestRule.runOnIdle { controller.play() } assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Failed) } + + @Test + fun loopConfigTrue_mapsToPlayerRepeatModeOne() { + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(loop = true)) + BpkVideoPlayer(controller = controller) + } + } + assertEquals(Player.REPEAT_MODE_ONE, controller.player.repeatMode) + } + + @Test + fun loopConfigFalse_mapsToPlayerRepeatModeOff() { + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(loop = false)) + BpkVideoPlayer(controller = controller) + } + } + assertEquals(Player.REPEAT_MODE_OFF, controller.player.repeatMode) + } + + @Test + fun startsMutedTrue_mapsToZeroVolume() { + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(startsMuted = true)) + BpkVideoPlayer(controller = controller) + } + } + assertEquals(0f, controller.player.volume, 0f) + } + + @Test + fun startsMutedFalse_mapsToFullVolume() { + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(startsMuted = false)) + BpkVideoPlayer(controller = controller) + } + } + assertEquals(1f, controller.player.volume, 0f) + } + + @Test + fun setMuted_updatesPlayerVolume() { + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig()) + BpkVideoPlayer(controller = controller) + } + } + + composeTestRule.runOnIdle { controller.setMuted(false) } + assertEquals(1f, controller.player.volume, 0f) + + composeTestRule.runOnIdle { controller.setMuted(true) } + assertEquals(0f, controller.player.volume, 0f) + } + + @Test + fun autoPlay_withReducedMotionDisabled_reachesPlaying() { + disableReducedMotionSignal() + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = true)) + BpkVideoPlayer(controller = controller) + } + } + + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Playing + } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Playing) + } + + @Test + fun autoPlayOff_neverStartsPlaying() { + disableReducedMotionSignal() + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = false)) + BpkVideoPlayer(controller = controller) + } + } + + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay + } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay) + } + + @Test + fun playPauseToggle_driveRealPlayback() { + disableReducedMotionSignal() + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = true)) + BpkVideoPlayer(controller = controller) + } + } + + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Playing + } + + composeTestRule.runOnIdle { controller.pause() } + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Paused + } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Paused) + + composeTestRule.runOnIdle { controller.toggle() } + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Playing + } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Playing) + + composeTestRule.runOnIdle { controller.toggle() } + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Paused + } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Paused) + } + + @Test + fun playbackReachesEnded_thenPlayRestartsFromStart() { + disableReducedMotionSignal() + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = true)) + BpkVideoPlayer(controller = controller) + } + } + + composeTestRule.waitUntil(timeoutMillis = ENDED_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Ended + } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Ended) + + composeTestRule.runOnIdle { controller.play() } + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Playing + } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Playing) + } + + @Test + fun resetToStart_afterEnded_settlesReadyToPlay() { + disableReducedMotionSignal() + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = true)) + BpkVideoPlayer(controller = controller) + } + } + + composeTestRule.waitUntil(timeoutMillis = ENDED_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Ended + } + + composeTestRule.runOnIdle { controller.resetToStart() } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay) + } + + @Test + fun dispose_releasesTheUnderlyingPlayer() { + lateinit var controller: BpkVideoPlayerController + var showPlayer by mutableStateOf(true) + composeTestRule.setContent { + BpkTheme { + if (showPlayer) { + controller = rememberBpkVideoPlayerController(playableConfig()) + BpkVideoPlayer(controller = controller) + } + } + } + + composeTestRule.runOnIdle { showPlayer = false } + composeTestRule.waitForIdle() + + assertEquals(Player.STATE_IDLE, controller.player.playbackState) + } + + private companion object { + const val ANIMATOR_DURATION_SCALE_KEY = "animator_duration_scale" + const val TRANSITION_ANIMATION_SCALE_KEY = "transition_animation_scale" + const val PLAYING_STATE_TIMEOUT_MS = 5_000L + const val ENDED_STATE_TIMEOUT_MS = 8_000L + } } diff --git a/backpack-compose/src/androidTest/res/raw/bpk_video_player_test.mp4 b/backpack-compose/src/androidTest/res/raw/bpk_video_player_test.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..b7f7569b343e9c7d4a7e3c924b1cc1e2b701a1cf GIT binary patch literal 7297 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 j(BpkVideoPlaybackState.Loading) @@ -51,28 +57,38 @@ class BpkVideoPlayerController internal constructor( private val _isMuted = mutableStateOf(config.startsMuted) val isMuted: State get() = _isMuted - internal val player: Player get() = handle.player + @OptIn(UnstableApi::class) + internal val player: ExoPlayer = run { + val applicationContext = context.applicationContext + ExoPlayer.Builder(applicationContext) + .setMediaSourceFactory( + DefaultMediaSourceFactory(applicationContext).setDataSourceFactory( + DefaultDataSource.Factory(applicationContext, DefaultHttpDataSource.Factory()), + ), + ) + .build() + } private var timeoutJob: Job? = null init { - handle.repeatMode = if (config.loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF - handle.volume = if (config.startsMuted) 0f else 1f - handle.playWhenReady = config.autoPlay && !(config.respectsReducedMotion && reducedMotionEnabled) - handle.addListener(playerListener()) - handle.setMediaItem(config.videoUrl.value) - handle.prepare() + player.repeatMode = if (config.loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + player.volume = if (config.startsMuted) 0f else 1f + player.playWhenReady = config.autoPlay && !(config.respectsReducedMotion && reducedMotionEnabled) + player.addListener(playerListener()) + player.setMediaItem(MediaItem.fromUri(config.videoUrl.value)) + player.prepare() startLoadTimeout() } fun play() { if (_playbackState.value is BpkVideoPlaybackState.Failed) return - if (_playbackState.value is BpkVideoPlaybackState.Ended) handle.seekTo(0) - handle.play() + if (_playbackState.value is BpkVideoPlaybackState.Ended) player.seekTo(0) + player.play() } fun pause() { - handle.pause() + player.pause() } fun toggle() { @@ -81,11 +97,11 @@ class BpkVideoPlayerController internal constructor( fun setMuted(muted: Boolean) { _isMuted.value = muted - handle.volume = if (muted) 0f else 1f + player.volume = if (muted) 0f else 1f } fun resetToStart() { - handle.seekTo(0) + player.seekTo(0) if (_playbackState.value is BpkVideoPlaybackState.Ended) { _playbackState.value = BpkVideoPlaybackState.ReadyToPlay } @@ -93,7 +109,7 @@ class BpkVideoPlayerController internal constructor( fun dispose() { timeoutJob?.cancel() - handle.release() + player.release() } private fun startLoadTimeout() { @@ -102,7 +118,7 @@ class BpkVideoPlayerController internal constructor( delay(config.loadTimeoutMs) if (_playbackState.value == BpkVideoPlaybackState.Loading) { _playbackState.value = BpkVideoPlaybackState.Failed(BpkVideoPlayerError.LoadTimeout) - handle.stop() + player.stop() } } } @@ -116,7 +132,7 @@ class BpkVideoPlayerController internal constructor( when (playbackState) { Player.STATE_READY -> { timeoutJob?.cancel() - apply(PlaybackEvent.Ready(isPlaying = handle.isPlaying)) + apply(PlaybackEvent.Ready(isPlaying = player.isPlaying)) } Player.STATE_BUFFERING -> apply(PlaybackEvent.Buffering) Player.STATE_ENDED -> apply(PlaybackEvent.Ended) @@ -143,7 +159,7 @@ fun rememberBpkVideoPlayerController(config: BpkVideoPlayerConfig): BpkVideoPlay BpkVideoPlayerController( config = config, scope = scope, - handle = PlayerFactory.build(context), + context = context, reducedMotionEnabled = isReducedMotionEnabled(context), ) } diff --git a/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/videoplayer/internal/PlayerFactory.kt b/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/videoplayer/internal/PlayerFactory.kt deleted file mode 100644 index 8ecdaab005..0000000000 --- a/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/videoplayer/internal/PlayerFactory.kt +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Backpack for Android - Skyscanner's Design System - * - * Copyright 2018 - 2026 Skyscanner Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package net.skyscanner.backpack.compose.videoplayer.internal - -import android.content.Context -import androidx.annotation.OptIn -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory - -internal object PlayerFactory { - @OptIn(UnstableApi::class) - fun build(context: Context): VideoPlayerHandle { - val applicationContext = context.applicationContext - val exoPlayer = ExoPlayer.Builder(applicationContext) - .setMediaSourceFactory( - DefaultMediaSourceFactory(applicationContext).setDataSourceFactory( - DefaultDataSource.Factory(applicationContext, DefaultHttpDataSource.Factory()), - ), - ) - .build() - return ExoPlayerHandle(exoPlayer) - } -} - -private class ExoPlayerHandle(override val player: ExoPlayer) : VideoPlayerHandle { - override var repeatMode: Int - get() = player.repeatMode - set(value) { player.repeatMode = value } - - override var volume: Float - get() = player.volume - set(value) { player.volume = value } - - override var playWhenReady: Boolean - get() = player.playWhenReady - set(value) { player.playWhenReady = value } - - override val isPlaying: Boolean get() = player.isPlaying - - override fun addListener(listener: Player.Listener) = player.addListener(listener) - override fun setMediaItem(uri: String) = player.setMediaItem(MediaItem.fromUri(uri)) - override fun prepare() = player.prepare() - override fun play() = player.play() - override fun pause() = player.pause() - override fun seekTo(positionMs: Long) = player.seekTo(positionMs) - override fun stop() = player.stop() - override fun release() = player.release() -} diff --git a/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/videoplayer/internal/VideoPlayerHandle.kt b/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/videoplayer/internal/VideoPlayerHandle.kt deleted file mode 100644 index 749d9bc41f..0000000000 --- a/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/videoplayer/internal/VideoPlayerHandle.kt +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Backpack for Android - Skyscanner's Design System - * - * Copyright 2018 - 2026 Skyscanner Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package net.skyscanner.backpack.compose.videoplayer.internal - -import androidx.media3.common.Player - -/** - * A narrow seam over the media player, exposing only the members [net.skyscanner.backpack.compose.videoplayer.BpkVideoPlayerController] - * needs. This keeps the concrete `ExoPlayer` out of the controller's logic so it can be driven by a - * fake in JVM unit tests, while [player] still hands the render surface a real [Player] for - * `ContentFrame`. - */ -internal interface VideoPlayerHandle { - val player: Player - var repeatMode: Int - var volume: Float - var playWhenReady: Boolean - val isPlaying: Boolean - fun addListener(listener: Player.Listener) - fun setMediaItem(uri: String) - fun prepare() - fun play() - fun pause() - fun seekTo(positionMs: Long) - fun stop() - fun release() -} diff --git a/backpack-compose/src/test/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerControllerTest.kt b/backpack-compose/src/test/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerControllerTest.kt deleted file mode 100644 index 408e0315a1..0000000000 --- a/backpack-compose/src/test/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerControllerTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Backpack for Android - Skyscanner's Design System - * - * Copyright 2018 - 2026 Skyscanner Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package net.skyscanner.backpack.compose.videoplayer - -import androidx.media3.common.Player -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class BpkVideoPlayerControllerTest { - - private fun config( - loop: Boolean = false, - startsMuted: Boolean = true, - autoPlay: Boolean = true, - respectsReducedMotion: Boolean = true, - loadTimeoutMs: Long = 7_000L, - ) = BpkVideoPlayerConfig( - videoUrl = BpkVideoUrl("https://example.com/video.mp4"), - loop = loop, - startsMuted = startsMuted, - autoPlay = autoPlay, - respectsReducedMotion = respectsReducedMotion, - loadTimeoutMs = loadTimeoutMs, - accessibilityLabel = "Test video", - ) - - private fun controller( - scope: CoroutineScope, - handle: FakeVideoPlayerHandle = FakeVideoPlayerHandle(), - config: BpkVideoPlayerConfig = config(), - reducedMotionEnabled: Boolean = false, - ) = BpkVideoPlayerController( - config = config, - scope = scope, - handle = handle, - reducedMotionEnabled = reducedMotionEnabled, - ) - - @Test - fun `init prepares the player with the configured media item`() = runTest { - val handle = FakeVideoPlayerHandle() - controller(this, handle) - assertEquals("https://example.com/video.mp4", handle.mediaUri) - assertTrue(handle.prepared) - assertEquals(1, handle.listeners.size) - } - - @Test - fun `loop config maps to repeat mode`() = runTest { - val looping = FakeVideoPlayerHandle() - controller(this, looping, config(loop = true)) - assertEquals(Player.REPEAT_MODE_ONE, looping.repeatMode) - - val once = FakeVideoPlayerHandle() - controller(this, once, config(loop = false)) - assertEquals(Player.REPEAT_MODE_OFF, once.repeatMode) - } - - @Test - fun `startsMuted config maps to volume`() = runTest { - val muted = FakeVideoPlayerHandle() - controller(this, muted, config(startsMuted = true)) - assertEquals(0f, muted.volume, 0f) - - val loud = FakeVideoPlayerHandle() - controller(this, loud, config(startsMuted = false)) - assertEquals(1f, loud.volume, 0f) - } - - @Test - fun `playWhenReady honours autoPlay and reduced motion`() = runTest { - // autoPlay on, no reduced motion -> plays - val a = FakeVideoPlayerHandle() - controller(this, a, config(autoPlay = true), reducedMotionEnabled = false) - assertTrue(a.playWhenReady) - - // autoPlay on, reduced motion respected -> blocked - val b = FakeVideoPlayerHandle() - controller(this, b, config(autoPlay = true, respectsReducedMotion = true), reducedMotionEnabled = true) - assertFalse(b.playWhenReady) - - // autoPlay on, reduced motion NOT respected -> plays despite reduced motion - val c = FakeVideoPlayerHandle() - controller(this, c, config(autoPlay = true, respectsReducedMotion = false), reducedMotionEnabled = true) - assertTrue(c.playWhenReady) - - // autoPlay off -> never plays - val d = FakeVideoPlayerHandle() - controller(this, d, config(autoPlay = false), reducedMotionEnabled = false) - assertFalse(d.playWhenReady) - } - - @Test - fun `play from Ended seeks to start`() = runTest { - val handle = FakeVideoPlayerHandle() - val controller = controller(this, handle) - handle.emitEnded() - assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Ended) - - controller.play() - assertEquals(0L, handle.lastSeekMs) - assertTrue(handle.playCalled) - } - - @Test - fun `play is a no-op when Failed`() = runTest { - val handle = FakeVideoPlayerHandle() - val controller = controller(this, handle, config(loadTimeoutMs = 100L)) - advanceTimeBy(150L) - advanceUntilIdle() - assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Failed) - - controller.play() - assertFalse(handle.playCalled) - } - - @Test - fun `setMuted toggles volume and state`() = runTest { - val handle = FakeVideoPlayerHandle() - val controller = controller(this, handle) - - controller.setMuted(false) - assertFalse(controller.isMuted.value) - assertEquals(1f, handle.volume, 0f) - - controller.setMuted(true) - assertTrue(controller.isMuted.value) - assertEquals(0f, handle.volume, 0f) - } - - @Test - fun `resetToStart seeks and clears Ended`() = runTest { - val handle = FakeVideoPlayerHandle() - val controller = controller(this, handle) - handle.emitEnded() - - controller.resetToStart() - assertEquals(0L, handle.lastSeekMs) - assertTrue(controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay) - } - - @Test - fun `load timeout transitions to Failed and stops the player`() = runTest { - val handle = FakeVideoPlayerHandle() - val controller = controller(this, handle, config(loadTimeoutMs = 500L)) - - assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Loading) - advanceTimeBy(600L) - advanceUntilIdle() - - val state = controller.playbackState.value - assertTrue(state is BpkVideoPlaybackState.Failed) - assertEquals(BpkVideoPlayerError.LoadTimeout, (state as BpkVideoPlaybackState.Failed).cause) - assertTrue(handle.stopped) - } - - @Test - fun `ready before timeout cancels the timeout`() = runTest(StandardTestDispatcher()) { - val handle = FakeVideoPlayerHandle() - val controller = controller(this, handle, config(loadTimeoutMs = 500L)) - - handle.emitReady(isPlaying = false) - advanceTimeBy(600L) - advanceUntilIdle() - - assertTrue(controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay) - } - - @Test - fun `dispose releases the player`() = runTest { - val handle = FakeVideoPlayerHandle() - val controller = controller(this, handle) - controller.dispose() - assertTrue(handle.released) - } -} diff --git a/backpack-compose/src/test/kotlin/net/skyscanner/backpack/compose/videoplayer/FakeVideoPlayerHandle.kt b/backpack-compose/src/test/kotlin/net/skyscanner/backpack/compose/videoplayer/FakeVideoPlayerHandle.kt deleted file mode 100644 index 58482df9df..0000000000 --- a/backpack-compose/src/test/kotlin/net/skyscanner/backpack/compose/videoplayer/FakeVideoPlayerHandle.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Backpack for Android - Skyscanner's Design System - * - * Copyright 2018 - 2026 Skyscanner Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package net.skyscanner.backpack.compose.videoplayer - -import androidx.media3.common.Player -import net.skyscanner.backpack.compose.videoplayer.internal.VideoPlayerHandle - -/** - * A recording fake of [VideoPlayerHandle] for JVM unit tests. It captures the calls the controller - * makes and lets a test drive the registered [Player.Listener] via the `emit*` helpers, without any - * ExoPlayer or Android framework dependency. - */ -internal class FakeVideoPlayerHandle : VideoPlayerHandle { - - val listeners = mutableListOf() - - var mediaUri: String? = null - private set - var prepared = false - private set - var playCalled = false - private set - var pauseCalled = false - private set - var stopped = false - private set - var released = false - private set - var lastSeekMs: Long? = null - private set - - override var repeatMode: Int = Player.REPEAT_MODE_OFF - override var volume: Float = 1f - override var playWhenReady: Boolean = false - override var isPlaying: Boolean = false - - // The render surface never runs in these tests, so the concrete Player is not needed. - override val player: Player get() = error("player is not available in FakeVideoPlayerHandle") - - override fun addListener(listener: Player.Listener) { - listeners += listener - } - - override fun setMediaItem(uri: String) { - mediaUri = uri - } - - override fun prepare() { - prepared = true - } - - override fun play() { - playCalled = true - } - - override fun pause() { - pauseCalled = true - } - - override fun seekTo(positionMs: Long) { - lastSeekMs = positionMs - } - - override fun stop() { - stopped = true - } - - override fun release() { - released = true - } - - fun emitReady(isPlaying: Boolean) { - this.isPlaying = isPlaying - listeners.forEach { it.onPlaybackStateChanged(Player.STATE_READY) } - } - - fun emitEnded() { - listeners.forEach { it.onPlaybackStateChanged(Player.STATE_ENDED) } - } -} From 18e19796fbfcc7464f72532ec331e683cb9ae420 Mon Sep 17 00:00:00 2001 From: "antoni.castejon" Date: Mon, 27 Jul 2026 11:18:20 +0200 Subject: [PATCH 2/4] Changes tests to given when then format --- .../compose/videoplayer/BpkVideoPlayerTest.kt | 107 ++++++++++++++---- 1 file changed, 85 insertions(+), 22 deletions(-) diff --git a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt index 47d6894708..89ec71eb2e 100644 --- a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt +++ b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt @@ -101,7 +101,8 @@ class BpkVideoPlayerTest { ) @Test - fun accessibilityLabel_isAppliedToSemantics() { + fun givenAccessibilityLabel_whenRendered_thenLabelIsAppliedToSemantics() { + // When composeTestRule.setContent { BpkTheme { val controller = rememberBpkVideoPlayerController(stubConfig) @@ -109,6 +110,7 @@ class BpkVideoPlayerTest { } } + // Then composeTestRule .onNodeWithContentDescription("Test video") .assertExists() @@ -116,7 +118,8 @@ class BpkVideoPlayerTest { } @Test - fun initialState_isLoading() { + fun givenStubConfig_whenRendered_thenInitialStateIsLoading() { + // When lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -125,11 +128,13 @@ class BpkVideoPlayerTest { } } + // Then assertTrue(controller.playbackState.value.isLoading) } @Test - fun setMuted_false_updatesIsMuted() { + fun givenMutedController_whenSetMutedFalse_thenIsMutedIsFalse() { + // Given lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -137,14 +142,18 @@ class BpkVideoPlayerTest { BpkVideoPlayer(controller = controller) } } - assertTrue(controller.isMuted.value) + + // When composeTestRule.runOnIdle { controller.setMuted(false) } + + // Then assertFalse(controller.isMuted.value) } @Test - fun setMuted_toggle_isIdempotent() { + fun givenMutedController_whenSetMutedToggledTwice_thenIsMutedIsRestored() { + // Given lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -153,14 +162,22 @@ class BpkVideoPlayerTest { } } + // When composeTestRule.runOnIdle { controller.setMuted(false) } + + // Then assertFalse(controller.isMuted.value) + + // When composeTestRule.runOnIdle { controller.setMuted(true) } + + // Then assertTrue(controller.isMuted.value) } @Test - fun play_whenFailed_doesNotCrash() { + fun givenFailedPlayback_whenPlayCalled_thenStateRemainsFailedWithoutCrash() { + // Given lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -174,18 +191,20 @@ class BpkVideoPlayerTest { BpkVideoPlayer(controller = controller) } } - composeTestRule.waitUntil(timeoutMillis = 2_000L) { controller.playbackState.value is BpkVideoPlaybackState.Failed } - // play() on a Failed state must be a no-op — no exception, state unchanged + // When composeTestRule.runOnIdle { controller.play() } + + // Then assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Failed) } @Test - fun loopConfigTrue_mapsToPlayerRepeatModeOne() { + fun givenLoopTrue_whenRendered_thenPlayerRepeatModeIsOne() { + // When lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -193,11 +212,14 @@ class BpkVideoPlayerTest { BpkVideoPlayer(controller = controller) } } + + // Then assertEquals(Player.REPEAT_MODE_ONE, controller.player.repeatMode) } @Test - fun loopConfigFalse_mapsToPlayerRepeatModeOff() { + fun givenLoopFalse_whenRendered_thenPlayerRepeatModeIsOff() { + // When lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -205,11 +227,14 @@ class BpkVideoPlayerTest { BpkVideoPlayer(controller = controller) } } + + // Then assertEquals(Player.REPEAT_MODE_OFF, controller.player.repeatMode) } @Test - fun startsMutedTrue_mapsToZeroVolume() { + fun givenStartsMutedTrue_whenRendered_thenPlayerVolumeIsZero() { + // When lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -217,11 +242,14 @@ class BpkVideoPlayerTest { BpkVideoPlayer(controller = controller) } } + + // Then assertEquals(0f, controller.player.volume, 0f) } @Test - fun startsMutedFalse_mapsToFullVolume() { + fun givenStartsMutedFalse_whenRendered_thenPlayerVolumeIsFull() { + // When lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -229,11 +257,14 @@ class BpkVideoPlayerTest { BpkVideoPlayer(controller = controller) } } + + // Then assertEquals(1f, controller.player.volume, 0f) } @Test - fun setMuted_updatesPlayerVolume() { + fun givenPlayableConfig_whenSetMutedToggled_thenPlayerVolumeUpdates() { + // Given lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -242,15 +273,22 @@ class BpkVideoPlayerTest { } } + // When composeTestRule.runOnIdle { controller.setMuted(false) } + + // Then assertEquals(1f, controller.player.volume, 0f) + // When composeTestRule.runOnIdle { controller.setMuted(true) } + + // Then assertEquals(0f, controller.player.volume, 0f) } @Test - fun autoPlay_withReducedMotionDisabled_reachesPlaying() { + fun givenAutoPlayWithReducedMotionDisabled_whenRendered_thenStateReachesPlaying() { + // Given disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { @@ -260,14 +298,18 @@ class BpkVideoPlayerTest { } } + // When composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { controller.playbackState.value is BpkVideoPlaybackState.Playing } + + // Then assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Playing) } @Test - fun autoPlayOff_neverStartsPlaying() { + fun givenAutoPlayOff_whenRendered_thenStateIsReadyToPlay() { + // Given disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { @@ -277,14 +319,18 @@ class BpkVideoPlayerTest { } } + // When composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay } + + // Then assertTrue(controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay) } @Test - fun playPauseToggle_driveRealPlayback() { + fun givenPlayingState_whenPauseAndToggleCalled_thenStateTransitionsCorrectly() { + // Given disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { @@ -293,24 +339,32 @@ class BpkVideoPlayerTest { BpkVideoPlayer(controller = controller) } } - composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { controller.playbackState.value is BpkVideoPlaybackState.Playing } + // When composeTestRule.runOnIdle { controller.pause() } + + // Then composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { controller.playbackState.value is BpkVideoPlaybackState.Paused } assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Paused) + // When composeTestRule.runOnIdle { controller.toggle() } + + // Then composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { controller.playbackState.value is BpkVideoPlaybackState.Playing } assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Playing) + // When composeTestRule.runOnIdle { controller.toggle() } + + // Then composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { controller.playbackState.value is BpkVideoPlaybackState.Paused } @@ -318,7 +372,8 @@ class BpkVideoPlayerTest { } @Test - fun playbackReachesEnded_thenPlayRestartsFromStart() { + fun givenEndedPlayback_whenPlayCalled_thenStateReachesPlayingAgain() { + // Given disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { @@ -327,13 +382,15 @@ class BpkVideoPlayerTest { BpkVideoPlayer(controller = controller) } } - composeTestRule.waitUntil(timeoutMillis = ENDED_STATE_TIMEOUT_MS) { controller.playbackState.value is BpkVideoPlaybackState.Ended } assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Ended) + // When composeTestRule.runOnIdle { controller.play() } + + // Then composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { controller.playbackState.value is BpkVideoPlaybackState.Playing } @@ -341,7 +398,8 @@ class BpkVideoPlayerTest { } @Test - fun resetToStart_afterEnded_settlesReadyToPlay() { + fun givenEndedPlayback_whenResetToStartCalled_thenStateIsReadyToPlay() { + // Given disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { @@ -350,17 +408,20 @@ class BpkVideoPlayerTest { BpkVideoPlayer(controller = controller) } } - composeTestRule.waitUntil(timeoutMillis = ENDED_STATE_TIMEOUT_MS) { controller.playbackState.value is BpkVideoPlaybackState.Ended } + // When composeTestRule.runOnIdle { controller.resetToStart() } + + // Then assertTrue(controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay) } @Test - fun dispose_releasesTheUnderlyingPlayer() { + fun givenPlayerComposed_whenRemovedFromComposition_thenUnderlyingPlayerIsReleased() { + // Given lateinit var controller: BpkVideoPlayerController var showPlayer by mutableStateOf(true) composeTestRule.setContent { @@ -372,9 +433,11 @@ class BpkVideoPlayerTest { } } + // When composeTestRule.runOnIdle { showPlayer = false } composeTestRule.waitForIdle() + // Then assertEquals(Player.STATE_IDLE, controller.player.playbackState) } From e4ca13209dbac7bb4e27cccfe84d98245d83a0ad Mon Sep 17 00:00:00 2001 From: "antoni.castejon" Date: Mon, 27 Jul 2026 11:55:25 +0200 Subject: [PATCH 3/4] Adds tests for bpkVideoDefaultControls --- .../BpkVideoPlayerDefaultControlsTest.kt | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerDefaultControlsTest.kt diff --git a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerDefaultControlsTest.kt b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerDefaultControlsTest.kt new file mode 100644 index 0000000000..1e6342f653 --- /dev/null +++ b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerDefaultControlsTest.kt @@ -0,0 +1,220 @@ +/** + * Backpack for Android - Skyscanner's Design System + * + * Copyright 2018 - 2026 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.skyscanner.backpack.compose.videoplayer + +import android.os.ParcelFileDescriptor.AutoCloseInputStream +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import net.skyscanner.backpack.compose.theme.BpkTheme +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class BpkVideoPlayerDefaultControlsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private var originalAnimationScales: Pair? = null + + private fun disableReducedMotionSignal() { + originalAnimationScales = + getGlobalSetting(ANIMATOR_DURATION_SCALE_KEY) to getGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY) + putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, "1") + putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, "1") + } + + @After + fun restoreReducedMotionSignalIfChanged() { + originalAnimationScales?.let { (animator, transition) -> + putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, animator ?: "1") + putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, transition ?: "1") + originalAnimationScales = null + } + } + + private fun getGlobalSetting(key: String): String? = + runShellCommand("settings get global $key") + .trim() + .takeUnless { it == "null" || it.isEmpty() } + + private fun putGlobalSetting(key: String, value: String) { + runShellCommand("settings put global $key $value") + } + + private fun runShellCommand(command: String): String = + AutoCloseInputStream(InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(command)) + .use { it.readBytes().decodeToString() } + + private fun bundledVideoUrl(): String { + val targetPackage = InstrumentationRegistry.getInstrumentation().targetContext.packageName + return "android.resource://$targetPackage/raw/bpk_video_player_test" + } + + private fun playableConfig(autoPlay: Boolean = true) = BpkVideoPlayerConfig( + videoUrl = BpkVideoUrl(bundledVideoUrl()), + startsMuted = true, + autoPlay = autoPlay, + loadTimeoutMs = 7_000L, + accessibilityLabel = "Test video", + ) + + @Test + fun givenLoadingState_whenControlsRendered_thenPlayButtonIsNotShown() { + // When + composeTestRule.setContent { + BpkTheme { + val controller = rememberBpkVideoPlayerController( + BpkVideoPlayerConfig( + videoUrl = BpkVideoUrl("https://example.com/stub.mp4"), + accessibilityLabel = "Test video", + ), + ) + BpkVideoPlayerDefaultControls( + controller = controller, + playContentDescription = PLAY_LABEL, + pauseContentDescription = PAUSE_LABEL, + ) + } + } + + // Then + composeTestRule.onNodeWithContentDescription(PLAY_LABEL).assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(PAUSE_LABEL).assertDoesNotExist() + } + + @Test + fun givenReadyToPlayState_whenControlsRendered_thenPlayButtonIsShown() { + // Given + disableReducedMotionSignal() + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = false)) + BpkVideoPlayerDefaultControls( + controller = controller, + playContentDescription = PLAY_LABEL, + pauseContentDescription = PAUSE_LABEL, + ) + } + } + composeTestRule.waitUntil(timeoutMillis = READY_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay + } + + // Then + composeTestRule.onNodeWithContentDescription(PLAY_LABEL).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(PAUSE_LABEL).assertDoesNotExist() + } + + @Test + fun givenPlayingState_whenControlsRendered_thenPauseButtonIsShown() { + // Given + disableReducedMotionSignal() + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = true)) + BpkVideoPlayerDefaultControls( + controller = controller, + playContentDescription = PLAY_LABEL, + pauseContentDescription = PAUSE_LABEL, + ) + } + } + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Playing + } + + // Then + composeTestRule.onNodeWithContentDescription(PAUSE_LABEL).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(PLAY_LABEL).assertDoesNotExist() + } + + @Test + fun givenReadyToPlayState_whenPlayButtonClicked_thenStateTransitionsToPlaying() { + // Given + disableReducedMotionSignal() + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = false)) + BpkVideoPlayerDefaultControls( + controller = controller, + playContentDescription = PLAY_LABEL, + pauseContentDescription = PAUSE_LABEL, + ) + } + } + composeTestRule.waitUntil(timeoutMillis = READY_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.ReadyToPlay + } + + // When + composeTestRule.onNodeWithContentDescription(PLAY_LABEL).performClick() + + // Then + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Playing + } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Playing) + } + + @Test + fun givenPlayingState_whenPauseButtonClicked_thenStateTransitionsToPaused() { + // Given + disableReducedMotionSignal() + lateinit var controller: BpkVideoPlayerController + composeTestRule.setContent { + BpkTheme { + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = true)) + BpkVideoPlayerDefaultControls( + controller = controller, + playContentDescription = PLAY_LABEL, + pauseContentDescription = PAUSE_LABEL, + ) + } + } + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Playing + } + + // When + composeTestRule.onNodeWithContentDescription(PAUSE_LABEL).performClick() + + // Then + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Paused + } + assertTrue(controller.playbackState.value is BpkVideoPlaybackState.Paused) + } + + private companion object { + const val PLAY_LABEL = "Play" + const val PAUSE_LABEL = "Pause" + const val ANIMATOR_DURATION_SCALE_KEY = "animator_duration_scale" + const val TRANSITION_ANIMATION_SCALE_KEY = "transition_animation_scale" + const val READY_STATE_TIMEOUT_MS = 7_000L + const val PLAYING_STATE_TIMEOUT_MS = 5_000L + } +} From 5db0de0b3de372522e48785ea70e8e21249b0b94 Mon Sep 17 00:00:00 2001 From: "antoni.castejon" Date: Mon, 27 Jul 2026 13:11:06 +0200 Subject: [PATCH 4/4] Address Copilot review comments on video player tests - Extract shared reduced-motion helpers and shell commands into VideoPlayerTestRule (ExternalResource) to eliminate duplication between BpkVideoPlayerTest and BpkVideoPlayerDefaultControlsTest - Fix dispose test to wait for Playing state before removing the player from composition, making the STATE_IDLE assertion unambiguously prove release() was called Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../BpkVideoPlayerDefaultControlsTest.kt | 56 +++----------- .../compose/videoplayer/BpkVideoPlayerTest.kt | 74 ++++-------------- .../videoplayer/VideoPlayerTestRule.kt | 76 +++++++++++++++++++ 3 files changed, 101 insertions(+), 105 deletions(-) create mode 100644 backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/VideoPlayerTestRule.kt diff --git a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerDefaultControlsTest.kt b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerDefaultControlsTest.kt index 1e6342f653..1dc9087bcc 100644 --- a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerDefaultControlsTest.kt +++ b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerDefaultControlsTest.kt @@ -18,14 +18,13 @@ package net.skyscanner.backpack.compose.videoplayer -import android.os.ParcelFileDescriptor.AutoCloseInputStream import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.performClick -import androidx.test.platform.app.InstrumentationRegistry import net.skyscanner.backpack.compose.theme.BpkTheme -import org.junit.After +import net.skyscanner.backpack.compose.videoplayer.VideoPlayerTestRule.Companion.PLAYING_STATE_TIMEOUT_MS +import net.skyscanner.backpack.compose.videoplayer.VideoPlayerTestRule.Companion.READY_STATE_TIMEOUT_MS import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -35,44 +34,11 @@ class BpkVideoPlayerDefaultControlsTest { @get:Rule val composeTestRule = createComposeRule() - private var originalAnimationScales: Pair? = null - - private fun disableReducedMotionSignal() { - originalAnimationScales = - getGlobalSetting(ANIMATOR_DURATION_SCALE_KEY) to getGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY) - putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, "1") - putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, "1") - } - - @After - fun restoreReducedMotionSignalIfChanged() { - originalAnimationScales?.let { (animator, transition) -> - putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, animator ?: "1") - putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, transition ?: "1") - originalAnimationScales = null - } - } - - private fun getGlobalSetting(key: String): String? = - runShellCommand("settings get global $key") - .trim() - .takeUnless { it == "null" || it.isEmpty() } - - private fun putGlobalSetting(key: String, value: String) { - runShellCommand("settings put global $key $value") - } - - private fun runShellCommand(command: String): String = - AutoCloseInputStream(InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(command)) - .use { it.readBytes().decodeToString() } - - private fun bundledVideoUrl(): String { - val targetPackage = InstrumentationRegistry.getInstrumentation().targetContext.packageName - return "android.resource://$targetPackage/raw/bpk_video_player_test" - } + @get:Rule + val videoPlayerTestRule = VideoPlayerTestRule() private fun playableConfig(autoPlay: Boolean = true) = BpkVideoPlayerConfig( - videoUrl = BpkVideoUrl(bundledVideoUrl()), + videoUrl = BpkVideoUrl(videoPlayerTestRule.bundledVideoUrl()), startsMuted = true, autoPlay = autoPlay, loadTimeoutMs = 7_000L, @@ -106,7 +72,7 @@ class BpkVideoPlayerDefaultControlsTest { @Test fun givenReadyToPlayState_whenControlsRendered_thenPlayButtonIsShown() { // Given - disableReducedMotionSignal() + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -130,7 +96,7 @@ class BpkVideoPlayerDefaultControlsTest { @Test fun givenPlayingState_whenControlsRendered_thenPauseButtonIsShown() { // Given - disableReducedMotionSignal() + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -154,7 +120,7 @@ class BpkVideoPlayerDefaultControlsTest { @Test fun givenReadyToPlayState_whenPlayButtonClicked_thenStateTransitionsToPlaying() { // Given - disableReducedMotionSignal() + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -183,7 +149,7 @@ class BpkVideoPlayerDefaultControlsTest { @Test fun givenPlayingState_whenPauseButtonClicked_thenStateTransitionsToPaused() { // Given - disableReducedMotionSignal() + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -212,9 +178,5 @@ class BpkVideoPlayerDefaultControlsTest { private companion object { const val PLAY_LABEL = "Play" const val PAUSE_LABEL = "Pause" - const val ANIMATOR_DURATION_SCALE_KEY = "animator_duration_scale" - const val TRANSITION_ANIMATION_SCALE_KEY = "transition_animation_scale" - const val READY_STATE_TIMEOUT_MS = 7_000L - const val PLAYING_STATE_TIMEOUT_MS = 5_000L } } diff --git a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt index 89ec71eb2e..70846db607 100644 --- a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt +++ b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/BpkVideoPlayerTest.kt @@ -18,7 +18,6 @@ package net.skyscanner.backpack.compose.videoplayer -import android.os.ParcelFileDescriptor.AutoCloseInputStream import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -26,9 +25,9 @@ import androidx.compose.ui.test.assertContentDescriptionContains import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.media3.common.Player -import androidx.test.platform.app.InstrumentationRegistry import net.skyscanner.backpack.compose.theme.BpkTheme -import org.junit.After +import net.skyscanner.backpack.compose.videoplayer.VideoPlayerTestRule.Companion.ENDED_STATE_TIMEOUT_MS +import net.skyscanner.backpack.compose.videoplayer.VideoPlayerTestRule.Companion.PLAYING_STATE_TIMEOUT_MS import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -40,59 +39,21 @@ class BpkVideoPlayerTest { @get:Rule val composeTestRule = createComposeRule() + @get:Rule + val videoPlayerTestRule = VideoPlayerTestRule() + private val stubConfig = BpkVideoPlayerConfig( videoUrl = BpkVideoUrl("https://example.com/stub.mp4"), accessibilityLabel = "Test video", ) - private var originalAnimationScales: Pair? = null - - // BpkVideoPlayerController defaults to respectsReducedMotion = true and treats animator/transition - // scale 0 (a common test-emulator setting, used to speed up Espresso) as a reduced-motion signal, - // which suppresses autoplay and leaves the player stuck at ReadyToPlay. - private fun disableReducedMotionSignal() { - originalAnimationScales = - getGlobalSetting(ANIMATOR_DURATION_SCALE_KEY) to getGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY) - putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, "1") - putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, "1") - } - - @After - fun restoreReducedMotionSignalIfChanged() { - originalAnimationScales?.let { (animator, transition) -> - putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, animator ?: "1") - putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, transition ?: "1") - originalAnimationScales = null - } - } - - // android.provider.Settings.Global requires WRITE_SECURE_SETTINGS, which the app under test does - // not hold; UiAutomation shell commands run with shell identity, which does. - private fun getGlobalSetting(key: String): String? = - runShellCommand("settings get global $key") - .trim() - .takeUnless { it == "null" || it.isEmpty() } - - private fun putGlobalSetting(key: String, value: String) { - runShellCommand("settings put global $key $value") - } - - private fun runShellCommand(command: String): String = - AutoCloseInputStream(InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(command)) - .use { it.readBytes().decodeToString() } - - private fun bundledVideoUrl(): String { - val targetPackage = InstrumentationRegistry.getInstrumentation().targetContext.packageName - return "android.resource://$targetPackage/raw/bpk_video_player_test" - } - private fun playableConfig( loop: Boolean = false, startsMuted: Boolean = true, autoPlay: Boolean = true, loadTimeoutMs: Long = 7_000L, ) = BpkVideoPlayerConfig( - videoUrl = BpkVideoUrl(bundledVideoUrl()), + videoUrl = BpkVideoUrl(videoPlayerTestRule.bundledVideoUrl()), loop = loop, startsMuted = startsMuted, autoPlay = autoPlay, @@ -289,7 +250,7 @@ class BpkVideoPlayerTest { @Test fun givenAutoPlayWithReducedMotionDisabled_whenRendered_thenStateReachesPlaying() { // Given - disableReducedMotionSignal() + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -310,7 +271,7 @@ class BpkVideoPlayerTest { @Test fun givenAutoPlayOff_whenRendered_thenStateIsReadyToPlay() { // Given - disableReducedMotionSignal() + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -331,7 +292,7 @@ class BpkVideoPlayerTest { @Test fun givenPlayingState_whenPauseAndToggleCalled_thenStateTransitionsCorrectly() { // Given - disableReducedMotionSignal() + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -374,7 +335,7 @@ class BpkVideoPlayerTest { @Test fun givenEndedPlayback_whenPlayCalled_thenStateReachesPlayingAgain() { // Given - disableReducedMotionSignal() + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -400,7 +361,7 @@ class BpkVideoPlayerTest { @Test fun givenEndedPlayback_whenResetToStartCalled_thenStateIsReadyToPlay() { // Given - disableReducedMotionSignal() + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController composeTestRule.setContent { BpkTheme { @@ -422,16 +383,20 @@ class BpkVideoPlayerTest { @Test fun givenPlayerComposed_whenRemovedFromComposition_thenUnderlyingPlayerIsReleased() { // Given + videoPlayerTestRule.disableReducedMotionSignal() lateinit var controller: BpkVideoPlayerController var showPlayer by mutableStateOf(true) composeTestRule.setContent { BpkTheme { if (showPlayer) { - controller = rememberBpkVideoPlayerController(playableConfig()) + controller = rememberBpkVideoPlayerController(playableConfig(autoPlay = true)) BpkVideoPlayer(controller = controller) } } } + composeTestRule.waitUntil(timeoutMillis = PLAYING_STATE_TIMEOUT_MS) { + controller.playbackState.value is BpkVideoPlaybackState.Playing + } // When composeTestRule.runOnIdle { showPlayer = false } @@ -440,11 +405,4 @@ class BpkVideoPlayerTest { // Then assertEquals(Player.STATE_IDLE, controller.player.playbackState) } - - private companion object { - const val ANIMATOR_DURATION_SCALE_KEY = "animator_duration_scale" - const val TRANSITION_ANIMATION_SCALE_KEY = "transition_animation_scale" - const val PLAYING_STATE_TIMEOUT_MS = 5_000L - const val ENDED_STATE_TIMEOUT_MS = 8_000L - } } diff --git a/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/VideoPlayerTestRule.kt b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/VideoPlayerTestRule.kt new file mode 100644 index 0000000000..73fa67cbfd --- /dev/null +++ b/backpack-compose/src/androidTest/kotlin/net/skyscanner/backpack/compose/videoplayer/VideoPlayerTestRule.kt @@ -0,0 +1,76 @@ +/** + * Backpack for Android - Skyscanner's Design System + * + * Copyright 2018 - 2026 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.skyscanner.backpack.compose.videoplayer + +import android.os.ParcelFileDescriptor.AutoCloseInputStream +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.rules.ExternalResource + +// BpkVideoPlayerController defaults to respectsReducedMotion = true and treats animator/transition +// scale 0 (a common test-emulator setting, used to speed up Espresso) as a reduced-motion signal, +// which suppresses autoplay and leaves the player stuck at ReadyToPlay. +// Call disableReducedMotionSignal() in tests that need real playback; the rule restores original +// values automatically after each test. +class VideoPlayerTestRule : ExternalResource() { + + private var originalAnimationScales: Pair? = null + + fun disableReducedMotionSignal() { + originalAnimationScales = + getGlobalSetting(ANIMATOR_DURATION_SCALE_KEY) to getGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY) + putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, "1") + putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, "1") + } + + override fun after() { + originalAnimationScales?.let { (animator, transition) -> + putGlobalSetting(ANIMATOR_DURATION_SCALE_KEY, animator ?: "1") + putGlobalSetting(TRANSITION_ANIMATION_SCALE_KEY, transition ?: "1") + originalAnimationScales = null + } + } + + fun bundledVideoUrl(): String { + val targetPackage = InstrumentationRegistry.getInstrumentation().targetContext.packageName + return "android.resource://$targetPackage/raw/bpk_video_player_test" + } + + // android.provider.Settings.Global requires WRITE_SECURE_SETTINGS, which the app under test + // does not hold; UiAutomation shell commands run with shell identity, which does. + private fun getGlobalSetting(key: String): String? = + runShellCommand("settings get global $key") + .trim() + .takeUnless { it == "null" || it.isEmpty() } + + private fun putGlobalSetting(key: String, value: String) { + runShellCommand("settings put global $key $value") + } + + private fun runShellCommand(command: String): String = + AutoCloseInputStream(InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(command)) + .use { it.readBytes().decodeToString() } + + companion object { + private const val ANIMATOR_DURATION_SCALE_KEY = "animator_duration_scale" + private const val TRANSITION_ANIMATION_SCALE_KEY = "transition_animation_scale" + const val READY_STATE_TIMEOUT_MS = 7_000L + const val PLAYING_STATE_TIMEOUT_MS = 5_000L + const val ENDED_STATE_TIMEOUT_MS = 8_000L + } +}