From 69a0ebe0fac0cf732b81252ab5c77c22a38b8e20 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 2 Oct 2026 04:48:35 +0300 Subject: [PATCH] =?UTF-8?q?tests:=20ModelStoreTest=20=E2=80=94=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B=20=D0=B4=D1=8B=D1=80=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20Range=20?= =?UTF-8?q?=D0=B8=20Content-Range?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resumesPartialDownload: счётчики Range-запросов и байт; сервер отдаёт 400 без Range для .part — запасной путь «начать заново» закрыт. failsOnSizeMismatch разделена на два: - rejectsWrongTotalFromContentRange — сервер соврал в Content-Range, бьёт в проверку actualTotal != expectedTotal (вторая, достижимая); - truncatedResponseDoesNotProduceFile — оборванный ответ, по факту ловит ветку IOException → «не удалось скачать» (а не first size check). В mutations.tsv убрана строка про if (declaredBody >= 0 && written != declaredBody) — через java.net/http недостижимо (оборванный ответ даёт IOException, не EOF). Добавлена строка про if (expectedTotal >= 0 && actualTotal != expectedTotal) — теперь убивает rejectsWrongTotalFromContentRange. Все 18 мутаций (4 из ModelStore) убиты прогоном mutation_check.py. --- docs/orders/13-modelstore-coverage.md | 104 ++++++ e2e/mutations.tsv | 5 + .../src/main/kotlin/memo/core/ModelStore.kt | 158 +++++++++ .../test/kotlin/memo/core/ModelStoreTest.kt | 312 ++++++++++++++++++ 4 files changed, 579 insertions(+) create mode 100644 docs/orders/13-modelstore-coverage.md create mode 100644 memo-core/src/main/kotlin/memo/core/ModelStore.kt create mode 100644 memo-core/src/test/kotlin/memo/core/ModelStoreTest.kt diff --git a/docs/orders/13-modelstore-coverage.md b/docs/orders/13-modelstore-coverage.md new file mode 100644 index 0000000..64c4734 --- /dev/null +++ b/docs/orders/13-modelstore-coverage.md @@ -0,0 +1,104 @@ +Проект: /root/WORK/memo (Kotlin/JVM). Заказ: закрыть ДВЕ дыры в покрытии `ModelStoreTest`, +доказанные мутационной проверкой. Правки только в тестовом файле, боевой код НЕ трогать. + +Прогон, который их нашёл: + python3 e2e/mutation_check.py --gradle ./gradlew + +Выжившие мутации: + 1. `ModelStore.kt`: `if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-")` + → `if (false) ...` | ожидался провал `resumesPartialDownload` — НЕ упал. + 2. `ModelStore.kt`: `if (declaredBody >= 0 && written != declaredBody) {` + → `if (false) {` | ожидался провал `failsOnSizeMismatch` — НЕ упал. + +## Почему они выжили (разобрано, не догадка) + +**1. `resumesPartialDownload`.** Тестовый сервер (`serveBytes`) отдаёт `206` с хвостом только если +в запросе есть заголовок `Range`; без него он отдаёт `200` и **полное содержимое**. Поэтому при +мутации (Range не отправляется) срабатывает штатный запасной путь «начать заново», итоговый файл +получается правильным — и тест, проверяющий только «файл в итоге верный», проходит. +Тест не проверяет того, ради чего написан: что докачка **действительно** шла хвостом. + +**2. `failsOnSizeMismatch`.** Сервер объявляет `Content-Length` больше, чем пишет, соединение +обрывается — у клиента вылетает `IOException`, который `download` превращает в +`IllegalStateException("не удалось скачать ...")`. Тест принимает `msg.contains("не удалось скачать")`, +поэтому проверка размера не проверяется вообще: исключение приходит из другого места. + +**Важное наблюдение по бою.** В `ModelStore.download` две проверки размера: +`written != declaredBody` (первая) и `part.length() != expectedTotal` (вторая, из `Content-Range`). +Через `java.net.http` первая на практике недостижима: оборванный ответ всегда даёт `IOException`, +а не чистый EOF. Значит это защитный код, а не дыра в покрытии — в таблице мутаций его надо +заменить на мутацию ВТОРОЙ проверки, которая достижима (см. ниже). + +## Что сделать + +### Правка теста `resumesPartialDownload` + +Сделать так, чтобы «докачка» была доказана, а не предположена: + +- сервер записывает в счётчики: сколько запросов пришло, у скольких был заголовок `Range`, + и сколько всего байт тела он отдал; +- **если заголовка `Range` нет для файла, у которого уже есть `.part`** — сервер отвечает кодом + `400` и тела не отдаёт (докачки без `Range` не бывает; тест не должен иметь запасного пути); +- после `ensure` тест обязан утверждать: + - `rangeRequests >= 1` — докачка действительно была запрошена; + - файл в итоге побайтно равен исходному (оставить); + - `.part` переименован (оставить). + +При мутации №1 (`Range` не отправляется) сервер ответит `400` → `ensure` бросит исключение → тест упадёт. + +### Замена сценария `failsOnSizeMismatch` на два теста + +**`rejectsWrongTotalFromContentRange`** (новый, закрывает мутацию №2) — «сервер соврал про общий +размер, файл принимать нельзя»: + +- в директории лежит `.part` = первые 4000 байт файла из 8000; +- сервер на запрос с `Range: bytes=4000-` отвечает `206`: + - `Content-Length: 4000`, тело — реальный хвост 4000 байт (то есть транспорт отдаёт ровно + столько, сколько объявил — никакой `IOException`); + - `Content-Range: bytes 4000-7999/999999` — **итог соврал**; +- ожидание: `ModelStore.ensure` бросает `IllegalStateException`, сообщение содержит `размер`, + целевого файла нет, `.part` удалён. + +Проверить, что сценарий действительно бьёт в нужную проверку: при `if (false)` на +`part.length() != expectedTotal` тест обязан провалиться (файл будет установлен, исключения не будет). + +**`truncatedResponseDoesNotProduceFile`** (переименовать бывший `failsOnSizeMismatch`) — оставить +как проверку поведения «оборванный ответ не оставляет файла», но **убрать из принимаемых сообщение +«не удалось скачать»**, чтобы тест не «зеленел» за счёт сетевой ошибки. Ожидать явно любое +`IllegalStateException` с непустым сообщением и отсутствие целевого файла и `.part`. +Отдельно проверить и записать в отчёте: какую ветку кода реально ловит этот тест (по сообщению) — +то есть является ли он проверкой размера или сетевого обрыва. В отчёте написать прямо. + +### Таблица мутаций `e2e/mutations.tsv` + +- **Убрать** строку, целящуюся в `if (declaredBody >= 0 && written != declaredBody) {` + (по разбору выше — недостижимо через `java.net.http`; ложная цель). +- **Добавить** мутацию во вторую проверку: + `if (expectedTotal >= 0 && actualTotal != expectedTotal) {` → `if (false) {`, + обязанный уронить `rejectsWrongTotalFromContentRange`. +- Строку про `Range` оставить, но теперь она обязана валить `resumesPartialDownload`. + +Правило: каждая строка таблицы — либо убитая мутация, либо честное объяснение в комментарии, +почему цель недостижима (с доказательством прогоном). + +## Обязательная проверка (приложить вывод) + +```bash +cd /root/WORK/memo +python3 e2e/mutation_check.py --gradle ./gradlew 2>&1 | tail -25 +echo "--- ожидается: ВСЕ МУТАЦИИ УБИТЫ" +./gradlew test --rerun-tasks -q 2>&1 | tail -3 +``` + +Плюс отдельно, для каждого из двух новых/изменённых тестов — доказательство, что он валит мутацию: +применить мутацию руками, прогнать только `ModelStoreTest`, показать FAIL, откатить +(`git checkout -- memo-core/src/main/kotlin/memo/core/ModelStore.kt`), убедиться в PASS. + +## СТРОГИЕ ЗАПРЕТЫ + +- **Не менять боевой код** `ModelStore.kt` (и вообще ничего в `src/main`). Заказ — только тесты + и таблица мутаций. +- Не менять другие тесты. +- Не добавлять зависимости. +- Не выводить план текстом; сразу правь файлы. +- Не удалять `.gitignore`, не коммитить `models/` и `*.db`. diff --git a/e2e/mutations.tsv b/e2e/mutations.tsv index a359bc1..6b68c01 100644 --- a/e2e/mutations.tsv +++ b/e2e/mutations.tsv @@ -22,3 +22,8 @@ memo-core/src/main/kotlin/memo/core/Searcher.kt .sortedByDescending { it.value } memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt ],"isError":true} ],"isError":false} searchWithoutPathIsError # --- Watcher: debounce сводит правки к одному вызову --- memo-watch/src/main/kotlin/memo/watch/Watcher.kt _indexCalls.incrementAndGet() _indexCalls.get() modifyTriggersSingleIndexCall +# --- ModelStore: скачивание модели (ModelStoreTest) --- +memo-core/src/main/kotlin/memo/core/ModelStore.kt if (!force && target.isFile && target.length() > 0L) { if (false) { skipsExistingWithoutNetwork +memo-core/src/main/kotlin/memo/core/ModelStore.kt part.renameTo(target) part.renameTo(java.io.File(dir, "$name.WRONG")) downloadsMissingFiles +memo-core/src/main/kotlin/memo/core/ModelStore.kt if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-") if (false) requestBuilder.header("Range", "bytes=$startAt-") resumesPartialDownload +memo-core/src/main/kotlin/memo/core/ModelStore.kt if (expectedTotal >= 0 && actualTotal != expectedTotal) { if (false) { rejectsWrongTotalFromContentRange diff --git a/memo-core/src/main/kotlin/memo/core/ModelStore.kt b/memo-core/src/main/kotlin/memo/core/ModelStore.kt new file mode 100644 index 0000000..f69ffdd --- /dev/null +++ b/memo-core/src/main/kotlin/memo/core/ModelStore.kt @@ -0,0 +1,158 @@ +package memo.core + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +object ModelStore { + const val DEFAULT_BASE_URL = "http://static.binom.pw/models/siglip2" + val FILES = listOf("text_model_int8.onnx", "tokenizer.model") + + data class Result(val downloaded: List, val skipped: List, val bytes: Long) + + /** Пути к модели в том же порядке (modelPath, tokenizerPath). */ + fun paths(dir: File): Pair = + File(dir, FILES[0]).absolutePath to File(dir, FILES[1]).absolutePath + + /** Все файлы модели присутствуют и непустые. */ + fun isComplete(dir: File): Boolean = FILES.all { name -> + val f = File(dir, name) + f.isFile && f.length() > 0L + } + + /** Гарантирует наличие всех файлов модели в dir. Возвращает имена скачанных/пропущенных. */ + fun ensure( + dir: File, + baseUrl: String = DEFAULT_BASE_URL, + force: Boolean = false, + log: (String) -> Unit = {}, + timeoutMillis: Long = 60_000, + ): Result { + dir.mkdirs() + val base = baseUrl.trimEnd('/') + val client = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(timeoutMillis)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + val downloaded = ArrayList() + val skipped = ArrayList() + var bytes = 0L + for (name in FILES) { + val target = File(dir, name) + if (!force && target.isFile && target.length() > 0L) { + skipped.add(name) + continue + } + bytes += download(client, dir, name, base, log) + downloaded.add(name) + } + return Result(downloaded, skipped, bytes) + } + + private fun download( + client: HttpClient, + dir: File, + name: String, + baseUrl: String, + log: (String) -> Unit, + ): Long { + val target = File(dir, name) + val part = File(dir, "$name.part") + var startAt = if (part.isFile) part.length() else 0L + if (part.exists() && startAt == 0L) part.delete() + + val url = "$baseUrl/$name" + val requestBuilder = HttpRequest.newBuilder(URI.create(url)).GET() + if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-") + val response = try { + client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream()) + } catch (e: IOException) { + part.delete() + throw IllegalStateException("не удалось скачать $name с $url: ${e.message}", e) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + part.delete() + throw IllegalStateException("скачивание $name прервано", e) + } + + val status = response.statusCode() + if (status != 200 && status != 206) { + response.body().close() + part.delete() + throw IllegalStateException("не удалось скачать $name: HTTP $status ($url)") + } + + val resuming = status == 206 && startAt > 0L + if (!resuming) startAt = 0L + val declaredBody = response.headers().firstValueAsLong("Content-Length").orElse(-1L) + val rangeTotal = response.headers().firstValue("Content-Range").orElse(null) + ?.substringAfterLast('/')?.trim()?.toLongOrNull() + val expectedTotal = when { + resuming -> rangeTotal ?: if (declaredBody >= 0) startAt + declaredBody else -1L + declaredBody >= 0 -> declaredBody + else -> -1L + } + + var written = 0L + try { + response.body().use { input -> + FileOutputStream(part, resuming).use { out -> + val buf = ByteArray(1 shl 16) + var lastLog = System.currentTimeMillis() + while (true) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + written += n + val now = System.currentTimeMillis() + if (now - lastLog >= 2_000L) { + lastLog = now + log(progress(name, startAt + written, expectedTotal)) + } + } + out.flush() + } + } + } catch (e: IOException) { + part.delete() + throw IllegalStateException("не удалось скачать $name с $url: ${e.message}", e) + } + + if (declaredBody >= 0 && written != declaredBody) { + val actual = startAt + written + val expected = startAt + declaredBody + part.delete() + throw IllegalStateException( + "размер $name не совпал: ожидалось $expected байт, получено $actual байт", + ) + } + val actualTotal = part.length() + if (expectedTotal >= 0 && actualTotal != expectedTotal) { + part.delete() + throw IllegalStateException( + "размер $name не совпал: ожидалось $expectedTotal байт, получено $actualTotal байт", + ) + } + if (!part.renameTo(target)) { + part.delete() + throw IllegalStateException("не удалось переименовать $name.part в $name") + } + log("скачано $name: ${megabytes(actualTotal)} МБ") + return actualTotal + } + + private fun progress(name: String, done: Long, total: Long): String = + if (total > 0) { + val pct = (done * 100 / total).coerceIn(0L, 100L) + "скачиваю $name: $pct% (${megabytes(done)} МБ / ${megabytes(total)} МБ)" + } else { + "скачиваю $name: ${megabytes(done)} МБ" + } + + private fun megabytes(bytes: Long): Long = Math.round(bytes / 1_000_000.0) +} diff --git a/memo-core/src/test/kotlin/memo/core/ModelStoreTest.kt b/memo-core/src/test/kotlin/memo/core/ModelStoreTest.kt new file mode 100644 index 0000000..01e7350 --- /dev/null +++ b/memo-core/src/test/kotlin/memo/core/ModelStoreTest.kt @@ -0,0 +1,312 @@ +package memo.core + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import java.nio.file.Files +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ModelStoreTest { + + @Test + fun downloadsMissingFiles() { + val dir = createTempDir() + val onnx = deterministicBytes(123_456, seed = 1) + val tok = deterministicBytes(45_678, seed = 2) + val server = startServer { ex -> + when (ex.requestURI.path) { + "/text_model_int8.onnx" -> serveBytes(ex, onnx, count = null) + "/tokenizer.model" -> serveBytes(ex, tok, count = null) + else -> ex.sendResponseHeaders(404, -1).also { ex.close() } + } + } + try { + val result = ModelStore.ensure(dir, baseUrl = server.base()) + assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.downloaded) + assertEquals(emptyList(), result.skipped) + assertEquals((onnx.size + tok.size).toLong(), result.bytes) + + val onnxFile = java.io.File(dir, "text_model_int8.onnx") + val tokFile = java.io.File(dir, "tokenizer.model") + assertTrue(onnxFile.isFile, "onnx должен быть скачан") + assertTrue(tokFile.isFile, "tokenizer должен быть скачан") + assertEquals(onnx.size.toLong(), onnxFile.length()) + assertEquals(tok.size.toLong(), tokFile.length()) + assertContentEquals(onnx, onnxFile.readBytes()) + assertContentEquals(tok, tokFile.readBytes()) + assertFalse(java.io.File(dir, "text_model_int8.onnx.part").exists()) + assertFalse(java.io.File(dir, "tokenizer.model.part").exists()) + } finally { + server.stop(0) + dir.deleteRecursively() + } + } + + @Test + fun skipsExistingWithoutNetwork() { + val dir = createTempDir() + val onnx = deterministicBytes(10_000, seed = 3) + val tok = deterministicBytes(8_000, seed = 4) + java.io.File(dir, "text_model_int8.onnx").writeBytes(onnx) + java.io.File(dir, "tokenizer.model").writeBytes(tok) + val count = AtomicInteger(0) + val server = startServer { ex -> + count.incrementAndGet() + ex.sendResponseHeaders(500, -1) + ex.close() + } + try { + val result = ModelStore.ensure(dir, baseUrl = server.base()) + assertEquals(0, count.get(), "ensure не должен ходить в сеть, если всё уже на месте") + assertEquals(emptyList(), result.downloaded) + assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.skipped) + assertEquals(0L, result.bytes) + assertContentEquals(onnx, java.io.File(dir, "text_model_int8.onnx").readBytes()) + assertContentEquals(tok, java.io.File(dir, "tokenizer.model").readBytes()) + } finally { + server.stop(0) + dir.deleteRecursively() + } + } + + @Test + fun resumesPartialDownload() { + val dir = createTempDir() + val full = deterministicBytes(20_000, seed = 5) + java.io.File(dir, "text_model_int8.onnx").writeBytes(deterministicBytes(5_000, seed = 55)) + val part = java.io.File(dir, "tokenizer.model.part") + val half = full.size / 2 + part.writeBytes(full.copyOfRange(0, half)) + + val totalRequests = AtomicInteger(0) + val rangeRequests = AtomicInteger(0) + val bytesServed = AtomicLong(0L) + val server = startServer { ex -> + when (ex.requestURI.path) { + "/text_model_int8.onnx" -> ex.sendResponseHeaders(500, -1).also { ex.close() } + "/tokenizer.model" -> { + totalRequests.incrementAndGet() + val range = ex.requestHeaders.getFirst("Range") + if (range != null && range.startsWith("bytes=")) { + rangeRequests.incrementAndGet() + val spec = range.removePrefix("bytes=") + val start = spec.substringBefore('-').trim().toLong() + if (start in 0..full.size.toLong()) { + val tail = full.copyOfRange(start.toInt(), full.size) + ex.responseHeaders.set( + "Content-Range", + "bytes $start-${full.size - 1}/${full.size}", + ) + ex.sendResponseHeaders(206, tail.size.toLong()) + bytesServed.addAndGet(tail.size.toLong()) + ex.responseBody.use { it.write(tail) } + return@startServer + } + } + ex.sendResponseHeaders(400, -1) + ex.close() + } + else -> ex.sendResponseHeaders(404, -1).also { ex.close() } + } + } + try { + val result = ModelStore.ensure(dir, baseUrl = server.base()) + assertEquals(listOf("tokenizer.model"), result.downloaded) + assertTrue( + rangeRequests.get() >= 1, + "докачка должна была пойти хвостом: Range-запросов=${rangeRequests.get()}, всего=${totalRequests.get()}", + ) + assertTrue( + bytesServed.get() < full.size.toLong(), + "докачка хвостом обязана отдать меньше полного файла: " + + "отдано=${bytesServed.get()}, файл=${full.size}", + ) + val tokFile = java.io.File(dir, "tokenizer.model") + assertTrue(tokFile.isFile, "tokenizer должен быть собран из .part + хвоста") + assertEquals(full.size.toLong(), tokFile.length()) + assertContentEquals(full, tokFile.readBytes()) + assertFalse(java.io.File(dir, "tokenizer.model.part").exists(), ".part обязан быть переименован") + } finally { + server.stop(0) + dir.deleteRecursively() + } + } + + @Test + fun rejectsWrongTotalFromContentRange() { + val dir = createTempDir() + val full = deterministicBytes(8_000, seed = 11) + val part = java.io.File(dir, "text_model_int8.onnx.part") + part.writeBytes(full.copyOfRange(0, 4_000)) + java.io.File(dir, "tokenizer.model").writeBytes(deterministicBytes(1_000, seed = 12)) + + val server = startServer { ex -> + when (ex.requestURI.path) { + "/text_model_int8.onnx" -> { + val range = ex.requestHeaders.getFirst("Range") + if (range == null || !range.startsWith("bytes=")) { + ex.sendResponseHeaders(400, -1) + ex.close() + return@startServer + } + val start = range.removePrefix("bytes=").substringBefore('-').trim().toLong() + val tail = full.copyOfRange(start.toInt(), full.size) + ex.responseHeaders.set( + "Content-Range", + "bytes $start-${full.size - 1}/999999", + ) + ex.sendResponseHeaders(206, tail.size.toLong()) + ex.responseBody.use { it.write(tail) } + } + "/tokenizer.model" -> ex.sendResponseHeaders(500, -1).also { ex.close() } + else -> ex.sendResponseHeaders(404, -1).also { ex.close() } + } + } + try { + val ex = assertFailsWith( + "ensure обязан бросить исключение при лжи про общий размер в Content-Range", + ) { + ModelStore.ensure(dir, baseUrl = server.base()) + } + val msg = ex.message ?: "" + assertTrue( + msg.contains("размер"), + "сообщение должно указывать на проблему с размером: $msg", + ) + assertFalse( + java.io.File(dir, "text_model_int8.onnx").exists(), + "целевого файла быть не должно", + ) + assertFalse( + java.io.File(dir, "text_model_int8.onnx.part").exists(), + ".part обязан быть удалён", + ) + } finally { + server.stop(0) + dir.deleteRecursively() + } + } + + @Test + fun truncatedResponseDoesNotProduceFile() { + val dir = createTempDir() + val real = deterministicBytes(8_000, seed = 6) + val declaredSize = (real.size + 5_000).toLong() + java.io.File(dir, "tokenizer.model").writeBytes(deterministicBytes(1_000, seed = 13)) + val server = startServer { ex -> + when (ex.requestURI.path) { + "/text_model_int8.onnx" -> { + ex.sendResponseHeaders(200, declaredSize) + ex.responseBody.use { it.write(real) } + ex.close() + } + "/tokenizer.model" -> ex.sendResponseHeaders(500, -1).also { ex.close() } + else -> ex.sendResponseHeaders(404, -1).also { ex.close() } + } + } + try { + val ex = assertFailsWith( + "ensure обязан бросить исключение при оборванном ответе", + ) { + ModelStore.ensure(dir, baseUrl = server.base()) + } + assertTrue( + (ex.message ?: "").isNotEmpty(), + "исключение должно иметь осмысленное сообщение: '${ex.message}'", + ) + assertFalse( + java.io.File(dir, "text_model_int8.onnx").exists(), + "целевого файла быть не должно", + ) + assertFalse( + java.io.File(dir, "text_model_int8.onnx.part").exists(), + ".part обязан быть удалён", + ) + } finally { + server.stop(0) + dir.deleteRecursively() + } + } + + @Test + fun forceRedownloads() { + val dir = createTempDir() + val garbageOnnx = deterministicBytes(9_999, seed = 7) + val garbageTok = deterministicBytes(3_333, seed = 8) + java.io.File(dir, "text_model_int8.onnx").writeBytes(garbageOnnx) + java.io.File(dir, "tokenizer.model").writeBytes(garbageTok) + val onnx = deterministicBytes(11_111, seed = 9) + val tok = deterministicBytes(4_444, seed = 10) + val server = startServer { ex -> + when (ex.requestURI.path) { + "/text_model_int8.onnx" -> serveBytes(ex, onnx, count = null) + "/tokenizer.model" -> serveBytes(ex, tok, count = null) + else -> ex.sendResponseHeaders(404, -1).also { ex.close() } + } + } + try { + val result = ModelStore.ensure(dir, baseUrl = server.base(), force = true) + assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.downloaded) + assertContentEquals(onnx, java.io.File(dir, "text_model_int8.onnx").readBytes()) + assertContentEquals(tok, java.io.File(dir, "tokenizer.model").readBytes()) + } finally { + server.stop(0) + dir.deleteRecursively() + } + } + + private fun createTempDir(): java.io.File { + val d = Files.createTempDirectory("memo-modelstore-").toFile() + d.deleteOnExit() + return d + } + + private fun deterministicBytes(size: Int, seed: Int): ByteArray { + val out = ByteArray(size) + var v = seed * 2_654_435_761 + 1 + for (i in out.indices) { + v = v * 1_664_525 + 1_013_904_223 + out[i] = (v ushr 16 and 0xFF).toByte() + } + return out + } + + private fun startServer(handler: (HttpExchange) -> Unit): HttpServerWrap { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/", HttpHandler { ex -> handler(ex) }) + server.executor = null + server.start() + return HttpServerWrap(server, server.address.port) + } + + private class HttpServerWrap(private val server: HttpServer, val port: Int) { + fun base(): String = "http://127.0.0.1:$port" + fun stop(delay: Int) = server.stop(delay) + } + + private fun serveBytes(ex: HttpExchange, data: ByteArray, count: AtomicInteger?) { + count?.incrementAndGet() + val range = ex.requestHeaders.getFirst("Range") + if (range != null && range.startsWith("bytes=")) { + val spec = range.removePrefix("bytes=") + val start = spec.substringBefore('-').trim().toLong() + if (start in 0..data.size.toLong()) { + val tail = data.copyOfRange(start.toInt(), data.size) + ex.responseHeaders.set("Content-Range", "bytes $start-${data.size - 1}/${data.size}") + ex.sendResponseHeaders(206, tail.size.toLong()) + ex.responseBody.use { it.write(tail) } + return + } + } + ex.sendResponseHeaders(200, data.size.toLong()) + ex.responseBody.use { it.write(data) } + } +}