diff --git a/tests/skills/README.md b/tests/skills/README.md index dc58b1f4..18738abd 100644 --- a/tests/skills/README.md +++ b/tests/skills/README.md @@ -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:"` — рабочая директория 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` пропускаются — эталон им не создаётся. + ### Когда обновлять - После **намеренного** изменения логики навыка (новый выход — новый эталон) diff --git a/tests/skills/cases/meta-validate/valid-reftype-resolves.json b/tests/skills/cases/meta-validate/valid-reftype-resolves.json index d7680dde..0e553c33 100644 --- a/tests/skills/cases/meta-validate/valid-reftype-resolves.json +++ b/tests/skills/cases/meta-validate/valid-reftype-resolves.json @@ -10,6 +10,7 @@ ], "params": { "objectPath": "Catalogs/ТестСсылка" }, "args_extra": ["-Detailed"], + "noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка; проверяется stdout", "expect": { "stdoutContains": "16. Reference types:", "stdoutNotContains": "16. Ссылочный тип" diff --git a/tests/skills/cases/meta-validate/valid-sessionparameter-basic.json b/tests/skills/cases/meta-validate/valid-sessionparameter-basic.json index 24ea45f1..b4db9767 100644 --- a/tests/skills/cases/meta-validate/valid-sessionparameter-basic.json +++ b/tests/skills/cases/meta-validate/valid-sessionparameter-basic.json @@ -10,6 +10,7 @@ ], "params": { "objectPath": "SessionParameters/ТекущийПользователь" }, "args_extra": ["-Detailed"], + "noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка; проверяется stdout", "expect": { "stdoutContains": "базовая структурная проверка" } diff --git a/tests/skills/cases/meta-validate/warn-reftype-missing.json b/tests/skills/cases/meta-validate/warn-reftype-missing.json index dd99fc37..a958d088 100644 --- a/tests/skills/cases/meta-validate/warn-reftype-missing.json +++ b/tests/skills/cases/meta-validate/warn-reftype-missing.json @@ -9,6 +9,7 @@ } ], "params": { "objectPath": "Catalogs/ТестСсылкаБитая" }, + "noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun (meta-compile), а не проверяемого навыка; проверяется stdout", "expect": { "stdoutContains": "16. Ссылочный тип 'CatalogRef.НетТакогоСправочника'" } diff --git a/tests/skills/runner.mjs b/tests/skills/runner.mjs index 4454928b..de4cf001 100644 --- a/tests/skills/runner.mjs +++ b/tests/skills/runner.mjs @@ -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)); } }