Files
houseplan-card/docs/reviews/CODE-REVIEW-482-r1.md
T
claude[bot] e9a1954506
Проверка (CI) / Классификация изменённых файлов (push) Successful in 38s
Проверка (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 1m40s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 2m22s
Проверка (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
docs: review document for #482
Issue: #482
User-Visible: no
2026-09-07 09:05:57 +00:00

286 lines
24 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.
# Код-ревью #482 — Доводка экспорта пространства в PDF
- Заход: r1 (первый заход код-ревью; спек-ревью прошло 2 захода и здесь не пересчитывается)
- Диапазон: `origin/dev...HEAD`, финальный SHA `9130c4fc59682e148698b2abed60dc06d6960ab5`
- ТЗ: `docs/specs/482-pdf-export-polish.md` (зелёное спек-ревью r2, `docs/reviews/SPEC-REVIEW-482-r2.md`)
## Скоуп
Задача переписывает четыре подсистемы PDF-экспорта: нормализацию контуров
перед построением размеров (устраняет ложные диагонали из #482), локальную
дедупликацию противоположных размеров, точное позиционирование размеров с
коллизиями, штриховку физических стен/перегородок/колонн, полную компоновку
листа по фактической аннотированной сцене, векторный компас и удаление
легенды, плюс адаптивный диалог экспорта. Diff: 85 файлов, ядро — 6 файлов в
`src/pdf/` (~2450 строк), остальное — тесты, golden, документация,
сгенерированный бандл (класс D).
## Как проверялось
Дешёвые гейты (`tsc`, `npm test`, `npm run build`) подтверждены зелёным
Validate на точном SHA `9130c4fc`
([run 34102355549](https://github.com/Matysh/houseplan-card/actions/runs/34102355549),
`conclusion: success`), это же покрывает `check-docs`, provenance-гейт,
process-гейт, бандл-синхронизацию и бюджет (`bundle:budget` внутри шага
«Card bundle trees in sync»), и все три шарда mutation-gate по диффу
(`Мутанты по диффу (1-3/3)` — success). Отдельно проверил job-список этого
прогона: `golden` — success на этом же SHA; `smoke` и `performance_smoke` в
этом Validate были **skipped** (heavy-гейтинг не активировался для этого
пуша), поэтому их я прогнал сам:
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → 11 прямых
совпадений + 2 зарегистрированные связи (`smoke_pdf_export`,
`smoke_plan_drawing_repairs`) = 13 смоков, совпадает с перечнем автора в S6.
- `npm run bundle:sync` (свежий бандл для demo-стенда), затем все 13
выбранных смоков вручную: `smoke_decor_layer_order`, `smoke_decor`,
`smoke_draw_wall_thickness`, `smoke_grid_snap`, `smoke_near_axis_optimize`,
`smoke_optional_space_model`, `smoke_room_resize`, `smoke_sun_soft`,
`smoke_wall_thickness`, `smoke_wallthick_standalone`,
`smoke_zero_divider_taper`, `smoke_pdf_export`, `smoke_plan_drawing_repairs`
— **13/13 OK** (все exit 0, все `checkAll` печатают `OK`).
- Performance-бюджет AC11 (`<200 ms`) — коммит `test: isolate PDF performance
budget` (63d519e3) переносит этот бюджет в юнит-тест
`test/pdf-scene.test.mjs`, то есть он уже прогнан в рамках зелёного
`npm test` на этом SHA; отдельный browser-perf прогон не требуется.
- Golden: уже прогнан и принят на этом SHA в CI (`golden` job success), а
сам accept сделан `--reviewed` с явным `--expect-change`/`--expect-new` по
полному Linux-артефакту (S6-комментарий, `run 34101566145`), трейлеры
`Release:`/`Baseline-Reviewed:` на коммите `9130c4fc` подтверждены
process/provenance-гейтом. Дополнительный ручной `golden:verify` не
требовался.
- Model invariants (`npm run invariants`) — **не прогонял и не считаю
обязательным**: diff не трогает модель пространства/рёбра/записи
толщины/`layout`/`marker.space`/`open_spans` (все изменения — только в
`src/pdf/**`, печатная геометрия строится поверх уже существующих
`geometryOuterRings`/`innerContourForRoom`/`wallBodiesGeometry` и не
модифицирует их; §15 ТЗ прямо фиксирует «модель… не меняются»). Проверил
это чтением: список файлов diff в `src/` ограничен `src/pdf/*.ts` и
`src/i18n/*.json`.
- `test/single-source-numbers.test.mjs` не изменён и не упоминает `pdf` —
проверил, что в новом коде нет второго источника для одной и той же
видимой величины (PDF-диалог показывает только чекбоксы, не числа; сам PDF
— единственный источник напечатанных размеров).
## Разбор по AC (чтением кода — не догадка, построчно против diff)
Читал полностью diff `src/pdf/pdf-dimensions.ts` (649 строк),
`pdf-collision.ts` (190, новый), `pdf-writer.ts` (215),
`pdf-compass.ts` (64, новый), `hp-pdf-dialog.ts` (74) и `pdf-scene.ts`
(736, самый крупный файл) лично; плюс тесты
`test/pdf-dimensions.test.mjs`, `test/pdf-writer.test.mjs`,
`test/pdf-compass.test.mjs`, `test/pdf-collision.test.mjs`,
`test/pdf-scene.test.mjs`, `demo/smoke_pdf_export.mjs`,
`scripts/mutation-gate.mjs`.
- **AC1 (диалог 320px, 4 локали)** — `hp-pdf-dialog.ts`: `.body`/`.row`
получили `min-width:0`/`box-sizing:border-box`; `@media(max-width:360px)`
переводит `.row` в `grid-template-columns:minmax(0,1fr)`, кнопки
`width:100%`. DOM-порядок — Cancel первым, Save вторым (строки 160/162),
что при grid-стекинге даёт «Отмена сверху, Сохранить снизу» как требует
§7. `demo/smoke_pdf_export.mjs` добавил `narrowLocales`-проверку на
320×760 по всем четырём локалям: `document/surface/body/footer` scroll
fits, кнопки ≥44×44 внутри footer, `tabIndex`/DOM-порядок сохранены,
Cancel закрывает диалог. Прогнал сам — зелёный. **Доказано.**
- **AC2 (схлопывание дублей перед коллинеарностью)** —
`pdf-dimensions.ts`: `normalizeRing()` вызывает
`collapseAdjacentDuplicates` (включая шов «последняя→первая» первым)
**до** `removeForwardCollinearPoint`, повторяет до fixed point
(строки 216–222). Юнит `test/pdf-dimensions.test.mjs` воспроизводит
ровно кейс issue (почти совпадающая вершина у угла) и проверяет
результат по значению, не по количеству точек; мутант
`pdf-duplicate-normalization-too-late` (глушит
`collapseAdjacentDuplicates`) прогнан в CI (Мутанты 1-3/3 — success).
**Доказано.**
- **AC3 (только H/V и near-axis ≤0.25°)** —
`projectDimensionEdge()` возвращает `null`, если
`minor/major > NEAR_AXIS_MAX_SLOPE`, без хорды к соседям (edge просто
выпадает из `dimensionEdgesFromNormalized`, соседние точки не
соединяются). Юнит проверяет включительную границу и её нарушение на
`*1.000001`, плюс кейс «диагональный угол не создаёт хорду соседям»
(`diagonalEdges.length === 3`). **Доказано.**
- **AC4 (локальная дедупликация пары, не глобальная по тексту)** —
`areOppositeDimensionEdges`/`dedupeOppositeDimensionEdges`: требует
общий `ringIndex`, противоположные нормали, совпадение
tangent-интервала и длины, midpoint внутри ring; текст в предикате не
участвует. Юнит `opposite dedupe preserves equal non-opposite L/C
contour spans and separate rings` и сценарий `dimension dedupe is local`
в `pdf-scene.test.mjs` (равные `8.25 m`/`4.65 m` в разных комнатах
сохраняются по 2 экземпляра) закрывают это по счётчикам, не только по
наличию функции. **Доказано.**
- **AC5 (центрирование, целая полоса, вогнутая нормаль)** —
`pdf-scene.ts`: внутренние/внешние лейауты используют точные
`pdfBoxTouchesGeometry`/`pdfSegmentTouchesGeometry`/`pdfSegmentTouchesBox`
(не сэмплированные точки), сдвиг полосы целиком — внутренние группы по
`normalKey` (ось+знак нормали) шагами 3мм, внешние — по
`groupCollinearDimensionEdges` (ось+знак+координата) шагами 4мм; при
неудаче на всех шагах подпись **опускается**, а не рисуется поверх стены
(комментарий «omit the unsafe label rather than knowingly printing it
through a wall»). Внутренняя группировка по одному только
axis+normalSign (не по точной коллинеарности) — на первый взгляд похоже
на потенциальную избыточную связку двух разных стен одного знака нормали
в ступенчатой комнате, но это **осознанное и протестированное** поведение:
`test/pdf-scene.test.mjs` → `'whole dimension lane keeps grouped labels
centered with 1 mm clearance and no tangent jitter'` — L-образная комната,
два горизонтальных ребра на разных Y с одинаковым знаком нормали,
утверждение «one collision moves every label in the normal group by the
same whole-lane amount» именно такое поведение и фиксирует, попутно
проверяя отсутствие коллизий с меткой комнаты/площадью. Не нахожу это
находкой — только уточнение для протокола. `inwardNormalForEdge` (probe
по обе стороны ребра) заменил старый `ringCentroid`-эвристику;
`ringCentroid`/`edgeNormal`/`outsideNormal` больше не импортируются в
`pdf-scene.ts` (проверил grep). **Доказано.**
- **AC6 (`#7f7f7f`, page-anchored hatch, even-odd, zero wall без
штриховки)** — `pdf-scene.ts`: `WALL = [127/255,127/255,127/255]`;
`pageHatchLines()` строит диагонали в **абсолютных координатах страницы**
(`intercept` от `-pageHeight` до `pageWidth+step`, шаг `3mm·√2`), один и
тот же массив передаётся во все стены/перегородки/колонны → фаза общая,
швов между соседними телами нет. `hatch` есть только у
`built.geometry.components` (физические тела ненулевой толщины);
нулевые стены (`built.zero.lines`) рисуются отдельным путём без `hatch`.
`pdf-writer.ts`: `hatch`-ветка эмитит `W* n` (even-odd clip) после
заливки, потом обводку сверху — порядок «fill → hatch clipped → stroke»
ровно как в §11. Цвет — отдельный `fmtColor` с точностью до 6 знаков
(`127/255` → `0.498039`, юнит проверяет и что координатный `fmt`
не изменился — `10.12`, не `10.1234`). Мутанты
`pdf-wall-material-not-grey`, `pdf-wall-hatch-removed`,
`pdf-hatch-loses-evenodd-hole` целятся ровно в эти три инварианта и
прогнаны в CI. **Доказано.**
- **AC7 (обе ориентации по полной сцене, максимальный масштаб,
центрирование ≤0.5мм)** — `buildPdfPage()`: `preparePdfScene()` считает
модель **один раз**, затем `PDF_SCALE_SERIES` перебирается по
возрастанию (значит по убыванию печатного масштаба — первый подошедший
деноминатор и есть «максимальный масштаб» в терминах ТЗ), для каждого
масштаба строится полная аннотированная сцена (`buildPdfCandidate`)
через дешёвый прематч `rawArchitectureCanFit` (консервативный, так как
добавление аннотаций может только увеличить bbox, никогда не уменьшить —
корректно как отсекающий фильтр), проверяется `pdfSceneFits(...,
0.5*MM)`, из подошедших выбирается `betterPdfCandidate` (по доле
заполнения поля, потом portrait). Старый безусловный резерв 30/48мм
удалён (grep по `dimensionReserveMm`/`calloutWidthMm` в этом файле —
не встречается, использованы только реальные bbox через
`pdfCommandBounds`). Для нефитующихся раскладок — bracket/refine до
шага 50 и `throw new Error('pdf.failed')`, никакого best-effort
обрезанного PDF. Мутант `pdf-layout-uses-raw-aspect` и
`pdf-scale-*`-семейство целятся в эту логику. **Доказано.**
- **AC8 (компас, вращение 0/90/180/270°, лицензия)** —
`pdf-compass.ts`: `PDF_COMPASS_PATHS` — оба `d`-пути побайтово совпадают
с каноническим SVG из тела issue (сверил построчно). Юнит
`test/pdf-compass.test.mjs` геометрически проверяет направление стрелки
для всех четырёх кардинальных углов через реальную трансформацию
(`(18,18)→(17,17)` — диагональ иглы), не полагаясь на код писателя;
подтверждает `0°→[0,-1]` (вверх страницы в top-down координатах),
`90°→[1,0]`, `180°→[0,1]`, `270°→[-1,0]` — совпадает с §13 буквально.
`pdf-scene.ts` вызывает `pdfCompassOps` только когда `north !== null`;
старый код рисования стрелки-треугольника полностью удалён (не оставлен
под флагом). `THIRD_PARTY_NOTICES.md` (корень и
`custom_components/houseplan/`) — идентичны побайтово (`diff` пусто),
содержат точный upstream commit, MIT, copyright VMware 2018; отдельный
юнит сверяет обе копии нотиса. Upstream-blob я не фетчил (WebFetch не
доступен в этой сессии — то же ограничение, что и в спек-ревью r1/r2);
полагаюсь на совпадение путей с телом issue, что автор спек-ревью уже
явно пометил как не до конца независимо подтверждённое (см. «Унаследовано»
ниже — не совсем то же самое, это код-ревью r1, но фиксирую тот же
остаточный пробел). **AC доказан для всего, что проверяемо без сетевого
доступа.**
- **AC9 (легенда удалена)** — `pdf-scene.ts`: блок построения `legend`
полностью вырезан (не спрятан за условием), footer оставляет дату/версию
и scale bar. `pdf.legend.*` удалены из всех 4 локалей (en/ru/de/fr —
сверил построчно, симметрично). Юнит
`'PDF scene integrates the vector compass and never restores the
architectural legend'` проверяет отсутствие текста легенды **и**
отсутствие обращений к `pdf.legend.*` в `t()`. **Доказано.**
- **AC10 (существующие гарантии #53)** — покрыт существующим/актуализированным
unit/smoke набором `#53`, входящим в зелёный `npm test`; отдельно не
перепроверял построчно, полагаюсь на факт отсутствия regressions в
golden/mutation прогонах на этом SHA.
- **AC11 (perf, lazy bundle)** — `preparePdfScene` вызывает
`physicalGeometry(input)` один раз и передаёт `prepared` во все
`buildPdfCandidate`-попытки (до 18+ штук при переборе масштаба/ориентации)
— модель не перепарсится и растр не передекодируется на каждой попытке.
Бюджет `<200мс` — юнит-тест (см. «Как проверялось»). Бандл: `manifest`/
`lazyPdfFiles` не расширился новым top-level импортом (compass — тоже
файл внутри `src/pdf/`, тянется тем же ленивым модулем), бюджет
`bundle:budget` зелёный на этом SHA. **Доказано.**
- **AC12 (документация)** — `docs/PDF-EXPORT.md`, `docs/USER-GUIDE(.ru).md`,
`docs/ARCHITECTURE.md`, `docs/STATUS.md`, `docs/TESTING.md`,
`docs/specs/053-pdf-export.md` (явная пометка «частично переопределено
#482»), `docs/specs/README.md` — все обновлены и терминологически
согласованы (штриховка/компас/легенда/адаптивный диалог описаны
одинаково в EN и RU). `check-docs` зелёный на этом SHA (screenshot
fingerprint покрывает весь `src/**`). **Доказано.**
## Трейлеры и changelog
Все 6 коммитов диапазона несут `Issue: #482`. `User-Visible: yes` —
`15b060ac` (fix: polish PDF export) и `7e7c9f3b` (fix: harden PDF dimension
placement) — оба правят `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том
же коммите; тексты синхронны по содержанию (штриховка/компас/дедупликация/
диалог). `9130c4fc` (test: accept PDF export baselines) несёт
`Release: v1.73.0-beta.4` и `Baseline-Reviewed:` на реальный прогон —
формат и наличие подтверждены зелёным provenance-гейтом в Validate.
Остальные три коммита — `User-Visible: no`, класс B/C (тесты, скриншот,
mutation-gate), трейлеры корректны.
## Мутационное покрытие (выборочно, не полный листинг)
Прочитал diff `scripts/mutation-gate.mjs` целиком и сверил именованные в
S6-хендоффе автора мутанты (`pdf-opposite-dedupe-ignores-safe-side`,
`pdf-rectangle-restores-unsafe-label`, `pdf-duplicate-normalization-too-late`)
и ещё шесть новых (`pdf-diagonal-dimension-restored`,
`pdf-equal-text-deduped`, `pdf-zero-wall-gets-solid-fallback`,
`pdf-wall-material-not-grey`, `pdf-wall-hatch-removed`,
`pdf-hatch-loses-evenodd-hole`, `pdf-layout-uses-raw-aspect`) построчно
против production-кода — каждый патч попадает ровно в ту строку, что
реализует соответствующий инвариант (не decorative-мутант, бьющий мимо).
Все три шарда `Мутанты по диффу` зелёные на этом SHA.
## Что не проверял и почему
- **Байты upstream SVG-коммита VMware** — не фетчил (`WebFetch` недоступен
в этой сессии); совпадение `d`-путей с телом issue сверил вручную
посимвольно, этого достаточно для AC8 в объёме, доступном инструментам
этой сессии.
- **Полный browser-smoke матрикс (229 файлов)** — не входит в объём этого
раунда; выбор по диффу (`smoke-select.mjs`) дал 13 файлов, все прогнаны.
Полный прогон — обязанность пред-релизного гейта (PROCESS.md §8), не
ревью.
- **`python -m pytest tests_backend`** — не прогонял: diff не трогает
`custom_components/**/*.py`.
- **`npm run invariants`** — не прогонял: diff не трогает модель
пространства/геометрии, только печатное представление поверх неё (см.
«Как проверялось»).
- **Полный `npm run golden:verify` локально** — не прогонял: golden уже
зелёный и явно принят `--reviewed` на этом же SHA в CI, включая новый
сценарий `pdf-export-polish-light`.
## Находки
Нет High. Нет Medium — ни в скоупе, ни вне скоупа. Единственное
наблюдение (см. AC5 выше про группировку внутренних лейнов по
axis+normalSign без учёта коллинеарности) — не находка: поведение
осознанно и закрыто отдельным юнит-тестом с явной формулировкой
инварианта.
## Вердикт
Зелёный. Реализация построчно соответствует ТЗ, каждый AC либо доказан
целевым автотестом с проверенной способностью падать (мутационный гейт),
либо разобран мной чтением кода с указанием конкретных строк. Трейлеры,
changelog, документация и лицензионный нотис в порядке.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/482-pdf-export-polish`, коммит `9130c4fc5968` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `9f961b813958a7c359d561ba8eea7ee4521aa5d0`
```
git log --all --format='%H %T' | grep 9f961b813958
```
- ТЗ `docs/specs/482-pdf-export-polish.md`, блоб `952f595fe2f61d375d317b929b0323bb2441262b`
```
git log --all --find-object=952f595fe2f61d375d317b929b0323bb2441262b -- docs/specs/482-pdf-export-polish.md
```