docs: review document for #89

Issue: #89
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-13 12:53:16 +00:00
parent 6ea3ebff17
commit e0ddbcd79e
+318
View File
@@ -0,0 +1,318 @@
# CODE-REVIEW-89-r2 — #89, этап 1: объёмный вид за флагом Labs
- Issue: [#89](https://github.com/Matysh/houseplan-card/issues/89)
- Этап: `code` (PROCESS.md §2.7)
- Диапазон: `origin/dev...HEAD`, `origin/dev` = `22e98c5`, `HEAD` = `6ea3ebf`
(ветка `issue/89-isometric-stage1`, детач `HEAD`)
- ТЗ: [`docs/specs/089-isometric-view-stage1.md`](../specs/089-isometric-view-stage1.md),
ревизия 3, ревью ТЗ зелёное — [`SPEC-REVIEW-89-r1.md`](SPEC-REVIEW-89-r1.md)
- Цикл: **r2/4**
- Вердикт: **красный**
## Скоуп ревью
35 файлов, +3079/−825 (генерируемые бандлы `dist/**`, `custom_components/houseplan/frontend/**`,
`demo/srv/assets/houseplan-card.js` — класс D, сверены байт-в-байт с `npm run build`).
Продуктовый код: `src/labs.ts` (новый), `src/iso-projection.ts` (новый),
`src/iso-walls.ts` (новый), `src/houseplan-card.ts` (+286/−18), `src/styles.ts`
(+27), `src/i18n/{en,ru}.json` (+2 ключа каждый). Тесты/гейты (класс B):
`test/iso-projection.test.mjs`, `test/iso-walls.test.mjs`, `test/labs.test.mjs`,
`test/isometric-contract.test.mjs`, `test/golden-matrix.test.mjs` (diff),
`demo/smoke_isometric_contract.mjs`, `demo/smoke_isometric_live_touch.mjs`,
`demo/golden/{harness,matrix}.mjs`, `demo/benchmark_large_house.mjs`,
`demo/performance/{card-contract.mjs,budgets-large-house-isometric.json,README.md}`,
`.github/workflows/performance.yml`, `tsconfig.test.json`, `package.json`.
Документация (класс C): `docs/SCOPE.md`, `docs/ISOMETRIC.md` (новый),
`docs/adr/089-isometric-stage1-renderer.md` (новый), `docs/DEVELOPMENT.md`,
`docs/STATUS.md`, `AGENTS.md`, `docs/specs/README.md`.
Backend (`custom_components/houseplan/**/*.py`), `manifest.json`, `hacs.json`,
README/User Guide/оба changelog — **не тронуты** (проверено `git diff --stat`),
что соответствует D1/D7/AC12/AC15 ТЗ.
Прочитано до вердикта: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md`, тело issue
#89 и все 15 комментариев (включая `PSEUDO_3D_SPECIFICATION.md` и решения
владельца Q1–Q6/O1–O6), `docs/specs/089-isometric-view-stage1.md` (ревизия 3),
`SPEC-REVIEW-89-r1.md`, `docs/ISOMETRIC.md`, ADR, `docs/CANVAS.md`,
`docs/WALL-THICKNESS.md`, `docs/LIGHT.md`, `docs/UX-MODES.md`,
`docs/TOUCH-SUPPORT.md`, весь новый и изменённый продуктовый код.
## Как проверялось
`npm ci` выполнен перед гейтами (чистая рабочая копия, зависимостей не было).
| Гейт | Команда | Результат |
|---|---|---|
| Типы | `npx tsc --noEmit` | green |
| Unit | `npm test` | **724/724** green |
| Сборка | `npm run build` | green |
| Синхронность бандлов | `cmp dist/… custom_components/…` и `cmp dist/… demo/srv/…` | обе пары идентичны байт-в-байт |
| Whitespace | `git diff --check origin/dev...HEAD` | 3 предупреждения — см. Low L1 |
| Backend (чистый, без HA) | `python -m pytest tests_backend -q` | pytest не установлен в среде; неприменимо — диапазон не трогает `custom_components/**/*.py` (`git diff --stat` пуст), гейт пуст по построению |
| Браузерные смоки (все 127) | `for f in demo/smoke_*.mjs; do node "$f"; done` (тот же цикл, что job `smoke` в `validate.yml`) | **126/127 green, 1 red** — см. находку H1 |
| Golden capture (весь матрикс v17) | `npm run golden:capture` | все существующие плоские сцены — `passed` (0 diff); 6 новых `isometric-*` сцен — `missing-baseline` (ожидаемо: новые эталоны не принимаются в этом коммите, см. AC/§11.3 ТЗ и PROCESS.md §12) |
| Perf-профиль (сквозной прогон, не CI-гейт) | `node demo/benchmark_large_house.mjs --profile=large-house-isometric-v1 --samples=1 --warmups=1` | выполняется целиком, `viewToggleMs` и `isoGeometry` cache size присутствуют в отчёте |
Установка headless Chromium (`npx playwright install --with-deps chromium`)
потребовалась и прошла успешно — без неё браузерные смоки/golden в CI-стиле
были бы недоступны локально, а именно они отвечают на вопрос «оно вообще
работает» для AC1–AC2, AC4, AC7–AC9, AC13–AC14.
### Мутанты (§11.4 ТЗ) — выборочно исполнены
- Мутант 1 («iso без флага») — покрыт `demo/smoke_isometric_contract.mjs`
(`flatDefault`, `removalIsImmediateFlat`) — прошёл.
- Мутант 4 («`expires` игнорируется») — `test/labs.test.mjs`
`'registry metadata and numeric-core lifetime fail closed'` — прошёл;
проверено также вручную (`liveLabsFlags('1.65.0-beta.1')` → флаг мёртв).
- Мутант 5 («боковые грани слоями») — `test/iso-walls.test.mjs`
`'one top and only O(E) visible sides...'` (`sides.length <= edgeCount`) —
прошёл; инъекция «удвоить `sides` без увеличения `edgeCount`» вручную в
Node REPL красит именно этот assert.
- Мутант про инъекцию рендер-исключения (`_isoSource` throws) —
`demo/smoke_isometric_contract.mjs` (`fallbackLatched`,
`explicitRetryRestoresIso`) — исполнен реальным браузером, прошёл; убедился,
что смок умеет падать: временно заменил `shouldFail = false` на `true` без
сброса — `explicitRetryRestoresIso` покраснел, как и ожидалось.
### Целевая проверка «работает ли AC7 на самом деле» (H1)
`demo/smoke_isometric_live_touch.mjs` красит `liveLayersPresent` и
`floorToOverlayOrderPreserved`. Изолировал причину отдельным скриптом:
`.sunlayer` не монтируется ни в flat, ни в iso при заданных в фикстуре
параметрах (`sun.sun.attributes.azimuth=180`, окно `angle:0, y:0.14` —
северная стена). Прогнал `computeSunRays`-путь через реальную карточку при
азимутах 0/45/…/315°: `.sunlayer` появляется только на 0°/45°/315°, никогда на
180°. Это математически корректное поведение `computeSunRays`
(`windowLit()` не пропускает свет через окно, когда солнце светит с
противоположной стороны дома) — то есть не регрессия рендерера, а ошибка в
геометрии самой фикстуры смока. Подробности — в находке H1.
## Находки
### H1 — обязательный по ТЗ смок `demo/smoke_isometric_live_touch.mjs` красный на сданном коммите
**Файл:** `demo/smoke_isometric_live_touch.mjs`
**Серьёзность:** High — блокирует.
**Воспроизведение:**
```
npm run build && cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
&& cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
node demo/smoke_isometric_live_touch.mjs
```
Результат (детерминированный, не флейк — параметры фикстуры захардкожены):
```
FAILED (2):
- liveLayersPresent: expected true, got false
- floorToOverlayOrderPreserved: expected true, got false
```
**Причина.** Фикстура (строки 24–29 файла) ставит `sun.sun` в
`{azimuth: 180, elevation: 24}` и единственное окно в
`{type:'window', x:0.28, y:0.14, angle:0}` — окно на северной стене комнаты
(`y=0.14`, `angle=0`). При азимуте 180° (юг) `computeSunRays()` →
`windowLit()` корректно не пропускает луч через окно, обращённое на север:
`.sunlayer` не появляется в DOM ни в flat, ни в iso. Проверил перебором
азимутов 0/45/90/135/180/225/270/315° на реальной карточке — луч есть только
при 0°/45°/315° (солнце с севера), при 180° луча нет никогда. Это ожидаемое,
корректное поведение существующего `computeSunRays()` (`src/sun.ts`), не
регрессия из этого диапазона.
**Следствие.** `before.sun` (строка 88 смока) остаётся `0`, поэтому
`liveLayersPresent` красный сам по себе; `.sunlayer` не существует в DOM,
поэтому `ordered.every(Boolean)` (строка 142) тоже красный —
`floorToOverlayOrderPreserved` падает не потому, что порядок слоёв нарушен, а
потому, что один из проверяемых узлов никогда не создаётся. Обе половины
теста, отвечающие за солнечные лучи (сохранение состояния и DOM-порядок
`.sunlayer` между flat/iso), **никогда фактически не исполняются** — ни на
этом коммите, ни, скорее всего, ни разу с момента написания файла, поскольку
per policy (`AGENTS.md` «During the implementation cycle only the fast gates
run») браузерные смоки не запускались до этого ревью.
**Почему это блокирует, а не Medium.** ТЗ (`089-isometric-view-stage1.md`
§11.6) называет именно «live-layer smoke» обязательным доказательством AC7
(«room fills/hover, Glow/spill, солнце… сохраняют state… между flat/iso») и
явно требует от код-ревью «выполнить HA-only update и проверить cache/
source/action parity» — то есть исполнением, не чтением. PROCESS.md §2.7:
«ревьюер убедился, что тест умеет падать» — здесь тест не просто способен
упасть, он **уже красный** на сданном коде. Раз ручного тестирования в цикле
нет, красный обязательный гейт — прямое основание не принимать этап: заявленный
в передаче «test: complete isometric stage 1 gates» набор гейтов не проходит
целиком при реальном исполнении.
**Смежная, не блокирующая часть той же находки.** Чтением кода (`_renderSunRays`
вызывается внутри того же главного `<svg>`, что и остальные floor-слои;
геометрия луча строится в план-координатах и не зависит от `_renderProjection`)
я убедился, что механизм солнечных лучей *устройственно* не привязан к
проекции — при исправленной фикстуре параллель flat/iso почти наверняка
подтвердится. Но это «проверено чтением», а не то же самое, что зелёный смок,
и ТЗ явно требует именно исполнения для этого AC — поэтому не снижаю
находку до Low/Medium на основании своего прочтения.
**Рекомендация автору (не мандат, техническое решение остаётся за ним):**
подобрать в фикстуре либо угол/положение окна, либо азимут солнца так, чтобы
`computeSunRays()` реально вернул луч (например, `azimuth: 0` или окно на
южной стене), и заново прогнать смок перед следующим циклом.
**Вердикт по находке:** блокирует, возврат в `S6-в-разработке`.
### L1 — три файла с лишней пустой строкой в конце (`git diff --check`)
**Файлы:** `docs/adr/089-isometric-stage1-renderer.md:106`,
`src/iso-projection.ts:113`, `test/iso-projection.test.mjs:61`.
**Серьёзность:** Low.
`git diff --check origin/dev...HEAD` печатает «new blank line at EOF» для
всех трёх. Чисто косметическая находка: не влияет на typecheck/test/build (все
три гейта зелёные), не меняет поведение.
**Вердикт по находке:** снимается с записью — правится по желанию автора в
следующем коммите этой же задачи, отдельного issue не требует.
## Что проверено и корректно
- **Гейты.** `tsc --noEmit`, `npm test` (724/724), `npm run build`, три копии
бандла синхронны байт-в-байт. Commit trailers всех 6 коммитов диапазона —
`Issue: #89` / `User-Visible: no`, что верно (Labs-скрытая фича, публичного
поведения нет).
- **AC1** (флаг выключен → без изменений). Подтверждено исполнением:
`demo/smoke_isometric_contract.mjs` (`flatDefault`) и полный golden-прогон —
**все** существующие плоские сцены дали `passed` (0 diff) против текущих
эталонов; отдельный unit (`isoEffectiveView`) и shy-построение геометрии
(`_isoSceneKey()` возвращает `null` при `!this._labsIso`) проверены чтением.
- **AC2–AC3** (грамматика Labs, версии, fail-closed). `test/labs.test.mjs`
прогнан — все случаи из §2.2.1/§2.4 ТЗ (`off,iso`, `iso,-iso`, приоритет хэша,
повтор параметра, malformed registry/version, недоступный storage) покрыты и
зелёные. Прочитан `src/labs.ts` целиком — реализация буквально соответствует
описанному в ТЗ алгоритму (порядок query→hash, `off` только «в этой позиции»,
`window.__hpLabs` — замороженный отсортированный массив).
- **AC4** (камера). `ISO_CAMERA` = `{rotDeg:0, tiltDeg:20, xyScale:1, zScale:1,
origin:[500,500]}` — внутри диапазона 18–22°, совпадает с pivot
`NORM_W/2` из ТЗ (§4.4.3). `test/iso-projection.test.mjs` проверяет это
утверждением на самой камере плюс round-trip на диапазоне `±5000`
(`docs/CANVAS.md`) — прошёл. Golden-скриншот
`isometric-geometry-view-dark` открыт и просмотрен визуально: стены не
повёрнуты по диагонали, углы прямые, парные стены/колонны и дверной проём
считываются корректно.
- **Математика проекции (проверено чтением + вручную).** `isoFloorMatrix()`
алгебраически выведена как афинная форма `projectPlanPoint(p,0)` — совпадает
(тест `'floor matrix is identical to point projection'` подтверждает).
Важный design-факт, который делает две раздельные SVG-подсистемы (плоскость
пола через `preserveAspectRatio="none"` на `floorView`, стены/маркеры через
`preserveAspectRatio="xMidYMid meet"` на `view`) согласованными пиксель-в-
пиксель: `_applyView()` всегда строит `fit = fitView(vb, this._stageAspect())`,
то есть аспект `view` **всегда** равен аспекту контейнера, поэтому у
«meet»-слоя letterbox нулевой и обе техники дают одну и ту же афинную карту.
Без этого инварианта расхождение floor/walls было бы реальным дефектом —
инвариант существовал до этого диапазона и не менялся.
- **AC5–AC6** (топология граней, O(E), проёмы). `test/iso-walls.test.mjs`
прогнан (top/hole rings, full-height gap без «мостика», jamb-грани). Golden
`isometric-geometry-view-dark/light` визуально показывают: физические стены
с верх/бок гранями, virtual boundary как пунктир на полу без объёма,
квадратную повёрнутую и круглую колонну с экструзией, дверной разрыв с
сохранённой дугой открывания. Источник геометрии — `wallBodiesGeometry(...)`
(тот же каноничный вызов, что использует свет, `docs/LIGHT.md`); `physicalBodies(...)`
в `_isoSource()` даёт тот же набор тел (partitions+drafts+columns), что
кэширующая обёртка `_physicalBodiesR()` во флэт-рендере — порядок элементов
отличается, для булевого объединения это не имеет значения (сверено чтением
обеих реализаций в `src/physical-geometry.ts` и `src/houseplan-card.ts`).
- **AC7 — частично.** Golden `isometric-live-layers-dark` и
`isometric-no-borders-dark` просмотрены: room fill/hover, Glow/spill (видимый
spill через проём в соседнюю комнату), декор/мебель, устройства с бэйджами
визуально присутствуют и выглядят корректно в iso; `show_borders:false`
корректно убирает грани стен, не убирая заливку/Glow. `demo/smoke_
isometric_live_touch.mjs` подтвердил исполнением: `flatIsoLayerParity`,
`liveLayersStable`, `spillBarrierStable`, `haColorUpdatePainted`,
`sameWallFingerprint`, `haUpdateReusesGeometry`, `flatIsoActionParity` —
все зелёные. Но солнечная часть и полная DOM-order часть этого же AC
**не подтверждены исполнением** — см. H1.
- **AC8** (одна проекция на всё, anchor ≤1px). `_scenePoint()` — единственная
точка входа для HTML-маркеров, room labels, vacuum trail/puck (grep по
`_scenePoint(` в диффе — используется во всех перечисленных местах, не
найдено отдельной «примерно такой же» формулы). `test/isometric-contract.test.mjs`
проверяет это как source-contract. `demo/smoke_isometric_contract.mjs`
(`anchorsFinite`, `haUpdateKeepsAnchor` — сдвиг ≤1px) и `smoke_isometric_live_touch.mjs`
(`orientationResizeKeepsIso`) исполнены и зелёные.
- **AC9** (редакторы всегда flat). `test/isometric-contract.test.mjs`
(`doesNotMatch(spaceCard/spaceRender, …)`), `demo/smoke_isometric_contract.mjs`
(`editorIsFlat`, `viewRestoresIso`) — исполнены, зелёные.
- **AC10** (fallback-защёлка). `demo/smoke_isometric_contract.mjs` —
инъекция исключения в `_isoSource`, подтверждён latch + explicit retry;
вручную убедился, что тест умеет падать (см. «Мутанты» выше).
- **AC11** (перф). Профиль `large-house-isometric-v1` и
`budgets-large-house-isometric.json` добавлены по контракту существующей
инфраструктуры (`--budgets=`, `benchmark:compare`), wiring в
`.github/workflows/performance.yml` корректен. Полный 7-sample
base/candidate прогон на exact-SHA Linux CI — по процессу это pre-beta
гейт, не гейт этого цикла; локально подтвердил только, что раннер
исполняется целиком без исключений на 1 сэмпле.
- **AC12** (backend/schema/space-card не меняются). `git diff --stat`
подтверждает отсутствие изменений в `custom_components/**/*.py`,
`manifest.json`, `hacs.json`; `src/space-card.ts`/`src/space-render.ts` не
содержат `iso`-путей (unit source-contract + `git diff --stat` = 0 для этих
файлов).
- **AC13** (без вертикальных дверей/окон). `test/isometric-contract.test.mjs`
(`doesNotMatch(card, /iso-window|window-light|vertical-door/)`) и визуальный
просмотр golden — дверной/оконный символ на плоскости пола сохранён.
- **AC14** (touch/kiosk). `demo/smoke_isometric_live_touch.mjs` — все
touch/kiosk/warm-remount/orientation проверки в этом же смоке (кроме двух,
см. H1) зелёные: `isoOnTouch`, `touchPinchKeepsIso`, `touchOpeningIsSafe`,
`touchLongPressHitsDevice`, `touchSpaceSwitchKeepsPerSpaceIso`,
`backgroundForegroundKeepsIso`, `warmRemountIso`, `kioskReadsPreference`,
`kioskHasNoToggle`, `kioskEmergencyOffIsFlat`, `orientationResizeKeepsIso`.
- **AC15** (согласованность документов). `docs/SCOPE.md` содержит узкое
исключение, буквально совпадающее с решением владельца O1; `docs/ISOMETRIC.md`,
ADR, `docs/DEVELOPMENT.md`, `AGENTS.md`, `docs/STATUS.md` обновлены и
согласованы друг с другом и с ТЗ; i18n-ключи `view.volumetric`/`view.flat`
присутствуют в `en.json` и `ru.json` с формулировками из ТЗ («Объёмный вид» /
«Volumetric view», «Плоский вид» / «Flat view»); README/README.ru/
`docs/USER-GUIDE.ru.md`/оба CHANGELOG не тронуты.
- **Отсутствие CSS 3D.** `test/isometric-contract.test.mjs`
(`doesNotMatch(styles, /perspective\s*:|preserve-3d|rotateX\(|rotateZ\(/)`)
подтверждён; вручную прочитан весь diff `src/styles.ts` — новые правила
(`.projection-toggle`, `.iso-walls-svg`, `.iso-wall-side/top`, тёмная тема) не
содержат запрещённых свойств.
- **Регрессии в остальной карточке.** Прогнал **весь** существующий
браузерный смок-набор (126 файлов, не считая двух новых iso-смоков) —
**все 126 зелёные**. Крупная интеграция (`_baseVb()`, `_applyView()`,
`_setMode()`/mode-transition, warm-remount, `_renderDevice`/`_renderRoomLabel`/
vacuum-рендер получили новый параметр проекции) не разбила ни один
существующий сценарий: zoom/pan, kiosk, warm-remount, editor gestures,
opening/measure, vacuum, glow-blending и т.д. — без изменений в поведении.
## Чего не проверял
- **Golden-эталоны для iso не принимал** и не мог принять — это соответствует
процессу (`npm run golden:accept -- --reviewed` только по полному Linux CI
артефакту, PROCESS.md §12, §13 правило 13). Отсмотрел 3 из 6 захваченных
`actual`-изображений визуально как sanity-check геометрии, не как приёмку.
- **Полный 7-sample perf-сравнение с бюджетом** (`budgets-large-house-isometric.json`,
20% допуск) — по контракту ТЗ (§8.2) это исключительно pre-beta exact-SHA
Linux CI гейт; локальный однократный прогон профиля подтвердил только
«раннер работает», не «бюджет выдерживается».
- **HA-harness backend-тесты** (`test_ha_*.py`) — не запускал: в среде нет
Home Assistant и нет установленного `pytest`; диапазон не меняет backend, так
что гейт по построению пуст, но формально не исполнялся.
- **Safari/WebKit и Firefox** — ADR явно откладывает это на отдельный
browser-валидационный прогон вне этого коммита; я тестировал только
Chromium (тот же движок, что CI `smoke`/`golden`).
- **`smoke_opening_measure.mjs`** — упомянутый в `AGENTS.md` как известно
окруженчувствительный (`place_dialog_x_magnetised`,
`place_committed_x_center`); в моём прогоне он тоже красным не оказался
(прошёл), так что этот риск не проявился, но не является частью проверки
этого диапазона.
## Вердикт
**Красный · цикл r2/4 · High: 1 · Medium: 0 → нет новых issue.**
Один блокирующий High: обязательный по ТЗ смок `demo/smoke_isometric_live_touch.mjs`
красный на сданном коде (H1) — AC7 не полностью подтверждён исполнением, хотя
причина изолирована до ошибки в геометрии тестовой фикстуры (сон-луч), а не до
дефекта рендерера. Вся остальная реализация — Labs-механизм, проекция и
топология стен, кэш/fallback, warm-remount/touch/kiosk-контракт, отсутствие
влияния на backend/схему/вторую карточку/публичную документацию — подтверждена
исполнением (unit 724/724, 126/127 браузерных смоков, полный golden-прогон без
диффа существующих сцен) либо чтением там, где исполнение не требуется по
процессу. Возврат в «В разработке» для исправления фикстуры смока и повторного
прогона `demo/smoke_isometric_live_touch.mjs` перед следующим циклом.