fix(app-phone): персистентный статус скачивания + докачка с места обрыва + StatusBlock

- DownloadManager: .part-файлы, resume через Range (206/200), status.json (фаза/процент/total), скан диска при старте, guard от повторной качки
- DetailsScreen: кнопка «✓ скачано на телефон»/«Продолжить (N%)», чипы из диска
- Components: StatusBlock Box→Column (строки статуса перекрывались кнопкой «Удалить»); приоритет бейджа: «✓» done выше «↓ часть»
- 8 новых тестов (Range, скан диска, status.json)
This commit is contained in:
Hermes Agent
2026-08-19 13:46:03 +03:00
parent 6069400b88
commit 724f90a385
4 changed files with 398 additions and 42 deletions
@@ -0,0 +1,118 @@
package pw.binom.viewmate.phone
import kotlinx.serialization.encodeToString
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())
}
}