29 KiB
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):
- Коммит
876e709c(«test: enter lazy editor in resize smoke» — тот же коммит, что сейчас на одну позицию перед HEAD) получил свой собственный прогон Validate (33136940969) до финального docs-коммита. В нём фронтенд, HACS, hassfest, backend, perf выполнились и прошли, но все три шарда smoke упали, golden упал (4 разных кадра), и preflight (docs/провенанс) тоже упал. - Следующий и последний коммит
6d338b78(«docs: refresh screenshots after dev rebase») меняет толькоdocs/images/06-device-editor.pngиdocs/images/screenshots.json. Jobchangesвvalidate.ymlпри push на ветку задачи считает diff инкрементально —git diffмежду предыдущим и новым tip пуша, а неmerge-base(dev)..HEAD. Поэтому для этого пушаfrontend=false, и jobsfrontend/smoke/golden/hacs/hassfest/backendпропущены (skipped, не «reused по маркеру» — маркер smoke/golden явно «Cache not found» в логе job «Переиспользование»). Итоговый зелёный статус прогона33137282955относится только к docs/provenance/process-gate этого инкремента и никогда не перепроверял smoke/golden после876e709c. - Следствие: ни один зелёный прогон CI на этой ветке не подтверждает, что
smoke/golden проходят на финальном дереве. Последний прогон, где они
реально исполнялись, был красным. Хендофф-комментарий автора («Целевые
browser smokes... green», «Точный CI:...— green: (переиспользованы зелёные
...markers)») интерпретирует переиспользование/пропуск как подтверждение,
но по факту это не так — маркер smoke/golden для точного дерева
876e709cне «success», а «Cache not found», и последующий пуш этот маркер не создал, потому что fingerprint jobs совпадает только внутриreuse, аchangesдля пути smoke/golden использует другой, инкрементальный механизм. - Мой независимый прогон на
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:
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:
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 проходят.