diff --git a/docs/reviews/CODE-REVIEW-160-r1.md b/docs/reviews/CODE-REVIEW-160-r1.md new file mode 100644 index 00000000..cf96d31b --- /dev/null +++ b/docs/reviews/CODE-REVIEW-160-r1.md @@ -0,0 +1,465 @@ +# CODE-REVIEW-160-r1 — Isometric Stage 3 + +- Issue: https://github.com/Matysh/houseplan-card/issues/160 +- Этап: код-ревью (PROCESS.md §2.7) +- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (предыдущая попытка + остановилась до чтения кода из-за конфликта ребейза — цикл не потрачен) +- Ветка: `issue/160-isometric-stage3` +- **SHA материала ревью: `ee7d486924d5b1641a56f671bbf94e965fb76493`** (проверено + `git rev-parse HEAD` непосредственно перед подведением итогов, PROCESS.md §2.7) +- ТЗ: `docs/specs/160-isometric-stage3.md` (commit `d82be48d`), спек-ревью — + зелёное, `docs/reviews/SPEC-REVIEW-160-r1.md` + +## Скоуп + +Изометрический Stage 3: фиксированный поворот камеры `rotDeg 0→4`, разделение +floor/raised плоскостей для device marker / room name-card / opening lock badge +(grounding shadow + tether + ограниченный inward nudge), более читаемая глубина +проёмов (jamb/reveal/leaf/frame/sill), общая система теней с одним light vector, +детерминированная theme-aware текстура, согласованный depth order, кэш/fingerprint +с cap 8, degradation для forced-colors/no-filter, полный AC1–AC15, 12 red witnesses +W1–W12. Diff: 90 файлов, +8759/−1374 строк одним продуктовым коммитом +`feat: implement isometric stage 3` (+3 doc/process коммита), трейлеры +`Issue: #160` / `User-Visible: no` на всех — сверено `git log origin/dev..HEAD`. + +Материал: `git log --oneline origin/dev..HEAD`, `git diff origin/dev...HEAD`. +Диапазон полный, не по дельте — это первый содержательный проход по коду +(предыдущая передача на ревью не читала код из-за конфликта ветки). + +## Как проверялось — гейты + +| Гейт | Команда | Результат | +|---|---|---| +| Typecheck | `npx tsc --noEmit` | зелёный | +| Unit | `npm test` | **2071 passed, 1 skipped, 0 failed** (совпадает с заявленным авторским прогоном) | +| Build | `npm run build` | зелёный, `dist` пересобран | +| Bundle sync | `npm run bundle:sync` | зелёный | +| Bundle budget | `npm run bundle:budget` | зелёный: initial View 299464 B gzip (потолок 300000±2000), lazy isometric 12083 B gzip; известное предупреждение о малом запасе (#367), не относится к #160 | +| `git diff --check` | — | чисто, конфликт-маркеров/trailing whitespace нет | +| **`node scripts/check-docs.mjs`** | обязателен диффом по `src/**` (PROCESS.md §2.7/§2.9) | **КРАСНЫЙ** — см. Находка H1 | +| `npm run golden:verify` (эта среда — Linux) | диагностика, не приёмка | все 137 незатронутых сценариев `passed`, 8 `isometric-*` — `different` (камера/Stage 3, ожидаемо), 5 новых `isometric-stage3-*` — `missing-baseline` (ожидаемо, не принимались) | +| `scripts/mutation-gate.mjs --id=stage3-w1…w12` | все 12 witnesses по отдельности | **12/12 подтверждены лично** (см. таблицу AC ниже) — не поверено на слово | +| `node demo/smoke_entry_stale.mjs` | по указанию `smoke-select.mjs` («зарегистрированная связь» через общий `EditorRuntimeLoader`) | зелёный | +| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | выбор целевых смоков | см. «Чего не проверял» | +| `demo/benchmark_large_house.mjs --profile=isometric-stage3-dense-v1 --samples=1` | однократный sanity-прогон нового профиля | зелёный, см. «Производительность» | + +Пояснение к `git diff --check`, unit-числу и bundle-budget: числа сошлись с тем, +что автор указал в хендоффе issue — независимая проверка, не копирование отчёта. + +## Таблица «AC · чем доказан · чем краснеет» (PROCESS.md §2.7, #435) + +Только защитные AC (валидация/guard/лимит/invariant). Расположенческие/текстовые +AC — отдельно ниже без третьего столбца. + +| AC | Доказано | Чем краснеет (лично прогнано) | +|---|---|---| +| AC2 камера +4°/20° | `test/iso-projection.test.mjs` («exact fixed») | `stage3-w1-camera-rotation-reset` (rotDeg 4→0) — **поймано 1 из 1** | +| AC3 raised/floor матрица (room label не должен остаться на floor) | `test/iso-overlays.test.mjs` («exact D2 overlay matrix») | `stage3-w2-room-label-left-on-floor` — **поймано 1 из 1** | +| AC3 vacuum остаётся floor-bound | `test/isometric-contract.test.mjs` («raises only device») | `stage3-w3-vacuum-raised-with-devices` — **поймано 1 из 1** | +| AC4 44×44 hit target | `demo/smoke_isometric_contract.mjs` | `stage3-w4-device-target-loses-44px-floor` — **поймано 1 из 1** | +| AC5/AC13 nudge никогда не пишет config/layout/storage | `demo/smoke_isometric_live_touch.mjs` | `stage3-w5-runtime-nudge-writes-storage` — **поймано 1 из 1** | +| AC7 `show_borders:false` не даёт raised-слоёв | `demo/smoke_isometric_live_touch.mjs` | `stage3-w6-no-borders-keeps-raised-plates` — **поймано 1 из 1** | +| AC8/AC12 HA Sun не входит в structural fingerprint/light vector | `demo/smoke_isometric_live_touch.mjs` | `stage3-w7-sun-state-enters-structural-key` — **поймано 1 из 1** | +| AC8 material defs O(1), не per-face | `demo/smoke_isometric_contract.mjs` | `stage3-w8-material-defs-created-per-face` — **поймано 1 из 1** | +| AC6 door/gate hinge·face·flip (заявленный контракт W9 — face turn direction) | `test/iso-scene-render.test.mjs` («gate flips move unhosted structural face») | `stage3-w9-gate-face-flip-reversed` — **поймано 1 из 1** | +| AC9/AC12 Flat-fallback не засчитывается как успешный Iso-сэмпл | `test/performance-workflow.test.mjs` («runner fails closed») | `stage3-w10-flat-fallback-counted-as-success` — **поймано 1 из 1** | +| AC5 tether обязателен после ненулевого nudge | `test/iso-overlays.test.mjs` («wall-aware nudge») | `stage3-w11-nudged-overlay-loses-tether` — **поймано 1 из 1** | +| AC1/AC13 нет отдельного Stage 3 expiry/URL-ключа | `test/isometric-contract.test.mjs` («Labs iso is presentation-only») | `stage3-w12-separate-alpha-url-key-restored` — **поймано 1 из 1** | + +Все 12 команд запущены мной лично (не по описанию автора), каждая дала +`поймано 1 из 1` — «тест умеет падать» подтверждено, а не принято на слово. + +**Пустых третьих столбцов нет**, кроме одного отдельно обсуждаемого случая: +AC6-hinge/flip покрыт для заявленного W9-пути (face turn direction в +`iso-scene-render.ts`), но у смежной функции `leafBasis` (`src/iso-openings.ts`) +есть непокрытая ветка того же семейства дефектов — см. Находку M3. + +AC10 (live layers), AC11 (lifecycle/touch/kiosk), AC14 (гейты), AC15 +(golden/docs) не заявляют отдельной защиты сверх вышеперечисленного — доказаны +combined-смоками/golden/гейтами таблицы выше и разобраны ниже по коду с пометкой +«проверено чтением». + +## Находки + +### H1 — `node scripts/check-docs.mjs` красный: скриншоты документации не пересняты (High, блокирует) + +**Воспроизведение** (без изменения рабочего дерева — через изолированные +`git worktree`, затем удалены): + +``` +git worktree add --detach /tmp/head-check ee7d486924d5b1641a56f671bbf94e965fb76493 +cd /tmp/head-check && node scripts/check-docs.mjs +→ ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs +→ exit 1 + +git worktree add --detach /tmp/dev-check origin/dev # afba49a3, ровно родитель ветки +cd /tmp/dev-check && node scripts/check-docs.mjs +→ Documentation checks passed (7 files, 12 external links) +→ exit 0 +``` + +Отпечаток в `docs/images/screenshots.json` (`manifest.sourceFingerprint`) +считается по всему `src/**` (`scripts/source-fingerprint.mjs`). Диапазон этой +задачи меняет `src/iso-projection.ts`, `src/iso-overlays.ts` (новый), +`src/iso-openings.ts`, `src/iso-scene-render.ts` (новый), +`src/houseplan-card.ts`, `src/styles/plan.styles.ts`, +`src/editor-runtime-loader.ts` — отпечаток обязан был устареть, и он устарел, а +`docs/images/*` не тронуты ни одним коммитом диапазона +(`git diff origin/dev...HEAD --stat -- docs/images` — пусто). + +Это ровно инцидент, названный в PROCESS.md §2.9 и AGENTS.md как прецедент: +«скриншоты не пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока +это не нашли при следующей задаче (#237)». `check-docs` — обязательная часть +гейта код-ревью «при любом diff'е по `src/**`» (PROCESS.md §2.7), не +предрелизная опция. + +**Почему не исправляю сам:** пересъёмка выполняется только джобой +`Docs screenshots` (`workflow_dispatch`) и принимается локально `npm run +docs:accept -- --reviewed --from=<артефакт>` — ревьюер не редактирует продукт и +не является тем, кто локально снимает кадры (байтовое расхождение +Chromium/окружения аннулировало бы весь набор). Автору нужно: запустить job → +`npm run docs:accept -- --reviewed --from=<распакованный артефакт>` → закоммитить +вместе с задачей. + +**Серьёзность:** High — сливание в этом виде оставляет `dev` с красным job +`docs`, то есть ровно то, что PROCESS.md прямо называет ценой пропуска. + +### M1 — floor anchor opening lock badge вычисляется двумя независимыми формулами (Medium, в скоупе) + +- `src/houseplan-card.ts:6170` — `_openingLockAnchor()`, используется Flat/`show_borders:false`-путём (`houseplan-card.ts:13097`); +- `src/iso-scene-render.ts:853` — `isoOpeningLockPlacement()`, используется raised/Iso-путём. + +Обе функции побайтово повторяют одну и ту же математику: `rad = +(angle+90)·π/180`, `gateFace` через `partitionOpeningFace`/ +`openingInnerFaceOffsetFromIndex` (в `houseplan-card.ts` это скрыто за приватным +хелпером `_openingFace`, `houseplan-card.ts:8377`, который делает ровно то же +ветвление), `lockOffset = gridVisualUnits(16, cellCm)`, +`offset = gateFace ? -lockOffset·side : lockOffset·(flip_v?-1:1)`, +`floorAnchor = [rx+cos(rad)·offset, ry+sin(rad)·offset]`. + +Показательная деталь: `iso-scene-render.ts` уже экспортирует +`isoOpeningLockAnchor()` (`iso-scene-render.ts:845`) — тонкую обёртку именно для +этого случая, а комментарий над `_openingLockAnchor` в `houseplan-card.ts` +буквально называет её «Shared Flat/Iso host-face semantics for raised opening +locks». Но экспорт нигде не импортируется (`grep -rn isoOpeningLockAnchor src +test demo` находит только само определение и сборочный chunk) — общая функция +задумана, но не подключена; `_openingLockAnchor` — независимая копия, а не +вызов. + +Это не архитектурная неизбежность лени-чанка: сама формула не использует ничего +специфичного для ленивого `iso-scene-render.ts` (только +`gridVisualUnits`/`Math.cos`/`Math.sin`/примитивы `iso-openings.ts`, который уже +загружается eagerly и из `houseplan-card.ts`, и из `iso-scene-render.ts`) — её +можно перенести в `iso-openings.ts` и импортировать из обоих мест без изменения +lazy/eager границы бандла. + +Сейчас числовое расхождение отсутствует — обе копии дают одинаковый результат +для всех типов проёма, я сверил формулы построчно. Риск ровно тот, о котором +предупреждает «одно число — один источник» (PROCESS.md, #234/#233): правка +одной копии (например знака `gateFace.side` для нового кейса) без парного теста +незаметно разведёт Flat/`show_borders:false` и Iso-положение lock badge. +Парного теста нет — `test/iso-scene-render.test.mjs` проверяет только +модульную версию. + +**Фикс в скоупе:** перенести формулу в `iso-openings.ts` (или в уже +существующий экспорт `isoOpeningLockAnchor`, но из eager-модуля) и заменить обе +копии на вызов; либо, как минимум, добавить parity-тест, вычисляющий обе +функции на одном наборе openings и сравнивающий floor anchor. + +### M2 — реальный алгоритм `isoRoomSafePoint` (grid-search fallback) не покрыт ни одним тестом (Medium, в скоупе) + +`src/iso-overlays.ts:178-222` — `isoRoomSafePoint()`. Функция сначала проверяет +дешёвый путь (`room.safePoint`, если он уже валиден), иначе строит кандидатов +(центр bbox, центроид, средние по тройкам соседних вершин контура) и в конце +перебирает выпуклую 15×15 сетку внутри bbox, выбирая точку с максимальным +clearance от границы/дыр — единственный способ получить «доказанную внутреннюю +точку» для вогнутых комнат и комнат с дырами (ссылка на §19.3 ТЗ: «иначе +используется ближайшая доказанная внутренняя точка»). + +Продакшен-вызов — `src/iso-scene-render.ts:723`: +``` +return { room, overlayRoom: { ...base, safePoint: isoRoomSafePoint(base) || undefined } }; +``` +`base` строится без поля `safePoint` (`iso-scene-render.ts:715-722`), поэтому +дешёвый путь не срабатывает НИКОГДА — каждый вызов в реальном рендере проходит +полный candidate+grid-search алгоритм. + +При этом все фикстуры `test/iso-overlays.test.mjs` (`square(...)`, строки +20-198) передают `safePoint` явно готовым (`[50,50]`, `[25,50]` и т.д.) — +значит каждый юнит-тест резолвера попадает исключительно в дешёвый путь и ни +разу не исполняет сам grid-search/candidate-перебор, включая тест на «room with +hole»/donut (строка 184), где как раз и важна корректность вычисления +внутренней точки для вогнутой/дырчатой геометрии. Я перечитал файл целиком и +подтверждаю: другого вызова `isoRoomSafePoint` с «пустым» room нет ни в одном +тесте. + +Это прямое нарушение дисциплины «тест умеет падать» именно там, где ТЗ (§19.3) +явно требует «pure tests подтверждают» этот алгоритм для использования в +качестве inward-направления nudge (AC5). Не найдено доказательств, что +алгоритм ошибается — но и доказательств обратного тоже нет: если он вернёт +точку в дыре или за пределами вогнутой комнаты, ни один тест этого не заметит, +а `pointStrictlyInRoom`-проверки в самой функции его подстрахуют лишь частично +(candidate просто не будет учтён `consider()`, но `best` может остаться `null` +для патологической геометрии, и последствия этого в резолвере nudge не +проверены). + +**Фикс в скоупе:** добавить unit-тесты, вызывающие `isoRoomSafePoint` +напрямую на комнатах БЕЗ предзаданного `safePoint`, включая вогнутую и +donut/с дырой геометрию, и проверяющие: (а) результат строго внутри комнаты, +(б) детерминированность при повторном вызове, (в) `null` для вырожденной +комнаты не ломает вызывающий резолвер. + +### M3 — `leafBasis` (`src/iso-openings.ts:187-209`): flipH не тестируется независимо от flipV (Medium, в скоупе) + +```js +const sx = input.flipH ? -1 : 1; +const sy = input.type === 'gate' ? 1 : input.flipV ? -1 : 1; +``` + +Единственное место во всём `test/iso-openings.test.mjs`, где `flipH: true` +вообще встречается — строка 211: +```js +const flipped = buildIsoOpeningBasis(opening({ flipH: true, flipV: true })); +``` +всегда вместе с `flipV: true`, и проверка — только +`assert.notDeepEqual(flipped.leaves.map(l=>l.hinge), normal.leaves.map(l=>l.hinge))`, +без точных ожидаемых координат. Если в `leafBasis` перепутать роли (например, +`sx` начать читать из `input.flipV`, а `sy` — из `input.flipH`), для двери +(`type !== 'gate'`) при `flipH=true, flipV=true` подстановка даёт `sx=-1, +sy=-1` что до, что после путаницы — тест остаётся зелёным. Изолированного +кейса `flipH:true, flipV:false` (или наоборот) с точным ожидаемым hinge нет. + +Важное уточнение: заявленный в §13 ТЗ witness **W9 покрывает другой код** (face +turn direction в `iso-scene-render.ts`, я лично подтвердил +`stage3-w9-gate-face-flip-reversed` — красит тест `test/iso-scene-render.test.mjs`) +и там всё корректно. Это отдельная, более узкая находка про смежную функцию +того же семейства (visual leaf hinge/vector в `iso-openings.ts`), не про +отсутствие защиты AC6 в целом. + +**Фикс в скоупе:** добавить `opening({ flipH: true, flipV: false })` (и +обратный кейс) с точными ожидаемыми `hinge`/`closedVector` для door и gate. + +### Low — не блокируют, оставлены с записью + +- **`IsoDecorationLayers.shadows` не зависит от `hideOpenings`** + (`src/iso-openings.ts:557`), хотя D5/AC7 формулируют «`hide_openings` скрывает + panels/reveal и их directional shadows». Фактическое поведение верное — + тени пропадают косвенно, потому что `layers.panels` (который зависит от + `hideOpenings`) обнуляет массив `openingSurfaces` + (`src/iso-scene-render.ts:1119`), из которого тени строятся; сам флаг + `shadows` в `resolveIsoDecoration` — общий, не opening-специфичный. Реальной + регрессии нет, но название вводит в заблуждение и не защищено собственным + тестом: будущий рефакторинг, который возьмёт тени из другого источника + геометрии, тихо вернёт directional shadow при `hide_openings:true`, и ни один + существующий тест этого не поймает. Оставляю с запиской, не как блокер. +- **`renderIsoRaisedOverlays`/`renderIsoOverlayGrounds` не проверяют + `layers.structural` сами** (`src/iso-scene-render.ts:1032,1049`) — W6 + фактически ловится caller-гейтом (`houseplan-card.ts:6195`, + `!layers?.structural`) и golden/contract-тестами, но defense-in-depth на + уровне самого модуля отсутствует. Рекомендация на будущее, не находка этого + ревью. +- **Дублирование вычисления fallback-ключа** между `_effectiveProjection()` + (`houseplan-card.ts:~6134-6160`) и catch-веткой в теле рендера + (`~11360-11382`) — оба независимо собирают + `disp.showBorders ? key : \`${space}|no-borders\``. Сейчас оба пути дают + одинаковый ключ (я сверил вручную), но это тот же паттерн риска, что M1, в + меньшем масштабе — не поднимаю до Medium, так как последствие расхождения + (неверный fallback-latch) менее вероятно и не про пользователем видимую + величину напрямую. +- **`hovered: true` захардкожен** для всех raised-overlay + (`iso-scene-render.ts:765`) — ветка D4 «tether может быть скрыт в свободной + области» никогда не срабатывает в проде. Это разрешённый (не обязательный) + выбор по ТЗ («может быть скрыт»), и в коде есть явный комментарий, + объясняющий это как осознанное решение («persistent tether… without a second + hover-only render pipeline»). Не нахожу нарушения, фиксирую как наблюдение. +- **`EPS = 1e-9`** в геометрических хелперах `iso-overlays.ts` — абсолютный, не + масштабируется от величины координат плана; теоретический риск на очень + больших планах, не воспроизведён. + +## Что проверено и корректно + +- **Камера (AC2, D1):** `ISO_CAMERA.rotDeg` ровно `4`, `tiltDeg` ровно `20`, + единая матрица `isoPlaneMatrix`/`applyIsoMatrix` для точки/floor-matrix/CSS — + никакой второй приближённой формулы (`src/iso-projection.ts`). Round-trip, + diag-frame и raised-height тесты в `test/iso-projection.test.mjs` содержательны + (точные числа, не только типы). W1 лично прогнан. +- **Raised/floor split (AC3, D2):** одна матрица для floor anchor, raised + anchor, plate-углов (`src/iso-overlays.ts`), никакой параллельной формулы. + Vacuum/Glow/room fill/sunlight/decor остаются floor-bound — подтверждено + W3 и отдельно кодом `_scenePoint`/`this._pos` путей в `houseplan-card.ts`. +- **Owning room и nudge (AC5, D4):** правило владения (явная привязка → строго + содержащая минимальная по площади комната → tie-break по id → fail-safe + `null`, без «угадывания») реализовано и покрыто тестами free/shared-wall/ + corner/room-with-hole/outside/no-owner (не считая пробела M2 в подчинённом + `isoRoomSafePoint`). Nudge детерминирован (бинарный поиск по CSS-px, без + `Math.random`), ограничен фиксированным cap, не двигает floor anchor/tether, + не пишет данные — grep не находит присваиваний в `input.*`/`store`/`save`/ + `dispatch`. W5, W11 лично прогнаны. +- **Плейт и content (AC4):** подложка floor-parallel, glyph/text screen-facing, + hit target реально не уменьшается (только новые правила `min-width/height: + 44px` добавлены в `plan.styles.ts:609-619,752-757`, старые не тронуты). W4 + лично прогнан. +- **Проёмы (AC6, D5):** ручек нет (grep по `handle|knob|lever` не находит + ничего в геометрии, только в редакторских resize-инструментах); passage + явно исключён из leaf/frame/sill (`iso-openings.ts:252,267,414`); окно — + светлая рама/sill, без тёмного стекла (цвета в `plan.styles.ts` + подтверждены); update `openingAmount()` не трогает structural cache/scene + (basis неизменен после проекции, `test/iso-openings.test.mjs:214-221` + `assert.deepEqual(normal, basisSnapshot)`); hinge/face turn direction (W9) + лично прогнан и корректен. Ограничение — M3 выше. +- **Materials/shadows/fingerprint (AC8, AC12, D6/D7, §7.2/§7.3):** layer order + `underlay → shadows → walls → grounds → raised` совпадает с §7.3 и + подтверждён z-index тестом (`test/isometric-contract.test.mjs:112-124`). + Structural fingerprint включает ровно нормативный список (geometry, camera + `+4°/20°`, heights, algorithm revision) и явно исключает HA state/theme/ + hover/tooltip — подтверждено кодом и W7. LRU cap 8 подтверждён кодом и + тестом на eviction/recency. Material defs — фиксированный набор ~9 id, не + растущий по face/marker — подтверждено кодом и W8, а также реальным прогоном + бенчмарка (`materialDefinitionCount: 9`, см. «Производительность» ниже). + Grounding/plate SVG-корни помечены `aria-hidden="true" pointer-events="none"`. +- **Degradation (AC9, §7.5):** forced-colors/no-filter снимают только + texture/shadows, geometry/openings/anchors/tether/actions остаются; только + structural exception латчит Flat fallback (W10 подтверждает, что Flat-сэмпл + не засчитывается успешным Iso-сэмплом в перф-раннере) — проверено чтением + `resolveIsoDecoration`/`resolveIsoDecorationLayers` и smoke-тестами автора. +- **Zigbee-топология и live layers (AC10):** `hp-zigbee-topology-overlay.ts` не + менялся и по-прежнему читает `marker.getBoundingClientRect()` — так как + `_renderDevice`/`_renderRoomLabel`/`_renderOpeningLocks` теперь пишут + `left/top` из `isoPlacement?.visualScene` (поднятая точка), топология + автоматически идёт к новой позиции без второй геометрической модели — + подтверждено чтением и новым тестовым блоком в + `demo/smoke_zigbee_topology_hover.mjs` (+78 строк: + `isoTopologyUsesRaisedDomCentres`, + `isoTopologyTracksRaisedDomCentresAfterPanZoom`). +- **`show_borders:false` (AC7, §7.4):** `isoOverlayPlane()` возвращает + `'floor'` при `!showBorders`; `_isoOverlayScene()` дополнительно гейтится на + `layers?.structural`; при отсутствии isoPlacement device/room-label/lock + откатываются на каноническую матрицу с `z=0` (не на старую two-corner + viewBox-аппроксимацию) — подтверждено кодом и smoke-тестом + `noBordersUsesTrueAffineFloor`, проверяющим реальные недиагональные элементы + матрицы. Замечена и попутная корректная правка бага: `_baseVb()` для + no-borders раньше передавал `wallHeight` стены в `projectedFrame`, теперь — + `0` (убирает невидимые Stage 3 bounds, ровно требование §7.4). +- **`hide_openings` vs lock badge:** lock badge не зависит от `hideOpenings` + (только от `!orphanReason && (door|gate) && lock && entityAvailable`) — + соответствует «lock badge остаётся raised, пока стена видима». +- **Одно число — один источник (кроме M1):** value/LQI-текст на raised-плейте + использует тот же `_devicePresentation`, что и обычный HTML-рендер (общий + форматтер, не пересчитан заново); `test/single-source-numbers.test.mjs` не + тронут диффом, что ожидаемо — задача не добавляет новый видимый формат + числа, только позицию (см. M1). +- **Негативный контракт (AC13):** нет новых зависимостей + (`package-lock.json` не менялся), новых i18n/storage/config/network путей — + подтверждено grep и чтением; legacy `hp-labs`/expiry не возвращены (W12). + Трейлеры всех 4 коммитов диапазона — `Issue: #160` / `User-Visible: no`, + сверено `git log`. +- **Golden (AC15), диагностика на этой Linux-машине:** `npm run golden:verify` + после свежего `bundle:sync` — 137 незатронутых сценариев `passed` (Flat не + сдвинулся ни на пиксель), 8 существующих `isometric-*` — `different` + (ожидаемо, камера/Stage 3), 5 новых `isometric-stage3-*` — + `missing-baseline` (ожидаемо, не принимались автором — правильно, приёмка + требует `--reviewed` и полного Linux CI артефакта, не решается на этом + этапе). Ни одного неожиданного отличия вне `isometric-*`. +- **Документация (AC15, §15):** `docs/ISOMETRIC.md`, `docs/ARCHITECTURE.md`, + новый `docs/adr/160-isometric-stage3-overlays.md`, `docs/STATUS.md`, + `docs/specs/README.md` обновлены и не содержат устаревших упоминаний + «expiring iso»; публичные `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`, + `docs/RELEASE-NOTES.md`, README, user guide не тронуты — соответствует + «Stage 3 остаётся скрытой». +- **Бюджеты производительности (AC12, §10):** `demo/performance/budgets-isometric-stage3-dense.json` + не мягче `demo/performance/budgets-large-house-isometric.json` (идентичные + regression ratio/noise/ceiling, идентичный `isoGeometry: 8`/growth 0) — + сверено построчно. `demo/performance/evaluate.mjs` отклоняет + `effectiveProjection !== 'iso'` и полный boolean rebuild на HA-only update. + `.github/workflows/performance.yml` добавляет отдельный matrix-профиль для + нового бюджета. + +## Производительность — локальный sanity-прогон + +`node demo/benchmark_large_house.mjs --profile=isometric-stage3-dense-v1 --samples=1` +запущен лично на этом SHA (Linux) как разовая проверка, что новый профиль и +плотная фикстура реально исполняются и дают осмысленные числа — не как замена +канонического 7-sample exact-SHA Linux CI прогона (тот остаётся pre-beta +гейтом §8/§10, вне объёма код-ревью). Результат зелёный и содержательный: + +- `effectiveProjection: "iso"` — сэмпл не попал в Flat fallback; +- `renderedOverlayCount: 82` и `raisedOverlayCount: 82` — все overlay + реально подняты, ни один не остался «недоподнятым»; +- `raisedVacuumCount: 0` — vacuum не поднят, как того требует AC3/W3; +- `materialDefinitionCount: 9` (`patterns: 3, filters: 4` + фон) — совпадает с + оценкой из чтения кода (см. AC8 выше), O(1) для плотной сцены с 82 + overlay и 261 opening-поверхностью (`door: 182, window: 55, gate: 24`); +- `isoStructuralBuilds.haUpdateDelta: 0` и + `stage3Diagnostics.steadyUpdateStructuralBuildDelta: 0` — HA-only update не + вызывает structural rebuild; +- `cacheGrowth.isoGeometry: 0` при `cacheEntries.isoGeometry: 3` (в пределах + cap 8). + +Одного сэмпла недостаточно для приёмки exact-SHA бюджета (нужны семь и +Linux CI) — это прямо и не заявлено; но сам факт, что профиль и фикстура +работают и дают именно те инварианты, которые требует AC12, — установленный +факт, а не «verified» без команды. + +## Чего не проверял и почему + +- **`npm run invariants`** — диапазон не меняет геометрическую модель + (комнаты/стены/толщины/`layout`/`marker.space`/`open_spans` в + `custom_components/**`/config schema); это исключительно рендер-слой поверх + уже провалидированной канонической геометрии (подтверждено AC13 и diff-статом + — ни одного изменения в `custom_components/**/*.py` или схеме конфига). + Инварианты неприменимы к чисто презентационному diff'у. +- **`python -m pytest tests_backend`** — ноль изменений в + `custom_components/houseplan/**/*.py` (diff stat подтверждает). +- **Полный `ls demo/smoke_*.mjs` набор (226 файлов)** — не прогонял. Ран + `scripts/smoke-select.mjs --base origin/dev --head HEAD` показал 104 «прямых + совпадения» и 35 «слабых связей» почти исключительно из-за общих приватных + полей `houseplan-card.ts` (`_baseVb`, `_viewOr`, `_mode` и т.п.), не + специфичных для Stage 3 логики — полный прогон здесь был бы предрелизным + объёмом, не гейтом ревью. + Прогнал лично: `demo/smoke_isometric_contract.mjs` и + `demo/smoke_isometric_live_touch.mjs` — по многу раз каждый, как чистый + baseline-прогон перед каждой из 6 smoke-guarded witness-мутаций W4–W8 (все + чистые прогоны зелёные, все мутации красные), и `demo/smoke_entry_stale.mjs` + («зарегистрированная связь» через общий `EditorRuntimeLoader`/ + `safeRuntimeDiagnostic` — зелёный). + Не прогонял отдельно: `demo/smoke_zigbee_topology_hover.mjs` и 11 + «legacy iso»-смоков, заявленных автором зелёными на точном SHA — полагаюсь + на их хендофф-отчёт и на то, что пересекающийся по контракту + `smoke_isometric_contract.mjs`/`smoke_isometric_live_touch.mjs` я исполнил + сам многократно и чисто; при необходимости могу прогнать по запросу. +- **Полный 7-sample exact-SHA `large-house-isometric-v1` и + `isometric-stage3-dense-v1`** — канонический гейт вынесен в Linux CI Full + Performance перед бетой (§8/§10), не в код-ревью; локально прогнан только + `--samples=1` sanity-чек нового профиля (см. «Производительность» выше). +- **Приёмка golden baseline** — не моя роль на этом этапе; выполнил только + диагностический `golden:verify` (см. выше), не `golden:accept`. + +## Итог + +**High: 1** (check-docs красный — блокирует), **Medium: 3**, все в скоупе +задачи (правятся в этой же ветке, отдельный issue не заводится). Все 15 AC по +существу выполнены и по большей части доказаны автотестами, которые я лично +проверил на способность падать (12/12 red witnesses), плюс независимый +canonical-Linux `golden:verify` и содержательный sanity-прогон нового +performance-профиля. Задача возвращается автору не из-за архитектурных +проблем, а из-за одного пропущенного, механического, но обязательного шага +(пересъёмка скриншотов документации) и трёх фиксируемых в скоупе усилений +тестового покрытия/устранения дублирования формулы. + +**Вердикт: красный · заход r1 · блокирующих циклов 0/4 · High: 1 · Medium: 3 → в задаче** + +--- + + + +## Материал раунда + +- Ветка: `issue/160-isometric-stage3`, коммит `ee7d486924d5` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. +- Дерево материала: `41472b7a410b83d6fa8a667df63d680b819d01f6` + ``` + git log --all --format='%H %T' | grep 41472b7a410b + ``` +- ТЗ `docs/specs/160-isometric-stage3.md`, блоб `20ed447b8f58bdfe9e48695584ff616c36b79b4d` + ``` + git log --all --find-object=20ed447b8f58bdfe9e48695584ff616c36b79b4d -- docs/specs/160-isometric-stage3.md + ```