Постмортем Electron‑редактора меню: SceneTree, PDFKit и тесты, которые доказали не то

Я вернул «Объём» в поле, для которого он был задуман.

Текст наполз на цену.

Четыре релиза подряд проходили автоматические тесты. Зелёный статус только подтверждал обходной путь, который модель сама придумала, реализовала и затем закрепила проверками.

Когда визуально все выглядело хорошо, пошла проверка деталей, на этапе которой результаты тестов разошлись с реальностью. Проверил руками и увидел, что объём лежит не там, где должен. Не в Position.volume, а внутри PriceNote. Четыре релиза. Четыре зелёных гейта. Четыре раза процесс сказал «всё правильно» тому, что правильным не было. После этого пришлось разбираться не только с багом, но и с контрактом, который его пропустил.

Бытовая задача с типографскими допусками

Я системный архитектор. Лет десять назад писал сайты на PHP и JavaScript, сейчас — небольшие скрипты на Python и VBS. На основной работе чаще разбираю требования, ограничения и архитектурные решения, чем пишу прикладной код. Супруга работает в крупной сети ресторанов. Ей пришли новые требования к печатным материалам и шаблоны Adobe Illustrator, а самого Illustrator не было и работать в нём никто не умел. Сначала задача сводилась к одной правке меню. Мой локальный Qwen 2.5 с ней справился, но его не поставишь в ресторан. Тут я увидел подходящий полигон, реальная задача, где можно посмотреть — Насколько быстро что‑то можно создать и может ли это быть понято, протестировано, поддержано и управляемо?

Нужна была не замена Illustrator, а редактор с узким коридором действий.

За несколько итераций один шаблон превратился в три семейства документов, появились русская и английская версии, undo/redo, форматирование текста, сохранение проекта, preflight, экспорт PDF и сборки под две ОС. Сам список функций был обычным. Необычной оказалась цена расхождения между тем, что видно на экране, и тем, что получаем на выходе в PDF.

Первый интерфейс появился быстро. Макета не было: я описывал панели в ФТ и ТЗ, а модель собрала рабочий экран и даже добавила перетаскивание элементов, которого я не просил. Выглядело убедительно.

В 2026 история «архитектор собрал приложение с агентами» уже не новость. Поэтому дальше не будет экскурсии по промптам. Интереснее другое: как процесс с разделёнными ролями, релизными гейтами и большим набором тестов всё равно выдал неверную сущность.

Экран прототипа из независимого UI-review; названия, цены, элементы обезличены
Экран прототипа из независимого UI‑review; названия, цены, элементы обезличены

Скриншот убедил меня раньше, чем следовало.

Симпатичный экран оказался простой хоть и функциональной частью. Сложности начались там, где документ превращался в PDF для типографии. В интерфейсе смещение на пару пикселей почти незаметно. В печатном макете важны реальные размеры, поля, переносы строк, встроенные шрифты, цветовая модель и точное положение каждого элемента.

Предпросмотр мог быть приблизительным. PDF — нет.

Исходные материалы тоже не были единым аккуратным стандартом. Вместо одного меню, которое я получил в начале, появились два дополнительных; у каждого — свои исключения, а часть эталонов расходилась с формальным ТЗ. Это исключало наивную схему «один CSS на всё».

Архитектура: одна сцена для экрана и PDF

Сразу оговорюсь: DOM в качестве источника печатной геометрии я отмёл. Зато совершил другой промах — просто забыл про существование готовых layout‑движков вроде Yoga или связки с headless Chrome. В итоге изобрел свой велосипед: компоновка написана строго под эту предметную область и считает координаты в миллиметрах. Зато теперь оба рендера получают на вход абсолютно одинаковую, уже рассчитанную сцену.

Один SceneTree задаёт геометрию и SVG-предпросмотра, и PDF.
Один SceneTree задаёт геометрию и SVG‑предпросмотра, и PDF.

Шаблон — это пакет конфигурации: манифест, версия, контрольные суммы, правила, карта шрифтов и геометрия панелей. Пользовательский проект хранится в версионированном JSON. buildSceneTree получает проект, шаблон и язык, а возвращает страницы, панели, блоки и bbox текстовых фрагментов.

Где модель данных допустила подмену

Ниже — сокращённый фрагмент TypeScript‑модели. У позиции и у ценовой сноски есть собственный объём, но смысл этих полей и маршрут до сцены различаются.

interface PriceNote {
  id: string;
  price: string;
  volume?: string;
  volumeEN?: string;
}

interface Position {
  id: string;
  price: string;
  volume?: string;
  volumeEN?: string;
  priceNotes: PriceNote[];
}

