test(runner): строгий режим снэпшотов — отсутствие эталона не проходит молча

compareSnapshot при отсутствии каталога эталона возвращал {match:true,
reason:'no snapshot (skipped)'}, причём reason никуда не выводился. Кейс без
эталона был молча зелёным, а «намеренно нет» и «эталон потерялся / не создан
при добавлении кейса» — неразличимы. README закреплял это как штатное
(«совпадает со snapshot (если есть)»).

Теперь эталон обязателен везде, кроме expectError, readonly external: и явного
opt-out. Диагностика — на месте кейса, с готовой командой; сводной статистики
не добавляем (вне контекста она ничего не сообщает).

- noSnapshot: "<причина>" — легальный пропуск. Причина обязательна: отключение
  сверки должно стоить автору формулировки, а ревьюеру быть видно в diff'е;
  осмысленность причины рантайм проверить не может. true/"" → падение.
- Нет эталона и нет opt-out → падение с рецептом (команда --update-snapshots
  либо подсказка объявить noSnapshot).
- Мёртвый эталон (noSnapshot + существующий каталог) → падение: не сверяется,
  но выглядит покрытием.
- updateSnapshot пропускает кейсы с noSnapshot — иначе --update-snapshots сам
  порождал бы противоречие. Опечатка в имени поля fail-safe: opt-out не
  сработает, кейс упадёт как «эталон отсутствует».
- Диагностика вынесена в общий snapshotErrors() — обе ветки (runCase /
  runCaseAsync) больше не дублируют логику.

Размечены 3 кейса meta-validate: навык только читает и печатает, эталон
зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка.

Проверка: до разметки сюита падала ровно на этих 3 кейсах (независимое
подтверждение аудита). Негативные сценарии проверены все пять: потерянный
эталон, мёртвый эталон, noSnapshot без причины, update на opt-out кейсе
(не создаёт), update на обычном (создаёт байт-в-байт прежний).
Полная сюита 566/566 ps1; python 563 passed + 3 skipped — идентично HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-25 15:25:20 +03:00
co-authored by Claude Opus 5
parent b194834f2b
commit 3b5444e69d
5 changed files with 102 additions and 26 deletions
+48 -3
View File
@@ -129,7 +129,11 @@ ibcmd-проход автоматически `○ skipped`, если рядом
}
```
Раннер проверит: exitCode=0 + выход совпадает со snapshot (если есть).
Раннер проверит: exitCode=0 + выход совпадает с эталоном.
Эталон **обязателен**: если его нет и кейс не объявил `noSnapshot`, тест падает. Иначе потерянный
(или не созданный при добавлении кейса) эталон неотличим от намеренного отсутствия — тест зелёный,
хотя выход не проверяется.
### С параметрами навыка
@@ -226,19 +230,60 @@ ibcmd-проход автоматически `○ skipped`, если рядом
| `preRun` | нет | Массив шагов подготовки (создание объектов и т.п.) |
| `expect` | нет | Дополнительные проверки: `files`, `stdoutContains` (строка/массив), `stdoutNotContains` (строка/массив) |
| `expectError` | нет | `true` или строка — ожидается ошибка |
| `noSnapshot` | нет | Непустая строка с причиной — кейс объявляет, что эталон не нужен (см. «Эталоны») |
## Эталоны (snapshots)
Эталон — директория `snapshots/<имя-кейса>/` внутри папки навыка. Содержит ожидаемый выход навыка после нормализации.
### Когда эталон обязателен
Всегда, кроме трёх случаев:
- `expectError` — проверяется факт ошибки, выхода нет;
- `setup: "external:<path>"` — рабочая директория read-only, эталон физически не создать;
- кейс объявил `noSnapshot` (см. ниже).
Во всех остальных случаях отсутствующий (или пустой) эталон — **падение** с подсказкой, что делать.
### `noSnapshot` — когда эталон не нужен
```json
{
"name": "Валидатор: ссылочный тип разрешается",
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun, а не проверяемого навыка; проверяется stdout",
"expect": { "stdoutContains": "16. Reference types:" }
}
```
Типичный случай — навык ничего не пишет в рабочую директорию (info/validate): эталон зафиксировал бы
выход `preRun`, а не проверяемого навыка, и дублировал бы эталоны того навыка.
**Причина обязательна** — непустая строка; `true` не принимается и валит кейс. Смысл в том, что
отключение сверки должно стоить автору формулировки, а ревьюеру быть видно в diff'е: проверить
осмысленность причины рантайм не может.
Если у кейса стоит `noSnapshot`, но каталог эталона существует — тоже падение: такой эталон
не сверяется и создаёт ложное впечатление покрытия. Удалите каталог либо снимите `noSnapshot`.
### Создание / обновление эталонов
```bash
node tests/skills/runner.mjs --update-snapshots # все кейсы
node tests/skills/runner.mjs cases/meta-compile/enum --update-snapshots # один кейс — предпочтительно
node tests/skills/runner.mjs cases/meta-compile --update-snapshots # один навык
node tests/skills/runner.mjs cases/meta-compile/enum --update-snapshots # один кейс
node tests/skills/runner.mjs --update-snapshots # все кейсы
```
> Прогон по навыку/сюите **перезаписывает эталоны всех** кейсов сразу: если побочно поехал вывод
> соседнего кейса, его эталон обновится вместе с целевым и непреднамеренная регрессия замаскируется.
> Поэтому по умолчанию — точечно по кейсу, а после массового пересъёма обязательно проверяйте
> `git diff` по `snapshots/`: каждая ± строка должна быть ожидаемой.
>
> Массовый пересъём легитимен, когда изменение вывода и правда затрагивает многих — например, правка
> `meta-compile` меняет фикстуры ~20 навыков, чьи кейсы строятся его `preRun`-прогоном.
>
> Кейсы с `noSnapshot` пропускаются — эталон им не создаётся.
### Когда обновлять
- После **намеренного** изменения логики навыка (новый выход — новый эталон)