22 KiB
CODE-REVIEW #369 — заход r3
- Issue: https://github.com/Matysh/houseplan-card/issues/369
- Ветка:
issue/369-audit-lows, ревью-SHA3965475c2d9ee19a2524a03d64f6b00264827233 - Вердикт: зелёный · заход 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 — построчно
- (а)
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 не покраснел (структурная часть контракта). - (б)
smoothVacPath(src/vacuum.ts:250-296) получил параметрwarn(defaultconsole.warn), считаетdroppedSegmentsи вызываетwarn(...)один раз на вызов, с числом сегментов, только еслиdroppedSegments > 0. Возврат функции не изменён по структуре (тот же алгоритм отсева). Юнитtest/vacuum.test.mjs:461-677проверяет: 2 из 3 «плохих» сегментов дают ровно один warn с текстом/2 segment/и/non-finite/; чистый путь не шумит. Мутантvac-trail-drop-warn- removedвырезает вызов warn — юнит падает (проверено запуском). - (в)
markerClimateTarget(src/devices.ts:1406) —===nullзаменено на== null. Юнитtest/devices.test.mjs:617-638проверяет три ветки: отсутствующий ключarea, явныйarea: null,area: 'kitchen'— все три дают ожидаемый ключ. Мутантclimate-legacy- area-undefined-lostвозвращает строгое равенство — юнит падает (проверено). - (г)
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падает (проверено). - (д)
_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 ассертов, все зелёные при исполнении (см. «Гейты»). - (е)
_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падает (проверено). - (ж)
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 и уже принято на спек-стадии. - Мутанты — все пять из ТЗ найдены в
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 со спек-стадии, не новая находка, фиксирую для
полноты, без правки.