69a0ebe0fa
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.
105 lines
8.2 KiB
Markdown
105 lines
8.2 KiB
Markdown
Проект: /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`.
|