mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
249 lines
21 KiB
Markdown
249 lines
21 KiB
Markdown
# Код-ревью 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`, которой было
|
||
продемонстрировано отсутствие защиты.
|