TypeScript не мог запретить ошибку: volume внутри PriceNote легален, потому что у настоящей ценовой сноски действительно бывает собственный объём. Значит, различие нужно было защищать не только типами, но и командами домена, правилами layout и приёмочными сценариями. Именно этой защиты не хватило.

Одна сцена, два тонких рендера

// Упрощённый путь данных в текущей реализации.
const scene = buildSceneTree(
  project, fonts, layout, rules, language
);

// UI читает bbox сцены через SVG
return <Preview sceneTree={scene} />;

// Экспорт получает тот же SceneTree
const pdf = await renderPdf(scene, fonts, exportProfile);

В SVG viewBox остаётся в миллиметрах; масштаб переводит сцену в пиксели только для показа. PDFKit получает те же координаты, добавляет MediaBox, TrimBox и BleedBox и встраивает утверждённые шрифты. Рендереры не вычисляют положение элементов заново — иначе экран и экспорт неизбежно начали бы расходиться.

Почему не печатать HTML‑страницу

Electron оказался вполне прагматичным выбором: один язык для UI, домена, layout и большей части тестов, плюс локальные файлы и установщики под Windows и macOS. Для небольшого редактора это заметный runtime, зато не пришлось поддерживать две технологические ветки.

React + TypeScript обслуживают интерфейс и команды, но не содержат печатной геометрии. SVG показывает рассчитанный SceneTree без растеризации и без повторной компоновки.

PDFKit рисует отдельный печатный документ: боксы страницы, встроенные шрифты, повторяемость выходного файла.

Тесты — отдельная история. Vitest, структурный разбор PDF и визуальные diff‑проверки закрывают разные уровни, и ни один из них в одиночку не является приёмкой. Именно на этом стыке и живёт баг, о котором речь ниже: каждый уровень по отдельности был зелёным, а вместе они не проверяли то, что нужно.

Самая полезная деталь выбора стека обнаружилась в PDFKit 0.19.1. PNG с альфа‑каналом встраивался асинхронно, а синхронный drain иногда заканчивался раньше записи xref, trailer и%%EOF.

На выходе получался обрезанный PDF.

Исправление было не в layout: буфер пришлось собирать по событиям data и end.

Развёл роли — и всё равно ошибся

Роли я закрепил сразу в проектной документации, но модели менял в процессе (KIMI k3, пробовал и локальный Qwen 2.5). Но важно тут именно разделение контекстов.

Итоговый стек:

ChatGPT 5.6 Sol — первичные — требования, архитектура, UI и визуальная сверка с макетами;

DeepSeek V4 pro‑ Независимый контроль, и постановка задач, интеграционные, E2E‑ и PDF‑проверки.

DeepSeek V4 Flash — production‑код по ограниченной карточке;

Сначала пробовал запускать на автомате через Cursor, но увидел как улетают токены и появилось ощущение потери контроля. Поэтому начал все сессии запускать вручную и сводить результаты. Проверяющий получал исходное требование и diff, но не рассуждения сессии отвечающей за реализации. Это повысило контроль и точность проверки, хотя, как показал баг, само по себе разделение контекстов не гарантирует отсутствие ошибок. Можно посадить двух агентов в разные комнаты, но если оба смотрят на одну и ту же неверную картинку, они независимо друг друга подтвердят одну и ту же ошибку.

Карточка задачи со временем превратилась в небольшой контракт:

** Формат задания после пересмотра процесса.**
## ЦЕЛЬ

Разместить объём отдельным `run` перед ценой; исключить пересечение.

---

## РАЗРЕШЕНО МЕНЯТЬ

- layout-движок
- SVG-превью и связанные тесты

---

## ЗАПРЕЩЕНО

- Менять схему данных и эталонную геометрию
- Выдавать объём за сноску
- Исправлять тест ради прохождения
- Добавлять частный hardcode для одного шаблона

---

## ГОТОВО, ЕСЛИ

Правый край объёма **≤** левого края цены в `SceneTree`, SVG и PDF.

---

## STOP

Если без запрещённого изменения задача не решается — **остановись**, опиши противоречие и предложи изменение контракта.

STOP тут оказался не декоративным пунктом.

Агент оптимизирует путь к заданному критерию. Если критерий неполон, он ослабит тест, добавит частный случай или тихо сменит смысл данных. Потому что критерий ему это позволяет. Возможность остановиться и вернуть противоречие нужно задавать явно, иначе модель всегда выберет короткий путь для достижения результата.

Как четыре релиза закрепили неверный маршрут

Объём рядом с ценой должен был идти отдельным SceneTextRun с role='volume'. Нужное начертание не проходило проверку. Вместо остановки модель нашла работающий механизм ценовых сносок, записала объём в PriceNote и получила визуально похожую строку.

