package pw.binom.viewmate.phone import kotlinx.serialization.encodeToString 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 DownloadManagerTest { private fun tempDir(): File = Files.createTempDirectory("dm-test").toFile() private fun newBaseDir(): File { val base = tempDir() base.deleteOnExit() return base } @Test fun rangeHeaderFormedFromPartSize() { val noResume = requestFor("https://s3/mirror/video.mkv", 0) assertNull(noResume.header("Range"), "без .part Range-заголовка быть не должно") val resume = requestFor("https://s3/mirror/video.mkv", 1_863_946_240) assertEquals("bytes=1863946240-", resume.header("Range")) } @Test fun scanFullFileMarksDownloadedAndRemovesPart() { val base = newBaseDir() val dir = File(base, "i1").apply { mkdirs() } File(dir, "video.mkv").writeBytes(ByteArray(10)) File(dir, "video.mkv.part").writeBytes(ByteArray(5)) // оставшийся после рестарта val result = scanDownloads(base, emptyMap()) assertEquals(setOf("i1"), result.downloaded) assertTrue(result.progress.isEmpty()) assertTrue(!File(dir, "video.mkv.part").exists(), ".part при наличии полного файла удаляется") } @Test fun scanPartOnlyMarksDownloadingWithPercent() { val base = newBaseDir() val dir = File(base, "i2").apply { mkdirs() } File(dir, "video.mkv.part").writeBytes(ByteArray(500)) val saved = mapOf("i2" to DownloadStatus(totalBytes = 1000)) val result = scanDownloads(base, saved) assertTrue(result.downloaded.isEmpty()) assertEquals( DownloadProgress("i2", DownloadPhase.DOWNLOADING, 50, "звук"), result.progress["i2"], ) } @Test fun scanPartWithoutTotalHasZeroPercent() { val base = newBaseDir() val dir = File(base, "i3").apply { mkdirs() } File(dir, "video.mkv.part").writeBytes(ByteArray(500)) val result = scanDownloads(base, emptyMap()) assertEquals(DownloadProgress("i3", DownloadPhase.DOWNLOADING, 0, "звук"), result.progress["i3"]) } @Test fun statusFileWritesAndReads() { val file = File(tempDir(), "status.json") val store = DownloadStatusStore(file) val statuses = mapOf( "i1" to DownloadStatus(phase = "downloading", stage = "видео", percent = 42, totalBytes = 1_000_000, updatedAt = 1234L), "i2" to DownloadStatus(phase = "done", percent = 100, totalBytes = 500, updatedAt = 5678L), ) store.write(statuses) val read = store.read() assertEquals(statuses, read) assertTrue(file.exists()) } @Test fun statusFileMissingReadsEmpty() { val store = DownloadStatusStore(File(tempDir(), "status.json")) assertTrue(store.read().isEmpty()) } @Test fun statusSerializationMatchesSpecKeys() { val text = statusJson.encodeToString( mapOf("i1" to DownloadStatus(phase = "downloading", stage = "видео", percent = 50, totalBytes = 100, updatedAt = 42L)), ) assertTrue(text.contains("\"phase\"")) assertTrue(text.contains("\"stage\"")) assertTrue(text.contains("\"percent\"")) assertTrue(text.contains("\"totalBytes\"")) assertTrue(text.contains("\"updatedAt\"")) assertTrue(text.contains("\"downloading\"")) assertTrue(text.contains("\"видео\"")) } @Test fun scanIgnoresNonPartFilesWithoutFinalVideo() { val base = newBaseDir() val dir = File(base, "i4").apply { mkdirs() } File(dir, "some-random.bin").writeBytes(ByteArray(10)) val result = scanDownloads(base, emptyMap()) assertTrue(result.downloaded.isEmpty()) assertTrue(result.progress.isEmpty()) } @Test fun fileKeysFromMirrorCoversVideoAndAllAudios() { val files = MirrorFiles( video = MirrorVideo(key = "v", url = "http://v"), audios = listOf( MirrorAudio(index = 0, key = "a0", url = "http://a0"), MirrorAudio(index = 1, key = "a1", url = "http://a1"), ), ) assertEquals(listOf("video", "audio-0", "audio-1"), fileKeys(files)) } @Test fun audioKeysFromMirrorCoversOnlyAudios() { val files = MirrorFiles( video = MirrorVideo(key = "v", url = "http://v"), audios = listOf( MirrorAudio(index = 0, key = "a0", url = "http://a0"), MirrorAudio(index = 1, key = "a1", url = "http://a1"), ), ) assertEquals(listOf("audio-0", "audio-1"), audioKeys(files)) } @Test fun audioKeysEmptyWithoutAudios() { val files = MirrorFiles( video = MirrorVideo(key = "v", url = "http://v"), audios = emptyList(), ) assertTrue(audioKeys(files).isEmpty()) } @Test fun isFullyOnDiskRequiresAllFilesNoParts() { val base = newBaseDir() val dir = File(base, "i").apply { mkdirs() } val keys = listOf("video", "audio-0", "audio-1") assertTrue(!isFullyOnDisk(dir, keys), "пустой каталог — не полный") File(dir, "video.mkv").writeBytes(ByteArray(10)) assertTrue(!isFullyOnDisk(dir, keys), "только видео — не полный") File(dir, "audio-0.ogg").writeBytes(ByteArray(5)) assertTrue(!isFullyOnDisk(dir, keys), "не все аудио — не полный") File(dir, "audio-1.ogg").writeBytes(ByteArray(5)) assertTrue(isFullyOnDisk(dir, keys), "все файлы — полный") } @Test fun isFullyOnDiskRejectsPartFiles() { val base = newBaseDir() val dir = File(base, "i").apply { mkdirs() } File(dir, "video.mkv").writeBytes(ByteArray(10)) File(dir, "audio-0.ogg").writeBytes(ByteArray(5)) File(dir, "audio-1.ogg").writeBytes(ByteArray(5)) File(dir, "audio-0.ogg.part").writeBytes(ByteArray(2)) assertTrue(!isFullyOnDisk(dir, listOf("video", "audio-0", "audio-1")), ".part не даёт полного") } @Test fun combinedPercentAveragesAcrossAllFiles() { val totals = mapOf("video" to 100L, "audio-0" to 50L, "audio-1" to 50L) val done = mapOf("video" to 100L, "audio-0" to 25L, "audio-1" to 0L) assertEquals(62, combinedPercent(totals, done)) // 125/200 } @Test fun diskPercentCountsPartsAndFinalFiles() { val base = newBaseDir() val dir = File(base, "i").apply { mkdirs() } File(dir, "video.mkv").writeBytes(ByteArray(100)) // видео полностью File(dir, "audio-0.ogg.part").writeBytes(ByteArray(50)) // аудио докачивается val sizes = mapOf("video" to 100L, "audio-0" to 100L, "audio-1" to 100L) assertEquals(50, diskPercent(dir, listOf("video", "audio-0", "audio-1"), sizes)) // 150/300 } @Test fun filePercentFromPartAndFinal() { val base = newBaseDir() val dir = File(base, "i").apply { mkdirs() } // ничего нет — процент посчитать нельзя assertNull(filePercent(dir, "video.mkv", emptyMap())) // .part: процент от известного размера дорожки File(dir, "video.mkv.part").writeBytes(ByteArray(50)) assertEquals(50, filePercent(dir, "video.mkv", mapOf("video" to 100L))) assertNull(filePercent(dir, "video.mkv", emptyMap()), "без размера дорожки процент не считаем") // полный файл — 100% (даже если размера в статусе нет) File(dir, "video.mkv").writeBytes(ByteArray(100)) assertEquals(100, filePercent(dir, "video.mkv", emptyMap())) // аудио не начато assertNull(filePercent(dir, "audio-0.ogg", mapOf("audio-0" to 100L))) } @Test fun scanWithExpectedFilesMarksPartialWithOverallPercent() { val base = newBaseDir() val dir = File(base, "i").apply { mkdirs() } File(dir, "video.mkv").writeBytes(ByteArray(100)) File(dir, "audio-0.ogg.part").writeBytes(ByteArray(50)) val saved = mapOf( "i" to DownloadStatus( phase = "downloading", expectedFiles = listOf("video", "audio-0", "audio-1"), fileSizes = mapOf("video" to 100L, "audio-0" to 100L, "audio-1" to 100L), ), ) val result = scanDownloads(base, saved) assertTrue(result.downloaded.isEmpty()) assertEquals(DownloadPhase.DOWNLOADING, result.progress["i"]?.phase) assertEquals(50, result.progress["i"]?.percent) } @Test fun scanWithExpectedFilesMarksFullyDownloadedOnlyWhenAllFiles() { val base = newBaseDir() val dir = File(base, "i").apply { mkdirs() } File(dir, "video.mkv").writeBytes(ByteArray(100)) File(dir, "audio-0.ogg").writeBytes(ByteArray(50)) val saved = mapOf( "i" to DownloadStatus(expectedFiles = listOf("video", "audio-0", "audio-1")), ) // нет audio-1 — не полный assertTrue(scanDownloads(base, saved).downloaded.isEmpty()) File(dir, "audio-1.ogg").writeBytes(ByteArray(50)) val result = scanDownloads(base, saved) assertEquals(setOf("i"), result.downloaded) assertTrue(result.progress.isEmpty()) } @Test fun fileKeyForMapsPartNamesToTrackKeys() { assertEquals("video", fileKeyFor("video.mkv.part")) assertEquals("audio-0", fileKeyFor("audio-0.ogg.part")) assertEquals("audio-1", fileKeyFor("audio-1.ogg.part")) assertEquals("video.mkv.part", partFileName("video")) assertEquals("audio-1.ogg.part", partFileName("audio-1")) assertEquals("video.mkv", finalFileName("video")) assertEquals("audio-0.ogg", finalFileName("audio-0")) } @Test fun removeDownloadStateIsIdempotent() { val statuses = mutableMapOf( "i1" to DownloadStatus(phase = "downloading", percent = 40), "i2" to DownloadStatus(phase = "done", percent = 100), ) val downloads = mutableMapOf( "i1" to DownloadProgress("i1", DownloadPhase.DOWNLOADING, 40, "видео"), "i2" to DownloadProgress("i2", DownloadPhase.DONE, 100, "готово"), ) val downloadedIds = mutableSetOf("i1", "i2") removeDownloadState(statuses, downloads, downloadedIds, "i1") assertTrue("i1" !in statuses, "статус удалённого itemId не должен оставаться") assertTrue("i1" !in downloads, "прогресс удалённого itemId не должен оставаться") assertTrue("i1" !in downloadedIds) // чужие элементы не трогаем assertEquals("done", statuses["i2"]?.phase) assertTrue("i2" in downloads) // повторный вызов (и даже третий) — идемпотентен, ничего не меняет val snapshot = Triple(statuses.toMap(), downloads.toMap(), downloadedIds.toSet()) removeDownloadState(statuses, downloads, downloadedIds, "i1") removeDownloadState(statuses, downloads, downloadedIds, "i1") assertEquals(snapshot, Triple(statuses.toMap(), downloads.toMap(), downloadedIds.toSet())) } @Test fun deleteBumpsGenerationInvalidatingRunningDownload() { val generations = mutableMapOf("i1" to 0L) val running = 0L assertTrue(isCurrentGeneration(generations, "i1", running), "до удаления качка актуальна") assertTrue(isCurrentGeneration(generations, "i2", 0L), "нет записи — поколение 0 актуально") // delete() увеличивает поколение generations["i1"] = (generations["i1"] ?: 0L) + 1 assertTrue( !isCurrentGeneration(generations, "i1", running), "старая незавершённая корутина после delete должна «умереть», а не воскресить качку", ) assertTrue(isCurrentGeneration(generations, "i1", 1L), "новая качка после delete актуальна") assertTrue(isCurrentGeneration(generations, "i2", 0L), "другие itemId не затронуты") } }