mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-26 22:51:03 +03:00
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:
co-authored by
Claude Opus 5
parent
b194834f2b
commit
3b5444e69d
+48
-3
@@ -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` пропускаются — эталон им не создаётся.
|
||||
|
||||
### Когда обновлять
|
||||
|
||||
- После **намеренного** изменения логики навыка (новый выход — новый эталон)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
],
|
||||
"params": { "objectPath": "Catalogs/ТестСсылка" },
|
||||
"args_extra": ["-Detailed"],
|
||||
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка; проверяется stdout",
|
||||
"expect": {
|
||||
"stdoutContains": "16. Reference types:",
|
||||
"stdoutNotContains": "16. Ссылочный тип"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
],
|
||||
"params": { "objectPath": "SessionParameters/ТекущийПользователь" },
|
||||
"args_extra": ["-Detailed"],
|
||||
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка; проверяется stdout",
|
||||
"expect": {
|
||||
"stdoutContains": "базовая структурная проверка"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
}
|
||||
],
|
||||
"params": { "objectPath": "Catalogs/ТестСсылкаБитая" },
|
||||
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка; проверяется stdout",
|
||||
"expect": {
|
||||
"stdoutContains": "16. Ссылочный тип 'CatalogRef.НетТакогоСправочника'"
|
||||
}
|
||||
|
||||
+51
-23
@@ -418,11 +418,24 @@ function listFilesRecursive(dir, base = '') {
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
function compareSnapshot(workDir, snapshotDir, snapshotConfig) {
|
||||
if (!existsSync(snapshotDir)) return { match: true, reason: 'no snapshot (skipped)' };
|
||||
// Строгий режим: отсутствие эталона НЕ проходит молча. Кейс либо сверяется со снэпшотом,
|
||||
// либо явно объявляет `"noSnapshot": "<причина>"`. Иначе потерянный (или не созданный при
|
||||
// добавлении кейса) эталон неотличим от намеренного отсутствия — тест зелёный, а не проверяет ничего.
|
||||
function compareSnapshot(workDir, snapshotDir, snapshotConfig, caseData) {
|
||||
const optOut = caseData?.noSnapshot;
|
||||
const hasSnapshotDir = existsSync(snapshotDir) && listFilesRecursive(snapshotDir).length > 0;
|
||||
|
||||
if (optOut !== undefined && optOut !== false) {
|
||||
// Причина обязательна: opt-out должен стоить автору формулировки, а ревьюеру — быть виден в diff'е.
|
||||
if (typeof optOut !== 'string' || !optOut.trim()) return { match: false, badOptOut: true };
|
||||
// Мёртвый эталон: помечен как ненужный, но лежит в репозитории — выглядит покрытием, не сверяется.
|
||||
if (hasSnapshotDir) return { match: false, deadSnapshot: true };
|
||||
return { match: true, reason: `no snapshot (opt-out: ${optOut})` };
|
||||
}
|
||||
|
||||
if (!hasSnapshotDir) return { match: false, missingSnapshot: true };
|
||||
|
||||
const snapshotFiles = listFilesRecursive(snapshotDir);
|
||||
if (snapshotFiles.length === 0) return { match: true, reason: 'empty snapshot (skipped)' };
|
||||
|
||||
const diffs = [];
|
||||
|
||||
@@ -463,7 +476,35 @@ function compareSnapshot(workDir, snapshotDir, snapshotConfig) {
|
||||
return { match: false, diffs };
|
||||
}
|
||||
|
||||
function updateSnapshot(workDir, snapshotDir, snapshotConfig) {
|
||||
// Диагностика снэпшот-сверки — общая для обеих веток запуска (runCase / runCaseAsync),
|
||||
// чтобы сообщения и условия не разъехались.
|
||||
function snapshotErrors(cmp, caseId) {
|
||||
if (cmp.match) return [];
|
||||
if (cmp.badOptOut) {
|
||||
return [`Snapshot: "noSnapshot" должен быть непустой строкой с причиной, почему эталон не нужен`];
|
||||
}
|
||||
if (cmp.deadSnapshot) {
|
||||
return [`Snapshot: кейс объявил "noSnapshot", но эталон существует — он не сверяется и вводит в заблуждение.\n`
|
||||
+ ` Удалите каталог snapshots/<кейс>/ либо снимите "noSnapshot"`];
|
||||
}
|
||||
if (cmp.missingSnapshot) {
|
||||
return [`Snapshot: эталон отсутствует. Создайте:\n`
|
||||
+ ` node tests/skills/runner.mjs ${caseId} --update-snapshots\n`
|
||||
+ `либо объявите в кейсе: "noSnapshot": "<почему эталон не нужен>"`];
|
||||
}
|
||||
const errs = [];
|
||||
for (const d of cmp.diffs || []) {
|
||||
if (d.type === 'missing') errs.push(`Snapshot: file missing — ${d.file}`);
|
||||
else errs.push(`Snapshot: ${d.file}:${d.line} differs\n expected: ${d.expected}\n actual: ${d.actual}`);
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
function updateSnapshot(workDir, snapshotDir, snapshotConfig, caseData) {
|
||||
// Кейс объявил, что эталон не нужен — не создаём. Иначе --update-snapshots по навыку
|
||||
// дорисовал бы эталон и сам породил противоречие с opt-out.
|
||||
if (caseData?.noSnapshot) return;
|
||||
|
||||
// Remove old snapshot
|
||||
if (existsSync(snapshotDir)) rmSync(snapshotDir, { recursive: true, force: true });
|
||||
|
||||
@@ -651,15 +692,10 @@ async function runCaseAsync(testCase, opts) {
|
||||
if (errors.length === 0 && !caseData.expectError && !workspace.readOnly) {
|
||||
const snapshotConfig = { ...skillConfig.snapshot, runtime: opts.runtime };
|
||||
if (opts.updateSnapshots) {
|
||||
updateSnapshot(workDir, snapshotDir, snapshotConfig);
|
||||
updateSnapshot(workDir, snapshotDir, snapshotConfig, caseData);
|
||||
} else {
|
||||
const cmp = compareSnapshot(workDir, snapshotDir, snapshotConfig);
|
||||
if (!cmp.match && cmp.diffs) {
|
||||
for (const d of cmp.diffs) {
|
||||
if (d.type === 'missing') errors.push(`Snapshot: file missing — ${d.file}`);
|
||||
else errors.push(`Snapshot: ${d.file}:${d.line} differs\n expected: ${d.expected}\n actual: ${d.actual}`);
|
||||
}
|
||||
}
|
||||
const cmp = compareSnapshot(workDir, snapshotDir, snapshotConfig, caseData);
|
||||
errors.push(...snapshotErrors(cmp, testCase.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,18 +871,10 @@ function runCase(testCase, opts) {
|
||||
if (errors.length === 0 && !caseData.expectError && !workspace.readOnly) {
|
||||
const snapshotConfig = { ...skillConfig.snapshot, runtime: opts.runtime };
|
||||
if (opts.updateSnapshots) {
|
||||
updateSnapshot(workDir, snapshotDir, snapshotConfig);
|
||||
updateSnapshot(workDir, snapshotDir, snapshotConfig, caseData);
|
||||
} else {
|
||||
const cmp = compareSnapshot(workDir, snapshotDir, snapshotConfig);
|
||||
if (!cmp.match && cmp.diffs) {
|
||||
for (const d of cmp.diffs) {
|
||||
if (d.type === 'missing') {
|
||||
errors.push(`Snapshot: file missing — ${d.file}`);
|
||||
} else {
|
||||
errors.push(`Snapshot: ${d.file}:${d.line} differs\n expected: ${d.expected}\n actual: ${d.actual}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const cmp = compareSnapshot(workDir, snapshotDir, snapshotConfig, caseData);
|
||||
errors.push(...snapshotErrors(cmp, testCase.id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user