Постмортем Electron‑редактора меню: SceneTree, PDFKit и тесты, которые доказали не то
Я вернул «Объём» в поле, для которого он был задуман.
Текст наполз на цену.
Четыре релиза подряд проходили автоматические тесты. Зелёный статус только подтверждал обходной путь, который модель сама придумала, реализовала и затем закрепила проверками.
Когда визуально все выглядело хорошо, пошла проверка деталей, на этапе которой результаты тестов разошлись с реальностью. Проверил руками и увидел, что объём лежит не там, где должен. Не в Position.volume, а внутри PriceNote. Четыре релиза. Четыре зелёных гейта. Четыре раза процесс сказал «всё правильно» тому, что правильным не было. После этого пришлось разбираться не только с багом, но и с контрактом, который его пропустил.
Бытовая задача с типографскими допусками
Я системный архитектор. Лет десять назад писал сайты на PHP и JavaScript, сейчас — небольшие скрипты на Python и VBS. На основной работе чаще разбираю требования, ограничения и архитектурные решения, чем пишу прикладной код. Супруга работает в крупной сети ресторанов. Ей пришли новые требования к печатным материалам и шаблоны Adobe Illustrator, а самого Illustrator не было и работать в нём никто не умел. Сначала задача сводилась к одной правке меню. Мой локальный Qwen 2.5 с ней справился, но его не поставишь в ресторан. Тут я увидел подходящий полигон, реальная задача, где можно посмотреть — Насколько быстро что‑то можно создать и может ли это быть понято, протестировано, поддержано и управляемо?
Нужна была не замена Illustrator, а редактор с узким коридором действий.
За несколько итераций один шаблон превратился в три семейства документов, появились русская и английская версии, undo/redo, форматирование текста, сохранение проекта, preflight, экспорт PDF и сборки под две ОС. Сам список функций был обычным. Необычной оказалась цена расхождения между тем, что видно на экране, и тем, что получаем на выходе в PDF.
Первый интерфейс появился быстро. Макета не было: я описывал панели в ФТ и ТЗ, а модель собрала рабочий экран и даже добавила перетаскивание элементов, которого я не просил. Выглядело убедительно.
В 2026 история «архитектор собрал приложение с агентами» уже не новость. Поэтому дальше не будет экскурсии по промптам. Интереснее другое: как процесс с разделёнными ролями, релизными гейтами и большим набором тестов всё равно выдал неверную сущность.
Скриншот убедил меня раньше, чем следовало.
Симпатичный экран оказался простой хоть и функциональной частью. Сложности начались там, где документ превращался в PDF для типографии. В интерфейсе смещение на пару пикселей почти незаметно. В печатном макете важны реальные размеры, поля, переносы строк, встроенные шрифты, цветовая модель и точное положение каждого элемента.
Предпросмотр мог быть приблизительным. PDF — нет.
Исходные материалы тоже не были единым аккуратным стандартом. Вместо одного меню, которое я получил в начале, появились два дополнительных; у каждого — свои исключения, а часть эталонов расходилась с формальным ТЗ. Это исключало наивную схему «один CSS на всё».
Архитектура: одна сцена для экрана и PDF
Сразу оговорюсь: DOM в качестве источника печатной геометрии я отмёл. Зато совершил другой промах — просто забыл про существование готовых layout‑движков вроде Yoga или связки с headless Chrome. В итоге изобрел свой велосипед: компоновка написана строго под эту предметную область и считает координаты в миллиметрах. Зато теперь оба рендера получают на вход абсолютно одинаковую, уже рассчитанную сцену.
Шаблон — это пакет конфигурации: манифест, версия, контрольные суммы, правила, карта шрифтов и геометрия панелей. Пользовательский проект хранится в версионированном 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 сцены через SVGreturn <Preview sceneTree={scene} />;// Экспорт получает тот же SceneTreeconst 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’) оставался фактически нерабочим. Четыре релиза. Никто не остановился.
На ручной проверке я вернул данные в 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‑условие. Если корректный путь требует выйти за границы задачи, агент возвращает блокер, а не подменяет сущность.
Вместо вывода
За месяц ИИ помог мне создать программу, на которую в обычном темпе я мог бы потратить год. Но ценность модели определяет не объем сгенерированного кода, а то, насколько требования, допустимые сценарии и критерии корректности собраны в единый контракт.
Я получил то, что искал. Мой главный вопрос заключался не в скорости разработки, а в том, можно ли этот результат понять, протестировать, поддерживать и контролировать.
ссылка на оригинал статьи https://habr.com/ru/articles/1078948/