Files
houseplan-card/docs/reviews/CODE-REVIEW-198-r2.md
T

249 lines
21 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.
# Код-ревью issue #198 — очистка изолированного микро-интервала толщины через Optimize (цикл r2)
- Цикл: r2/4
- Вердикт: **жёлтый** · High: 0 · Medium: 1 (в скоупе задачи)
- Диапазон: `git log --oneline origin/dev..HEAD` = `9d6cd8b` (ТЗ), `8c9241b`
(ревью ТЗ r1), `0df8051` (реализация), `f4ab0b3` (ревью-документ r1),
`1c9575a` (follow-up тесты на замечания r1), `0625b6f` (обновление
provenance скриншотов после изменения `src/`)
- Диапазон диффа: `git diff origin/dev...HEAD`
- ТЗ: [`docs/specs/198-optimize-micro-interval.md`](../specs/198-optimize-micro-interval.md)
(ревью ТЗ зелёное, r1)
- Предыдущий цикл: [`docs/reviews/CODE-REVIEW-198-r1.md`](CODE-REVIEW-198-r1.md)
(жёлтый · Medium-1 AC5, Medium-2 AC8 — обе «доказательство обещанным
способом отсутствует», не дефект поведения)
## Скоуп
Тот же, что в r1: явное действие «Общие настройки → Оптимизировать планы»
получает строго ограниченный lossy-шаг — схлопывание одиночного
изолированного интервала толщины стены короче половины шага сетки между
двумя одинаковыми коллинеарными соседями, при отсутствии room-vertex/
opening-узла на его концах. Runtime и редактор остаются lossless.
Этот цикл — не новая функциональность, а закрытие двух Medium-находок r1:
- **AC5**: добавлен прямой unit `collapseIsolatedWallThicknessIslands()` на
реверс endpoints, перестановку `walls`, перестановку/реверс winding
`rooms`, неизменность входов (`test/plan-optimizer.test.mjs`).
- **AC8**: добавлен прямой regression unit для `normalizeWallIntervals()` и
`degradeWalls()` на изолированном `22→15→22`-профиле вне Optimize
(`test/wall-thickness.test.mjs`, ранее не тронут диапазоном).
Дополнительно найден и учтён коммит `0625b6f` — он появился на ветке уже
после старта этого цикла ревью (см. «Как проверялось»): обновляет
`docs/images/screenshots.json` + один PNG (`06-device-editor.png`,
отличие на 1 байт) вслед за изменением `sourceFingerprint` `src/`, класс C,
`User-Visible: no`, не относится к продуктовому поведению #198.
Продуктовый код (`src/plan-optimizer.ts`) в этом цикле не менялся —
`git diff 0df8051..HEAD -- src/` пуст. Проверка была нужна только для
двух добавленных тестовых файлов и одного docs-коммита.
## Как проверялось
Материал — актуальный tip ветки. Важно: сессия ревью изначально была
развёрнута на коммите `1c9575a`, но `git fetch` показал, что ветка
`issue/198-optimize-micro-interval` к моменту ревью уже продвинулась на
`0625b6f` (owner исправил red `docs`-job). Переключился на
`origin/issue/198-optimize-micro-interval` перед тем, как выносить
вердикт, — иначе ревью судило бы устаревший срез.
| Гейт | Результат | Команда |
|---|---|---|
| typecheck | green | `npx tsc --noEmit` |
| unit | green, 917/917 (было 915/915 в r1, +2 новых теста) | `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, все 10 подпроверок | `node demo/smoke_optimize_micro_interval.mjs` |
| mutation-gate, полный self-check | green, 36/36 | `node scripts/mutation-gate.mjs --check` |
| mutation-gate, целевой мутант (AC9) | мутант пойман 1/1 | `node scripts/mutation-gate.mjs --id=optimizer-micro-interval-cleanup-disabled` |
| docs-gate (затронут `0625b6f`) | green | `node scripts/check-docs.mjs --external` |
| whitespace | чисто | `git diff --check origin/dev...HEAD` |
| **адверсариальная проверка новых AC5/AC8-тестов мутацией кода** (см. находку ниже) | AC8-тест ловит регрессию; AC5-тест — нет для одного из сценариев | см. «Находки» |
**Не прогонялось и почему (без изменений относительно r1 — диапазон с r1
по продуктовому коду и UI-поверхности не менялся):**
- `npm run golden:verify` и смежные `smoke_grid_snap`/`smoke_align_guides` —
этот цикл не трогает `src/plan-optimizer.ts`, рендер, геометрию,
стили или UI-пайплайн Optimize; изменились только `test/*.mjs` и
provenance двух docs-артефактов. Повторный прогон той же UI-поверхности
без изменений в ней добавил бы время без нового сигнала — уже
перепрогнан и подтверждён зелёным в r1 против того же кода.
- `python -m pytest tests_backend -q` — ни один файл
`custom_components/**/*.py` не входит в диапазон ни в r1, ни в r2.
- performance-профили — не названы в AC; изменения этого цикла — только
тесты и docs-provenance, нет тронутых perf-путей.
- Полный набор из 127 browser-смоков — диапазон r2 не касается ни одной
новой UI-поверхности; единственный релевантный (`smoke_optimize_micro_interval`)
прогнан.
### Проверка дисциплины «тест должен уметь падать» для двух новых юнитов
Не ограничился чтением — реально мутировал код и убедился в результате,
затем вернул рабочее дерево в чистое состояние (`git diff` пуст, `npm
test` 917/917 после отката).
**AC8-тест (`test/wall-thickness.test.mjs`, «lossless wall helpers preserve
an isolated sub-half-step thickness island outside Optimize») —
подтверждённо способен падать.** Внёс в `degradeWalls()` (`src/wall-thickness.ts`)
однострочную мутацию входа (`walls[0].cm = 999`) сразу после guard `if
(!walls?.length) return [];`. Прогон `npm test` дал 5 красных тестов,
включая именно новый (`not ok 843 - lossless wall helpers preserve an
isolated sub-half-step thickness island outside Optimize`), плюс два
существующих соседних `degradeWalls`-теста и оба уже покраснели бы сами
по себе — то есть новый тест не единственная защита, но он сам по себе
дискриминативен для входной мутации. Откатил правку (`git checkout --
src/wall-thickness.ts`), `npm test` снова 917/917.
**AC5-тест (`test/plan-optimizer.test.mjs`, «micro-interval cleanup is
endpoint, input-order and room-order invariant without mutation») — не
ловит именно тот сценарий регрессии, который Medium-1 из r1 назвал
целевым.** Подробности — находка ниже.
## Находки
### Medium-1 (в скоупе) — новый AC5-тест не хермитичен к сценарию входной мутации, который он должен доказывать
**Файл:** `test/plan-optimizer.test.mjs`, тест «micro-interval cleanup is
endpoint, input-order and room-order invariant without mutation» (строки
127–159 в HEAD)
**Суть:** тест сперва вызывает `baseline = collapseIsolatedWallThicknessIslands(
[fixture.rooms[0], detached], fixture.walls, ...)` **на непроклонированном**
`fixture.walls`/`fixture.rooms`, а затем строит `reversedMiddle` и итоговый
`walls`, **читая** `fixture.walls[1]` уже **после** этого первого вызова
(`{...fixture.walls[1], a: [...fixture.walls[1].b], b: [...fixture.walls[1].a]}`).
Если бы `collapseIsolatedWallThicknessIslands` регрессировала ровно так,
как предупреждал Medium-1 в r1 — начала мутировать входной массив `walls`
на месте (`wall.cm = target` вместо иммутабельного `{...wall, cm:
target}` внутри `out.map(...)` на строках 178–182 `src/plan-optimizer.ts`) —
то `baseline`-вызов сам испортил бы `fixture.walls[1].cm` (15 → 22) ещё
**до** того, как из него строится `reversedMiddle`/`walls` для проверки
инвариантности. К моменту, когда тест снимает `beforeWalls =
structuredClone(walls)`, входной центральный интервал уже был бы виден
как `cm: 22`, кандидат схлопывания отфильтровался бы условием `centreCm
=== leftCm` (строка 138 `src/plan-optimizer.ts` — уже «канонично», сливать
нечего), второй вызов не тронул бы объект вообще, и
`assert.deepEqual(walls, beforeWalls)` прошла бы тривиально, потому что
мутировать было уже нечего.
**Проверено исполнением, не только чтением.** Ввёл в
`src/plan-optimizer.ts` минимальную адверсариальную мутацию — заменил
`out = out.map((wall) => {... return wall.cm === target ? wall : {...wall,
cm: target}; })` на цикл `for (const wall of out) { ...; wall.cm =
target; }` (мутация того же самого объекта, на который ссылаются и
`out`, и исходный `walls`, поскольку `out = walls.slice()` — мелкое
копирование массива, не элементов). Прогнал `npm test`: **все 917 тестов
остались зелёными**, включая AC5-тест. Отдельным изолированным
node-скриптом против пересобранного `test-build/plan-optimizer.js`
подтвердил механизм: `fixture.walls[1].cm` меняется с `15` на `22` сразу
после `baseline`-вызова (для немутированного, реального кода —
остаётся `15`, для мутированного — становится `22`). Значит именно
описанный сценарий воспроизводится, а не является гипотетическим.
**Сценарий отказа, который прошёл бы незамеченным:** будущий рефакторинг
`collapseIsolatedWallThicknessIslands`, меняющий иммутабельный `.map` на
мутацию на месте (естественная логическая ошибка при, например,
попытке «оптимизировать» цикл), не будет пойман этим тестом — именно
тем автотестом, который ТЗ и r1-ревью называют доказательством AC5.
`test/wall-thickness.test.mjs`-тест того же follow-up-коммита для AC8
устроен иначе (сравнивает итоговую переменную `walls` с её собственным
`before`-снимком, снятым до **любых** вызовов) и корректно ловит тот же
класс регрессии — см. проверку выше. Это подтверждает, что дефект
специфичен для одного теста, а не общая недостижимая планка.
**Почему Medium, а не Low:** это не стилистика — тест был написан
специально в ответ на Medium-1 из r1 как «Reversal/permutation/
immutability unit» для строгого продуктового инварианта неизменности
персистентных данных (`docs/WALL-THICKNESS.md`), и для сценария, прямо
названного в тексте самого Medium-1 r1 («если бы будущая правка начала
мутировать переданный `walls`-массив на месте»). Регрессия именно этого
класса останется незамеченной автотестами задачи.
**Что нужно:** в начале теста один раз снять `structuredClone` со всего,
что дальше используется как источник и для `baseline`, и для проверяемого
вызова (например, клонировать `fixture` целиком перед первым вызовом,
либо строить `reversedMiddle`/`walls`/`rooms` из независимой копии
fixture, а не из объекта, уже прошедшего через `baseline`-вызов). После
правки повторить именно ту адверсариальную мутацию, которой я проверял
эту находку (иммутабельный `.map` → мутирующий `for`), и убедиться, что
тест в таком варианте краснеет.
### Medium-2 — нет (закрыта)
Assertion из r1 подтверждена исполнением как реально дискриминативная
(см. «Проверка дисциплины „тест должен уметь падать“» выше) — новых
находок по AC8 нет.
### Low — нет
Находок уровня Low не выявлено.
## Что проверено и корректно
- **AC8 закрыт полностью и доказан заявленным способом.** Новый unit в
`test/wall-thickness.test.mjs` вызывает `normalizeWallIntervals()` и
`degradeWalls()` напрямую (без `collapseIsolatedWallThicknessIslands`,
то есть вне Optimize) на изолированном `22→15→22`-профиле короче
половины шага и подтверждает: обе lossless-функции не трогают его, вход
не мутируется. Дискриминативность подтверждена адверсариальной
мутацией (см. выше) — тест реально краснеет на регрессии.
- **Продуктовый код не менялся с r1.** `git diff 0df8051..HEAD -- src/`
пуст — контракт §6 ТЗ, non-cascading snapshot, разрешение
неоднозначности через `targets.size === 1` и защиту topology-узлов из
r1 не требовалось перепроверять заново по существу; перепроверены
только заново пересобранные гейты (typecheck/test/build/mutation-gate/
targeted smoke), все зелёные на актуальном коде.
- **Docs-provenance коммит `0625b6f` корректен и не расширяет скоуп.**
`sourceFingerprint` в `docs/images/screenshots.json` актуализирован под
изменившийся `src/` (тестовые правки этого же issue меняют fingerprint,
т.к. он покрывает весь `src/` + build inputs), `node scripts/check-docs.mjs
--external` зелёный, класс C, `User-Visible: no`, никакой продуктовой
логики не касается. 1-байтовое отличие PNG `06-device-editor.png`
проверено на невоспроизводимость по байтам: два последовательных
локальных прогона `npm run build && node demo/docs/capture.mjs`
дают разные SHA-256 даже для одного и того же коммита — это известная
нестрогая детерминированность самого capture-пайплайна (не связана с
#198), а `check-docs.mjs` проверяет только внутреннюю согласованность
манифеста и файла, а не побитовую воспроизводимость между запусками,
так что это не дефект.
- **Idempotenность и mutation guard.** `node scripts/mutation-gate.mjs
--check` (36/36) и целевой `--id=optimizer-micro-interval-cleanup-disabled`
(1/1) зелёные на актуальном HEAD.
- **Трейлеры.** Все пять новых/изменённых коммитов диапазона несут
`Issue: #198`; `User-Visible: no` на всех — корректно, ни один из них
(тесты, ревью-документы, docs-provenance) не меняет видимое поведение;
changelog-и не тронуты этим циклом и не должны были быть (правки
r1 уже внесли оба changelog в `0df8051`, `User-Visible: yes`,
подтверждено в r1 и повторно проверено — диапазон не менялся).
- **Три копии бандла синхронны.** `cmp` дал совпадение бит-в-бит для
`dist/`, `custom_components/houseplan/frontend/` и `demo/srv/assets/`
после `npm run build` на актуальном HEAD.
## Чего не проверял
- Полный HA backend harness (`tests_backend` под настоящим Home Assistant) —
диапазон не содержит правок в `custom_components/**/*.py`.
- `npm run golden:verify` и полный browser-smoke набор (127 файлов) —
диапазон r2 не трогает рендер/геометрию/UI-пайплайн; обоснование то же,
что в r1, и продуктовый код не менялся.
- Производительность/бенчмарки — не названы в AC, изменения цикла — только
тесты и docs-provenance.
- Повторный ручной прогон adversarial-мутаций на самом `exactMatches`
(реверс endpoints без общей мутации входа) — этот сценарий был
исполнен в r1 (см. `CODE-REVIEW-198-r1.md`, Medium-1) и один раз
дополнительно в этом цикле при диагностике находки выше; отдельно
третий раз не повторял, так как вывод не менялся между попытками.
## Вывод
High-находок нет. Одна Medium-находка — строго в скоупе этой же задачи
(это тест, добавленный именно как ответ на Medium-1 из r1 для доказательства
AC5, и находка о том, что он не доказывает заявленное для конкретного
класса регрессии), поэтому решение не заводит отдельный issue, а
возвращает задачу автору жёлтым вердиктом. Продуктовый код и AC8
подтверждены; исправить нужно только конструкцию AC5-теста (устранить
разделяемую немутированную ссылку на `fixture` между `baseline`-вызовом
и построением проверяемых `walls`/`rooms`) и повторно прогнать `npm
test` + адверсариальную мутацию `.map`→`for`, которой было
продемонстрировано отсутствие защиты.