# CODE-REVIEW-230-r1 - Issue: [#230](https://github.com/Matysh/houseplan-card/issues/230) - Ветка: `issue/230-hatch-density-normalization` - Коммит на ревью: `edf1cca8e3651003808627246312a37923a2e181` - ТЗ: `docs/specs/230-hatch-density-normalization.md`, ревью ТЗ зелёное на заходе r2 (`docs/reviews/SPEC-REVIEW-230-r2.md`) - Заход код-ревью: r1 · блокирующих циклов израсходовано 0/4 до этого вердикта ## 1. Скоуп Диапазон `origin/dev...HEAD`, один продуктовый коммит `edf1cca` ("feat: hatch density is a distance, not a count of units"). Изменения: - `src/wall-thickness.ts` — новые чистые функции `wallHatchStepUnits`, `wallHatchNeedsSolid` и константы `HATCH_*` (спец §8.1, §8.4). - `src/houseplan-card.ts` — `_wallHatchDefs` и порог `solid` читают шаг из `wallHatchStepUnits`, зумовая компенсация `1/zoom` убрана. - `src/space-render.ts` — статический рендерер переведён на ту же функцию вместо собственной константы `8`. - `test/wall-thickness.test.mjs` — 7 юнит-тестов на AC1–AC6, AC9. - `demo/smoke_wall_hatch_density.mjs` — новый смок на AC7, AC8, AC12. - `scripts/mutation-gate.mjs` — 7 новых мутантов (§14 ТЗ). - `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`, `docs/USER-GUIDE.ru.md`, `docs/WALL-THICKNESS.md` — в том же коммите, `User-Visible: yes`. - `demo/golden/baselines/{baselines-index.json, large-house-zoom-040-dark.png, large-house-zoom-250-dark.png}` — принято по AC11. - Три копии бандла (`dist/`, `custom_components/.../frontend/`, `demo/srv/assets/`) и `docs/images/screenshots.json` — сгенерированное, класс D. Все 12 AC ТЗ разобраны по коду и/или автотесту (см. §3 ниже). ## 2. Как проверялось — таблица гейтов | Гейт | Прогнан | Результат | |---|---|---| | `npx tsc --noEmit` | да | чисто | | `npm test` | да | 1014/1014, включая 7 новых `issue 230` тестов | | `npm run build` + сверка 3 копий бандла (`cmp`) | да | байт-в-байт совпадают, `git status` чист после пересборки | | `node demo/smoke_wall_hatch_density.mjs` (AC7/AC8/AC12) | да | `OK`, все 10 подпроверок `true` | | `node demo/smoke_render_parity.mjs` | да | `OK` | | `node demo/smoke_visual_continuity.mjs` | да | `OK` | | `node demo/smoke_wall_thickness.mjs` | да | `OK` | | `node demo/smoke_wall_junctions.mjs` | да | `OK` | | `npm run golden:verify` (AC10/AC11) | да | все 82 сцены `passed`, расхождений 0 | | `node scripts/check-docs.mjs` | да | чисто (7 файлов, 10 внешних ссылок) | | `node scripts/process-gate.mjs` | да | 0 предупреждений (офлайн, без `--issues`) | | `node scripts/mutation-gate.mjs --id=<7 новых мутантов>` | да, по одному | **4 из 7 не работают** — см. High-2 | | `python -m pytest tests_backend -q` | нет | диапазон не трогает `custom_components/**/*.py` — не применимо | | `npm run golden:capture` / полный `demo/smoke_*` (127 шт.) | нет | diff локален (один паттерн, оба рендерера), `verify` и именованные + смежные смоки покрывают затронутые поверхности; полный набор — предрелизный гейт (§8) | | `performance_smoke` | нет | не названо в AC, спец §10 явно оценивает эффект как нейтральный/положительный (уборка зависимости от `_zoom`) | Остальные ~120 браузерных смоков и `mutation-gate.mjs` без `--id` (полный прогон всех ~90 мутантов, дорогой пересбор бандла на каждого) не прогонялись — вне periметра диффа и не в AC. ## 3. AC — разбор | AC | Доказательство | Вердикт | |---|---|---| | AC1 | `wallHatchStepUnits(5) === 8` — юнит-тест «the reference scale is untouched», прогнан | ✅ | | AC2 | юнит-тест «one wall carries the same stripes at every grid scale» (cell 1,2,5,10,25,50, точность 1e-9), прогнан; пересчитано вручную (`node -e`) — совпадает с таблицей §8.3 ТЗ | ✅ | | AC3 | юнит-тест «density is physical, so a thicker wall gets more stripes» (30 см = 2× от 15 см на cell 1/5/25), прогнан | ✅ | | AC4 | юнит-тест «a missing or broken cell_cm falls back to the reference» (0, -5, NaN, undefined, null, 'wide', {}) — все дают 8, прогнан | ✅ | | AC5 | юнит-тест «the step stays inside its limits» + ручной пересчёт границ (cell 0.1→80, cell 1000→0.5, cell 0.5→80 точно, cell 80→0.5 точно) | ✅ | | AC6 | юнит-тест «stripes too close on screen ask for a solid body», включая NaN/0/отрицательные аргументы, прогнан | ✅ | | AC7 | смок: `noZoomScaleAtReference` (cell_cm 5, нет `scale` в transform) — прогнан и `true`. При cell_cm 25 явной повторной проверки «нет scale» в смоке нет (см. Low-1), но мутант `hatch-zoom-compensation-back` (возврат scale) пойман именно на этой же проверке — косвенно закрыто | ✅ (с Low-замечанием) | | AC8 | смок: `zoomDoesNotChangeThePattern` — полное совпадение объекта паттерна при zoom 1 и zoom 3, прогнан и `true` | ✅ | | AC9 | юнит-тест «a thin wall is not turned into a blot by the new rule» — `wallHatchNeedsSolid` не имеет мнения о тонкой стене 3 см, `wallBodyNeedsSolid` как раньше владеет этим случаем, прогнан | ✅ | | AC10 | `npm run golden:verify` — прогнан, все 82 сцены `passed`, расхождений нет ни на одной (в т.ч. на двух зумовых) | ✅ | | AC11 | Расхождение объяснено в хендоффе (что изменилось, почему ожидалось), `golden:accept -- --reviewed` выполнен, `baselines-index.json` меняет ровно 2 хэша сцен. Формальное условие принятия (трейлеры коммита) не выполнено — см. **High-1** | ⚠️ содержательно да, процедурно нет | | AC12 | смок: `staticRendererFollowsTheCell` (width 1.6 при cell_cm 25) и `bothRenderersAgree` (интерактивный и статический паттерн идентичны), прогнан и `true`; прочитано также по коду — оба рендерера берут шаг из одной функции `wallHatchStepUnits` | ✅ | ## 4. Находки ### High-1. Коммит с golden-эталонами не несёт обязательных трейлеров — CI уже красный на этом SHA Коммит `edf1cca` меняет `demo/golden/baselines/baselines-index.json` и два PNG, но заканчивается только `Issue: #230` / `User-Visible: yes` — без `Release:` и `Baseline-Reviewed:`. Это прямое требование `AGENTS.md` («A commit touching `demo/golden/baselines/**` additionally requires: `Release:`, `Baseline-Reviewed:`») и фактическая проверка `scripts/validate-commit-provenance.mjs`, которую исполняет и `.githooks/commit-msg`, и job `provenance` в `validate.yml` — а PROCESS.md прямо отдаёт приоритет исполняемой автоматизации над описанием при расхождении. Проверено выполнением, не догадкой: ``` $ node -e "import('./scripts/validate-commit-provenance.mjs').then(m => { const msg = readFileSync('/tmp/msg.txt','utf8'); const files = ...git show --name-only edf1cca...; console.log(m.validateCommitMessage(msg, files)); })" [ 'golden baseline commit requires one Release trailer', 'golden baseline commit requires one Baseline-Reviewed trailer' ] ``` И живой CI это уже подтвердил независимо: ``` $ gh run view 32480753934 --repo Matysh/houseplan-card --json jobs \ -q '.jobs[] | select(.name=="provenance")' {"conclusion":"failure","name":"provenance","status":"completed"} ``` Прецедент в истории `dev` подтверждает, что так не делается: каждое принятие golden-эталонов там — отдельный коммит с обоими трейлерами (например `e0b3c471` — «test: accept reviewed v1.66.0-beta.1 goldens», `Release: v1.66.0-beta.1`, `Baseline-Reviewed: .../runs/32368355958`). Здесь же принятие эталонов замешано в один коммит с продуктовым кодом и трейлеров не несёт. **Воспроизведение:** `git show --name-only edf1cca` содержит `demo/golden/baselines/baselines-index.json` и обе PNG; `git log -1 --format=%B edf1cca` не содержит строк `Release:`/`Baseline-Reviewed:`. Содержательно AC10/AC11 выполнены (расхождение объяснено, `golden:accept -- --reviewed` реально прогнан) — но формальный артефакт принятия не соответствует процессу, и это не абстракция: реальный гейт `provenance` уже красный на этом SHA. ### High-2. Четыре из семи новых мутантов не работают — гейт красен без мутации `scripts/mutation-gate.mjs` (§14 ТЗ) регистрирует 7 мутантов на #230. У четырёх (`hatch-step-ignores-cell-cm`, `hatch-step-inverted`, `hatch-step-unclamped`, `hatch-density-solid-threshold-off`) guard — `node --test --test-name-pattern="issue 230" test/wall-thickness.test.mjs`, без сборки `test-build/` (в отличие от соседних юнит-мутантов, у которых guard начинается с `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && ...`). `runCleanGuards` в `mutation-gate.mjs` собирает мутанта в изолированном `git worktree`, где `test-build/` (в `.gitignore`) не существует и не пересобирается — `buildBundle()` гоняет только `rollup`, не `tsc`. **Воспроизведение (реальный прогон, не чтение):** ``` $ node scripts/mutation-gate.mjs --id=hatch-step-ignores-cell-cm FAIL чистый прогон: node --test --test-name-pattern="issue 230" test/wall-thickness.test.mjs красный без мутанта ... Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/hp-mutant-.../test-build/wall-thickness.js' ``` То же для `hatch-step-inverted`, `hatch-step-unclamped`, `hatch-density-solid-threshold-off` — во всех четырёх «чистый прогон» (без мутации!) уже красный по той же причине, то есть тест «покраснел бы» и на правильном коде, и на сломанном — не доказывает вообще ничего. Три оставшихся мутанта на смоке (`hatch-stroke-not-scaled`, `hatch-zoom-compensation-back`, `hatch-static-renderer-untouched`) работают корректно — прогнаны по одному, каждый даёт `поймано 1 из 1`. Дешёвая половина гейта (`test/mutation-gate.test.mjs`, идёт в `npm test`) этого не ловит — она проверяет только, что якорь патча существует один раз в файле и что *какой-то* `.mjs`-файл из строки guard существует, но не выполняет сам guard. `npm run mutation-gate --check` (флаг `--check`) тоже не ловит — он делает то же самое. Отсюда 1014/1014 в `npm test` и авторская запись «мутанты проверены применением патча» одновременно верны и не противоречат тому, что сам гейт нерабочий: автор, судя по числу «падений» в хендоффе (например «2 падения» для `hatch-step-ignores-cell-cm»), скорее всего гонял тест не через изолированный `mutation-gate.mjs`, а прямо в корне репозитория, где `test-build/` уже существовал от предыдущего `npm test» — то есть не тем путём, которым его прогонит реальный гейт. Практическое следствие: `.github/workflows/mutation-gate.yml` гоняет полный `node scripts/mutation-gate.mjs` по расписанию (понедельник, раз в неделю) и перед стабильным релизом (PROCESS.md §8). После мержа в `dev` этот прогон упадёт на первом же из четырёх новых мутантов и остановится (`runCleanGuards` прерывается на первой красной guard-команде) — то есть падение затронет не только четыре мутанта #230, а весь гейт целиком, включая уже существующие десятки мутантов, идущие в реестре после них. Находка в скоупе задачи (сама задача создала эти мутанты) — правится в текущем issue. ### Low-1. AC7 не перепроверяет отсутствие `scale` на втором `cell_cm` Спец §12 требует AC7 «проверяется в браузере при двух разных `cell_cm»» — смок явно проверяет `noZoomScaleAtReference` только при `cell_cm: 5`; при `cell_cm: 25` проверяются `width`/`height`/`d`/`stroke`, но не отдельно отсутствие `scale` в `patternTransform`. Не блокирует: мутант `hatch-zoom-compensation-back` (возврат `1/zoom`) всё равно пойман — он ловится на эталонной проверке раньше, чем код доходит до сцены с `cell_cm: 25`, а `bothRenderersAgree` транзитивно требует совпадения `transform` между рендерерами и с эталонным. Снимаю с записью, правки не прошу — риск чисто регрессионный и уже перекрыт другим путём. ## 5. Что проверено и корректно - Формула `wallHatchStepUnits` — точное совпадение на эталоне (`cell_cm: 5` → 8), корректная физическая пропорция (AC2/AC3), правильные пределы клампа (AC5, включая точные границы 0.5/80), корректный откат на дефолт при невалидном входе (AC4) — всё численно пересчитано независимо и совпадает с таблицами ТЗ. - Оба рендерера (`houseplan-card.ts`, `space-render.ts`) читают шаг из одной функции; зумовая компенсация убрана в обоих; смок подтверждает побитовое совпадение паттерна между интерактивной картой и статическим рендерером на `cell_cm: 25` — расхождение путей, названное в риске §11.3 ТЗ, реально устранено. - Толщина штриха масштабируется тем же множителем в обоих рендерерах — соотношение «штрих/просвет» инвариантно (AC проверено смоком численно: `0.4` при `cell_cm: 25`, ожидаемое `2 × 1.6/8`). - Новый порог `wallHatchNeedsSolid` объединён с существующим `wallBodyNeedsSolid` через «или» в обоих рендерерах — по коду и по спеку §8.4, независимая защита от каши на экране. - `golden:verify` — 0 расхождений на всех 82 сценах, в т.ч. на двух зумовых, подтверждающих AC10/AC11 по содержанию. - Три копии бандла байт-в-байт идентичны после локальной пересборки. - Трейлеры `Issue: #230` / `User-Visible: yes` на месте, ровно один issue, оба CHANGELOG и строка `USER-GUIDE.ru.md` в том же коммите, что и код. `docs/WALL-THICKNESS.md` §3 обновлён каноническим текстом подсистемы. - `_cellCm`-геттер и фолбэк на невалидный `cell_cm` в `wallHatchStepUnits` согласованы (одна и та же логика «> 0 иначе 5»), как и было обещано в ТЗ §8.1. ## 6. Чего не проверял - Полный `demo/smoke_*` (127 файлов) — прогнаны только именованные в AC/хендоффе плюс смежные по затронутым поверхностям (`wall_hatch_density`, `render_parity`, `visual_continuity`, `wall_thickness`, `wall_junctions`). Диф локален (одна паттерн-функция, оба существующих рендерера), полный набор — предрелизный гейт (§8), не гейт ревью. - `python -m pytest tests_backend` — не прогонял, диапазон не трогает `custom_components/**/*.py`. - `performance_smoke` — не прогонял, эффект в ТЗ §10 оценён как нейтральный/ положительный (снятие зависимости паттерна от `_zoom`), в AC не назван. - Полный `node scripts/mutation-gate.mjs` без `--id` (все ~90 мутантов реестра, включая давно существующие, не относящиеся к #230) — дорогой пересбор бандла на каждого; проверены только 7 новых, по одному через `--id=`. Не проверял, не сломала ли эта задача что-то в **других**, предыдущих мутантах реестра — диф их не касается текстуально, но по негативному опыту находки High-2 (одна дырка в guard-конвенции ломает общий прогон) не исключаю, что стоит перепроверить весь реестр перед публикацией беты, отдельно от этой задачи. - Историю прочих коммитов веток `docs: review document…` — не тело ревью (уже опубликованные документы SPEC-REVIEW r1/r2), не относится к код-ревью. ## 7. Вердикт Жёлтый. Оба High в скоупе задачи (эта же задача создала оба артефакта — коммит с golden и реестр мутантов) — чинятся автором в текущем issue, без нового issue. Остальные девять AC (1–10, 12) выполнены и доказаны исполнением, не только текстом. Low-1 снят с записью, без обязательной правки.