mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Ревью #682 r1, Medium: перенос добавляет документу уровень вложенности (`docs/reviews/X.md` → `legacy/reviews/<тег>/X.md`, `docs/specs/` → `legacy/specs/`), а относительные ссылки внутри перенесённых документов и в соседях, ссылавшихся на них, никто не пересчитывал — на `97d19268` 53 битые ссылки в 46 файлах (заявление «все 26 резолвятся» в `7feb6177` было верно только до переноса документов ревью). Гейты архив не смотрят. `reviews-archive.mjs`: `repairLinks` пересчитывает ссылку, если она не резолвится от нового места, а цель находится от нового или старого места через карту переносов; битая и до переноса ссылка не трогается. `--apply` делает это само, `--repair-links=<rev>` — для всех переименований `<rev>..HEAD`, `--check-links` печатает битые. Этим коммитом `--repair-links=origin/dev` переписал ровно 53 ссылки в 46 файлах; остались две прежние «...»-заглушки в CODE-REVIEW-448-r2 (битые и на dev). Тесты: перенесённый документ, сосед со ссылкой в архив, ТЗ со ссылкой на позже перенесённое ревью, битая-до-переноса не трогается, в `legacy/` битых нет; мутант `reviews-archive-links-from-new-place-only`. PROCESS §2.10 и DEVELOPMENT › Release называют переписывание и `--check-links`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
236 lines
22 KiB
Markdown
236 lines
22 KiB
Markdown
# Код-ревью issue #198 — очистка изолированного микро-интервала толщины через Optimize
|
||
|
||
- Цикл: r1/4
|
||
- Вердикт: **жёлтый** · High: 0 · Medium: 2 (обе — в скоупе задачи)
|
||
- Диапазон: `git log --oneline origin/dev..HEAD` = `0df8051` (реализация),
|
||
`8c9241b` (ревью ТЗ r1), `9d6cd8b` (ТЗ)
|
||
- Диапазон диффа: `git diff origin/dev...HEAD`
|
||
- ТЗ: [`docs/specs/198-optimize-micro-interval.md`](../../specs/198-optimize-micro-interval.md)
|
||
(ревью ТЗ зелёное, r1)
|
||
|
||
## Скоуп
|
||
|
||
Issue #198: явное действие «Общие настройки → Оптимизировать планы» получает
|
||
новый, строго ограниченный lossy-шаг — схлопывание одиночного изолированного
|
||
интервала толщины стены короче половины шага сетки между двумя одинаковыми
|
||
коллинеарными соседями, при отсутствии room-vertex/opening-узла на его концах.
|
||
Runtime и редактор остаются lossless (не трогаются).
|
||
|
||
Файлы диапазона: `src/plan-optimizer.ts` (новый helper
|
||
`collapseIsolatedWallThicknessIslands` + его вызов в `optimizePlans()`),
|
||
`test/plan-optimizer.test.mjs` (unit-матрица), `demo/smoke_optimize_micro_interval.mjs`
|
||
(новый targeted browser smoke), `scripts/mutation-gate.mjs` (новый mutant
|
||
`optimizer-micro-interval-cleanup-disabled`), три синхронные копии бандла,
|
||
`docs/CHANGELOG.md` + `.ru.md`, `docs/USER-GUIDE.ru.md`, `docs/WALL-THICKNESS.md`,
|
||
`docs/TESTING.md`, `docs/STATUS.md`, `docs/specs/README.md`.
|
||
|
||
`custom_components/houseplan/**/*.py` не затронут — корректно, конфиг уже
|
||
приходит с фронтенда готовым (подтверждено чтением: диапазон не содержит правок
|
||
в `websocket_api.py`/`validation.py`).
|
||
|
||
## Как проверялось
|
||
|
||
| Гейт | Результат | Команда |
|
||
|---|---|---|
|
||
| typecheck | green | `npx tsc --noEmit` |
|
||
| unit | green, 915/915 | `npm test` |
|
||
| build + 3 копии бандла | green, побайтово идентичны | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` |
|
||
| targeted smoke (AC7) | green | `node demo/smoke_optimize_micro_interval.mjs` |
|
||
| смежный Optimize-smoke (surface `_openAlignDialog`/`_runAlignToGrid`, задет вставкой шага в пайплайн) | green | `node demo/smoke_grid_snap.mjs` |
|
||
| смежный align/guides smoke | green | `node demo/smoke_align_guides.mjs` |
|
||
| mutation-gate, полный self-check | green, включая новый мутант | `node scripts/mutation-gate.mjs --check` |
|
||
| mutation-gate, целевой мутант (AC9) | мутант пойман 1/1 | `node scripts/mutation-gate.mjs --id=optimizer-micro-interval-cleanup-disabled` |
|
||
| whitespace | чисто | `git diff --check origin/dev...HEAD` |
|
||
| **прямая проверка AC5 и AC8 чтением + исполнением** (см. находки ниже) | функционально корректно, но не покрыто автотестом задачи | одноразовый node-скрипт против `test-build/plan-optimizer.js` и `test-build/wall-thickness.js` (см. ниже) |
|
||
|
||
**Не прогонялось и почему:**
|
||
- `npm run golden:verify` — диапазон не трогает рендер/геометрию/стили/слои,
|
||
только данные `walls` внутри explicit maintenance-действия; ТЗ §10 явно
|
||
освобождает от golden, визуальный итог доказывается persisted profile.
|
||
Пропуск обоснован diff'ом, не автоматический.
|
||
- `python -m pytest tests_backend -q` — ни один файл `custom_components/**/*.py`
|
||
не входит в диапазон.
|
||
- performance-профили — не названы в AC; ТЗ §11 явно фиксирует «без работы в
|
||
render/state tick», проход выполняется только по явному admin-действию на
|
||
малых wall lists.
|
||
- Полный набор из 127 browser-смоков — диапазон касается только поверхности
|
||
Optimize; прогнаны все три smoke, реально трогающие эту поверхность
|
||
(`smoke_optimize_micro_interval`, `smoke_grid_snap`, `smoke_align_guides`).
|
||
Остальные 124 не относятся к затронутым файлам.
|
||
|
||
## AC — построчно
|
||
|
||
| AC | Статус | Доказательство |
|
||
|---|---|---|
|
||
| AC1 (`22→15→22` → один 22-см entry) | ✅ | `test/plan-optimizer.test.mjs` — тест «Optimize collapses one isolated thickness micro-interval…», прогнан, зелёный |
|
||
| AC2 (строгий порог `0.5×pitch`, обе координатные шкалы) | ✅ | «micro-interval cleanup has a strict half-step boundary…», прогнан для `S/3`, `S/2`, `S/2+S/100`, обе шкалы |
|
||
| AC3 (торец/разные соседи/цепочка/offset/неоднозначное перекрытие сохраняются) | ✅ | «micro-interval cleanup preserves ambiguous and topological boundaries», 5 под-сценариев, прогнан |
|
||
| AC4 (room vertex / opening endpoint на границе центра запрещает очистку) | ✅ | тот же тест, под-сценарии `atVertex`/`atOpening`, прогнан. Вершина проверена только на левом конце центра — по коду проверка `isTopologyNode(a) \|\| isTopologyNode(b)` симметрична, поэтому это репрезентативно, а не дыра |
|
||
| AC5 (реверс endpoints / перестановка записей не меняют результат; входы не мутируют) | ⚠️ **не покрыто выделенным автотестом** | см. Medium-1 ниже — проверено вручную чтением + исполнением, автотеста в диффе нет |
|
||
| AC6 (preview `changed`/`canonicalized`/`wallsMerged`, идемпотентность) | ✅ | тот же первый тест, `second.changed === false` |
|
||
| AC7 (UI Preview/Apply/Undo/Cancel) | ✅ | `node demo/smoke_optimize_micro_interval.mjs`, прогнан против production-бандла, все 10 подпроверок green |
|
||
| AC8 (lossless-helpers вне Optimize сохраняют тот же микро-интервал) | ⚠️ **обещанный «focused regression unit» отсутствует** | см. Medium-2 ниже — проверено вручную вызовом `normalizeWallIntervals`/`degradeWalls` напрямую, `test/wall-thickness.test.mjs` не тронут этим диффом |
|
||
| AC9 (mutation guard) | ✅ | `node scripts/mutation-gate.mjs --id=optimizer-micro-interval-cleanup-disabled` → «мутант поймано 1 из 1» |
|
||
| AC10 (рабочие гейты зелёные) | ✅ | см. таблицу гейтов выше |
|
||
|
||
## Находки
|
||
|
||
### Medium-1 (в скоупе) — AC5 не доказан заявленным способом
|
||
|
||
**Файл:** `test/plan-optimizer.test.mjs`
|
||
|
||
**Суть:** ТЗ §9/§10 явно обещает для `collapseIsolatedWallThicknessIslands`
|
||
«Reversal/permutation/immutability units» как доказательство AC5. В диффе
|
||
такого теста нет: единственная проверка неизменности входа (`preview must not
|
||
mutate its input`, строка ~44) относится к `optimizePlans()` целиком и ничего
|
||
не доказывает о новом helper'е — `optimizePlans` клонирует `configIn` в самом
|
||
начале (`const config = clone(configIn)`), поэтому даже гипотетическая мутация
|
||
внутри `collapseIsolatedWallThicknessIslands` на этом уровне невидима.
|
||
|
||
**Сценарий отказа, который мог бы проскочить незамеченным:** если бы будущая
|
||
правка `collapseIsolatedWallThicknessIslands` начала мутировать переданный
|
||
`walls`-массив на месте (`walls[i].cm = target` вместо иммутабельного `.map`),
|
||
или если бы порядок `walls`/`rooms` на входе стал влиять на результат —
|
||
ни один существующий тест не покраснел бы.
|
||
|
||
**Проверено чтением, не только исполнением, для этого цикла ревью:** я
|
||
исполнил вручную три пробы против собранного `test-build/plan-optimizer.js`
|
||
(реверс `a`/`b` центрального интервала, перестановка порядка массива `walls`,
|
||
реверс winding комнаты) — во всех случаях результат идентичен по толщине,
|
||
а входной массив остаётся байт-в-байт неизменным (`JSON.stringify` до/после
|
||
совпадает). Функционально AC5 выполняется: `out = walls.slice()` +
|
||
неизменяющий `.map`, `exactMatches()` явно проверяет обе ориентации
|
||
endpoints, а порядок кандидатов принудительно стабилизирован
|
||
`.sort(...localeCompare)` перед применением. Дефекта в коде нет.
|
||
|
||
**Почему это Medium, а не Low:** это не стилистическая придирка — ТЗ прошло
|
||
ревью и легло в DoR с явно названным способом доказательства именно для этого
|
||
AC; отсутствие теста означает, что будущая регрессия (см. сценарий выше) не
|
||
будет поймана автоматически, а полагаться на то, что каждый ревьюер повторит
|
||
этот ручной прогон, недопустимо для строгого продуктового инварианта
|
||
`docs/WALL-THICKNESS.md` (lossless по умолчанию).
|
||
|
||
**Что нужно:** добавить в `test/plan-optimizer.test.mjs` unit, прогоняющий
|
||
`collapseIsolatedWallThicknessIslands` на fixture из AC1 с (а) реверсированными
|
||
`a`/`b` одного из трёх wall entries, (б) переставленным порядком массива
|
||
`walls`, (в) явной проверкой, что входной `walls`-массив и его объекты не
|
||
меняются (`deepEqual` копии до/после), и по возможности — переставленным
|
||
порядком `rooms`.
|
||
|
||
### Medium-2 (в скоупе) — AC8 не доказан заявленным способом
|
||
|
||
**Файл:** `test/wall-thickness.test.mjs` (не тронут этим диффом)
|
||
|
||
**Суть:** ТЗ §10 обещает для AC8 «Existing + focused regression unit» — то
|
||
есть новый целевой unit, доказывающий, что `normalizeWallIntervals()` и
|
||
`degradeWalls()`, вызванные напрямую (без `collapseIsolatedWallThicknessIslands`,
|
||
то есть вне Optimize), сохраняют именно этот `22→15→22` fixture нетронутым.
|
||
Диапазон не содержит ни одной правки в `test/wall-thickness.test.mjs`.
|
||
Существующие тесты рядом («different solid thicknesses remain separate atomic
|
||
keys», «closing the sole geometric split preserves different thicknesses»)
|
||
доказывают лишь общий принцип «разные толщины не сливаются» на двухсегментных
|
||
fixture, а не именно на трёхсегментном sub-half-step острове из #198/#197 —
|
||
то есть не тот сценарий, который является предметом этой задачи.
|
||
|
||
**Проверено чтением и исполнением для этого цикла:** `src/wall-thickness.ts`
|
||
в диапазоне вообще не изменён — правка целиком локализована в
|
||
`src/plan-optimizer.ts`. Я вызвал `normalizeWallIntervals()` и `degradeWalls()`
|
||
напрямую (без `collapseIsolatedWallThicknessIslands`) на fixture из AC1 через
|
||
собранный `test-build/wall-thickness.js`: результат — 3 записи без изменений
|
||
(`22`, `15`, `22`), в точности как до задачи. AC8 функционально выполняется,
|
||
но обещанного нового регрессионного теста для него нет.
|
||
|
||
**Что нужно:** добавить в `test/wall-thickness.test.mjs` фокусированный unit,
|
||
вызывающий `normalizeWallIntervals`/`degradeWalls` напрямую на fixture из #198
|
||
(либо переиспользовать `microIntervalFixture` из `plan-optimizer.test.mjs`) и
|
||
доказывающий, что без прохода через `collapseIsolatedWallThicknessIslands`
|
||
три записи остаются как есть.
|
||
|
||
### Low — нет
|
||
|
||
Находок уровня Low не выявлено.
|
||
|
||
## Что проверено и корректно
|
||
|
||
- **Контракт §6 ТЗ (шесть условий безопасного схлопывания)** реализован
|
||
предметно, а не приблизительно: строгий порог `< 0.5×GRID_PITCH` с
|
||
ulp-допуском (`gridPitch * 1e-9`, комментарий в коде честно объясняет
|
||
зачем), проверка коллинеарности через принадлежность одному `parent`-ребру
|
||
`roomWallProfile`, узловая защита через объединённый список вершин комнат +
|
||
концов `openCuts`, запрет каскада через сбор кандидатов на одном
|
||
pre-change snapshot до применения любых замен.
|
||
- **Неоднозначность.** `candidates.get(key).targets` как `Set` корректно
|
||
улавливает случай, когда два разных room-profile или два конфликтующих
|
||
exact-owner’а той же физической стороны называют разную целевую толщину —
|
||
такой кандидат отбрасывается фильтром `targets.size === 1`, дополнительно
|
||
подстрахован проверкой `new Set(owners.map(w => w.cm)).size > 1` на втором
|
||
проходе. Подтверждено и тестом «conflicting exact owners…», и чтением.
|
||
Не забыл проверить именно то, о чём предупреждает ТЗ («если один interval
|
||
участвует в нескольких неоднозначных кандидатах — сохраняется»): единая
|
||
идентичность кандидата по `optimizerSpanKey(a,b)` гарантирует, что один и
|
||
тот же физический интервал не может одновременно быть двумя разными
|
||
candidate-записями.
|
||
- **Порядок вставки helper'а** — между `rekeyWallsAfterMove()` и
|
||
`normalizeWallIntervals()` (строки 475–487 `plan-optimizer.ts`), как и
|
||
описано в ревью ТЗ; подтверждено чтением диффа `optimizePlans()`.
|
||
`normalizeWallIntervals()` сразу после схлопывания реально собирает
|
||
22+22 в единый maximal run — воспроизведено вручную (см. пробу к Medium-1),
|
||
результат `normalizeWallIntervals` над выводом `collapseIsolatedWallThicknessIslands`
|
||
даёт один 22-см entry, что и утверждает AC1.
|
||
- **Preview/Apply/Cancel/Undo (AC7).** Прогнан целевой browser-smoke против
|
||
production-бандла: preview не пишет на сервер, Cancel не пишет, Apply
|
||
делает ровно один атомарный `houseplan/plan/optimize`, Undo — ровно один
|
||
`houseplan/plan/optimize_undo` и восстанавливает три исходных exact entries
|
||
байт-в-байт. Смежные `smoke_grid_snap` и `smoke_align_guides` (тот же UI
|
||
путь `_openAlignDialog`/`_runAlignToGrid`, задетый вставкой нового шага в
|
||
пайплайн) остаются зелёными — регрессии в остальном поведении Optimize нет.
|
||
- **Mutation guard (AC9).** Новый мутант `optimizer-micro-interval-cleanup-disabled`
|
||
корректно бьёт именно в целевую ветку (обходит вызов `collapseIsolatedWallThicknessIslands`,
|
||
оставляя старые lossless-строки), guard-команда сужена
|
||
`--test-name-pattern="isolated thickness micro-interval"` — не общий прогон,
|
||
а именно целевой AC1-тест. Проверено запуском `--id=…`: чистая ветка зелёная,
|
||
мутант красный, «поймано 1 из 1».
|
||
- **Идемпотентность и model_version.** Второй `optimizePlans()` на
|
||
результате первого возвращает `changed: false`, `canonicalized: 0`,
|
||
`wallsMerged: 0`, конфиг побитово идентичен — воспроизводит ключевой факт из
|
||
тела issue #198 («Повторный optimizePlans() считает такую конфигурацию
|
||
канонической») ровно наоборот: после первого прохода дальше сходиться
|
||
нечему. `PLAN_MODEL_VERSION` не менялся этим issue (версия и её
|
||
bump-условие не тронуты).
|
||
- **Non-scope соблюдён.** Ни `normalizeWallIntervals()`, ни `degradeWalls()`,
|
||
ни `wallIntervals()`, ни persisted schema не изменены — подтверждено:
|
||
`git diff` в `src/wall-thickness.ts` пуст. Backend, i18n, миграция схемы
|
||
не затронуты — соответствует ТЗ §5/§12.
|
||
- **Документация и трейлеры.** `docs/CHANGELOG.md` + `.ru.md` в одном
|
||
коммите с кодом, `Issue: #198` / `User-Visible: yes` на месте;
|
||
`docs/USER-GUIDE.ru.md` формулирует правило в терминах, согласованных с
|
||
`docs/WALL-THICKNESS.md` (изолированный участок / одна толщина с обеих
|
||
сторон / нет вершины комнаты или границы проёма — совпадает дословно);
|
||
`docs/WALL-THICKNESS.md` §3 отдельно фиксирует lossless-инвариант рантайма
|
||
против lossy-исключения Optimize, не ослабляя первое.
|
||
- **Три копии бандла синхронны.** `cmp` дал совпадение бит-в-бит для
|
||
`dist/`, `custom_components/houseplan/frontend/` и `demo/srv/assets/`.
|
||
|
||
## Чего не проверял
|
||
|
||
- Полный HA backend harness (`tests_backend` под настоящим Home Assistant) —
|
||
диапазон не содержит правок в `custom_components/**/*.py`.
|
||
- `npm run golden:verify` и полный browser-smoke набор (127 файлов) — не
|
||
оправданы объёмом диффа; см. таблицу «Не прогонялось и почему» выше.
|
||
- Производительность/бенчмарки — не названы в AC, изменение — O(rooms×edges)
|
||
проверка соседства на действующих малых `wall list`, вне render/state tick.
|
||
- Поведение на реальном сохранённом плане пользователя из #197/#199/#201 (вне
|
||
синтетических fixture) — не воспроизводилось; unit и smoke используют
|
||
минимальные искусственные fixture, что соответствует ТЗ (полноразмерный
|
||
25-wall/#197 fixture назван в ТЗ как желательный для unit-матрицы, но не
|
||
является отдельным обязательным AC).
|
||
|
||
## Вывод
|
||
|
||
High-находок нет, обе Medium-находки — в скоупе этой же задачи (собственные
|
||
AC198, не постороннее поведение), поэтому решение не заводит отдельный issue
|
||
(#202), а возвращает задачу автору. Обе находки — не баг в коде (поведение
|
||
проверено вручную и корректно), а недостача именно обещанных ТЗ автотестов
|
||
для AC5 и AC8. Требуется: добавить unit из «Что нужно» в обеих находках и
|
||
повторно прогнать `npm test` + целевой mutation-guard.
|