Files
cc-1c-skills/tests/skills/README.md
T
Nick ShirokovandClaude Opus 5 d7fd6d79d3 test(runner): байтовые проверки канона вместо масок (#57)
Раннер сам прятал дефекты, которые чинит #57. normalizeXmlContent при
runtime=python снимал ровно четыре измерения: пробел перед `/>`, whitespace между
тегами, пустую пару <Tag></Tag> и хвостовой пробельный мусор. Паритет PS<->PY по
этим измерениям проверялся ЧЕРЕЗ маску — расхождение физически не могло упасть.

Снято три из четырёх (пробел, пустая пара, хвост). Схлопывание whitespace между
тегами оставлено: порты кое-где расставляют отступы иначе, это форматирование, а
не байтовый канон.

Ужесточён checkPreserves: eol теперь считает ОДИНОЧНЫЕ LF, а не «есть ли хоть один
CR». Прежняя проверка пропускала смешанный выход — cfe-init давал 10 CR на 70
строк и проходил её, то есть головной дефект тикета был ей невидим. Добавлены
ключи selfClose:"tight" и noEmptyPairs; preserves проставлен в 13 кейсах
навыков-эмиттеров (по одному на навык, на его СОБСТВЕННЫЙ артефакт).

Снятие масок сразу вскрыло четыре реальных расхождения портов:

- form-edit собирает выход из OuterXml и писал `<a />`; в списке 17 навыков его не
  было, потому что искал по вызовам Save — здесь другой путь. Тот же случай, что
  с Form.xml в cfe-borrow;
- meta-edit py дописывал хвостовой перевод в создаваемый Ext/Predefined.xml и
  читал существующий без newline='' (терял CRLF);
- skd-info py писал отчёт -OutFile без хвостового перевода, PS через WriteAllLines
  — с ним. Это текстовый отчёт, канон Конфигуратора к нему не относится, поэтому
  выровнял py по существующему эталону;
- фикстуры кейсов содержали <Vendor></Vendor> — снимок нашего же старого вывода.
  Конфигуратор пустых пар не пишет, .NET их сохраняет, lxml схлопывает. Поправлены
  10 фикстур: пары → самозакрывающиеся, EOL и BOM не тронуты.

Результат: PS 641/641, python 638/641 (+3 runtimeOnly-скипа) — со снятыми масками.
Дрейф снэпшотов: 10 файлов, только пробельные теги и пустые пары.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:23:13 +03:00

374 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Регресс-тесты навыков
Snapshot-тестирование скриптов навыков: навык получает вход → генерирует файлы → результат сравнивается с эталоном.
Быстрые, файловые, без зависимости от платформы 1С.
## Запуск
```bash
node tests/skills/runner.mjs # все кейсы
node tests/skills/runner.mjs cases/meta-compile # один навык
node tests/skills/runner.mjs cases/meta-compile/catalog-basic # один кейс
node tests/skills/runner.mjs --verbose # подробный вывод (дерево)
node tests/skills/runner.mjs --update-snapshots # обновить эталоны
node tests/skills/runner.mjs --runtime python # запуск на PY-версиях
node tests/skills/runner.mjs --json report.json # JSON-отчёт
node tests/skills/runner.mjs --concurrency 4 # ограничить параллельность
node tests/skills/runner.mjs --with-validation # + платформенная валидация
node tests/skills/runner.mjs --help # полный список опций
```
Exit code: 0 = все прошли, 1 = есть падения.
### Платформенная верификация снапшотов
```bash
node tests/skills/verify-snapshots.mjs --skill form-compile # один навык
node tests/skills/verify-snapshots.mjs --case table # один кейс
node tests/skills/verify-snapshots.mjs --help # полный список опций
```
Перепрогоняет навык из DSL кейса и грузит результат в 1С — отлавливает случаи, когда снапшоты обновили, но платформа уже не принимает выход.
## Интеграционные тесты
Помимо snapshot-кейсов есть многошаговые сценарии в `integration/<имя>.test.mjs` — цепочка навыков (init → compile → build → validate…), проверяющая что навыки работают вместе. Запуск:
```bash
node tests/skills/runner.mjs integration # все интеграционные
node tests/skills/runner.mjs integration/platform-partial # один сценарий
```
Тест-модуль экспортирует `name`, `setup`, `steps` и опционально:
| Экспорт | Описание |
|---|---|
| `requiresPlatform` | `true` — нужен 1С (резолвится из `.v8-project.json`). Без платформы тест `○ skipped` |
| `engines` | Массив движков для **матрицы**: по умолчанию `['1cv8']`. `['1cv8','ibcmd']` — те же шаги прогоняются на обоих движках |
### Движковая матрица (1cv8 / ibcmd)
Навыки `db-*`/`epf-*` выбирают движок по имени exe в `-V8Path` (опт-ин: `ibcmd.exe` → ibcmd, иначе DESIGNER). Тест с `engines: ['1cv8','ibcmd']` прогоняется по разу на каждый движок: на ibcmd-проходе плейсхолдер `{v8path}` подставляется в `ibcmd.exe`, на 1cv8 — в каталог `bin` (авто-резолв `1cv8.exe`). Результаты помечаются суффиксом id: `… [1cv8]` / `… [ibcmd]`.
ibcmd-проход автоматически `○ skipped`, если рядом с `1cv8.exe` нет `ibcmd.exe`. Шаги тестов при этом **не меняются** — добавляется одна строка `export const engines`. Так контракт «операция держится на обоих движках» кодируется без дублирования сценария.
### Типы шагов
Шаг — это запуск навыка (`script` + `args` + опц. `input`/`validate`) либо один из вспомогательных:
| Поле шага | Действие |
|---|---|
| `script` + `args` | Запустить навык. `args` поддерживают плейсхолдеры `{workDir}`, `{inputFile}`, `{v8path}` и др. |
| `input` | JSON, передаётся навыку через temp-файл (`{inputFile}`) |
| `writeFile` + `content` | Записать файл (путь — плейсхолдеры) |
| `editFile` + `replace` + `with` | Подстановочная замена в файле (напр. вставить маркер). Падает, если паттерн не найден |
| `assertContains` + `expect` | Упасть, если файл не содержит подстроку (проверка round-trip) |
| `validate` | Доп. валидация навыком после шага (только с `--with-validation`) |
## Что делать при падении
1. Смотри **case id** в выводе — это путь к файлу кейса (можно перезапустить: `node runner.mjs <case-id>`)
2. Открой `.json` кейса — посмотри что на входе
3. Открой `snapshots/<кейс>/` — посмотри эталон
4. Если изменение **намеренное** (доработка навыка) — обнови эталон: `node runner.mjs <case-id> --update-snapshots`
5. Если **баг** — починить скрипт навыка и перезапустить тест
## Как добавить навык
1. Создать папку `tests/skills/cases/<имя-навыка>/`
2. Положить `_skill.json` — описание навыка для раннера
3. Добавить кейсы — по одному `.json` файлу на кейс
### Формат _skill.json
```json
{
"script": "meta-compile/scripts/meta-compile",
"setup": "empty-config",
"args": [
{ "flag": "-JsonPath", "from": "inputFile" },
{ "flag": "-OutputDir", "from": "workDir" }
],
"snapshot": {
"root": "workDir",
"normalizeUuids": true
}
}
```
| Поле | Описание |
|---|---|
| `script` | Путь от `.claude/skills/`, без расширения. Раннер добавит `.ps1` (по умолчанию) или `.py` |
| `setup` | Фикстура: `"empty-config"`, `"base-config"`, `"none"`, `"fixture:<name>"` (из `fixtures/` папки навыка), `"external:<path>"` (реальная выгрузка, read-only, skip если недоступна) |
| `args` | Маппинг параметров навыка (см. ниже) |
| `snapshot` | Настройки сравнения: `root` (`"workDir"` или `"outputPath"`) и `normalizeUuids` |
### Значения `from` в args
| Значение | Что подставляется |
|---|---|
| `"inputFile"` | Путь к temp-файлу с `case.input` (JSON) |
| `"workDir"` | Рабочая директория (копия фикстуры) |
| `"outputPath"` | `workDir` + `case.outputPath` |
| `"workPath"` | `workDir` + значение из `params.<field>`. Поле указывается в `mapping.field` (по умолчанию `objectPath`) |
| `"case.<field>"` | Значение из `params.<field>` (приоритет) или корня кейса |
| `"switch"` | Флаг без значения (напр. `-Detailed`) |
| `"literal"` | Фиксированное значение из `mapping.value` |
## Как добавить кейс
Положить `.json` файл в папку навыка. Имя файла = имя кейса.
### Позитивный кейс (минимальный)
```json
{
"name": "Простой справочник",
"input": { "type": "Catalog", "name": "Валюты" }
}
```
Раннер проверит: exitCode=0 + выход совпадает с эталоном.
Эталон **обязателен**: если его нет и кейс не объявил `noSnapshot`, тест падает. Иначе потерянный
(или не созданный при добавлении кейса) эталон неотличим от намеренного отсутствия — тест зелёный,
хотя выход не проверяется.
### С параметрами навыка
```json
{
"name": "Обзор справочника",
"params": { "objectPath": "Catalogs/Номенклатура" },
"expect": { "stdoutContains": "Номенклатура" }
}
```
`params` — параметры для навыка. Используются через `case.<field>` и `workPath` в `_skill.json`.
`expect.stdoutContains` / `expect.stdoutNotContains` — строка **или массив строк**. Каждая подстрока проверяется на наличие (`stdoutContains`) или отсутствие (`stdoutNotContains`) в stdout навыка. Удобно для info-навыков: проверить, что нужная строка есть, а лишней — нет.
```json
{
"name": "Представление типа у ПВХ",
"setup": "external:C:/WS/tasks/cfsrc/erp_8.3.24",
"params": { "objectPath": "ChartsOfCharacteristicTypes/ВидыСубконтоХозрасчетные" },
"expect": {
"stdoutContains": ["Представление типа: Вид субконто", "Представление объекта: Вид субконто"],
"stdoutNotContains": "Представление списка:"
}
}
```
### С дополнительными CLI-аргументами
```json
{
"name": "Конфигурация с поставщиком",
"params": { "name": "Бухгалтерия" },
"args_extra": ["-Vendor", "Тест", "-Version", "2.0.1"]
}
```
`args_extra` — дополнительные аргументы, не описанные в `_skill.json`, передаются навыку как есть.
### С предварительными шагами
```json
{
"name": "Добавление реквизита к справочнику",
"preRun": [
{
"script": "meta-compile/scripts/meta-compile",
"input": { "type": "Catalog", "name": "Контрагенты" },
"args": { "-JsonPath": "{inputFile}", "-OutputDir": "{workDir}" }
}
],
"params": { "objectPath": "Catalogs/Контрагенты" },
"input": { "operations": [{ "op": "add-attribute", "name": "ИНН", "type": "String", "length": 12 }] }
}
```
`preRun` — шаги подготовки перед основным навыком. Каждый шаг: `script` (путь без расширения), `input` (JSON), `args` (маппинг с `{workDir}` и `{inputFile}` плейсхолдерами).
### Кейс с реальной выгрузкой
```json
{
"name": "Реальный справочник Номенклатура (БП)",
"setup": "external:C:/WS/tasks/cfsrc/acc_8.3.24",
"params": { "objectPath": "Catalogs/Номенклатура" },
"expect": { "stdoutContains": "Номенклатура" }
}
```
`setup: "external:<path>"` — использует реальную выгрузку конфигурации 1С как read-only рабочую директорию (без копирования). Если путь недоступен — тест пропускается (`○ skipped`), не падает. Подходит для info/validate навыков, которые не модифицируют файлы.
### Негативный кейс
```json
{
"name": "Ошибка: пустое имя",
"input": { "type": "Catalog", "name": "" },
"expectError": true
}
```
`expectError: true` — ожидается exitCode≠0. Строковое значение — проверит наличие в stderr.
### Все поля кейса
| Поле | Обязательно | Описание |
|---|---|---|
| `name` | да | Название теста (отображается в отчёте) |
| `input` | нет | JSON-объект, передаётся навыку через temp-файл |
| `params` | нет | Параметры для `case.<field>` и `workPath` маппинга |
| `setup` | нет | Переопределение setup из `_skill.json` |
| `outputPath` | нет | Относительный путь для навыков с `-OutputPath` |
| `args_extra` | нет | Массив дополнительных CLI-аргументов |
| `preRun` | нет | Массив шагов подготовки (см. ниже) |
| `expect` | нет | Дополнительные проверки (см. ниже) |
| `expectError` | нет | `true` или строка — ожидается ошибка |
| `noSnapshot` | нет | Непустая строка с причиной — кейс объявляет, что эталон не нужен (см. «Эталоны») |
| `idempotent` | нет | `true` — повторный прогон с теми же аргументами должен дать байт-в-байт тот же `workDir` |
| `runtimeOnly` | нет | `"powershell"` / `"python"` — кейс имеет смысл только на одном порте, на другом `○ skipped` |
| `osOnly` | нет | `"win32"` / `"darwin"` / `"linux"` — кейс работает только на этой ОС (напр. фейк платформы написан как `.cmd`), на других `○ skipped` |
| `cwd` | нет | `"workDir"` — запустить навык из рабочего каталога (нужно, если кейс кладёт туда `.v8-project.json`) |
| `skipValidation` | нет | `true` — не запускать `postValidate` из `_skill.json` (только при `--with-validation`) |
### Ключи `expect`
| Ключ | Описание |
|---|---|
| `files` | Массив путей относительно `workDir` — каждый должен существовать после прогона |
| `stdoutContains` | Строка или массив строк — все должны присутствовать в stdout |
| `stdoutNotContains` | Строка или массив строк — ни одной не должно быть в stdout |
| `preserves` | Объект (или массив объектов) — байтовые свойства файла, которые навык обязан сохранить |
`preserves` проверяет то, что снэпшот-сравнение нормализует и потому увидеть не может:
| Ключ | Описание |
|---|---|
| `file` | Путь к файлу относительно `workDir` (обязателен) |
| `bom` | `true`/`false` — наличие UTF-8 BOM |
| `eol` | `"crlf"` / `"lf"` — проверяются ОДИНОЧНЫЕ переводы строк, поэтому смешанный выход падает |
| `encoding` | Ожидаемое значение в XML-декларации, напр. `"UTF-8"` |
| `finalNewline` | `true`/`false` — перевод строки в конце файла |
| `noCR13` | `true` — в выходе не должно быть литерала `&#13;` |
| `selfClose` | `"tight"` — пустой элемент только как `<a/>`, без пробела перед `/>` |
| `noEmptyPairs` | `true` — пустого элемента в форме `<a></a>` быть не должно |
Канон выгрузки Конфигуратора (issue #57), измеренный на чистой выгрузке пустой ИБ на Windows
и macOS и на 8 выгрузках в `cfsrc/`: **CRLF, BOM, последний байт `>` (без перевода строки),
`<a/>` без пробела, ноль пустых пар, `encoding="UTF-8"`.** Для файла, который навык СОЗДАЁТ,
ожидается канон; для файла, который он ПРАВИТ, — стиль входного файла (контракт #44/#46/#47),
поэтому в кейсах `roundtrip-crlf-preserve` ожидания могут отличаться от канона.
`preserves` и эталон **дополняют** друг друга: первый следит за байтовым стилем файла, второй — за
структурой содержимого. Наличие одного не отменяет необходимости другого.
### Шаги `preRun`
Массив шагов, выполняемых до запуска проверяемого навыка:
| Форма шага | Описание |
|---|---|
| `{ "script": "<навык>/scripts/<файл>", "input": {...}, "args": { "-Flag": "{inputFile}" } }` | Прогон другого навыка для подготовки фикстуры. Плейсхолдеры: `{inputFile}`, `{workDir}` |
| `{ "writeFile": { "path": "<путь>", "content": "<строка или объект>" } }` | Записать произвольный файл в `workDir` (объект сериализуется в JSON) |
## Эталоны (snapshots)
Эталон — директория `snapshots/<имя-кейса>/` внутри папки навыка. Содержит ожидаемый выход навыка после нормализации.
### Когда эталон обязателен
Всегда, кроме трёх случаев:
- `expectError` — проверяется факт ошибки, выхода нет;
- `setup: "external:<path>"` — рабочая директория read-only, эталон физически не создать;
- кейс объявил `noSnapshot` (см. ниже).
Во всех остальных случаях отсутствующий (или пустой) эталон — **падение** с подсказкой, что делать.
### `noSnapshot` — когда эталон не нужен
```json
{
"name": "Валидатор: ссылочный тип разрешается",
"noSnapshot": "meta-validate только читает и печатает — эталон зафиксировал бы выход preRun, а не проверяемого навыка; проверяется stdout",
"expect": { "stdoutContains": "16. Reference types:" }
}
```
Типичный случай — навык ничего не пишет в рабочую директорию (info/validate) **и фикстуру не
собирает**: сверять нечего, проверяется stdout.
**Но если у такого кейса есть `preRun`, собирающий фикстуру, — эталон нужен.** Он фиксирует не
выход проверяемого навыка, а **вход теста**. Без него дрейф навыка-генератора меняет фикстуру
молча: ожидание вида `"stdoutContains": "Составной (6)"` начинает проверяться уже на другом
объекте — в лучшем случае кейс падает с необъяснимой причиной, в худшем сходится случайно и
перестаёт что-либо проверять. `meta-compile` такой дрейф даёт регулярно, задевая эталоны
десятков навыков, и ловят его именно эталоны. Поэтому info-навыки в `cases/*-info/` эталоны
имеют — это осознанно.
Правило: **есть `preRun` с генерацией фикстуры → эталон; нет `preRun` (или фикстура тривиальна)
`noSnapshot`.**
**Причина обязательна** — непустая строка; `true` не принимается и валит кейс. Смысл в том, что
отключение сверки должно стоить автору формулировки, а ревьюеру быть видно в diff'е: проверить
осмысленность причины рантайм не может.
Если у кейса стоит `noSnapshot`, но каталог эталона существует — тоже падение: такой эталон
не сверяется и создаёт ложное впечатление покрытия. Удалите каталог либо снимите `noSnapshot`.
### Создание / обновление эталонов
```bash
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 --update-snapshots # все кейсы
```
> Прогон по навыку/сюите **перезаписывает эталоны всех** кейсов сразу: если побочно поехал вывод
> соседнего кейса, его эталон обновится вместе с целевым и непреднамеренная регрессия замаскируется.
> Поэтому по умолчанию — точечно по кейсу, а после массового пересъёма обязательно проверяйте
> `git diff` по `snapshots/`: каждая ± строка должна быть ожидаемой.
>
> Массовый пересъём легитимен, когда изменение вывода и правда затрагивает многих — например, правка
> `meta-compile` меняет фикстуры ~20 навыков, чьи кейсы строятся его `preRun`-прогоном.
>
> Кейсы с `noSnapshot` пропускаются — эталон им не создаётся.
### Когда обновлять
- После **намеренного** изменения логики навыка (новый выход — новый эталон)
- После сертификации: загрузить результат в 1С (`db-load-xml`), убедиться что платформа приняла, затем `--update-snapshots`
- **Не обновлять** если падение — неожиданный побочный эффект (это баг)
### Нормализация
Перед сравнением (и при сохранении) применяется:
- **UUID** → `UUID-001`, `UUID-002`... (по порядку появления, ссылочная целостность сохраняется)
- **BOM** (U+FEFF) — удаляется
- **Line endings** — `\r\n``\n`
## Структура
```
tests/skills/
runner.mjs # тест-раннер (snapshot-сравнение + интеграционные)
verify-snapshots.mjs # платформенная верификация снапшотов
README.md # этот файл
.cache/ # кэш фикстур (в .gitignore)
integration/ # многошаговые сценарии (*.test.mjs), в т.ч. движковая матрица 1cv8/ibcmd
cases/
<навык>/
_skill.json # конфиг навыка
<кейс>.json # тестовый случай
snapshots/
<кейс>/ # эталон
fixtures/ # broken-фикстуры (для validate-навыков)
<имя>/ # сломанный XML, ссылка: "setup": "fixture:<имя>"
```