22 KiB
Код-ревью — issue #210, цикл r1
- Issue: https://github.com/Matysh/houseplan-card/issues/210
- ТЗ:
docs/specs/210-fixed-floor-card.md(зелёное ревью, r2,docs/reviews/SPEC-REVIEW-210-r2.md) - Диапазон:
origin/dev..HEAD, единственный продуктовый коммит6063eea(«fix: pin card instances to configured spaces»), трейлерыIssue: #210/User-Visible: yesна месте. - Трек: обычный (не
small/trivial) — лимит циклов 4. - Вердикт: зелёный · цикл r1/4 · High: 0 · Medium: 0
Скоуп ревью
Проверялся диапазон git diff origin/dev...HEAD против принятого ТЗ:
src/types.ts, src/initial-load.ts, src/houseplan-card.ts, src/editor.ts,
src/styles.ts, src/i18n/{en,ru}.json, тесты
(test/fixed-floor-contract.test.mjs, обновления
test/initial-load.test.mjs, test/optional-space-model-contract.test.mjs),
новый смок demo/smoke_fixed_floor.mjs, scripts/mutation-gate.mjs, три
tracked bundle-копии и documentation-артефакты (docs/ARCHITECTURE.md,
docs/TESTING.md, docs/USER-GUIDE{,.ru}.md, оба changelog,
docs/specs/README.md, docs/images/screenshots.json). Backend
(custom_components/**/*.py) и golden-baselines не тронуты — вне скоупа ТЗ,
подтверждено git diff --stat.
Как проверялось
Сессия свежая, без контекста ревью ТЗ (§6 PROCESS.md). Прочитаны
docs/SCOPE.md, AGENTS.md, PROCESS.md целиком в этой сессии, тело issue
#210 и все комментарии (включая рекомендацию по имплементации и хендофф
автора), ТЗ 210-fixed-floor-card.md целиком и оба документа ревью ТЗ.
Код читался построчно по каждой зоне, названной в ТЗ §11 («Ожидаемые зоны изменений»), а не только по диффу:
- Pure resolver (
src/initial-load.ts,resolveFixedFloor) — проверена каждая ветка: absent → нет свойства; строка → пустая невалидна, иначе точное совпадение поspaceIds, без coercion; число →Number.isFinite,Number.isInteger,>= 0, индекс в диапазоне; любой другойtypeof→invalid-type. Побайтово сверено, чтоresolveInitialSpace(legacy) не изменена ни на строку — из диффаsrc/initial-load.tsудалена только одна строка (сигнатура типаInitialSpaceSource, аддитивно расширенная), тело легаси-функции нетронуто. - Единая mutation boundary (
src/houseplan-card.ts,_canCommitSpace/_commitSpace) — грепомthis\._space\s*=(?!=)подтверждено ровно одно вхождение прямого присваивания во всём файле, и оно лежит внутри самого_commitSpace. Все 14 остальных мест, где раньше стоялоthis._space = …(слайд, hash, warm-viewport,setConfig,default_floor, создание/удаление пространства, backup-import, geometry drag, marker-dialog переходы), переведены на_commitSpace/_slideToс проверкой возврата там, где переход мог быть отклонён.test/fixed-floor-contract.test.mjsдержит этот инвариант структурным тестом по исходнику — деградация до regex-проверки признана достаточной, т.к. независимо от неё это же перепроверено прогоном мутанта (см. ниже). - Приоритет и pending-состояние —
_fixedFloorState()до получения авторитетного ответа сервера (this._loadOk) переводит valid/out-of-range numeric index и valid/unknown-id string вpending, а не даёт немедленныйvalid/invalid: числовой индекс не может быть принят из непроверенного кэша (порядок пространств мог измениться), аunknown-idот кэша не должен стать терминальной ошибкой раньше свежей загрузки — ровно то, что требует ТЗ §7 и таблица рисков §14. Прочитано разбором, эмпирически не воспроизведено (см. «Чего не проверял»). - GUI editor (
src/editor.ts) — синтетический токен__houseplan_yaml_floor_index__:<n>для числового YAML-значения: подтверждено, что_formDataподставляет токен как текущее значение селекта,_schemaвключает его как единственный дополнительный вариант, а_valueChangedпреобразует''→delete config.floorи токен обратно в исходное число. Обратное преобразование опирается на контракт HAha-selector-select(«select эмитит ровноvalueвыбранной опции») — это тот же admitted-риск, что спек-ревью r2 явно оставило коду-ревью («не тестировалось эмпирически»); в демо-стендеha-form/ha-selectorне подключены ни в одном существующем редакторном смоке (grepпоdemo/*.mjsне нашёл ни одного использования этих тегов кроме объявления вsrc/editor.ts), так что это ограничение стенда, а не упущение задачи — оценка риска ниже. - View/kiosk gating —
navigationSpaces,.tabadd,_swipeZone,.kioskdots,_syncCycleTimer/_cycleTick— каждое условие читаетthis._hasFixedFloorсимметрично на всех точках входа (рендер вкладок, свайп-зона, доты, install/re-sync таймера кружки). - Compatibility —
default_floorпуть (if (!this._hasFixedFloor && config.default_floor) this._commitSpace(config.default_floor, true)) и_savedNav/_saveNav(if (this._hasFixedFloor) return null/return;) читаны построчно: отсутствиеfloorне меняет ни один из легаси-путей. - Прочитаны все затронутые документы:
docs/ARCHITECTURE.md,docs/TESTING.md,docs/USER-GUIDE.md/.ru.md, оба changelog,docs/specs/README.md— термины совпадают с ТЗ и друг с другом (Initial space / Стартовое пространство; Fixed space / Закреплённое пространство).
Гейты — что прогнано и с каким результатом
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | npx tsc --noEmit |
OK, без вывода |
| Unit | npm test |
# tests 944 / pass 944 / fail 0 |
| Build + bundle sync | 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 |
OK, обе копии побайтово совпадают с dist/ |
| Целевой смок (новый) | node demo/smoke_fixed_floor.mjs |
OK, 14/14 фактов true |
| Регрессия навигации | node demo/smoke_nav_persist.mjs |
OK, 7/7 фактов true |
| Регрессия kiosk | node demo/smoke_kiosk.mjs |
OK, 12/12 фактов true |
Blast-radius: warm remount (диф трогает _warmAdopt/_warmVpArmed) |
node demo/smoke_warm_remount.mjs |
OK, 14/14 фактов true |
Blast-radius: пустое пространство (диф трогает тот же _space='' cleanup) |
node demo/smoke_optional_space_model.mjs |
OK, 9/9 фактов true |
| Docs-гейт | node scripts/check-docs.mjs |
Documentation checks passed (7 files, 10 external links) |
| Whitespace | git diff origin/dev...HEAD --check |
чисто, без вывода |
| Дисциплина «тест умеет падать» для нового guard-мутанта | node scripts/mutation-gate.mjs --id=fixed-floor-transition-guard-bypassed |
ok чистый прогон + ok fixed-floor-transition-guard-bypassed: тест покраснел, как обязан — независимо подтверждено ревьюером, не только со слов автора |
Не прогонялось и почему:
- Полный набор из 127 браузерных смоков — диф не задевает весь продукт;
прогнаны названные в плане тестирования ТЗ (
smoke_fixed_floor,smoke_nav_persist,smoke_kiosk) плюс два дополнительных смока по местам, которые диф трогает напрямую, но план тестирования не называл (warm remount, optional-space cleanup). Остальные touch/kiosk-смоки (smoke_kiosk_pan_lock,smoke_touch_tips,smoke_isometric_live_touch) не запускались: диф не меняет их код напрямую, а свайп/dots/cycle-путь, который он трогает, уже покрыт зелёнымsmoke_kiosk.mjs(swipeSwitches,noSwipeZoomed,dblTapResets,iconScaleWorks). npm run golden:verify— диф не меняет визуальную геометрию/раскладку; ошибочный и invalid-state рендерятся как обычныйha-cardс DOM/computed assertions в смоке, что и обещало ТЗ §13 («отдельный visual golden не нужен»).python -m pytest tests_backend -q— ни один файлcustom_components/houseplan/**/*.pyне тронут (git diff --statподтверждает).- Performance-профили — не названы в AC и не затронуты (pure O(число пространств) резолвер, без per-frame/per-marker работы, ТЗ §14).
- Полный mutation-gate (
node scripts/mutation-gate.mjsбез--id) — дорогой прогон всего реестра, это предрелизный гейт (§8 PROCESS.md); прогнан только новый мутант задачи, который и требовалось доказать.
Находки
Ни одной High, ни одной Medium, ни одной Low после независимого чтения кода и прогона гейтов.
Проверка по AC1…AC12
| AC | Как доказан | Вердикт |
|---|---|---|
| AC1 | resolveFixedFloor unit-тест (test/initial-load.test.mjs, «distinguishes absence…») + smoke_fixed_floor.mjs idWinsEveryInitialSource (валидный ID выигрывает при одновременно выставленных hash и saved nav на другой этаж). |
Автотест, тест умеет падать (сам pure-резолвер тривиально ломается изменением порядка веток — проверено чтением условий). |
| AC2 | Unit-таблица невалидных числовых кейсов + spaceIds: ['1', 'other'], floor: '1' → id: '1', source: 'id' (строка не превращается в индекс) + smoke_fixed_floor.mjs indexUsesServerOrder. |
Автотест. |
| AC3 | Unit (empty-id, unknown-id, non-finite-index и т.д.) + smoke_fixed_floor.mjs invalidFailsVisibly (data-attr reason=unknown-id, role="alert", нет .stage). Stale-cache-ветка (pending) проверена чтением _fixedFloorState, не исполнением — см. «Чего не проверял». |
Автотест + частично чтением. |
| AC4 | smoke_fixed_floor.mjs держит одновременно смонтированными fixedId (ID) и fixedIndex (index) плюс позже ordinary при общем houseplan_card_nav_v1 — legacyCardStillRestoresNav подтверждает, что соседняя обычная карточка не пострадала. |
Автотест. |
| AC5 | fixedDoesNotWriteNav (после _saveNav() на fixed-карточке общий ключ остаётся тем, что был до неё) + структурный тест test/fixed-floor-contract.test.mjs (_savedNav/_saveNav гварды по regex) + legacyCardStillRestoresNav. |
Автотест. |
| AC6 | tabAndTransitionBlocked (_commitSpace(ids[1]) возвращает false), hashIgnored, fixedKioskCycleDisabled, fixedKioskSwipeDisabled + грепом подтверждено единственное прямое присваивание _space во всём файле (структурная гарантия, не только тест). |
Автотест + чтением (инвентаризация присваиваний). |
| AC7 | onlyFixedTab (ровно один таб, без .tabadd), fixedKioskDotsHidden. Права gear/editors не менялись (диф не трогает _canEdit) — проверено чтением: рендер вкладок и gear используют тот же _spaceModel()/_canEdit, что и раньше. |
Автотест + чтением. |
| AC8 | editorPreservesYamlIndex, editorClearDeletesKey + структурный тест на точные строки преобразования токена. Реальное поведение ha-selector-select при клике в браузере не воспроизведено — деманд стенда, см. выше. |
Автотест (метод) + чтением контракта HA-селектора. |
| AC9 | Чтением: setConfig сбрасывает _hashApplied/_navApplied/_warmVpArmed при fixedChanged и безусловно перевызывает _adoptInitialSpace; оба сетевых обработчика (_adoptStructuralResponses + WS update) вызывают _adoptInitialSpace(this._model, true) после каждого принятого server config, что переоценивает floor заново и переводит карточку в invalid при удалённом ID. smoke_fixed_floor.mjs remountStillFixed подтверждает воспроизведение после технического remount, но не «удалить пространство через gear своего же экземпляра и получить invalid по live-ответу сервера» — это разобрано чтением, не исполнением. |
Автотест (частично) + явно «проверено чтением, не исполнением». |
| AC10 | smoke_kiosk.mjs (не тронутый iconScaleWorks, dblTapResets, noSwipeZoomed и т.д. — регрессии нет) + smoke_nav_persist.mjs/smoke_warm_remount.mjs/smoke_optional_space_model.mjs зелёные на blast-radius, который диф трогает напрямую. Touch-специфичные смоки не перезапускались — их код не тронут. |
Автотест. |
| AC11 | git diff --stat подтверждает правки в обоих docs/CHANGELOG*.md со ссылкой на #210 в том же коммите, docs/USER-GUIDE{,.ru}.md объясняют floor vs default_floor, ID vs index, invalid-поведение; node scripts/check-docs.mjs зелёный. |
Автотест (provenance/docs check) + чтением текста. |
| AC12 | typecheck/test/build/named smokes зелёные (таблица выше); node scripts/mutation-gate.mjs --id=fixed-floor-transition-guard-bypassed независимо подтверждает, что чистый бандл проходит, а мутированный (guard инвертирован) красит smoke_fixed_floor.mjs — «умеет падать» доказано ревьюером, не только записью автора. |
Автотест, независимо перепрогнан. |
Проверено и признано корректным
- Единственная mutation boundary реально единственная (грепом, не на слово
автора): весь легаси-код навигации (
_slideTo, hash-listener,setConfig/default_floor, warm-viewport adopt, создание/удаление пространства, geometry-drag commit, backup-import, marker-dialog cross-space переходы) переведён на_commitSpace/_canCommitSpace. pending-состояние корректно закрывает риск «кэш на миг покажет чужой индексированный этаж» из ТЗ §14: числовой индекс и cache-onlyunknown-idне финализируются доthis._loadOk; остальные типы невалидности (пустая строка, дробное/отрицательное/бесконечное число, посторонний тип) не зависят от модели и репортятся немедленно.- Легаси-путь (нет
floor) не тронут ни строкой вresolveInitialSpace; расширение union-типаInitialSpaceSourceаддитивно. - GUI: удаление ключа при очистке и сохранение YAML-индекса при несвязанном
редактировании реализованы так, как описывало исправление M1 в ТЗ; риск,
прямо оставленный код-ревью спек-ревьюером, разобран и признан низким по
причине, названной выше (харнесс не подключает
ha-formвообще ни для одного существующего редакторного поля — это не новый риск задачи). - Терминология синхронизирована: «Initial space / Стартовое пространство» и «Fixed space / Закреплённое пространство» одинаковы в GUI-строках, RU/EN guide и changelog.
- Коммит один, трейлеры
Issue: #210/User-Visible: yesна месте, оба changelog в нём же; сгенерированные копии бандла побайтово идентичны.
Чего не проверял
- Реальное поведение
ha-selector-selectв браузере при явном клике по пустому варианту — демо-стенд не подключаетha-form/ha-selectorни для одного существующего поля редактора (grep поdemo/*.mjsне нашёл ни одного прецедента), поэтому иsmoke_fixed_floor.mjsвызывает_valueChangedнапрямую с сконструированнымCustomEvent. Это ограничение стенда, не этой задачи; риск оценён как низкий по стандартному контракту HA select (эмитит ровноvalueвыбранной опции). pending-рендер (data-fixed-floor-state="pending") не пойман ни одним тестом — существующий смок ждётawait wait(450)иupdateCompleteперед проверками, к этому моменту_loadOkуже true. Логика разобрана чтением (см. выше) и признана корректной, но живой DOM сrole="status"в состоянии загрузки эмпирически не воспроизведён.- AC9, сценарий «удалить закреплённое пространство через его же gear и
дождаться invalid по live server response» — разобран чтением
(
_adoptInitialSpaceвызывается после каждого принятого конфига), не исполнением: ни unit, ни browser-тест не воспроизводит именно эту последовательность (delete → save → WS update → invalid render). - Полный набор 127 смоков,
golden:verify,performance_smoke,pytest tests_backend— не запускались; обоснование в таблице гейтов выше, ни один из этих гейтов не относится к тронутым файлам или AC. - Ручного тестирования в браузере HA (не demo-стенде) не было — вне процесса на этой стадии.
Итог
ТЗ выполнено без сужения и без расширения скоупа. Единая mutation boundary
подтверждена структурно (один this._space = во всём файле) и мутационным
прогоном, а не только описанием автора. Компатибильность легаси-пути
подтверждена побайтовой неизменностью resolveInitialSpace. Два риска,
явно вынесенных на код-ревью спек-ревьюером (ha-selector clear-контракт и
stale-cache pending-ветка), разобраны чтением и признаны низкими без находок,
требующих правки. Новых High/Medium/Low не найдено.
Вердикт: зелёный · цикл r1/4 · High: 0 · Medium: 0