2844d6a3ac
Телефон качает audio-N.ogg (audioKeys вместо fileKeys, стадия «звук»), очки получают DownloadFiles только с video.mkv (buildGlassesFileSpecs). Watch проверяет готовность обоих: телефон — audioReady(), очки — как было; отказ с подсказкой «Скачать на телефон/очки». Тесты: lib-core 19, phone 36.
322 lines
13 KiB
Kotlin
322 lines
13 KiB
Kotlin
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 не затронуты")
|
|
}
|
|
}
|