Код и тесты согласовались друг с другом, но не с исходной семантикой.
Код и тесты согласовались друг с другом, но не с исходной семантикой.

Я запретил менять функциональность, но не зафиксировал точный семантический маршрут и обязательную проверку исходного поля. Формально задача разрешала короткий путь. Модель им воспользовалась. Та же модель затем проверила визуальное положение объёма и зафиксировала вывод через PriceNote в приёмочном тесте. Новые функции нарастали сверху, проверки проходили, а правильный маршрут Position.volume → SceneTextRun(role='volume') оставался фактически нерабочим. Четыре релиза. Никто не остановился.

Реальная панель ценовой сноски: у PriceNote есть собственные поля «Объём» и «Цена». Значения обезличены.
Реальная панель ценовой сноски: у PriceNote есть собственные поля «Объём» и «Цена». Значения обезличены.

На ручной проверке я вернул данные в Position.volume и увидел наложение объёма на цену. Пришлось откатить четыре релиза и переделать связанные изменения. Потери — около восьми часов и денег на API‑запросы.

Как вылядило на рендере после моего теста
Как вылядило на рендере после моего теста

1939 тестов — всё ещё не доказательство

В релизе v1.1.0 зафиксированы 197 тестовых файлов, 1939 тестов PASS и ещё три skipped. Появился повод перестать использовать количество тестов как меру уверенности.

Проблемная проверка отвечала на вопрос «совпадает ли текущий рендер с текущим решением?». Нужен был другой вопрос: «пришёл ли объём из правильной сущности и не пересекается ли он с ценой?».

Если одна и та же ошибка заложена и в продакшен, и в эталонные тесты, зелёная полоска лишь подтвердит их согласованность. Риск особенно велик, когда модель получает право сама сформулировать критерий приёмки.

Сессия не выдаёт финальный вердикт по собственной реализации
Сессия не выдаёт финальный вердикт по собственной реализации

Вот один фрагмент — без реконструкции. Он до сих пор лежит в app/tests/domain/g3-10-r2-content‑roundtrip.test.ts:

// round-trip сохраняет price и volume внутри PriceNote с одним значением «820»
const omlet = loaded.positions['pos-main-omlet']!;
expect(omlet.priceNotes.length).toBe(2);
expect(omlet.priceNotes[0]!.price).toBe('820');
expect(omlet.priceNotes[0]!.volume).toBe('820');

Этот тест не был ложным в буквальном смысле: он честно проверял сериализацию. Но для верификации бизнес‑логики он был бесполезен. Он доказывал, что дублированное значение переживает save/load. Не доказывал, что объём пришёл из Position.volume. Не доказывал, что роль в сцене — 'volume'. Не доказывал, что координаты в PDF не пересекаются с ценой. Четыре «не доказывал» на один зелёный expect. После исправления отдельные проверки контролируют источник данных, роль, геометрию сцены, координаты PDF и ручную сверку конечного файла.

Что получилось за месяц

Проект занял около месяца по три‑четыре часа в день. Сейчас в опытной эксплуатации версия 1.1.0: установщик Windows, universal‑сборка для macOS, основной сценарий работает на другом компьютере и выдаёт нужный PDF.

Главный выигрыш был в коротком цикле обратной связи: вечером сформулировал изменение, получил реализацию, собрал, увидел результат. Та же скорость быстро размножает ошибочную гипотезу — вместе с тестами, документацией и следующими релизами.

Что я изменил в процессе

  • Сначала фиксирую доменные сущности и маршруты данных. Одинаковое поле volume в двух типах не означает одинаковый смысл.

  • Печатный контракт появляется в первом прототипе. SceneTree, боксы страницы и шрифты нельзя оставлять на этап «потом доведём экспорт».

  • Автор и проверяющий работают в разных контекстах. Проверяющий получает требование и diff, а не объяснение, почему решение автора якобы верно.

  • Проверяется конечный артефакт. Для этого проекта — PDF, его координаты, шрифты, боксы и повторяемость, а не только функции в TypeScript.

  • В каждой карточке есть запреты и STOP‑условие. Если корректный путь требует выйти за границы задачи, агент возвращает блокер, а не подменяет сущность.

Вместо вывода

За месяц ИИ помог мне создать программу, на которую в обычном темпе я мог бы потратить год. Но ценность модели определяет не объем сгенерированного кода, а то, насколько требования, допустимые сценарии и критерии корректности собраны в единый контракт.

Я получил то, что искал. Мой главный вопрос заключался не в скорости разработки, а в том, можно ли этот результат понять, протестировать, поддерживать и контролировать.

Комментарии (0)