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

24 KiB
Raw Blame History

Код-ревью #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, 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, документация и лицензионный нотис в порядке.


Материал раунда

  • Ветка: 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