Add an end-to-end subtitle delay playback test
All checks were successful
review/code-claude Code APPROVE: zero path identical, exact shift, on-time end
review/design-claude Design PASS: existing delay row only, no new UI

This commit is contained in:
justinzeus 2026-11-08 00:41:26 +01:00
parent 7c03a3fe0d
commit 0a7c059f24
3 changed files with 190 additions and 0 deletions

View file

@ -0,0 +1,186 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.services
import android.content.Context
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.test.utils.FakeClock
import androidx.media3.test.utils.robolectric.CapturingRenderersFactory
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.play
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Plays a short audio file with a sideloaded SRT through a real [ExoPlayer] and checks when the
* subtitles appear for a given delay, and that playback still ends when the media does.
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [30])
class SubtitleDelayPlaybackTest {
@get:Rule val folder = TemporaryFolder()
private val context = ApplicationProvider.getApplicationContext<Context>()
private val clock = FakeClock(true)
private val shownCues = mutableListOf<Pair<String, Long>>()
private val playbackStates = mutableListOf<Int>()
private lateinit var player: ExoPlayer
@Before
fun setUp() {
player =
ExoPlayer
.Builder(context, SubtitleDelayRenderersFactory(CapturingRenderersFactory(context, clock)))
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
.setClock(clock)
.build()
player.addListener(
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
cueGroup.cues.firstOrNull()?.let { shownCues.add(it.text.toString() to player.currentPosition) }
}
override fun onPlaybackStateChanged(playbackState: Int) {
playbackStates.add(playbackState)
}
},
)
player.setMediaItem(mediaItem())
player.prepare()
// Subtitles never hold up playback, so let the sideloaded file load before playing
run(player).untilState(Player.STATE_READY)
run(player).untilLoadingIs(false)
assertTrue(player.currentTracks.isTypeSelected(C.TRACK_TYPE_TEXT))
}
@After
fun tearDown() {
player.release()
}
@Test
fun positiveDelayShowsSubtitlesLaterAndPlaybackEndsOnTime() {
player.applySubtitleDelay(2.seconds)
val playStartMs = clock.elapsedRealtime()
play(player).untilState(Player.STATE_ENDED)
assertShownAt("First" to 3.seconds)
val playedMs = clock.elapsedRealtime() - playStartMs
assertTrue(
"ended after $playedMs ms of playback, expected $DURATION",
abs(playedMs - DURATION.inWholeMilliseconds) <= TOLERANCE_MS,
)
}
@Test
fun negativeDelayShowsSubtitlesEarlier() {
player.applySubtitleDelay((-500).milliseconds)
play(player).untilState(Player.STATE_ENDED)
assertShownAt("First" to 500.milliseconds, "Last" to 2.seconds)
}
@Test
fun changingTheDelayWhilePlayingRetimesWithoutRebuffering() {
play(player).untilPosition(0, 500)
player.applySubtitleDelay(1.seconds)
play(player).untilState(Player.STATE_ENDED)
assertShownAt("First" to 2.seconds, "Last" to 3_500.milliseconds)
assertEquals(listOf(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED), playbackStates)
}
private fun assertShownAt(vararg expected: Pair<String, Duration>) {
assertEquals(expected.map { it.first }, shownCues.map { it.first })
expected.zip(shownCues).forEach { (want, got) ->
assertTrue(
"${want.first} shown at ${got.second} ms, expected ${want.second}",
abs(got.second - want.second.inWholeMilliseconds) <= TOLERANCE_MS,
)
}
}
private fun mediaItem(): MediaItem {
val audio = folder.newFile("silence.wav").apply { writeBytes(silentWav()) }
val subtitles = folder.newFile("subtitles.srt").apply { writeText(SRT) }
return MediaItem
.Builder()
.setUri(Uri.fromFile(audio))
.setSubtitleConfigurations(
listOf(
MediaItem.SubtitleConfiguration
.Builder(Uri.fromFile(subtitles))
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
.setLanguage("en")
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build(),
),
).build()
}
/** A mono 16-bit PCM WAV file of silence lasting [DURATION] */
private fun silentWav(): ByteArray {
val dataSize = (SAMPLE_RATE * DURATION.inWholeMilliseconds / 1000 * 2).toInt()
return ByteBuffer
.allocate(44 + dataSize)
.order(ByteOrder.LITTLE_ENDIAN)
.put("RIFF".toByteArray())
.putInt(36 + dataSize)
.put("WAVEfmt ".toByteArray())
.putInt(16)
.putShort(1)
.putShort(1)
.putInt(SAMPLE_RATE)
.putInt(SAMPLE_RATE * 2)
.putShort(2)
.putShort(16)
.put("data".toByteArray())
.putInt(dataSize)
.array()
}
private companion object {
const val SAMPLE_RATE = 8_000
const val TOLERANCE_MS = 100L
val DURATION = 4.seconds
val SRT =
"""
1
00:00:01,000 --> 00:00:02,000
First
2
00:00:02,500 --> 00:00:03,500
Last
""".trimIndent()
}
}