package pw.binom.viewmate.phone import pw.binom.viewmate.core.media.MirrorAudio import pw.binom.viewmate.core.media.MirrorFiles import pw.binom.viewmate.core.media.MirrorVideo import java.io.File import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue class AudioSyncPlayerTest { // --- мягкая синхронизация через скорость (decideSync): speed вместо резкого seek --- @Test fun noCorrectionWhenFullyAligned() { val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true) assertEquals(1f, d.speed, "полное совпадение — темп не трогаем") assertNull(d.seekToMs) assertNull(d.playing) } @Test fun speedStaysFlatInsideTriggerBand() { val d = decideSync(audioPosition = 10_000, videoPosition = 10_100, audioPlaying = true, videoPlaying = true) assertEquals(1f, d.speed, "diff=100мс внутри гистерезиса [-150;+150] — дребезга нет") assertNull(d.seekToMs) assertNull(d.playing) } @Test fun speedsUpWhenAudioLagsBehind() { val d = decideSync(audioPosition = 10_000, videoPosition = 10_300, audioPlaying = true, videoPlaying = true) assertEquals(1f + 300f * DRIFT_GAIN, d.speed, "звук отстаёт — догоняем ускорением ${1f + 300f * DRIFT_GAIN}") assertNull(d.seekToMs, "diff=300мс внутри HARD-порога — seek не нужен") assertNull(d.playing) } @Test fun slowsDownWhenAudioAhead() { val d = decideSync(audioPosition = 10_300, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) assertEquals(1f - 300f * DRIFT_GAIN, d.speed, "звук впереди — тормозим до ${1f - 300f * DRIFT_GAIN}") assertNull(d.seekToMs) } @Test fun smallDriftJustOutsideBandIsProportional() { val d = decideSync(audioPosition = 10_151, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) assertEquals(1f - 151f * DRIFT_GAIN, d.speed, "diff=151мс (чуть за зоной) — слабый пропорциональный темп") } @Test fun speedSaturatesAtMaxDrift() { val behind = decideSync(audioPosition = 9_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) assertEquals(1f + MAX_DRIFT_SPEED, behind.speed, "diff=−1000мс → 1+0.2 упирается в потолок ${1f + MAX_DRIFT_SPEED}") val ahead = decideSync(audioPosition = 11_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) assertEquals(1f - MAX_DRIFT_SPEED, ahead.speed, "diff=+1000мс → 1−0.2 упирается в потолок ${1f - MAX_DRIFT_SPEED}") } @Test fun bandBoundaryExactly150MsStaysFlat() { val ahead = decideSync(audioPosition = 10_150, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) assertEquals(1f, ahead.speed, "diff=+150мс — граница закрытой зоны, темп 1f") val behind = decideSync(audioPosition = 9_850, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) assertEquals(1f, behind.speed, "diff=-150мс — граница закрытой зоны, темп 1f") } @Test fun hardSeekWhenAudioFarBehind() { val d = decideSync(audioPosition = 10_000, videoPosition = 16_000, audioPlaying = true, videoPlaying = true) assertEquals(16_000L, d.seekToMs, "diff=6000мс > 5000мс — жёсткий seek на позицию очков") assertEquals(1f, d.speed, "после seek темп возвращается в 1f — дальше мягко") assertNull(d.playing) } @Test fun hardSeekWhenAudioFarAhead() { val d = decideSync(audioPosition = 26_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true) assertEquals(20_000L, d.seekToMs) assertEquals(1f, d.speed) } @Test fun hardThresholdBoundaryDoesNotSeek() { val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true) assertNull(d.seekToMs, "diff=2000мс не БОЛЬШЕ порога — подгоняем скоростью, без рывка") assertEquals(1f + MAX_DRIFT_SPEED, d.speed, "diff=2000мс → 1+0.4 — упирается в потолок ${1f + MAX_DRIFT_SPEED}") } @Test fun hardThresholdPlusOneSeeks() { val d = decideSync(audioPosition = 10_000, videoPosition = 15_001, audioPlaying = true, videoPlaying = true) assertEquals(15_001L, d.seekToMs, "diff=5001мс > 5000мс — уже жёсткий seek") } @Test fun playWhenVideoPlayingButAudioPaused() { val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true) assertEquals(1f, d.speed, "play/pause влияние только на playing, не на скорость") assertEquals(true, d.playing, "очки играют — аудио должно играть") assertNull(d.seekToMs) } @Test fun pauseWhenVideoPausedButAudioPlaying() { val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false) assertEquals(1f, d.speed) assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться") assertNull(d.seekToMs) } @Test fun seekAndPlayApplyTogether() { val d = decideSync(audioPosition = 5_000, videoPosition = 15_000, audioPlaying = false, videoPlaying = true) assertEquals(15_000L, d.seekToMs) assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync") assertEquals(1f, d.speed) } // --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») --- private fun mirrorFiles(audios: List = listOf()) = MirrorFiles( video = MirrorVideo(key = "v", url = "https://s3/mirror/video.mkv"), audios = audios, ) private fun audioDir(): File = Files.createTempDirectory("audio-src-test").toFile() @Test fun sourceIsNullWhenMirrorFilesMissing() { val result = resolveAudioSourceDetailed(null, 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" } assertNull(result.source) assertTrue(result.reason.contains("null"), "причина должна указывать на отсутствующее зеркало: ${result.reason}") } @Test fun sourceIsNullWhenAudiosEmpty() { val result = resolveAudioSourceDetailed(mirrorFiles(), 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" } assertNull(result.source) assertTrue(result.reason.contains("audios пусто"), "причина должна указывать на пустой список дорожек: ${result.reason}") } @Test fun usesRequestedAudioIndex() { val dir = audioDir() File(dir, "audio-1.ogg").writeBytes(ByteArray(5)) val audios = listOf( MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"), MirrorAudio(index = 1, key = "a1", url = "https://s3/audio-1.ogg"), ) val result = resolveAudioSourceDetailed(mirrorFiles(audios), 1, { File(dir, "audio-$it.ogg") }) { "signed:$it" } assertEquals(File(dir, "audio-1.ogg").absolutePath, result.source) assertTrue(result.reason.contains("локальный файл"), "выбрана именно дорожка #1") } @Test fun fallsBackToFirstAudioWhenIndexMissing() { val dir = audioDir() File(dir, "audio-0.ogg").writeBytes(ByteArray(5)) val audios = listOf( MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"), MirrorAudio(index = 2, key = "a2", url = "https://s3/audio-2.ogg"), ) val result = resolveAudioSourceDetailed(mirrorFiles(audios), 7, { File(dir, "audio-$it.ogg") }) { "signed:$it" } assertEquals(File(dir, "audio-0.ogg").absolutePath, result.source, "нет #7 — берём первую дорожку") } @Test fun usesLocalFileWhenDownloaded() { val dir = audioDir() val local = File(dir, "audio-3.ogg").apply { writeBytes(ByteArray(10)) } val audios = listOf(MirrorAudio(index = 3, key = "a3", url = "https://s3/audio-3.ogg")) val result = resolveAudioSourceDetailed(mirrorFiles(audios), 3, { File(dir, "audio-$it.ogg") }) { "signed:$it" } assertEquals(local.absolutePath, result.source) } @Test fun streamsPresignedUrlWhenNoLocalFile() { val dir = audioDir() val audios = listOf(MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg")) val result = resolveAudioSourceDetailed(mirrorFiles(audios), 0, { File(dir, "audio-$it.ogg") }) { "signed:$it" } assertEquals("signed:https://s3/audio-0.ogg", result.source) assertTrue(result.reason.contains("стриминг"), "без локального файла — стриминг: ${result.reason}") } }