docs: review document for #369

Issue: #369
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-29 08:44:52 +00:00
parent 3965475c2d
commit e21f02b295
+253
View File
@@ -0,0 +1,253 @@
# 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 со спек-стадии, не новая находка, фиксирую для
полноты, без правки.