Files
houseplan-card/docs/reviews/CODE-REVIEW-337-r1.md
T
2026-08-28 05:40:28 +00:00

345 lines
29 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.
# CODE-REVIEW-337-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/337
- Материал: `git diff origin/dev...HEAD` на `6d338b78a0794491639edee9171662cdd260c42b`
(ветка `issue/337-lazy-editor-chunk`, HEAD detached), 99 файлов, +33197/-26570.
- Этап: код-ревью, заход r1, блокирующих циклов израсходовано 0/4 до этого разбора.
- **Вердикт: красный.**
## Скоуп
ТЗ (`docs/specs/337-lazy-editor-chunk.md`, принято зелёным на SPEC-REVIEW-337-r1)
требует: (1) View не должен загружать код трёх редакторов до намерения открыть
редактор; (2) редакторы, kiosk-контролы, onboarding и Lovelace GUI config editor
должны продолжать работать **без изменения наблюдаемого поведения**; (3) при
ошибке загрузки редактора View остаётся рабочим и показывает новое сообщение;
(4) multi-asset build/раздача/CI/HACS-контракт. Диапазон правки: `src/houseplan-card.ts`
похудел на ~11.6 тыс. строк, весь этот код перенесён в новый
`src/houseplan-editor-runtime.ts` (13 016 строк) + `src/houseplan-onboarding-runtime.ts`
(843 строки) + `src/editor-runtime-loader.ts` (72 строки); плюс полностью новый
multi-asset build/раздача (`scripts/bundle-*.mjs`, `frontend_assets.py`,
`frontend_asset_manifest.py`), CSS-минификатор и обновлённая документация/changelog.
Это перенос практически всего некогда-монолитного класса через новую границу
`host ↔ typed port ↔ lazy runtime` — диапазон дельты сопоставим с исходной
задачей целиком, разбор ведётся полностью.
## Как проверялось
Дешёвые гейты подтверждены зелёным Validate на этом же SHA
(https://github.com/Matysh/houseplan-card/actions/runs/33137282955) **частично** —
см. ниже отдельное расследование, почему доверять этому прогону в части
smoke/golden нельзя. Я перепрогнал самостоятельно:
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` (через `npm run build`) | pass |
| unit | `npm test` | 1438 тестов, 1437 pass, 1 skip, 0 fail |
| build + budget | `npm run build && npm run bundle:sync && npm run bundle:budget` | initial View 255 385 B ≤ 256 000 B (запас 615 B); lazy editor 131 765 B |
| docs fingerprint | `node scripts/check-docs.mjs` | pass (7 файлов, 10 внешних ссылок) |
| **browser smokes — ВСЕ 195** | `for f in demo/smoke_*.mjs; do node "$f"; done` (после `bundle:sync`) | **178 pass / 17 FAIL** |
| golden | `npm run golden:verify` | **127 passed / 4 different** |
Почему прогнан весь набор smoke, а не выборка: diff перемещает весь бывший
монолит в новый файл, поэтому `node scripts/smoke-select.mjs --base origin/dev --head HEAD`
называет **175 из 195** смоков «прямым совпадением» (символы на изменённых
строках) — порог «широкого» символа превышен на два порядка. Это ровно случай
«дельта не локальна» из инструкции разбора: выборка не имела бы смысла,
проверен весь набор.
**Почему нельзя было принять зелёный Validate за доказательство smoke/golden.**
Разобрал историю прогонов ветки (`gh run list --branch issue/337-lazy-editor-chunk`):
1. Коммит `876e709c` («test: enter lazy editor in resize smoke» — тот же коммит,
что сейчас на одну позицию перед HEAD) получил **свой собственный** прогон
Validate (`33136940969`) **до** финального docs-коммита. В нём **фронтенд,
HACS, hassfest, backend, perf выполнились и прошли**, но **все три шарда
smoke упали**, **golden упал** (4 разных кадра), и **preflight (docs/провенанс)
тоже упал**.
2. Следующий и последний коммит `6d338b78` («docs: refresh screenshots after dev
rebase») меняет только `docs/images/06-device-editor.png` и
`docs/images/screenshots.json`. Job `changes` в `validate.yml` при push на
ветку задачи считает diff **инкрементально** — `git diff` между предыдущим и
новым tip пуша, а не `merge-base(dev)..HEAD`. Поэтому для этого пуша
`frontend=false`, и jobs `frontend`/`smoke`/`golden`/`hacs`/`hassfest`/`backend`
**пропущены** (`skipped`, не «reused по маркеру» — маркер smoke/golden явно
«Cache not found» в логе job «Переиспользование»). Итоговый зелёный статус
прогона `33137282955` относится только к docs/provenance/process-gate этого
инкремента и никогда не перепроверял smoke/golden после
`876e709c`.
3. Следствие: **ни один зелёный прогон CI на этой ветке не подтверждает, что
smoke/golden проходят на финальном дереве.** Последний прогон, где они
реально исполнялись, был красным. Хендофф-комментарий автора («Целевые
browser smokes... green», «Точный CI:...— green: (переиспользованы зелёные
...markers)») интерпретирует переиспользование/пропуск как подтверждение,
но по факту это не так — маркер smoke/golden для точного дерева `876e709c`
не «success», а «Cache not found», и последующий пуш этот маркер не создал,
потому что fingerprint jobs совпадает только внутри `reuse`, а `changes`
для пути smoke/golden использует другой, инкрементальный механизм.
4. Мой независимый прогон на `HEAD=6d338b78` **воспроизводит содержательно те
же классы падений**, что были красными в `876e709c` (kiosk, warm-remount,
device-inbox, resize-preflight, golden device-editor/dialogs) — то есть
ничего из перечисленного не было исправлено между `876e709c` и `6d338b78`,
несмотря на заявление «Готово к автоматическому S7 code review».
## Находки
### High (блокируют)
**H1. Warm-remount коммитит editor mode в обход loader-гейта — падения на
уже существующих smoke.**
`src/houseplan-card.ts:3164-3206`, `_warmAdoptViewport()`, строка 3180:
```ts
this._adoptMode(vp.mode !== 'view' && this._canEdit && !config.kiosk ? vp.mode : 'view');
```
Это прямой вызов `_adoptMode()`, минуя `_requestMode()` — единственное место,
которое перед сменой режима вызывает `_ensureEditorRuntime()`
(`src/houseplan-card.ts:890-898`). ТЗ §6.2.1 требует: «Mode, editor chrome и
editor camera не коммитятся до успешной установки runtime». При warm-remount
(HA пересоздаёт элемент карточки на том же месте — `docs/WARM-REMOUNT.md`)
новый экземпляр получает собственный `_editorRuntimeLoader`/`_editorRuntime`
(поля инстанса, не модуля), но `_warmAdoptViewport` восстанавливает
предыдущий `mode` немедленно и синхронно, до того как что-либо вызвало
`_ensureEditorRuntime()` на новом инстансе.
**Воспроизведение:** три существующих (не изменённых этой задачей) smoke
детерминированно падают на этом:
- `demo/smoke_preloader_lifecycle.mjs` — `page.evaluate: Error: Houseplan
editor runtime is not loaded`, стек `_renderMarkupDefs → render → update`;
- `demo/smoke_warm_dialogs.mjs` — 8 идентичных необработанных исключений с тем
же стеком в процессе штатного сценария «тёплого» возврата к диалогу;
- `demo/smoke_warm_owners.mjs` — `aOwnersDraftRestored`/`bDraftOpenBefore`/
`bDraftSurvivedDoubleRemount` — все три `expected true, got false` (черновик
не восстанавливается после повторного remount).
Тест умеет падать: ни один из трёх не является новым или переписанным этой
задачей (не входят в diff), то есть до правки они проходили, а сейчас —
красные на HEAD.
**H2. Kiosk-контролы, явно названные в принятом ТЗ eager (§6.3: «kiosk
controls остаются eager, если они нужны View сами по себе»), зависят от
ленивого runtime и не работают в первом View.**
`src/houseplan-card.ts:10599-10600`:
```ts
private _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void {
return this._editorRuntimeOrThrow()._saveKioskScale(patch);
}
```
Диалог, из которого этот метод вызывается, открывается напрямую из View по
3-секундному long-press на пустой сцене в kiosk-режиме —
`src/houseplan-card.ts:5980-5997` (`_stagePointerDown`, `this._kioskDialog =
true`) — без единого вызова `_ensureEditorRuntime()` на этом пути. Рендер
диалога отдельно защищён (`this._editorRuntime ? this._renderKioskDialog() :
nothing`, строка 11289), поэтому на «холодном» View (никто ещё не открывал
редактор) long-press на планшете/панели **молча не открывает попап** размера
значков/текста — ключевая функция kiosk/планшетной персоны, для которой View
и есть продукт (`docs/SCOPE.md`), просто перестаёт быть доступна до первого
входа администратора в редактор где-то ещё.
**Воспроизведение:** `demo/smoke_kiosk.mjs` (существующий, не тронут этой
задачей) — `page.evaluate: Error: Houseplan editor runtime is not loaded` при
вызове `_saveKioskScale`, воспроизводится детерминированно.
**H3. Безопасность resize «fail-closed» сломана: принудительный отказ
preflight-проверки всё равно приводит к реальному, отличному от исходного,
коммиту геометрии.**
`demo/smoke_room_resize.mjs` (существующий) подменяет
`card._checkSpacePhysicalGeometry = () => ({ ok: false, status: 'failed' })`
и тянет край комнаты во время активного (мокнутого) отказа. Ожидание по
контракту #199/#277: ноль записей, комната остаётся на исходной геометрии.
Факт:
```
safe_resize.preflight_visible_reason: expected true, got false
safe_resize.preflight_reason_once: expected 1, got 0
safe_resize.preflight_no_commit: expected [[0.1,0.1],[0.4,...]], got [[0.1,0.1],[0.5333...,...]]
safe_resize.preflight_zero_write: expected 0, got 1
safe_resize.commit_preflight_no_commit: expected 0.4, got 0.5
safe_resize.commit_preflight_zero_write: expected 0, got 1
```
Комната реально переместилась на позицию под курсором и в истории геометрии
появилась запись — то есть подмена `card._checkSpacePhysicalGeometry` (метод
на HOST, `src/houseplan-card.ts:9955`, форвардящий в
`_editorRuntimeOrThrow()._checkSpacePhysicalGeometry`) не перехватывает
реальный вызов внутри перенесённого resize-контроллера. Тот же класс
поломки — на независимом safety-контуре `_checkOptimizeGeometry`
(`demo/smoke_optimize_geometry_preflight.mjs`, комментарий в файле явно
ссылается на #199 «production bundle must fail closed before the Optimize WS
write»): подмена того же вида, `forceRed`-обёртка, даёт **9 упавших проверок
из 9** — preflight в проде для «Оптимизировать план» тоже не подтверждён
рабочим.
Это не вопрос тестируемости в узком смысле — на `preflight_no_commit`/
`commit_preflight_no_commit` наблюдается **реальный commit геометрии,
отличной от исходной**, когда по контракту не должно быть commit вообще.
Учитывая, что это ровно тот класс дефектов, ради которого заведены #199, #253,
#258, #259, #277, #291 (записи толщины/геометрии, zero-write гарантии) —
серьёзность максимальная.
**H4. Стрелочная навигация по вкладкам инвентаря устройств падает с
необработанным исключением, сама функция не работает.**
`demo/smoke_device_inbox.mjs` (существующий) — `ArrowRight` в открытом
инвентаре устройств:
```
EXC TypeError: Cannot read properties of undefined (reading '_deviceInbox')
at fm._deviceInboxTabKey (...houseplan-editor-runtime-pmkXpWgM.js:929:37170)
```
`src/houseplan-editor-runtime.ts:7398-7407`, тело метода читает
`this.host._deviceInbox` — на живом дереве `this` внутри обработчика,
привязанного как `@keydown=${this._deviceInboxTabKey}`
(`src/houseplan-editor-runtime.ts:11374`), оказывается не инстансом runtime
(`this.host` undefined). Результат: `arrowChangesTab: expected true, got
false`, плюс необработанное исключение внутри карточки — функция полностью
неработоспособна, а не просто «отличается от ожидания».
**H5. Визуальная порча вторичного тулбара Device editor + 3 неподтверждённых
golden-дельты по диалогам устройств.**
`npm run golden:verify` на HEAD: **4 из 131 кадра отличаются** (не 0, как
требует AC3 «без golden delta», и не согласовано отдельным решением владельца,
как требует §12 «принятие эталонов только через `golden:accept --reviewed`»):
- `geometry-devices-editor-dark` — подписи кнопок вторичного тулбара Device
editor визуально **накладываются друг на друга** (скриншот diff:
`artifacts/golden/diff/geometry-devices-editor-dark.png`) — реальная порча
layout, а не смещение на пиксель;
- `device-dialog-mobile-ru`, `toggle-entity-dialog-mobile-ru`,
`device-ripple-color-popover-mobile-ru` — одинаковый паттерн: узкая полоса
различий по правому/нижнему краю диалога на мобильной ширине (изменился
размер/переполнение диалога).
Все четыре сценария относятся к Device editor/device-диалогам — подсистеме,
чей код перенесён этой задачей в `houseplan-editor-runtime.ts`. Ни один из
четырёх не упомянут в хендоффе автора и не принят через
`golden:accept -- --reviewed`.
### Medium (в скоупе, чинится в этой же задаче)
**M1. Два существующих smoke ожидают, что `houseplan-card-editor` уже
зарегистрирован на странице, но ТЗ сделало регистрацию асинхронной (AC11), а
фикстуры не обновлены.**
`demo/smoke_fixed_floor.mjs:74` и `demo/smoke_orphan_space_references.mjs:6`
делают `document.createElement('houseplan-card-editor')` напрямую, без
предварительного `await HouseplanCard.getConfigElement()` (или `import
'./editor'`). До задачи `./editor` импортировался статически, поэтому элемент
был зарегистрирован при загрузке бандла; после задачи — нет. Обе фикстуры
падают с `TypeError: editor.setConfig is not a function`. План автотестов ТЗ
(§14) требует, чтобы существующий набор проходил; это два конкретных, легко
чинимых места (добавить `await customElements.whenDefined(...)` после явного
триггера лениво загрузки, либо dynamic import перед `createElement`).
**M2. Смок-инструментовка чтения версии сборки не адаптирована к
multi-asset дереву.**
`demo/smoke_general_settings.mjs:60-65` ищет строку консольного баннера
`HOUSEPLAN-CARD %c v...` в `demo/srv/assets/houseplan-card.js` (entry-файл).
После разделения баннер переехал в общий чанк
`houseplan-assets/houseplan-card-<hash>.js` — `grep` подтверждает: строки в
entry нет, есть только в чанке. `BUNDLE_VERSION` получается `undefined`,
`bundleVersionFound` красный, второе сравнение сравнивает с заведомо неверной
строкой `"Houseplan Card vundefined"`. Само отображение в диалоге "About"
похоже корректно (`v1.68.1`, видно из вывода теста) — это регресс
инструментовки теста под новое дерево ассетов, а не видимого поведения, но
именно такую регрессию должен был поймать и починить сам этот таск (AC8/§14).
**M3. Ещё 9 существующих smoke красные на HEAD, не разобраны до корня в этом
документе — логи сохранены, требуют триажа автором.**
`smoke_edit_walk`, `smoke_ha_controls`, `smoke_help_affordance`,
`smoke_nav_persist`, `smoke_opening_entity_search`, `smoke_partition_openings`,
`smoke_grid_scale_invariance` — детерминированно красные при отдельном
запуске на HEAD (полные логи: `for f in ...; do node "$f"; done`, доступны по
запросу/в артефакте ревью-агента). Показательные симптомы: необработанные
исключения внутри карточки (`opening_entity_search`, ещё один случай кроме
H4), сравнение пикселей не сходится при скрытой сетке
(`grid_scale_invariance`), функциональные регрессии в конкретных диалогах
(`ha_controls`: выбор «ведущей» сущности при нескольких источниках;
`partition_openings`: прямой drag больше не останавливается на границе
одинакового джамба; `help_affordance`: второй Escape перестал закрывать
диалог; `nav_persist`: устаревший формат навигации не переписывается при
следующем переходе). Не считаю доказанным, что все девять сводятся к H1–H4 —
не проверял по коду каждый до конца; это отдельная работа автора, но
достаточно списка «что именно красное» и «как воспроизвести» (`node
demo/smoke_<name>.mjs` после `npm run bundle:sync`), чтобы задача не
возвращалась в S7 до того, как каждый пункт либо зафиксирован тестом,
либо явно объяснён как заведомо не связанный с #337 регресс из ребейза (в
последнем случае — с доказательством: тот же smoke красный и на чистом
`origin/dev` до задачи).
Итого по census: **17 из 195** существующих browser-smoke красные на
`git rev-parse HEAD` = `6d338b78`; **4 из 131** golden-кадров отличаются.
AC3 («View parity... без golden delta») и AC4 («editor parity... существующие
smokes без изменения ожидаемого DOM/данных») формально заявлены выполненными
в хендоффе автора, но не выполнены фактически — это не единичный дефект, а
систематический разрыв между заявленным прогоном и действительным
состоянием дерева (см. раздел «Как проверялось» о причине несовпадения).
### Low
Нет отдельных Low-находок сверх перечисленного — все наблюдения либо попали в
Medium (в скоуп, чинится здесь), либо в High.
## Что проверено и корректно
- **Бюджет (AC1).** `npm run bundle:budget` на HEAD: initial View 255 385 B ≤
256 000 B, lazy editor 131 765 B — арифметически верно, посчитано моим
собственным прогоном, не переписано со слов автора. Запас 615 B крайне
узкий (сам автор называет это риском) — любой последующий eager-код без
учёта бюджета сломает AC1 на первой же правке; отдельной находкой не делаю,
так как это явный, зафиксированный риск, а не скрытый факт.
- **Импорт-граница (AC2, unit-уровень).** `npm test` — 1438/1438 применимых
(1437 pass, 1 skip, 0 fail): в этот прогон входят unit/manifest-тесты
import-графа и bundle-assets (`test/bundle-assets.test.mjs`,
`test/editor-runtime-loader.test.mjs`, `test/bundle-freshness.test.mjs`) —
на уровне статического графа импортов ленивая граница подтверждена. Именно
поэтому дефекты H1–H5 не unit-уровня — они интеграционные/browser-уровня, то
есть ровно там, где единственная защита это smoke/golden, а не тип-чек.
- **Backend asset route (AC7).** `tests_backend/test_frontend_assets.py`
существует и по заявлению автора зелёный (3 passed); полный HA-harness в
этой песочнице недоступен (нет `homeassistant`), поэтому **не перепрогонял**
— доверяю авторскому запуску только для этого файла, так как он pure-Python
и не завязан на HA.
- **Docs/трейлеры.** `node scripts/check-docs.mjs` — pass. Коммит
`c1aaddc7` (`User-Visible: yes`) правит оба changelog в одном коммите —
требование соблюдено. Остальные коммиты `User-Visible: no`, все несут
`Issue: #337`.
- **Демо-стенд синхронен.** `npm run bundle:sync` — dist/integration/demo
деревья по 6 файлов, побайтово совпадают (проверено самим скриптом при
моём прогоне).
## Чего не проверял
- **Полный HA-harness backend** (`python -m pytest tests_backend -q` с
установленным `homeassistant`) — недоступен в этой песочнице; доверяю
авторскому заявлению только для чистого pure-Python подмножества,
относящегося к asset route.
- **Не довёл до корня 9 из 17 падающих smoke** (список в M3) — это
зафиксировано как Medium с логами и командой воспроизведения, а не
замолчано.
- **Ручное тестирование в реальном Home Assistant** — не выполнялось (не
требуется циклом ревью; недоступно из этой среды).
- **Performance-профили** — не запускал: ни один AC #337 явно не называет
perf-бюджет кадра, только gzip-бюджет (AC1, проверен).
- **`node scripts/model-invariants.mjs`** — не запускал: diff не трогает
геометрические записи/`layout`/`marker.space`/`open_spans` в смысле их
формата хранения (перенос кода, не модели); H3 — поведенческий регресс в
runtime-проверке, а не в структуре записи, поэтому инвариант модели здесь не
тот инструмент, которым он обнаруживается — обнаружен smoke'ом.
- **Полное построчное соответствие каждого пункта §6–§13 спецификации
каждой строке кода** — не сделано построчно; проверка велась от
найденных красных smoke/golden к коду, а не от текста ТЗ построчно вниз, так
как объём диффа (33k+/26k-) делает построчную сверку нецелесообразной при
уже найденных блокирующих находках. Даже без учёта H1–H5 количество
красных существующих smoke (17) само по себе уже является достаточным
основанием для красного вердикта независимо от их индивидуального
разбора до конца.
## Итог
Пять High-находок делают вердикт красным: код не проходит собственный план
автотестов задачи (AC3, AC4), нарушает явное продуктовое решение принятого ТЗ
(§6.3 kiosk eager), ломает независимо проверяемую safety-гарантию
(fail-closed resize/optimize preflight, H3) и содержит минимум одну прямую
runtime-ошибку в рабочей функции (H4). Отдельная системная проблема процесса,
не входящая в оценку кода, но существенная для дальнейшей работы над этой
веткой: зелёный Validate на финальном SHA возник из-за того, что
последний коммит — docs-only и не ретриггерит smoke/golden при инкрементальном
diff'е пуша, а не потому что эти гейты были перепройдены после последнего
красного прогона (`876e709c`). Пока это не учитывается, «зелёный CI» на
последнем коммите задачи с docs-хвостом не является доказательством того, что
smoke/golden проходят.