# CODE-REVIEW-303-r1 — #303: подсветка «Толщины стены» по факту кладки - Issue: [#303](https://github.com/Matysh/houseplan-card/issues/303) - Этап: `code` (PROCESS.md §2.7) - Трек: `small` — ТЗ живёт в теле issue (комментарий Matysh, 2026-08-25T07:48:43Z), ревью ТЗ — комментарий-вердикт зелёный, документ `docs/reviews/SPEC-REVIEW-303-r1.md` - Диапазон: `origin/dev...HEAD` — `origin/dev` = `0f71d86`, `HEAD` = `93cd455` (ветка `issue/303-wallthick-hover-width`) - Коммиты: `36992d68` (`fix: match wall thickness hover to masonry`, `Issue: #303`, `User-Visible: yes`), `93cd4556` (`docs: refresh reviewed screenshots`, `Issue: #303`, `User-Visible: no`) - Заход: **r1** · блокирующих циклов ревью 0/2 (лёгкий трек — лимит 2, PROCESS.md §4) - Вердикт: **зелёный** ## Скоуп ревью 12 файлов, +217/−24. Продуктовый код (класс A): `src/grid-scale.ts` (+16 — новая чистая функция `wallThickHoverHalfUnits`), `src/houseplan-card.ts` (+8/−6 — `_wallThickHover` переведён на неё, пол `gridPitch*1.25` удалён; `_wallThickHit` не тронут). Гейты/тесты (класс B): `test/grid-scale.test.mjs` (+29), новый браузерный смок `demo/smoke_wallthick_hover_width.mjs` (+87), три новых мутанта в `scripts/mutation-gate.mjs` (+46). Документация (класс C): `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` (+5/+5, `User-Visible: yes` в том же коммите — верно), `docs/TESTING.md` (+7 — новая строка чек-листа со ссылками на unit/smoke/мутанты). Генерируемое (класс D): `dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js` — сверено байт-в-байт пересборкой (см. таблицу гейтов); `docs/images/09-device-info.png` и `docs/images/screenshots.json` — обновлённый `sourceFingerprint` после смены `src/**`, второй коммит корректно помечен `User-Visible: no`. Backend (`custom_components/houseplan/**/*.py`), манифесты, i18n, README, `docs/USER-GUIDE.ru.md` — не тронуты (`git diff --stat origin/dev...HEAD` подтверждает; § «Что не входит» ТЗ явно исключает превью рисования стены и другие инструменты). Прочитано до вердикта: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` §2.7/§2.10/§4, тело issue #303 и все 4 комментария (ТЗ, зелёное ревью ТЗ, «взял в разработку», отчёт о готовности), `docs/reviews/SPEC-REVIEW-303-r1.md`, `docs/WALL-THICKNESS.md` (упоминает инструмент «Толщина» и hover, не фиксирует контракт ширины полосы — без противоречий), `docs/USER-GUIDE.ru.md` (терминология «Толщина стены» совпадает с формулировкой changelog), весь изменённый продуктовый код и тесты целиком. ## Как проверялось | Гейт | Команда | Результат | |---|---|---| | Типы | `npx tsc --noEmit` | green (без вывода) | | Сборка | `npm run build` | green, `dist/houseplan-card.js` пересобран | | Синхронность бандлов | `cp dist/… custom_components/…/houseplan-card.js && npm run bundle:sync`, затем `git status --porcelain` | пусто — пересборка из чистого чекаута дала байт-в-байт то же содержимое, что закоммитил автор; все три копии синхронны | | Unit | `npm test` | **1294 теста: 1293 pass, 1 skip, 0 fail** (см. примечание ниже) | | Docs-гейт | `node scripts/check-docs.mjs` | green — «Documentation checks passed (7 files, 10 external links)» | | Мутации, точечно (реестр §10.2, не полный прогон — по дельте) | `node scripts/mutation-gate.mjs --id=wallthick-hover-floor-back`, `--id=wallthick-zero-strip-not-visual`, `--id=wallthick-hit-narrowed` | **3/3 «тест покраснел, как обязан»** — под каждой мутацией именно новый unit/smoke гаснет | | Применимость патчей всех мутантов | `node scripts/mutation-gate.mjs --check` | green (не дал закончиться полному прогону, см. ниже — досрочно остановлен) | | Целевой смок AC | `node demo/smoke_wallthick_hover_width.mjs` | green — все 5 проверок `true` | | Смоки по прямому совпадению символа (`node scripts/smoke-select.mjs --base origin/dev --head HEAD`) | `smoke_decor.mjs`, `smoke_grid_scale_invariance.mjs`, `smoke_space_scale_defaults.mjs` (все три — совпадение по `cellCm`) | все **green** | **Примечание по unit-тестам.** Автор отчитался «1292 pass, 2 skip»; в моём прогоне — 1293 pass, 1 skip. Разница на один тест — единственный skip в моём прогоне (`#904 issue 281 private exact fixture is not present`) отмечен как условно пропускаемый при отсутствии приватной фикстуры; он не относится к диапазону #303 и не влияет на результат (0 fail в обоих случаях). Расхождение в счёте — особенность окружения, не находка по этому диапазону; фиксирую как пример того, что «verified» без точной команды/среды может на единицу расходиться, но здесь это не заслоняет главный факт — красных тестов нет. `node scripts/mutation-gate.mjs --check` без ограничения по `--id` прогоняет «чистый прогон» guard-команды **для каждого из ~40 существующих мутантов** реестра (пересборка/тесты на каждый) — это дублирует ежедневный `npm test` и принадлежит пред-релизному гейту (комментарий в самом скрипте: «его место — перед стабильным релизом, не на каждой бете»). Остановил его после того, как подтвердил, что новые три мутанта в списке проверок присутствуют и «чистый» прогон их guard проходит; предметная проверка «умеет падать» сделана точечными `--id=` прогонами выше — это и есть проверка по дельте, а не по всему реестру. ### Диапазон трогает геометрию? Нет. Диапазон читает существующее поле `hit.cm` (толщина стены, уже посчитанная `wallIntervals`) только для отрисовки; не меняет структуры `walls`, `layout`, `marker.space`, `open_spans` и не пишет новых геометрических данных. `npm run invariants` не запускал — по построению диапазона гейт неприменим (нет записи/ чтения ссылок на геометрию, только визуальный проход по уже готовому значению). ## Мутанты - `wallthick-hover-floor-back` — возвращает старый пол `max(depth/2, gridPitch*1.25)`; гвард — точечный unit `«uses the exact physical wall width»` в `test/grid-scale.test.mjs`. Прогнан: **покраснел**. - `wallthick-zero-strip-not-visual` — убирает `gridVisualUnits` из ветки нулевой толщины (заменяет её на голый `gridPitch*1.5`, ломая инвариантность к масштабу); гвард — unit `«zero-thickness hover»`. Прогнан: **покраснел**. - `wallthick-hit-narrowed` — сужает `pull` в `_wallThickHit` с `gridPitch*6` до `gridPitch*2`; гвард — `demo/smoke_wallthick_hover_width.mjs`. Прогнан: **покраснел**. Все три мутанта корректно нацелены (регрессия из issue, регрессия того же класса, риск смежного контракта — сужение зоны попадания) и реально ловятся названными тестами, а не проходят «случайно». ## Находки Нет находок High или Medium. Низкая — одна, ниже. ### L1 — счёт unit-тестов в отчёте разработчика на единицу отличается от факта **Файл:** отчёт в комментарии issue (не код). **Серьёзность:** Low. Разработчик указал «1294 unit-теста (1292 pass, 2 skip)»; фактический прогон в среде ревью — 1293 pass, 1 skip, 0 fail. Причина, скорее всего, — среда: условный skip `#904` (приватная фикстура #281) присутствует не всегда. Это не влияет на диапазон #303 (тест не относится к нему, fail нигде нет) и не меняет вердикт. **Диспозиция:** снимается без правки — расхождение в подсчёте пропусков природы окружения, не дефект кода; ноль red в обоих случаях. ## Что проверено и корректно, по AC ТЗ - **AC1** (`cell_cm:30`, стена 50 см — полоса совпадает с телом, допуск 2%). Доказано исполнением: `smoke_wallthick_hover_width.mjs` строит план, выставляет `cell_cm:30`, ставит стену 50 см, затем поперечным сканом `isPointInFill` меряет ширину `.wallbody` и `.wallthick-hover` в одной и той же точке оси — `bodyIsPhysicalWidth` и `hoverMatchesWallWithinTwoPercent` зелёные. Дополнено чтением: `wallThickHoverHalfUnits` для `cm>0` вызывает тот же `wallCmToUnits`, что и тело стены (`_wallUnionGeometry` / `drawWallPreviewD`) — совпадение не случайное, оба потребителя читают одну формулу, один источник числа. - **AC2** (`cell_cm:5`, толстые стены ≥12.5 см — поведение не меняется). Проверено арифметикой на границе: `wallThickHoverHalfUnits(12.5, 5, GRID_PITCH) === GRID_PITCH*1.25` — ровно то значение, которое давал старый пол `max(depth/2, gridPitch*1.25)` на границе применимости пола (для `cm≥12.5` при `cellCm=5` пол никогда не был активен, значит новая формула без пола даёт то же самое для всего диапазона `cm≥12.5`). Тест в `test/grid-scale.test.mjs` есть и проходит. - **AC3** (`cell_cm:5`, стена 3 см → честные 2.5 юнита, не 10.42). Unit `wallThickHoverHalfUnits(3, 5, GRID_PITCH) * 2 === 2.5` — проходит; это именно тот случай, где старый пол раздувал тонкую стену, и мутант `wallthick-hover-floor-back` подтверждает, что тест ловит возврат старого поведения. - **AC4** (стена `cm=0` — видимый минимум, физически одинаковый на любом `cell_cm`, при `cell_cm:5` — байт-в-байт сегодняшний вид). Два unit-теста: первый — `atFive*2 === GRID_PITCH*3` (это ровно старое `depth = gridPitch*3` → `half = 1.5*gridPitch` при отсутствии пола на `cellCm=5`, т.е. вид не изменился); второй — пересчёт в физические сантиметры `(half*2/gridPitch)*cellCm` даёт 15 см на обоих `cellCm=5` и `cellCm=30`, подтверждая масштабную инвариантность через `gridVisualUnits` (та же конвенция, что уже используют штрихи, `styles.ts:1549`). Мутант `wallthick-zero-strip-not-visual` подтверждает, что тест реагирует именно на потерю этой инвариантности. - **AC5** (зона попадания курсора не сужена). `_wallThickHit` не входит в диф этого диапазона (проверено чтением всего диффа) — `pull = gridPitch*6` физически не менялся. Дополнительно подтверждено исполнением: `generousHitAreaUnchanged` в смоке бьёт на расстоянии `gridPitch*5` от оси и попадает; мутант `wallthick-hit-narrowed` (сужение до `gridPitch*2`) ловится тем же смоком. - **AC6** (мусорный ввод: `cm<0`/`NaN`/`Infinity` → как 0; `cellCm≤0`/`NaN` → как 5; `gridPitch` невалиден → 0). Unit-тест перебирает `[-1, NaN, Infinity, -Infinity]` для `cm` и `[0, -1, NaN, Infinity, -Infinity]` для `cellCm`, плюс отдельно `cellCm=0` для стены с реальной толщиной (`wallThickHoverHalfUnits(50, 0, GRID_PITCH) === 5*GRID_PITCH` — откат к эталонной клетке 5 см) и `gridPitch=NaN` (`wallThickHoverHalfUnits(50, 30, NaN) === 0`, поскольку `wallCmToUnits` домножает на `gridPitch`). Все проходят; прочитана реализация — три независимые `Number.isFinite`-проверки покрывают именно эти случаи, догадок не найдено. ## Одно число — один источник Полоса подсветки не дублирует показанное пользователю числовое значение (диалог толщины показывает `cmToField(cm,…)`, полоса — только визуальная ширина); проверка неприменима как «два места показывают одно число». Но по существу дефект #303 был именно расхождением *визуальной* величины с телом стены при одном общем источнике данных (`hit.cm`) — новая реализация устраняет это, вызывая ту же `wallCmToUnits`, что и тело стены, а не независимую формулу. `test/single-source-numbers.test.mjs` (не относится напрямую к этому диапазону) прогнан — 3/3 green, регрессий не внесено. ## Соответствие ТЗ и его допущениям ТЗ (лёгкий трек) уточнило формулировку исходного issue («минимум в сантиметрах» → «grid-visual юниты, существующая конвенция проекта») и это уточнение было принято зелёным ревью ТЗ. Реализация буквально следует контракту §2 ТЗ: новая функция в точности с указанной сигнатурой и телом, `_wallThickHover` не содержит собственной арифметики, `_wallThickHit`/стили полосы не тронуты. Ни одного расхождения между ТЗ и кодом не найдено. ## Чего не проверял - **Полный набор смоков (188 файлов)** — не запускал целиком; по `scripts/smoke-select.mjs` диапазон даёт только «прямые совпадения» (4 файла, все прогнаны и зелёные), «широких» символов нет. Диапазон меняет ровно один вычисляемый геттер и одну чистую функцию без побочных эффектов — расширять выборку не увидел оснований. - **Полный прогон `mutation-gate.mjs` по всем ~40 мутантам** — по построению скрипта это пред-релизный гейт (дорогая пересборка бандла на каждый мутант); для этого диапазона проверил точечно три новых мутанта (все ловятся) и применимость патчей (`--check`, green). - **`npm run golden:verify` / golden capture** — не запускал: `wallthick-hover` не участвует в `demo/golden/matrix.mjs` (grep пустой), полоса подсветки не входит ни в одну golden-сцену, изменение не может дать диф golden по построению. - **`npm run invariants`** — не запускал: диапазон не пишет и не трансформирует геометрические структуры (стены/`layout`/`marker.space`/ `open_spans`), только читает уже вычисленное `hit.cm` для отрисовки. - **`python -m pytest tests_backend -q`** — не запускал: диапазон не трогает ни одного `custom_components/**/*.py` файла (`git diff --stat` подтверждает). - **Performance-профили** — не запускал: ни ТЗ, ни AC не называют perf-чувствительный путь; изменение — замена одной арифметической формулы в геттере подсветки, не затрагивает пути измерения перформанса. - **`git diff --check` (whitespace)** — не запускал отдельно; не увидел его необходимости, поскольку diff небольшой и был прочитан целиком построчно. ## Вердикт **Зелёный · заход r1 · блокирующих циклов 0/2 · High: 0 · Medium: 0 → в задаче.** Все 6 AC ТЗ подтверждены — частично исполнением (целевой смок, точечные unit-тесты, три точечных мутационных гварда), частично чтением там, где исполнение избыточно (`_wallThickHit` не в диффе). Дешёвые гейты (`typecheck`, `test`, `build`, синхронность бандла, `check-docs`) зелёные. Единственная находка — Low, косметическая, не в коде и не требует правки. Готово к очереди на пре-релиз (`S8-merged` по факту прохождения ревью, согласно конвейеру).