Files
houseplan-card/docs/reviews/CODE-REVIEW-253-r1.md
T
2026-08-23 08:59:49 +00:00

191 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CODE-REVIEW-253-r1
- Issue: [#253](https://github.com/Matysh/houseplan-card/issues/253) — Resize в некоторых случаях теряет толщину стен
- Этап: code (PROCESS.md §2.7)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4
- Ветка: `issue/253-resize-wall-thickness`, HEAD `ffb609d47144c19dd7d4a18a9491216ae7b619f6`
- Диапазон материала: `origin/dev..HEAD` (4 коммита: `d9f7861`, `9d6a4e3`, `bfdeb49`, `ffb609d`)
- ТЗ: `docs/specs/253-resize-wall-thickness.md`, одобрено SPEC-REVIEW-253-r1 (зелёный, High 0 / Medium 0)
## Скоуп проверки
Продуктовый код: `src/wall-thickness.ts` (`rekeyWallsAfterMove`, полностью переписана
процедура сопоставления записей). `src/houseplan-card.ts` не изменён — по замыслу
ТЗ (§17.1) фикс целиком в чистой функции; `_rszApplyPreview` вызывает её тем же
контрактом `oldSpans/newSpans`, что и раньше.
Сопутствующее: `test/wall-thickness.test.mjs` (+8 табличных тестов),
`demo/smoke_resize_wall_thickness.mjs` (новый production-bundle smoke),
`scripts/mutation-gate.mjs` (+2 мутанта), `scripts/smoke-links.mjs` (+1 связь),
RU/EN `docs/WALL-THICKNESS.md`, `docs/RESIZE.md`, `docs/ARCHITECTURE.md`,
`docs/USER-GUIDE{,.ru}.md`, оба `docs/CHANGELOG{,.ru}.md`, обновление
`docs/TESTING.md`, обновление статуса ТЗ, отпечаток скриншотов
(`docs/images/screenshots.json`, `09-device-info.png`).
Диапазон трогает геометрию, привязанную к `space.walls` → инварианты модели
обязательны (см. ниже). Видимого поведения без изменения контракта UI нет
(handles/snap/минимальные размеры не менялись) — сверено с `docs/RESIZE.md` и
`docs/USER-GUIDE.ru.md`, терминология описания результата взята оттуда же
("толщина", "проём", "стена"), новых терминов не изобретено.
## Как проверялось
### Алгоритм по коду (проверено чтением и числовым воспроизведением)
Прочитал полный диф `rekeyWallsAfterMove` (`src/wall-thickness.ts:438-…`) и
сверил его с контрактом §6.2/§6.3/§6.4 ТЗ пункт за пунктом:
- **Партиция exact-записи.** Для каждой точной записи (`entrySpan` не null)
собираются все `moves` (реально сдвинутые рёбра, отфильтрованные по
`exactEps`), коллинеарные записи (`angleClose` + `lineDistance ≤ tol`);
их проекции на ось записи дают набор `[lo, hi]`; границы всех пересечений
сортируются и дедуплицируются в `bounds`; каждый получившийся fragment либо
переносится по `t` из старого в новое ребро (`mapPoint`), либо остаётся как
есть, если fragment не покрыт ни одним move. Это ровно алгоритм §6.2.
- **Несколько согласованных transform.** Для fragment, попавшего в диапазон
двух `moves` (общая стена, обе смежные комнаты в `g.changed`), берётся первый
кандидат, остальные сверяются `closePoint`; при расхождении срабатывает
fail-closed — исходная геометрия fragment сохраняется без выбора по порядку
массива (§6.3). Числовой тест с переставленными аргументами это подтверждает.
- **Дедупликация.** `pushExact` объединяет результат только при совпадении
canonical `a/b` (`closePoint`) **и** `cm`; одинаковый `key` не является
условием слияния — легаси и exact-ветки идут раздельно, `used`-множества по
ключу больше нет нигде в функции (§6.4).
- **Легаси key-only записи.** Используют `keyMoves` (карта "старый ключ → все
целевые ключи"), и переносятся только при однозначном совпадении
(`size === 1`); при неоднозначности запись остаётся под старым ключом, а не
теряется — это расширение духа fail-closed на путь, для которого в ТЗ нет
отдельного явного пункта, но оно не противоречит ни одному AC.
### Тест "умеет падать" — временный откат
Скопировал `src/wall-thickness.ts` из `origin/dev`, пересобрал `test-build`
(`npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs`) и прогнал
только новые тесты:
```
node --test --test-name-pattern="issue 253" test/wall-thickness.test.mjs
```
Результат на **дефектном** коде: 6 из 8 падают (включая контрольный сценарий
"splits a longer exact wall", "key collisions never erase", "deduplicates only
identical exact geometry", "500-record boundary"). Вернул файл на версию из
диффа, тот же прогон — 8/8 зелёных. Тесты не переформулируют старое поведение,
они действительно проверяют исправленный дефект.
### Числовая проверка реального сценария (AC1, AC6)
`demo/smoke_resize_wall_thickness.mjs` воспроизводит контрольный сценарий §7.3
ТЗ (сауна, 33 см, `x 0.0708…0.4208` → раздел на `x 0.0708…0.2042` и
`x 0.2042…0.4208`) через настоящие обработчики (`pointerdown/move/up`,
`_rszEdgeDown → _rszMove → _rszUp` тем же путём, что мышь), плюс проём на
перемещаемой части и `_undoGeometry()`. Прогнал:
```
node demo/smoke_resize_wall_thickness.mjs → 7/7 OK
```
Проверяет: `liveKeepsBothIntervals`, `liveOpeningMoves`, `liveThicknessVisible`
(через `_intervalCm`, ту же функцию, что использует рендер для штриховки),
`commitKeepsBothIntervals`, `commitOpeningMoves`, `undoRestoresExactSource`
(побайтовое сравнение JSON `rooms/walls/openings` до и после Undo).
### Мутационные стражи (AC4, AC5)
```
node scripts/mutation-gate.mjs --id=resize-wall-partial-overlap-not-split → поймано 1 из 1
node scripts/mutation-gate.mjs --id=resize-wall-key-collision-drops-record → поймано 1 из 1
```
Оба мутанта содержательные: первый убирает границы partition (регресс к
whole-edge поведению), второй возвращает дедупликацию по `key` вместо
canonical geometry+cm (буквально старый баг).
### Общие гейты
- `npx tsc --noEmit` → чисто.
- `npm test` → 1148/1148 pass, 0 fail (у автора в отчёте "1147 pass, 1 skip" —
разошлось с моим прогоном на 1 skip; итог тот же: 0 fail, не блокирует).
- `npm run build` + `npm run bundle:sync` → SHA-256 `dist/houseplan-card.js` и
`custom_components/houseplan/frontend/houseplan-card.js` совпадают
(`645731f3…304e`), как заявлено в handoff.
- `node scripts/check-docs.mjs` → "Documentation checks passed (7 files, 10
external links)" — обязателен, т.к. diff трогает `src/**`.
- `node scripts/process-gate.mjs --range origin/dev..HEAD --issues` → зелёный,
предупреждений 0. Трейлеры `Issue: #253` / `User-Visible: yes` — в коммите
`bfdeb49`, вместе с продуктовым кодом и обоими changelog в одном коммите.
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → "изменено
файлов src/**: 1", одна **зарегистрированная связь**:
`demo/smoke_resize_wall_thickness.mjs` (прямого совпадения по имени символа
инструмент не даёт, так как бандл не экспортирует имя чистого
трансформера — это ожидаемо описано в самой записи связи). Прогнал его
(см. выше), плюс два смока, которые автор назвал регресс-контролем той же
области: `smoke_wall_thickness.mjs` (31/31 OK), `smoke_resize_virtual_thick.mjs`
(23/23 OK) — оба покрывают соседний контракт (общие/виртуальные интервалы),
который эта правка не должна была задеть.
- Инварианты модели (diff трогает `space.walls`, геометрию): `npm test` гоняет
`checkWallRecordsPreserved` (в `test/model-invariants.test.mjs` и
`test/wall-thickness.test.mjs:310`) — зелёные. Отдельно собрал минимальный
конфиг с итоговой (после фикса) геометрией сценария §7.3 и прогнал
`node scripts/model-invariants.mjs --config <файл>` — "неразрешимых ссылок не
найдено": обе половины разрезанной записи 33 см лежат на действительных
рёбрах комнат, ни `wall_carrier`, ни `open_span_carrier` не нарушены.
### Не проверял / прогнал не всё
- **Golden.** `npm run golden:verify` не запускал. Raster baseline не менялась
(единственная тронутая картинка — доковский скриншот `09-device-info.png`,
часть отпечатка `docs:accept`, не golden-контур); правка не трогает пути
рендера (SVG/hatch/layout), только персистентную модель `space.walls`.
Совпадает с решением автора и §12.4 п.8 ТЗ.
- **Backend pytest.** Не запускал — `custom_components/**/*.py` не менялся.
- **Полный `demo/smoke_*.mjs` (172 шт.).** Не прогонял весь набор — задача
локальна к одной чистой функции, `smoke-select` не выявил широкого символа
(10 символов на изменённых строках, порог "широкого" — 34). Прогнал
зарегистрированную связь и два тематических регресс-смока (см. выше), не
прогонял, например, `smoke_wall_junctions.mjs` (сценарий про T-стыки, а не
про частичное перекрытие толщины — нет ни прямого совпадения, ни
зарегистрированной связи, ни слабой связи по имени).
- **Полный HA harness / performance-профили.** Не заявлены в AC, не запускал.
- **Ручное тестирование в браузере.** Не выполнялось (не входит в цикл
ревью); замена — production-bundle smoke через реальные pointer-обработчики
и деталь: сборка использует финальный `dist` бандл, а не тестовые заглушки.
## Находки
Нет находок уровня High или Medium. Просмотрел отдельно риск "same exact
geometry, different `cm` — конфликт не должен решаться молча" (§6.4 ТЗ,
AC5): проверка есть, `test/wall-thickness.test.mjs` — тест
`issue 253 deduplicates only identical exact geometry with identical thickness`
подаёт на вход три записи (`cm=20` дважды с одной геометрией в двух
направлениях + `cm=30` на той же геометрии) и проверяет, что результат — ровно
две записи `[20, 30]`, то есть слияние происходит только для полностью
идентичной пары, а конфликтная по `cm` запись сохраняется отдельно. AC5
доказан полностью, находки не завожу.
Low-уровня находок, требующих отдельной записи, тоже нет: код без
`TODO/FIXME`, без забытых `console.log`, без отклонений между документацией
(`WALL-THICKNESS.md`, `RESIZE.md`, `ARCHITECTURE.md`, `USER-GUIDE.ru.md`) и
фактическим поведением функции.
## Проверено и корректно (по каждому AC)
| AC | Статус | Как подтверждено |
|---|---|---|
| AC1 | Подтверждено | `demo/smoke_resize_wall_thickness.mjs` 7/7; временный откат на `origin/dev` показал, что тот же сценарий (тест "splits a longer exact wall") красный без фикса |
| AC2 | Подтверждено | `test/wall-thickness.test.mjs`: горизонталь/вертикаль/диагональ/reversed, числовые concrete endpoints, а не только count |
| AC3 | Подтверждено | `npm test` 1148/1148, включая существующие whole-edge/legacy/partial-virtual тесты |
| AC4 | Подтверждено | тест "equivalent shared-room transforms apply once and conflicts fail closed": одинаковый transform схлопывается в одну запись; при перестановке аргументов конфликтный результат идентичен (`conflictA === conflictB` по значению) и не выбирается по порядку |
| AC5 | Подтверждено | коллизия key у разных exact/legacy записей не удаляет ни одну (тест "key collisions never erase…"); идентичная geometry+cm объединяется, а geometry+другой cm — нет (тест "deduplicates only identical…"); мутант `resize-wall-key-collision-drops-record` пойман |
| AC6 | Подтверждено | тот же production-bundle smoke: `liveOpeningMoves`/`commitOpeningMoves` через реальный проём на перемещаемой части, тело кладки проверено через `_intervalCm` (функция самого рендера, не дублирующая логика) |
| AC7 | Наследовано без изменений кода | `_rszApplyPreview`/commit/cancel контракт не тронут (`src/houseplan-card.ts` вне диффа); smoke дополнительно подтверждает Undo восстанавливает точный JSON-снимок |
| AC8 | Подтверждено | `checkWallRecordsPreserved` в объединённом прогоне `npm test`; ни один `cm`, представленный до операции, не исчезает по всем табличным сценариям, включая три исходных регресс-сценария issue |
| AC9 | Подтверждено | тест "unchanged context edges preserve the wall array semantically": при отсутствии реального движения выход `deepEqual` входу (новый массив, те же ссылки на записи — `wallEntry`/`{...w}` не создаётся заново) |
| AC10 | Подтверждено | `check-docs.mjs` зелёный; RU/EN `USER-GUIDE`/`CHANGELOG` описывают результат согласованно; bundle SHA-256 совпадают |
## Вердикт
Зелёный. Код реализует ТЗ буквально, все 10 AC доказаны инструментами,
названными в самом ТЗ, мутационные стражи содержательны, а не тавтологичны,
контрольный числовой сценарий из issue воспроизведён и исправлен. Находок нет.