Files
houseplan-card/docs/reviews/CODE-REVIEW-276-r1.md
T
2026-08-24 02:51:26 +00:00

276 lines
22 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-276-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/276
- **Ветка:** `issue/276-reconcile-coincident-partition`
- **Проверенный HEAD:** `496e79a6dff56db2d5e491d11d02c67ca77d08d2` (совпадает с заявленным в хендоффе автора)
- **Диапазон:** `origin/dev...HEAD` (`origin/dev` = `240316a6`)
- **ТЗ:** `docs/specs/276-coincident-partition-reconciliation.md`, зелёное ревью
`docs/reviews/SPEC-REVIEW-276-r3.md` (3 захода на этапе spec, 1/4
блокирующих циклов израсходовано там; счётчик код-ревью отдельный)
- **Заход:** r1 (код-ревью) · блокирующих циклов до этого раунда: 0/4
## 1. Скоуп проверки
Полный разбор (это первый заход код-ревью, §2.10 не применяется).
Диапазон коммитов:
```
a06c94b9 docs: specify coincident partition reconciliation (ТЗ, r1..r3 циклы spec)
ca516643 docs: address spec review for partition reconciliation
cfc8f211 docs: cover nested coincident partition thickness
a6056111 fix: reconcile coincident partitions during Optimize ← продуктовый код
434995be test: strengthen coincident partition mutation
496e79a6 docs: refresh screenshot fingerprint
```
Продуктовая логика — единственный коммит `a6056111`. Он несёт `Issue: #276`,
`User-Visible: yes`, и в нём же — оба changelog (`docs/CHANGELOG.md`,
`docs/CHANGELOG.ru.md`), `docs/USER-GUIDE.md`/`.ru.md`, `docs/WALL-THICKNESS.md`,
`docs/ARCHITECTURE.md`, `docs/TESTING.md`, `docs/STATUS.md`, все тесты, demo
smoke, `scripts/mutation-gate.mjs`, `scripts/smoke-links.mjs` и три копии
бандла. `434995be` — тест-только правка мутанта (`User-Visible: no`, верно).
`496e79a6` — обновление отпечатка скриншотов (`User-Visible: no`, верно,
отдельный коммит по прецеденту #237).
## 2. Как проверялось (гейты)
Дешёвые гейты — всегда, в этом раунде тоже:
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | green, без вывода |
| Unit | `npm test` | **1176/1176 passed**, 0 skipped, 0 failed (авторский хендофф заявлял 1175 passed + 1 штатно skipped — расхождение на 1 тест воспроизведено средой: пропуски в `test/process-gate.test.mjs` условны на `git`/`gh`-стаб и различаются Windows/Linux; не дефект диффа) |
| Build + sync | `npm run build && npm run bundle:sync` | green; `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `demo/srv/assets/houseplan-card.js` — три копии byte-identical, `git status` чистый после сборки |
| check-docs | `node scripts/check-docs.mjs` | green: «Documentation checks passed (7 files, 10 external links)» — обязателен, диффа задел `src/**` |
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` — полный вывод
приложен ниже с решением по каждой строке.
```
Прямое совпадение (13): smoke_zero_divider_taper, smoke_decor_layer_order,
smoke_drag_bounds, smoke_furniture, smoke_glow, smoke_grid_snap,
smoke_infinite_canvas, smoke_merge_split, smoke_open_passage,
smoke_opening_tunnel_fill, smoke_optimize_micro_interval,
smoke_optional_space_model, smoke_split_corner_wall
Зарегистрированная связь (3): smoke_optimize_coincident_partition,
smoke_resize_wall_thickness, smoke_wall_key_roundtrip
```
Решение: все 13 «прямых совпадений» — следствие того, что новый модуль
импортирует широко используемые имена (`wallKey`, `NORM_W`, `GRID_PITCH`,
`SpaceModel`), а не потому что диффа меняет их контракт — классический
«слабая связь по распространённому имени» (PROCESS.md §8). Ни один из этих
13 смоков не относится к теме issue (decor layer order, furniture, glow
generic, grid snap, infinite canvas…), и полный прогон всех 16 не
пропорционален задаче. Прогнаны:
- `demo/smoke_optimize_coincident_partition.mjs` — прямо назван в AC6/AC7,
запущен: **все 11 проверок true** (Preview не пишет, один атомарный
Apply, reload сохраняет каноническое тело, one-shot Undo восстанавливает
hosted-форму, Boundary видит shared wall, Thickness меняет единственное тело);
- `demo/smoke_wall_key_roundtrip.mjs` — «зарегистрированная связь» напрямую
касается AC10 (consumer parity Plan/View/kiosk/Static/hidden Iso/light):
запущен, **все 24 проверки true**;
- `demo/smoke_resize_wall_thickness.mjs` — «зарегистрированная связь» по
`WallEntry`, который использует `setWallThickness`: запущен, **все 7
проверок true**; Resize сам по себе вне скоупа (#277), но это дешёвая
проверка отсутствия регресса в общем помощнике.
Остальные 10 «прямых совпадений» не прогонялись — тема (decor/furniture/
glow/grid snap/infinite canvas/merge-split/open passage/tunnel fill/split
corner) не пересекается ни с одним AC #276, а связь чисто по имени
импортированной утилиты.
Мутации (AC12, три идентификатора):
```
node scripts/mutation-gate.mjs --id=optimizer-coincident-opening-rehost-disabled
→ поймано 1 из 1
node scripts/mutation-gate.mjs --id=optimizer-coincident-partial-accepted
→ поймано 1 из 1
node scripts/mutation-gate.mjs --id=optimize-preflight-bypassed (общий #199 мутант,
переиспользован — reconciliation идёт через тот же `d.preflight?.ok` гейт)
→ поймано 1 из 1
```
Инварианты модели (диффа меняет геометрию/ссылки — обязателен, PROCESS.md §8,
#254/#253/#244/#252/#258/#259). Прогнан на выходе `optimizePlans()` для
фикстуры issue (5-см офсеты, room 15/partition 20) и на синтетической
nested-fixture (room 30/partition 20):
```
npm run invariants -- --config /tmp/276-reconciled.json
→ Инварианты выполнены: ссылки разрешимы, записи толщины находятся.
npm run invariants -- --config /tmp/276-nested.json
→ Инварианты выполнены: ссылки разрешимы, записи толщины находятся.
```
Оба файла получены прогоном `test-build/plan-optimizer.js` на входных
данных теста (команда воспроизводима, не «verified» без результата).
**Одно число — один источник.** Новые видимые числа — два счётчика Optimize
report (`partitionsReconciled`, `openingsRehosted`); каждый рендерится ровно
в одном месте (`src/houseplan-card.ts` вокруг строки 16233) через
существующий `_t()`-путь, как остальные счётчики отчёта. Итоговая толщина
общей стены (`max(roomCm, partitionCm)`) после Apply читается Thickness и
Boundary из одного и того же `space.walls` — тот же источник, что и для
любой обычной стены, второго хранения нет. `node --test
test/single-source-numbers.test.mjs` — 3/3 green, диффа не касается
форматтера.
## 3. Не прогонялось и почему
- **Полный `golden` матрикс (155 сценариев)** — предрелизный гейт (PROCESS.md
§8, §11.4), не гейт код-ревью; диффа не меняет ни один существующий golden
fixture (нет coincident-partition геометрии ни в одном текущем сценарии
`demo/golden/matrix.mjs`), поэтому целевой прогон был бы no-op, а полный —
не соразмерен задаче. См. находку M3 — целевой сценарий для #276 в матрицу
не добавлен вовсе, поэтому полный/предрелизный прогон и не смог бы его
проверить.
- **`python -m pytest tests_backend`** — диффа не касается `custom_components/**/*.py`.
- **Полная performance-матрица / `demo/benchmark_large_house.mjs` как есть** —
не тот профиль (это render/pointer-путь, не «Optimize candidate pass»);
вместо него — целевое измерение см. §4, M1.
- **`node demo/smoke_*.mjs` полный набор (177)** — не соразмерно; выбор по
инструменту и теме см. §2.
## 4. Находки
Все три ниже — **Medium, в скоупе задачи** (High не найдено, красного нет).
По правилу §2.7/§4 это жёлтый вердикт, автор чинит в этом же issue, фикс
проходит следующий раунд.
### M1 — AC11 (перф-бюджет §10) не доказан ни автотестом, ни явным
«проверено чтением, не исполнением»
`docs/specs/276-coincident-partition-reconciliation.md` §10 обещает
«дополнительный p95 не превышает 15%... и 25 ms абсолютного overhead» на
large-house fixture, а AC11 называет доказательство: «Benchmark +
call-count test». Ни то, ни другое не добавлено:
- `demo/benchmark_large_house.mjs` — рендер/pointer-профиль, не измеряет
`optimizePlans()`;
- `demo/benchmark_optimize_geometry_preflight.mjs` — измеряет только #199
preflight, вызывается на данных, подготовленных `prepareSpacePhysicalGeometryInputs`,
и не включает новый pass;
- ни один тест не проверяет счётчик вызовов `reconcileCoincidentPartitions`
(call-count) — то есть будущая регрессия «функция стала вызываться на
render/pointer path» не будет отловлена ничем.
Я измерил бюджет сам (не входит в зачёт «тест умеет падать» — это ad hoc
проверка ревьюера, а не доказательство автора), чтобы понять, реален ли
риск:
```
git worktree add --detach /tmp/hp-baseline cfc8f211 # до продуктового коммита
# baseline (без reconciliation), large-house fixture, N=30:
BASELINE min 31.20 median 34.59 p95 50.25 max 107.17
# HEAD (496e79a6), тот же прогон:
HEAD min 30.62 median 34.07 p95 50.98 max 105.23
```
Разница p95 — 0.73 ms (~1.5%), внутри бюджета §10 с большим запасом.
**Фактического риска для перфоманса нет**, но это устанавливает ревьюер
разовым замером, а не тест, который переживёт следующую правку и умеет
упасть. Чинится добавлением: (а) целевого бенчмарка `optimizePlans()` на
large-house fixture с ассертом бюджета §10, аналогично
`benchmark_optimize_geometry_preflight.mjs`; (б) unit-теста, считающего
вызовы `reconcileCoincidentPartitions` только из `optimizePlans()` (например,
через инструментированный импорт/счётчик), либо равноценного доказательства
«не вызывается на render/pointer» по коду с явной пометкой в тесте.
### M2 — AC3 «несколько hosted openings на одной partition» не покрыто тестом
Спецификация (AC3) и план тестов (§13) требуют доказательства для «один И
**несколько** hosted door/window/gate». Все три источника (fixture
`test/fixtures/276-coincident-partition.json`, `test/coincident-partitions.test.mjs`,
`test/plan-optimizer.test.mjs`) используют ровно один hosted opening на
кандидата; парный overlap-цикл в `src/coincident-partitions.ts:265-270`
(`for i; for j > i;`) и `openingsRehosted += nextHosted.length` для случая
`length > 1` остаются непроверенными автотестом.
Проверил сам, чтобы понять фактический риск (ad hoc, не входит в зачёт
автора):
```js
// два hosted openings (door + window) на одной coincident partition
partitionsReconciled 1 openingsRehosted 2
// оба opening корректно материализованы на своих x/y/angle, host снят,
// повторный optimizePlans() — changed=false
```
Код и в этом случае работает верно — но это не устраняет разрыв: без
committed-теста регресс в парном overlap-цикле не будет пойман никем. Чинится
добавлением одного кандидата с двумя (лучше — тремя, чтобы задеть все пары
`i<j`) hosted openings в `test/coincident-partitions.test.mjs` либо
`test/plan-optimizer.test.mjs`, включая позитивный (не конфликтующие) и
негативный (два новых opening перекрылись бы после rehost → fail-closed)
случай.
### M3 — AC5 «targeted golden» не добавлен ни в каком виде
`docs/specs/276-coincident-partition-reconciliation.md` §13 обещает
«targeted golden: short 5-cm offsets + hosted door до/после 10/30/virtual», и
AC5 называет доказательством «Canonical geometry unit + targeted golden».
`demo/golden/matrix.mjs` не тронут этим диффом — ни один сценарий для #276 не
существует. Геометрическая часть AC5 полностью доказана (`wallIntervals`
unit-проверка в `test/coincident-partitions.test.mjs`/`plan-optimizer.test.mjs`
+ browser-smoke), но визуальный/пиксельный канал, который отдельно ловил
предыдущие регрессы такого рода (#234: подсветка «Толщины» и запись
расходились именно на уровне рендера, не геометрии), для этой фичи не
существует вовсе — ни сейчас, ни в будущем предрелизном прогоне, потому что
сценарий просто не описан в матрице. Это не блокирует Apply/AC своей
неполнотой сегодня (риск чисто визуального дефекта низкий: путь рендера
переиспользует давно проверенный `physicalBodyParts()`/`wallBodiesGeometry`,
никакой новой отрисовки не добавлено), но обещанный тест-план не выполнен.
Чинится добавлением сценария в `demo/golden/matrix.mjs` (короткие 5-см
офсеты + hosted door, до/после 10/30/virtual) — сама запись сценария не
требует прохождения через `golden:accept`, это делает пре-релизный гейт
(§8/§11.4) при первом реальном захвате.
## 5. Проверка AC — что доказано и как
| AC | Статус | Доказательство |
|---|---|---|
| AC1 | ✅ | `test('issue 276 exact proof ignores endpoint direction and room order')` — матрица 2×2 (direction×room order), все проходят |
| AC2 | ✅ | `test/plan-optimizer.test.mjs`: same-cm (20/20), nested narrower (30/20→30), nested wider (20/30→30), partial (byte-equivalent), ambiguous second partition (byte-equivalent); `test/coincident-partitions.test.mjs`: orphan/overlap/draft/column/unknown-field все byte-equivalent |
| AC3 | 🟡 частично | один hosted opening — unit + unknown-field round-trip (`future_field`) доказаны; **несколько** — не покрыто автотестом (M2), верно по факту исполнения (проверено мной ad hoc), но не доказано committed-тестом |
| AC4 | ✅ | `test('issue 276 fails closed for an orphan host, overlap, draft, column and unknown partition data')` — 5 вариантов, все fail-closed |
| AC5 | 🟡 частично | геометрическая эквивалентность — unit (`wallIntervals` после Apply) + browser smoke; **targeted golden не добавлен** (M3) |
| AC6 | ✅ | `smoke_optimize_coincident_partition.mjs`: `boundarySeesSharedWall`, `thicknessToolSelectsCanonicalWall`, `thicknessChangesSingleBody`, `previewDoesNotWrite` — все true |
| AC7 | ✅ | i18n unit (`test/i18n.test.mjs` проверяет обе строки в исходнике card) + smoke (`reportRendersBothCounters`, `applyUsesOneAtomicWrite`, `applyEnablesUndo`, `undoRestoresHostedPartition`) |
| AC8 | ✅ | Идемпотентность доказана в обоих unit-тестах (`second.changed === false`, `second.report.partitionsReconciled === 0`, `deepEqual(second.config, first.config)`) и smoke (reload) |
| AC9 | ✅ (проверено чтением, переиспользует #199) | `_runAlignToGrid()` блокирует Apply на `!d.preflight?.ok` (src/houseplan-card.ts:15112) — путь общий для всех Optimize-кандидатов, включая новый; мутант `optimize-preflight-bypassed` подтверждён (1/1) |
| AC10 | ✅ (проверено чтением) | Все консумеры (Plan/View/kiosk/Static/hidden Iso/Glow/sun) читают геометрию из одного `model.partitions`/`space.walls` через `physicalBodyParts()`/`modelOf()` — нет отдельной копии на consumer; после Apply запись `partitions` удаляется из персистентной конфигурации целиком, поэтому расхождения консумеров структурно невозможны. Отдельного dedicated parity-теста для #276 нет, но `smoke_wall_key_roundtrip.mjs` (запущен, 24/24 green) подтверждает единый источник для смежного случая |
| AC11 | 🟡 частично | call-count и бюджет §10 не доказаны автотестом (M1); эмпирически бюджет выполняется (p95 +1.5%, см. M1) |
| AC12 | ✅ | 3 мутанта, все 1/1 (см. §2) |
| AC13 | ✅ | typecheck/test/build+sync — все green, см. §2 |
| AC14 | ✅ | оба changelog, USER-GUIDE (RU/EN), WALL-THICKNESS.md, ARCHITECTURE.md, TESTING.md, STATUS.md — все в одном коммите с кодом, терминология («Толщина», «Граница») соответствует USER-GUIDE |
## 6. Прочее (не находки, для полноты)
- Хостинг документации/строк использует существующую терминологию
(«Граница», «Толщина») — не изобретена.
- `rawPartitionKnown()` фильтрует по сырому JSON (`{id,a,b,cm}`), а не по
нормализованной модели — верно, иначе неизвестное поле терялось бы до
проверки; подтверждено тестом `unknown partition data`.
- Эпсилон `Math.max(GRID_PITCH * 0.0002, 1e-9)` — доля шага сетки (~0.02%
клетки), а не сама сетка; соответствует запрету ТЗ «пользовательская сетка
не является epsilon».
- `columnBlocks()` использует точный circumradius (квадрат — `size *
Math.SQRT1_2`, круг — `size * 0.5`), что совпадает с фактической
геометрией `columnBody()` в `src/physical-geometry.ts` — не эвристика.
## 7. Вывод
Ядро реализации корректно, идемпотентно и хорошо покрыто тестами по
основным путям; все проверенные мной гейты (typecheck/test/build/check-docs/
целевые smoke/mutation/invariants) зелёные, три копии бандла синхронны,
трейлеры и changelog на месте. Три Medium-находки — все про **полноту
доказательной базы**, названной в самом ТЗ (AC3/AC5/AC11), а не про дефект
поведения; ни одна не про-функциональна (я лично исполнением подтвердил, что
множественные hosted openings и перф-бюджет фактически работают верно).
Все три в скоупе issue #276 и чинятся автором в этом же issue.
**Вердикт: жёлтый.**