# Тестирование llm-proxy Все тесты — обычные модульные, живут в `src/commonTest/kotlin/pw/binom/llmproxy/`. ## Запуск - `./gradlew jvmTest` — прогнать все тесты; - `./gradlew clean jvmTest fatJar` — полная сборка с нуля; - результат смотреть в `build/test-results/jvmTest/*.xml` (атрибуты `tests`/`failures`/`errors`), потому что строки вида «N tests completed» печатаются только при падениях. ## Что покрыто | Файл | Что проверяет | | --- | --- | | `ThinkTagSplitterTest` | Автомат рассечения think-тегов: passthrough при off, вырезание рассуждений при split, отбрасывание при strip, удержание разрезанного тега, несколько блоков, незакрытый блок; | | `ConfigLogicTest` | Разбор конфига (`reasoning_field`/`reasoning_empty_ok` провайдера и их дефолты), приоритет источников (апстрим важнее провайдера), слияние патчей, выбор апстрима и лимиты конкурентности, заголовки, сессии; | | `ReasoningFieldTest` | Достройка нативного поля рассуждений (`applyReasoningField`/`reasoningTextOf`): форма клиента opencode (`reasoning` + `reasoning_details`), чужой `type` в details, склейка нескольких details, запрет перезаписи непустого поля, заполнение поля на **каждом** assistant-сообщении (с `tool_calls`, без, `tool_calls: []` — без текста и `emptyOk=false` не трогаем; `emptyOk=true` — пустая строка), неприкосновенность `user`/`tool`/`system`, `reasoning_field: null`, отсутствие `messages`, сохранение порядка и прочих полей; | | `NullToleranceTest` | Устойчивость разбора ответов апстрима к JSON-`null` (`choices`, `delta.tool_calls`, `delta.content`) — пропуск вместо исключения; | | `ThinkTagTransformTest` | Non-stream путь `transformThinkMessage`: перенос рассуждений в `reasoning_content`, дописывание к уже имеющемуся, strip, незакрытый блок, отсутствие изменений → null; | | `ThinkTagChunkTest` | SSE-чанки `transformThinkChunk`: удержание хвоста тега между чанками, независимые сплиттеры по index, удаление пустого `content`; | | `ThinkTagStreamTest` | Обвязка стрима `streamSseWithThinkTags`: разрез тега между data-событиями, сброс удержанного хвоста в финиш-чанке, прохождение служебных строк и `[DONE]`, битый JSON, чанк без choices, strip. | | `StreamDoneContractTest` | Контракт конца SSE: детектор `finish_reason`/`[DONE]` (в т.ч. разрезанных границей чтения), дописывание `data: [DONE]\n\n` в сыром passthrough и в think-обвязке при штатном закрытии без маркера, отсутствие маркера при обрыве без `finish_reason`, отсутствие дублирования. | | `BackoffTest` | Экспоненциальный backoff: удвоение интервала отката до потолка (cap), сброс счётчика при успехе, окно охлаждения (`isCoolingAt`/`remainingAt`), ISO-8601-разбор cap (`PT30M`, `P1D`, `PT1M30S`) и конфиг-парсер `parseIsoDuration` (ленивые Y/M: `P1M`≈30d, `P1Y`≈365d; мусор — ошибка), скоупы: общий провайдерский счётчик на все его модели, приоритет `upstreams[].backoff` над провайдерским, независимость скоупов, backoff не настроен → без отката. | | `MetricsTest` | `/metrics`: counters по исходам запросов (`ok/4xx/429/402/5xx/net_err/cancelled`), гейджи `inflight`/`fail_streak`/`cooling_seconds` (общий провайдерский скоуп виден в метриках), экранирование меток, маппинг HTTP-статуса → `result`. | ## Проверка качества тестов (мутационная приёмка) Приём по шагам: 1. Забэкапить файл. 2. Внести РОВНО одну поломку в боевой код. 3. Прогнать `./gradlew cleanJvmTest jvmTest`. 4. Посмотреть XML — тест, который не упал, считается пустым. 5. Откатить (`git checkout -- <файл>`). Обязательно: `cleanJvmTest` обязателен, иначе прогон не перезапустится. Проверенные мутации, каждая из которых ДОЛЖНА ронять тесты: - `transformThinkMessage` возвращает null → падают тесты non-stream; - `transformThinkChunk` возвращает null → падают тесты чанков и стрима; - блок финиш-чанка в `streamSseWithThinkTags` не выполняется → падает тест про удержанный хвост; - `holdableSuffix` всегда 0 (хвост тега не удерживается) → падают тесты автомата, чанков и стрима; - `strip` начинает отдавать рассуждения → падает тест автомата; - `missingDoneMarker` всегда `false` → падают тесты сырого пути (`rawStreamIsByteExactAndAppendsDone`) и тест разрезанного маркера; - `if (sawFinishReason && !sawDone)` → `if (!sawDone)` в `streamSseWithThinkTags` → падает `thinkStreamTruncatedNeedsNoMarker`; - `if (sawFinishReason && !sawDone)` → `if (false)` в `streamSseWithThinkTags` → падает `thinkStreamAppendsDoneWhenUpstreamClosedWithoutMarker`. Правило: боевой код нельзя подгонять под тест; если тест не проходит, неверен тест. ## Поле рассуждений для Console Go (релиз 11) — замеры приёмки Причина правки: апстрим `deepseek-v4.1-flash` у провайдера `opencode` (Console Go) в thinking-режиме требует `reasoning_content` в assistant-сообщениях с `tool_calls`, а клиент opencode присылает рассуждения как `reasoning` + `reasoning_details` — отсюда `400 The reasoning_content in the thinking mode must be passed back to the API`. **Границы требования (замер прямыми запросами к Console Go, одинаковое тело):** | assistant-сообщение | HTTP | | --- | --- | | с `tool_calls`, без reasoning вовсе | 400 | | с `tool_calls`, `reasoning` + `reasoning_details` (форма opencode) | 400 | | с `tool_calls`, только `reasoning_details` | 400 | | с `tool_calls`, `reasoning_content` + `reasoning` + `reasoning_details` (аддитивно) | 200 | | с `tool_calls`, `reasoning_content: ""` | 200 | | без `tool_calls`, без reasoning | 200 | **Живая приёмка (локальный инстанс на 8101, конфиг-копия боевого, модель с единственным апстримом Console Go; тело — как у opencode: assistant + `reasoning` + `reasoning_details` + `tool_calls`):** | Конфиг | `stream=false` | `stream=true` | | --- | --- | --- | | без правки (`reasoning_field` не задан) | **400** — та самая ошибка про `reasoning_content` | **400** | | с правкой (`reasoning_field: reasoning_content`) | **200**, модель продолжила диалог после tool-результата | **200** | **Регрессия на боевых цепочках (тот же инстанс, то же тело с `tool_calls`):** `codding-big` → 200 (ушло на `minimax-m3`, поле не добавляется — флаг объявлен только у провайдера `opencode`), `codding` → 200 (`qwen-3.8`, local), `assistant` → 200 (Console Go с правкой). **Мутационная приёмка:** 4 мутации из ТЗ отработал кодер, две проверены вручную (`cleanAllTests jvmTest`): снятие охраны `tool_calls` в `applyReasoningField` → падают `assistantWithoutToolCallsIsUntouched` и `assistantWithEmptyToolCallsIsUntouched`; возврат `obj["choices"]?.jsonArray` в `rebuildFromChunks` → падает `rebuildFromChunksToleratesNullChoices`. **Поправка после боевого 400 (2026-09-13):** живые stateful-сессии Console Go (сессия, где глюч уже видел thinking-ответы) всё-таки требовали `reasoning_content` и на assistant-сообщениях **без** `tool_calls` — свежая сессия такой истории прощала (все формы выше — 200). Опция «на каждом assistant-сообщении» совпадает с тем, что делает сам opencode («Deepseek requires all assistant messages to have reasoning on them», пустая reasoning-часть дописывается тоже). Охрана `tool_calls` в `applyReasoningField` снята: поле дописывается на каждом assistant-сообщении (текст — из `reasoning`/`reasoning_details`, при `emptyOk` — пустая строка). Тесты `assistantWithoutToolCallsIsUntouched`/`assistantWithEmptyToolCallsIsUntouched` заменены на `assistantWithoutToolCallsAlsoGetsField`, `assistantWithoutReasoningAndNoEmptyOkIsUntouched`, `assistantWithoutToolCallsWithEmptyOkFillsEmpty`, `assistantWithEmptyToolCallsGetsField`. ## Известное ограничение Живой стрим в реальном апстриме модульными тестами не проверяется: обвязка испытывается на синтетическом SSE через каналы ktor. Реальный апстрим проверяется только после деплоя. Правка поля рассуждений — исключение: она проверена живым инстансом против настоящего Console Go (таблица выше) до релиза.