mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -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 со спек-стадии, не новая находка, фиксирую для
|
||||
полноты, без правки.
|
||||
Reference in New Issue
Block a user