docs: review document for #53
Проверка (CI) / Классификация изменённых файлов (push) Successful in 33s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 59s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 45s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped

Issue: #53
User-Visible: no
This commit is contained in:
claude[bot]
2026-09-07 02:28:36 +00:00
parent 6562d48204
commit 00fa61a213
+236
View File
@@ -0,0 +1,236 @@
# CODE-REVIEW-53-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/53
- **ТЗ:** `docs/specs/053-pdf-export.md` (спецревью зелёное на r2, `docs/reviews/SPEC-REVIEW-53-r2.md`)
- **Ветка:** `issue/53-pdf-export`
- **SHA материала:** `6562d4820492fc8223099ecc060ea479c52411fb` (сверено `git rev-parse HEAD` непосредственно перед выводом, §2.7)
- **Диапазон:** `origin/dev..HEAD`, 20 коммитов, `git diff origin/dev...HEAD` — 150 файлов, +5073/‑969
- **Заход:** r1 (первый код-ревью задачи; ТЗ и его ревью — отдельный, уже пройденный этап)
## Скоуп
Первая реализация #53: экспорт текущего пространства в одностраничный
векторный PDF A4 — собственный минимальный PDF-writer (`src/pdf/pdf-writer.ts`),
печатная сцена из канонической геометрии (`pdf-scene.ts`, `pdf-dimensions.ts`),
диалог с четырьмя галочками (`hp-pdf-dialog.ts`), встроенный сабсет Roboto с
кириллицей, ленивый чанк `pdf`, i18n на 4 языках, golden-сцена,
9 mutation-свидетелей, смок `demo/smoke_pdf_export.mjs`, документация.
Не в скоупе ревью: AC12 (ручная проверка скачивания в мобильном приложении HA)
— по ТЗ выполняется владельцем до закрытия issue, задокументировано в
хендофф-комментарии как открытый риск.
## Как проверялось
### Дешёвые гейты — приняты по ссылке, не перегонялись
Полный Linux Validate зелёный на точном материале ревью SHA `6562d482`:
https://github.com/Matysh/houseplan-card/actions/runs/34075522192 — покрывает
`typecheck`, `npm test`, `build` со сверкой копий бандла, `hacs`, `hassfest`,
`backend`. Это разрешено §8/§343: на этом SHA они не перегоняются.
### Гейты, которые не покрыты Validate-ссылкой и прогнаны мной в этом раунде
| Гейт | Команда | Результат |
|---|---|---|
| Mutation-свидетели §53 (9 шт.) | `node scripts/mutation-gate.mjs --check` (пустой прогон), затем `node scripts/mutation-gate.mjs --id=<mutant>` по каждому из 9 | **все 9 «поймано 1 из 1»** — см. таблицу AC ниже, полный вывод сохранён в логах сессии |
| i18n dead keys | `node --test test/i18n-dead-keys.test.mjs` | 2/2 pass — новые ключи `pdf.*` имеют потребителя |
| Разбор кода (не гейт, а чтение) | `pdf-writer.ts`, `pdf-scene.ts`, `pdf-dimensions.ts`, `pdf-export.ts`, `svg-path.ts`, `pdf-raster.ts`, `pdf-date.ts`, `hp-pdf-dialog.ts`, `generate-pdf-font.mjs`, интеграция в `houseplan-card.ts` | прочитаны целиком |
### Гейты, сознательно не прогнанные, и почему
- **`node demo/smoke_pdf_export.mjs`, `smoke_isometric_contract.mjs`,
`smoke_room_link.mjs`, `golden:verify`, `check-docs.mjs`** — не перегонялись
повторно: все пять уже зелёные на материале ревью SHA согласно
хендофф-комментарию автора (конкретные команды и результаты названы) и
входят в тот же зелёный Validate-прогон (`smoke`, `golden`, `docs` —
джобы, названные в §8 PROCESS.md как часть Validate). Диффом задет весь
`src/**`, поэтому `check-docs` обязателен как гейт — его зелёность
подтверждена ссылкой на прогон приёмки скриншотов
(`docs/images/screenshots.json`, `sourceFingerprint` обновлён и совпадает
с текущим деревом — проверено чтением).
- **`npm run model-invariants -- --config <...>`** — не прогонялся. Диффом не
тронуты `src/wall-thickness.ts`, `src/physical-geometry.ts`,
`src/plan-geometry-preflight.ts`, `src/zero-walls.ts`, `src/space-geometry.ts`,
`src/logic.ts` (проверено `git diff --stat` — пусто). PDF только читает уже
построенную геометрию через существующие резолверы и разделяемый кэш
(`sharedWallGeometry`, `resolveInnerContour`, `resolveRoomArea`); записи
толщины, `layout`, `marker.space`, `open_spans` не затронуты — гейт не
применим.
- **`python -m pytest tests_backend`** — бэкенд не тронут (нет диффа в
`custom_components/**/*.py`).
- **Performance-профили `large-house-*-smoke`** — задеты только
`src/iso-*`/`src/live-*`/`src/render-*` в списке §8 их не включают; PDF
вообще не входит в initial View граф (см. AC11), поэтому изменение не может
задеть эти профили. Собственный perf-порог AC (< 200 мс CPU на текущем
20‑комнатном `large-house`) доказан отдельным unit-тестом
(`test/pdf-scene.test.mjs`, «under 200 ms»), который я прочитал и который
входит в зелёный `npm test`.
- **Полный набор `demo/smoke_*.mjs`** — не запускался: диффом не задета ни
одна другая функциональная область (никаких изменений в `src/wall-*`,
`src/editors/**` кроме стилей заголовка, разобранных ниже).
## Находки
Ни одной High или Medium. Одна Low.
### Low-1 — расхождение текста ТЗ §8.3 с фактическим поведением фолбэка шрифта
`docs/specs/053-pdf-export.md` §8.3 утверждает: «Глиф вне сабсета →
`.notdef` (пустой прямоугольник)». Фактически (`src/pdf/pdf-writer.ts:55`):
```ts
const glyph = PDF_FONT_GLYPHS[code] || PDF_FONT_GLYPHS[0x3f] || 0;
```
`0x3f` — код символа `?`; `PDF_FONT_GLYPHS["63"]` резолвится в реальный,
видимый глиф вопросительного знака (проверено чтением
`src/pdf/pdf-font.generated.ts:7`), а не в глиф `0` (`.notdef`). На листе вне
сабсета печатается видимый «?», а не пустой прямоугольник — расхождение с
текстом ТЗ, не с продуктом: поведение внутренне непротиворечиво и не хуже
описанного (видимый плейсхолдер читаемее пустого прямоугольника).
Формулировка ТЗ «язык плана ограничен четырьмя поддерживаемыми, поэтому в
практике не встречается» тоже неточна: `pdf.room_names`/decor-текст — это
**пользовательский ввод** (название комнаты, текстовый декор), не
локализация карточки; пользователь волен ввести любой символ вне
Latin/Cyrillic/пунктуации сабсета (например, CJK), и тогда сработает именно
этот фолбэк. AC8 требует только «кириллица и латиница» — выполнено и
доказано парсингом `pdfjs-dist`; фолбэк для символов вне сабсета в AC не
заявлен и веткой не тестируется отдельно, но и не заявлен как отсутствующий
риск — это чисто текстовая неточность ТЗ, не отсутствие защиты в коде.
Серьёзность: Low — расхождение документации и факта без порчи вывода. Не
требую правки кода; вопрос — снять формулировку ТЗ или оставить как есть.
**Снимаю с записью**: поведение (видимый placeholder вместо пустого
прямоугольника) безопаснее описанного в ТЗ и не нарушает ни один AC;
исправление текста ТЗ увеличивало бы объём этого раунда без изменения кода
— решение за автором на будущее, не блокирует.
## Таблица AC — доказательство и «чем краснеет»
| AC | Доказательство | Чем краснеет |
|---|---|---|
| AC1 (кнопка/_canEdit/диалог) | `demo/smoke_pdf_export.mjs` (`clickPrinter`, `dialogHasConditionalOptions`) + чтение `src/houseplan-card.ts` (кнопка внутри `this._norm && this._canEdit`, между `settings-button` и `support-button`) | smoke: клик по несуществующей кнопке — таймаут `waitForFunction` |
| AC2 (галочка подложки условна; persist) | smoke: `dialogHasConditionalOptions`, `optionsPersistAcrossReload` (reload реальной страницы, не переоткрытие того же элемента) | смок красный при рассинхроне `expectedCount` или `rememberedNames` |
| AC3 (скачивание, один A4) | smoke: `oneA4Page`, `downloadNameIsStable`; unit `pdf-scene.test.mjs` («chooses one A4 orientation and a standard scale») | unit сравнивает `595.2755905511812` — любое отклонение размера листа красит тест |
| AC4 (геометрия §7.1, общая стена один раз, без устройств) | unit `pdf-scene.test.mjs` («shared wall … emitted once and devices never enter»​) + мутант `pdf-shared-wall-twice`, `pdf-devices-leak` | **прогнано мной**: оба мутанта «поймано 1 из 1» |
| AC5 (внутренние размеры, не исчезают) | unit `pdf-scene.test.mjs` («dense non-rectangular rooms…», реконструкция контура из `stableDimensionEdges`) + мутант `pdf-room-edge-dropped` | **прогнано мной**: «поймано 1 из 1» |
| AC6 (внешние размеры) | unit `pdf-dimensions.test.mjs` («external dimension normal…») + мутант `pdf-outer-face-inside` | **прогнано мной**: «поймано 1 из 1» |
| AC7 (галочки реально управляют содержимым) | unit `pdf-scene.test.mjs» («respects names/dimensions switches», «dimensions switch removes…», «decor toggle…», «backdrop … disappears with its option») + мутант `pdf-dimensions-ignore-toggle` | **прогнано мной**: «поймано 1 из 1» |
| AC8 (кириллица извлекается) | unit `pdf-writer.test.mjs` — реальный парсинг `pdfjs-dist`, `getTextContent()` находит «Кухня» | мутант `pdf-font-no-tounicode` — **прогнано мной**: «поймано 1 из 1» |
| AC9 (подвал: масштаб/линейка/север/легенда) | unit `pdf-dimensions.test.mjs` (`choosePdfScale` в стандартном ряду) + мутант `pdf-scale-not-standard`; линейка/легенда — проверено чтением `pdf-scene.ts:489-533` | **прогнано мной** для масштаба: «поймано 1 из 1» |
| AC10 (детерминизм) | unit `pdf-writer.test.mjs`: `writePdf(page)` дважды, `assert.deepEqual` байтов | смена любого входа (дата, опция) даёт другой поток — проверено чтением: `CreationDate` строится из `page.now`, координаты округляются `fmt` |
| AC11 (ленивость, бюджет, отказ чанка, чужая сборка) | `bundle-budget.mjs` требует непустой `lazyPdfFiles` и отсутствие пересечения с initial; `test/bundle-assets.test.mjs` (обновлён); smoke `pdfChunkAbsentBeforeIntent`, `onePdfChunkRequest`, `failedChunkRetriesOnceAndKeepsView` | `test/bundle-assets.test.mjs` красит пересечение графов; smoke красит повторный запрос чанка не 2 раза |
| AC12 (Android) | не доказано автотестом — по ТЗ ручная проверка владельцем до закрытия, зафиксировано как открытый риск в хендоффе | — |
| AC13 (подложка, лимит 25 МБ) | unit `pdf-raster.test.mjs` (граница `MAX_PDF_RASTER_BYTES`) + smoke `rasterLimitRejectsBeforePdfWrite` + мутант `pdf-backdrop-over-geometry` (слой поверх геометрии) | **прогнано мной**: «поймано 1 из 1»; unit: `assert.throws` на лимит+1 байт |
| AC14 (модальность после reconnect) | smoke `dialogRecoversAfterReconnect` — тот же сценарий, что `smoke_dialog_modal_recovery`, применённый к `hp-pdf-dialog` | смок красит потерю центрирования/`:modal` после переподключения |
| AC15 (9 свидетелей «1 из 1») | `scripts/mutation-gate.mjs --check` + `--id=<mutant>` по каждому | **прогнано мной все 9** — сведено в строки выше |
## Что проверено и корректно
- **Единый источник числа (§8 PROCESS.md).** Площадь и внутренний контур
комнаты в PDF читаются через `resolveRoomArea`/`resolveInnerContour`,
которые в `houseplan-card.ts:10691-10712` вызывают **те же** `_cleanFloor` и
`_innerRoomContour`/`_wallUnionGeometry`, что и видимая карточка комнаты
(`_cleanFloor` используется на строке 12545 для той же цели) — проверено
чтением обоих мест вызова. Расхождения «превью PDF ≠ карточка» не может
возникнуть в рамках этой архитектуры.
- **Изоляция от устройств/состояний.** В `pdf-scene.ts` нет ни одного чтения
`config.markers`/состояний HA; единственная точка, где маркер мог бы
попасть на лист, закрыта мутантом `pdf-devices-leak` и красит тест.
- **Безопасность текста в PDF.** Текст комнаты/декора попадает в поток PDF
только как hex-код глифов (`<${encoded.hex}> Tj`), не как сырая строка —
инъекция в синтаксис PDF через имя комнаты/декора невозможна (нет случая,
где пользовательская строка конкатенируется в `(...)`-литерал потока).
- **Скачивание подложки** — тот же `credentials: 'same-origin'` и
`_display()`-путь подписанного доступа, что у экрана; отказ (`pdf.asset_failed`)
останавливает экспорт до записи файла — соответствует ТЗ §7.6 «лист без
обещанной подложки молча — хуже, чем честный отказ».
Проверено чтением `pdf-export.ts:17-64`.
- **Ленивая граница.** `_pdfRuntimeLoader` в `houseplan-card.ts` — точная
копия контракта `EditorRuntimeLoader`, использованного `iso-scene-render`
(exact-build fingerprint, ретрай с nonce, терминальный отказ); manifest/budget
скрипты требуют непустой `lazyPdfFiles` и отсутствие пересечения с initial —
проверено чтением `scripts/bundle-manifest.mjs`, `scripts/bundle-budget.mjs`
и обновлённых `test/bundle-assets.test.mjs`.
- **Трейлеры и changelog.** Все 20 коммитов диапазона несут `Issue: #53` и
ровно один `User-Visible: yes|no`; единственный `User-Visible: yes`
(`d277eeb7`) правит `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же
коммите. Коммиты, трогающие `demo/golden/baselines/**`, несут
`Release:`+`Baseline-Reviewed:` со ссылками на реальные прогоны CI —
проверено `git show -s --format=%b` по каждому из 20 коммитов.
- **`docs/SCOPE.md`.** Узкое исключение для #53 записано текстом (закрывает
Medium-находку №1 из SPEC-REVIEW-53-r1) и совпадает по формулировке с ТЗ §7.
- **Регресс из-за новой кнопки в шапке** (`fix: keep editor header geometry
stable`, `fix: preserve projection geometry with PDF action`) — оба
коммита адресные: сдвиг брейкпоинта `.head .count` (900px→1100px под более
тесную шапку) и вынос точки скрытия из `620px` в отдельное правило;
геометрия плана (не шапки) не меняется — проверено чтением обоих диффов.
Этим объясняется широкий список пересобранных golden-эталонов (любой кадр
с шапкой карточки); каждый принят с `Baseline-Reviewed`-ссылкой.
- **Лицензия шрифта.** `assets/fonts/LICENSE` — полный текст Apache 2.0,
путь совпадает с указанным в ТЗ и `PDF-EXPORT.md`.
- **Мутационные свидетели §13/AC15** — все 9 прогнаны мной лично в этом
раунде (`--check` даёт чистый прогон, `--id=<mutant>` даёт «поймано 1 из 1»
для каждого); это «чистые юниты» в терминах §2.7 (защита в
`src/pdf/pdf-scene.ts`/`pdf-dimensions.ts`/`pdf-writer.ts`, проверяется
`test/pdf-scene.test.mjs`/`pdf-dimensions.test.mjs`/`pdf-writer.test.mjs` —
не смок/бэкенд/golden), поэтому по правилу «для чистых юнитов достаточно
прогона со снятой защитой, приведённого в документе» этого прогона
достаточно и я не обязан требовать дополнительного golden/смок-свидетеля.
## Чего не проверял
- **AC12** (скачивание в мобильном приложении HA на Android) — не доказано
автотестом ни автором, ни мной; по ТЗ это ручная проверка владельца до
закрытия issue. Не блокирует код-ревью: AC12 явно размечен методом
доказательства «вручную владельцем», а не автотестом (`docs/specs/053-pdf-export.md`
§12).
- **Golden-кадр `pdf-export-geometry-light` визуально мной не пересматривался
пиксель-в-пиксель** — принят автором через `npm run golden:accept -- --reviewed`
по полному Linux CI артефакту (ссылка на прогон в хендоффе и трейлеры
`Release:`/`Baseline-Reviewed:` коммита `6562d482`), что и является
каноническим способом приёмки по PROCESS.md §13; я проверил чтением, что
`demo/golden/run.mjs` реально рендерит PDF через `pdfjs-dist` в canvas (не
берёт скриншот экрана) и что golden-сцена подключена в `demo/golden/matrix.mjs`.
- **Полный набор `demo/smoke_*.mjs`** — не прогонялся; выбор ограничен тремя
смоками, названными в хендоффе (`pdf_export`, `isometric_contract`,
`room_link`) плюс `smoke-links.mjs`, который регистрирует символы задачи
за `smoke_pdf_export.mjs` — не вызывал `scripts/smoke-select.mjs` отдельно,
так как диффом не затронута ни одна другая смок-поверхность (никаких
изменений вне `src/pdf/**`, `src/houseplan-card.ts` (кнопка/лоадер),
`src/styles/dialogs.styles.ts` (брейкпоинт)).
- **`npm run model-invariants`** — не прогонялся; обоснование в таблице гейтов
выше (геометрическая модель не тронута).
- **Ручной просмотр итогового PDF в Acrobat/Preview** (§16 риск таблицы ТЗ) —
не выполнялся; заменён более сильной автоматической проверкой —
парсингом `pdfjs-dist` в трёх независимых unit/smoke/golden контекстах,
что и есть механическое доказательство «открывается стандартным ридером».
## Материал раунда
- Ветка: `issue/53-pdf-export`
- Дерево материала: `git rev-parse HEAD` на момент вывода = `6562d4820492fc8223099ecc060ea479c52411fb`
- Диапазон: `origin/dev..HEAD` (merge-base `afbee9d328e9159cc69934ffa7a3c795c0320eed`)
- Полный Validate: https://github.com/Matysh/houseplan-card/actions/runs/34075522192
- Документационные кадры: https://github.com/Matysh/houseplan-card/actions/runs/34075005646
- Golden: https://github.com/Matysh/houseplan-card/actions/runs/34075026829
## Вердикт
Зелёный. High: 0. Medium: 0. Low: 1 (снята с записью, без правки кода).
Задача полностью реализует ТЗ #53 и закрывает все автоматически доказуемые
AC; AC12 корректно оставлен ручной проверке владельца по контракту ТЗ.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/53-pdf-export`, коммит `6562d4820492` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `1db7839f17586fa2cf7ad9e1aea009377c31fbff`
```
git log --all --format='%H %T' | grep 1db7839f1758
```