--- title: 🔁 Round-trip key verification — проверка нового ключа конвертера namespace: personal type: how-to created: '2026-09-17' updated: '2026-09-17' tags: - personal - how-to - testing - converters - reference related: - '[[personal/projects/zont-config-compiler]]' --- # 🔁 Round-trip key verification > Зеркало навыка `roundtrip-key-verification` (категория `software-development`, Hermes). > Появился из реального бага в ZONT-конвертере 2026-09-17 (коммит `87e315c`). > Разбор в проекте — [[personal/projects/zont-config-compiler]] §10.1. ## Когда применять Любой двусторонний конвертер с тестом байтовой точности: `.txt ⇄ .yml`, сериализатор с decode + encode, dump/undump AST, DB ⇄ ORM. **Триггер:** добавил **новый ключ** в декодированный вывод и round-trip зелёный. ## 🔴 Ловушка Байтовый round-trip доказывает, что **вывод парсера == вход**. Про энкодер он не говорит ничего — энкодер получает ровно то, что парсер положил. Если энкодер **игнорирует** новый ключ, тест всё равно зелёный: ``` original.txt → parse → YAML(с новым ключом) → encode(ключ игнорируется) → original.txt ✅ diff = 0 ▲ ключ добавлен ▲ ключ потерян, старое поле доносит байты ``` Потеря невидима: старое поле (`args`) рядом и по-прежнему собирает байты. **Реальный случай:** ключ `set_var` добавлен в парсер типа 59, энкодер поправлен **только** в `emit_action`. Вторая точка входа — инлайн-ветка `emit_step` (`'descr' in step and 'args' in step`) — ключ игнорировала. Round-trip 🟢, правка пользователя молча терялась. ## Обязательный шаг: тест подменой (mutation) `read back` («записалось») — **не** проверка. Подменить новое значение и пересобрать; эффект обязан появиться в выходе. ```bash cd /tmp && rm -rf t && mkdir t && cd t cp <реальный-артефакт>.yml t.yml python3 - <<'EOF' s = open('t.yml', encoding='utf-8').read() old = "...блок со старым значением нового ключа..." new = "...тот же блок, значение заменено на маркер (напр. 7777)..." assert s.count(old) == 1, s.count(old) # assert уникальности — без случайных мультиправок open('t.yml', 'w', encoding='utf-8').write(s.replace(old, new)) EOF python3 <проект>/encode.py t.yml > out.txt diff <(iconv -f <вх> -t utf-8 <оригинал> | tr -d '\r' | sort) \ <(iconv -f <вых> -t utf-8 out.txt | tr -d '\r' | sort) # ✅ изменилась РОВНО одна строка, в ней маркер # ❌ ноль изменений = энкодер ключ проигнорировал → искать ДРУГУЮ ветку кода ``` Проверять **обе** половины: 1. **Парсер** — ключ есть в промежуточном файле, на ожидаемом месте. 2. **Энкодер** — подмена доезжает до выхода, `diff` = ровно 1 строка. ## Найти ВСЕ точки входа У конвертеров их обычно две и больше: рекурсивный обход и инлайн/ссылочная ветка, либо проход по реестру типов и явный цикл. - `grep -n "def emit_\|def dump_" *.py` — выписать **всех** вызывающих. - Не править одну и полагать, что покрыта другая. Дублированные ветки — норма. - Симптом пропущенной ветки: в тесте подменой остаётся старое значение. В **каждой** ветке ставить проверку типа, чтобы плохое значение падало громко (`exit 2`), а не тихо уходило сырым полем. ## Смежные питфоллы - **Проверять тип производного ключа.** Парсер, сохраняющий whole-float как float (`14.5`, `1.0`), даст не-int для ключа-идентификатора. Guard: `isinstance(v, int) and not isinstance(v, bool) and not isinstance(v, float) and v != 0`. `0` = «цели нет», а не валидный id — пропускать, не выдумывать. - **Один объект в двух секциях** → ключ править во **всех** местах отображения, иначе одна проекция показывает новую семантику, другая старую. Round-trip и тут зелёный. - **Считать вхождения по данным**, не по плану: «12 элементов» из плана оказались 10 в конфиге. - **Не резолвить имя из другого объекта.** Производный ключ несёт сырой id; `target_name` вместо `target` — запрещённая подстановка. ## Что записать после проверки 1. Новый ключ, где эмитится, какие точки входа поправлены. 2. Команду подмены и наблюдённый результат («1 строка изменилась, маркер на месте»). 3. Питфолл «две точки входа» — чтобы следующая сессия сразу проверяла обе.