Files
houseplan-card/docs/reviews/CODE-REVIEW-369-r3.md
T
2026-08-29 08:44:52 +00:00

22 KiB
Raw Blame History

CODE-REVIEW #369 — заход r3

  • Issue: https://github.com/Matysh/houseplan-card/issues/369
  • Ветка: issue/369-audit-lows, ревью-SHA 3965475c2d9ee19a2524a03d64f6b00264827233
  • Вердикт: зелёный · заход r3 · блокирующих циклов израсходовано 1 из 4 · High: 0 · Medium: 0

Скоуп разбора и почему он полный, а не по дельте

Владелец закрыл предыдущий (r2, красный, H1) вердикт коммитом 2c984d8f на SHA b5c5bff3, затем перебрал ветку набело поверх свежего dev (#366 въехал; в комментарии закрытия явно сказано «ветка пересобрана начисто поверх свежего dev»). b5c5bff3 не существует в текущей истории репозитория (git cat-file -t b5c5bff3 → Not a valid object name) — это не ошибка синхронизации, это последствие ребейза: код на новом основании технически другой (PROCESS §7.2), поэтому по правилу из инструкции этого раунда («ребейз на ушедший вперёд dev... — разбор полный») я разбираю весь диапазон origin/dev..HEAD, а не только изменения после r2, и подтверждаю закрытие r2-H1 прямым чтением кода на текущем SHA, а не диффом от отсутствующего объекта.

merge-base(origin/dev, HEAD) == origin/dev — ветка ровно на один шаг впереди dev содержательно (три коммита: 8ee1c91f, 2c984d8f, 3965475c), несмотря на то что git diff origin/dev...HEAD --stat по всему дереву показывает бандл/скриншоты — это пересборка, не чужой код (проверено: npm run build + bundle:sync воспроизводят те же байты, git status чист после пересборки).

Диапазон диффа по существу (без бандла/скриншотов): assets/furniture/**, demo/smoke_furniture_polish.mjs (новый), docs/{CHANGELOG.md,CHANGELOG.ru.md,FURNITURE.md,USER-GUIDE.md, USER-GUIDE.ru.md,VACUUM.md}, scripts/generate-furniture-assets.mjs, scripts/mutation-gate.mjs, src/{device-presentation.ts,devices.ts, houseplan-card.ts,houseplan-editor-runtime.ts,vacuum.ts}, test/{device-presentation,devices,furniture-assets,vacuum}.test.mjs. Это ровно семь пунктов ТЗ ревизии 2 (docs/specs/369-audit-lows.md) — новой подсистемы дельта не касается.

Закрытие раунда r2

r2 (красный, SHA b5c5bff3) назвал одну блокирующую находку H1 и одну Low.

Находка r2 Чем закрыта Где это видно
H1 — Shift-слушатели превью мебели переживают Escape (_onKey закрывает палитру прямым присваиванием, минуя _furnShiftDetach) В ветке e.key === 'Escape' добавлен вызов this._editorRuntime?._furnShiftDetach(); рядом с _clearFurniturePreview() src/houseplan-card.ts:2807, внутри if (this._decorTool === 'furniture') блока Escape-обработчика (_onKey, decor-режим)
H1 — те же слушатели переживают disconnectedCallback (вызывался только _clearFurniturePreview()) В disconnectedCallback добавлен this._editorRuntime?._furnShiftDetach(); следом за _clearFurniturePreview() src/houseplan-card.ts:2700, внутри disconnectedCallback() (границы метода — строки 2572–2717, проверено чтением)
Low — дублирующий idempotent-вызов _furnShiftDetach() в _furnPlace Вызов остался в единственном экземпляре src/houseplan-editor-runtime.ts:4757 — grep -n _furnShiftDetach по файлу показывает ровно один вызов внутри _furnPlace (было два в r2)

Дополнительное доказательство, не заявленное автором как самостоятельный пункт, но проверенное мной: новый смок получил Escape-регресс (rearmAttachesAgain, escapeClosesPalette, escapeDetachesListeners, demo/smoke_furniture_polish.mjs:146-161) — прогнан, все три ассерта зелёные (см. «Гейты» ниже), и мутант furniture-shift-listeners-not- attached по-прежнему валит именно этот файл, значит регресс способен падать.

Унаследовано из r2

Из документа r2 (не найден в docs/reviews/** — публикация ревью-дока для code-стадии #369, судя по дереву, ещё не коммитилась; текст находки и её разбор взяты из комментария ревьюера в issue от r2, SHA b5c5bff3) принимаю без повторного разбора «с нуля», но перепроверяю на текущем SHA, а не по вере:

  • Пункты (а), (б), (в), (г), (ж) — r2 подтвердил их доказанными (автотестом с «умеющим падать» мутантом либо прямым чтением). Я не повторяю продуктовое рассуждение по каждому с нуля, но перечитал итоговый код каждого на 3965475c (см. «Что проверено» ниже), потому что ребейз формально меняет файл целиком и правка H1 живёт в тех же файлах (houseplan-card.ts, houseplan-editor-runtime.ts), где могла задеть соседний код.
  • Low «буквально пустой repo-wide grep Matyushin недостижим» — принято ещё на спек-стадии (SPEC-REVIEW-369-r1, раздел о L1/Low), прецедент CODE-REVIEW-269-r1.md. Проверено: grep -rn Matyushin вне docs/specs/** и docs/reviews/** действительно пуст.

Как проверялось (полный разбор, не по дельте — см. выше)

Материал: git log --oneline origin/dev..HEAD, git diff origin/dev...HEAD, тело issue #369, docs/specs/369-audit-lows.md (ревизия 2), все комментарии issue (включая сорванную попытку ревью из-за конфликта слияния и оба реальных раунда). Ручного тестирования в UI не было — весь разбор через код, автотесты и браузерные смоки на реальном собранном бандле.

AC — построчно

  1. (а) docs/VACUUM.md получил раздел «Deleting and restoring a vacuum marker (#369)»; docs/USER-GUIDE.md (англ., раздел про пылесосы) и docs/USER-GUIDE.ru.md получили зеркальные абзацы. Прочитано целиком — формулировки согласованы друг с другом и с реальным поведением async_purge_orphans (#335). check-docs не покраснел (структурная часть контракта).
  2. (б) smoothVacPath (src/vacuum.ts:250-296) получил параметр warn (default console.warn), считает droppedSegments и вызывает warn(...) один раз на вызов, с числом сегментов, только если droppedSegments > 0. Возврат функции не изменён по структуре (тот же алгоритм отсева). Юнит test/vacuum.test.mjs:461-677 проверяет: 2 из 3 «плохих» сегментов дают ровно один warn с текстом /2 segment/ и /non-finite/; чистый путь не шумит. Мутант vac-trail-drop-warn- removed вырезает вызов warn — юнит падает (проверено запуском).
  3. (в) markerClimateTarget (src/devices.ts:1406) — ===null заменено на == null. Юнит test/devices.test.mjs:617-638 проверяет три ветки: отсутствующий ключ area, явный area: null, area: 'kitchen' — все три дают ожидаемый ключ. Мутант climate-legacy- area-undefined-lost возвращает строгое равенство — юнит падает (проверено).
  4. (г) controllerAvailability (src/device-presentation.ts:210-231) получил disabledRoster. Прочитал вызов d.entities до места, где он строится (src/devices.ts:1198,1210: entities: entIds, где entIds = bindingStatus.enabledEntityIds при kind==='active') — значит ownEntities.length===0 уже эквивалентно «enabledEntityIds пуст» для устройств bindingKind==='device', и добавленная проверка allEntityIds.length>0 в связке с этим и с внешним ownEntities. length===0 реализует контракт «ростер непуст, но всё отключено» корректно, а не только по счастливой случайности. Отдельно заметил избыточность: дизъюнкт d.bindingStatus?.kind === 'ha_disabled' внутри disabledRoster никогда не меняет результат, потому что итоговое && уже требует d.bindingStatus?.kind === 'active' — мёртвое (но безвредное) условие. Это тот же L1, что SPEC-REVIEW-369-r1 уже принял как Low на спек-стадии («избыточное условие в (г), не блокирует») — не завожу повторно, привожу здесь только как подтверждение, что это старая, а не новая находка. Юниты test/device-presentation.test.mjs:1192-1207 кроют все три случая ((i) истинно безростерный — available, (ii) ростер отключён пользователем — не available, (iii) ha_disabled — гасится на уровне effectiveHidden, не через эту функцию). Мутант opted-out-roster- looks-alive падает (проверено).
  5. (д) _furnShiftListener/_furnShiftAttach/_furnShiftDetach (src/houseplan-editor-runtime.ts:4681-4704) — идемпотентны через флаг _furnShiftAttached. Attach — только в _furnPick (символ вооружён, превью может появиться). Detach — во всех точках снятия палитры: _furnPlace (успешная установка), все обработчики закрытия палитры/категории/бэка в _renderFurnPalette и _renderDecorBar/ _renderDecorSecondary, плюс (после r2-H1) Escape и disconnectedCallback. Проверил, что превью и клик читают один и тот же источник истины (живое состояние клавиши Shift): клик — ev.shiftKey на момент pointerdown (_furnPlace(...,ev.shiftKey,...), houseplan-editor-runtime.ts:4790), превью — _furnPreviewInput.free, обновляемый и из pointermove (ev.shiftKey), и из keydown/keyup — рассинхронизации источника нет. Смок smoke_furniture_polish.mjs покрывает связку: 14 ассертов, все зелёные при исполнении (см. «Гейты»).
  6. (е) _decorPointerDown (src/houseplan-editor-runtime.ts:4076- 4082): ev.pointerType==='mouse' && ev.button!==0 && t!=='select' && t!=='erase' → return false, до всех веток инструментов (line/rect/ ellipse/text/furniture), но не для select/erase — соответствует контракту «правая кнопка не ставит объект», не трогает select/erase и не вызывает preventDefault (контекстное меню браузера не подавлено). Смок проверяет оба клика (правый — no-op с сохранением armed- палитры, левый — ставит). Мутант placement-accepts-any-mouse-button падает (проверено).
  7. (ж) LICENSE.md, README.md, pack.json в assets/furniture/houseplan-0.3.0/, docs/FURNITURE.md:41 и scripts/generate-furniture-assets.mjs (provenance-проверка) все несут «Sergey Matyunin». README.md получил абзац о том, что байты архива не менялись и старая романизация может остаться внутри исходника. grep -rn Matyushin . (вне .git) даёт только docs/specs/369-audit-lows.md и docs/reviews/SPEC-REVIEW-369-r1.md — оба исторические/описательные цитаты, ожидаемо по AC7 и уже принято на спек-стадии.
  8. Мутанты — все пять из ТЗ найдены в scripts/mutation-gate.mjs (vac-trail-drop-warn-removed, climate-legacy-area-undefined-lost, opted-out-roster-looks-alive, furniture-shift-listeners-not- attached, placement-accepts-any-mouse-button) и прогнаны мной по одному — 5 из 5 пойманы адресно (см. «Гейты»).

Гейты — что прогнал сам и с каким результатом

Зелёного Validate на 3965475c не найдено, поэтому прогнал сам:

  • npx tsc --noEmit — чисто, без вывода.
  • npm test — # tests 1538 / # pass 1537 / # fail 0 / # skipped 1 — совпадает с заявленным автором 1537/0.
  • npm run build — сборка без ошибок; git status --porcelain после сборки пуст → dist/, custom_components/houseplan/frontend и demo/srv/assets (после npm run bundle:sync) байт-в-байт совпадают с закоммиченными копиями.
  • node scripts/check-docs.mjs — «Documentation checks passed (7 files, 10 external links)» — обязателен, так как диапазон трогает src/**.
  • node scripts/smoke-select.mjs --base origin/dev --head HEAD — 5 символов на изменённых строках, 8 «прямых совпадений»: demo/ smoke_furniture.mjs, demo/smoke_furniture_polish.mjs (по _furnPreviewInput — узкая, специфичная связь), и шесть смоков по _editorRuntime (smoke_cold_view_toggle, smoke_cold_view_vacuum, smoke_grid_scale_invariance, smoke_ha_controls, smoke_lazy_editor_chunk, smoke_partition_openings) — широкая связь (_editorRuntime встречается в 7 из 202 смоков, не редкость, но и не «40+», порог инструмента для «широкого» символа). Решение по каждому:
    • smoke_furniture_polish.mjs — прогнан (целевой смок задачи, новый). Результат: все 14 ассертов true, OK.
    • smoke_furniture.mjs — прогнан (та же подсистема — превью/палитра мебели, автор заявлял «зелёный без правок»). Результат: OK, все ассерты true (включая noSymbolNoStamp, unarmedCanvasDismiss* — смежные с (е) сценарии клика по стейджу).
    • smoke_cold_view_toggle.mjs, smoke_cold_view_vacuum.mjs — прогнаны, а не пропущены как «слабая связь»: r2-H1 был именно про disconnectedCallback, а это единственные два смока, которые реально гоняют холодный disconnect/reconnect цикл карточки — самая чувствительная часть правки. Результат: OK в обоих, noPageErrors: true, runtimeColdAfter: true.
    • smoke_grid_scale_invariance.mjs, smoke_ha_controls.mjs, smoke_lazy_editor_chunk.mjs, smoke_partition_openings.mjs — НЕ прогнаны. Связь по _editorRuntime признана случайной: ни один не трогает мебель, Shift, кнопки мыши или lifecycle отключения карточки; символ на изменённых строках — это просто this._editorRuntime?. в других, не связанных с этим диффом местах тех же двух файлов.
  • Мутанты #369 (5/5) — раздел «AC» выше, каждый прогнан отдельно (node scripts/mutation-gate.mjs --id=<id>), «поймано 1 из 1» на каждом, чистый прогон перед мутацией зелёный.

Чего не проверял и почему

  • npm run invariants — диапазон не трогает геометрию комнат, толщину стен, layout, marker.space (кроме чтения существующего ключа в (в), не записи) или open_spans. Правило триггера инвариантов не выполнено — не прогонял.
  • npm run golden:verify — ни один пункт не меняет геометрию/стили/ слои по умолчанию: (д) требует нажатия Shift без движения мыши, (е) — правого клика, (г) — специфической комбинации bindingStatus, ни один сценарий не входит в обычный набор golden-кадров. ТЗ прямо пишет «golden/performance: не задеты (проверить прогоном обычного гейта)» — обычный гейт (build+test+check-docs) прогнан и зелёный; полный golden-прогон посчитал избыточным для этой задачи (несоразмерный объём).
  • python -m pytest tests_backend — custom_components/**/*.py не тронут.
  • Performance-профили — не названы в AC, чувствительные к перфу пути не тронуты (только доп. addEventListener/removeEventListener на время жизни палитры и одна проверка ev.button).
  • CODE-REVIEW-369-r1/r2 как файлы в docs/reviews/ — не найдены в дереве ни на HEAD, ни в origin/dev; текст r2 восстановлен из комментария ревьюера в issue (см. выше), а не из документа. Это не блокирует мой вывод (комментарий содержит SHA и полный текст находки), но стоит того, чтобы шаг публикации ревью-документов для code-стадии проверили отдельно — по SPEC-стадии документы SPEC-REVIEW-369-r1.md коммитятся исправно, по CODE-стадии #369 — нет ни одного.
  • Ручное тестирование в браузере — не проводилось; вся уверенность — через автотесты, мутанты и смоки на реальном собранном бандле (Chromium headless через demo/serve.mjs), запущенные лично в этой сессии.

Итог

Все семь пунктов ТЗ ревизии 2 реализованы и доказаны — либо автотестом с подтверждённой «умеющей падать» проверкой (5/5 мутантов адресно), либо прямым чтением кода с явной пометкой. Обе точки утечки Shift-слушателей, названные в r2 (Escape, disconnectedCallback), закрыты именно в тех строках, которые назвал ревьюер, и регресс на них теперь ловится смоком. Новых High или Medium в скоупе не найдено. Единственная сопутствующая наблюдение — мёртвый (безвредный) дизъюнкт в disabledRoster — это старый принятый Low со спек-стадии, не новая находка, фиксирую для полноты, без правки.