mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
@@ -0,0 +1,304 @@
|
||||
# CODE-REVIEW-491-r1
|
||||
|
||||
Issue: [#491](https://github.com/Matysh/houseplan-card/issues/491) — «Optimize/Undo: не терять незавершённую парную транзакцию при следующей записи».
|
||||
Материал ревью: ветка `issue/491-optimize-undo-pair-recovery`, **точный SHA `4c08b8abfca4a81240f98d654cc4766faae8ec58`** (рабочая копия уже на нём).
|
||||
Заход: r1. Блокирующих циклов израсходовано: 0/4.
|
||||
ТЗ: `docs/specs/491-optimize-undo-pair-recovery.md`, ревью ТЗ зелёное (`SPEC-REVIEW-491-r1`, `S5-ready`).
|
||||
|
||||
## Скоуп диффа
|
||||
|
||||
`git diff origin/dev...HEAD --stat`:
|
||||
|
||||
```
|
||||
custom_components/houseplan/__init__.py | 86 ++----
|
||||
custom_components/houseplan/store.py | 151 ++++++++++
|
||||
custom_components/houseplan/websocket_api.py | 251 ++++++++++++-----
|
||||
docs/ARCHITECTURE.md | 26 +-
|
||||
docs/CHANGELOG.md | 5 +
|
||||
docs/CHANGELOG.ru.md | 7 +
|
||||
docs/CONFIG-COMPATIBILITY.md | 20 ++
|
||||
docs/TESTING.md | 15 +
|
||||
docs/USER-GUIDE.md | 7 +
|
||||
docs/USER-GUIDE.ru.md | 8 +
|
||||
docs/reviews/SPEC-REVIEW-491-r1.md | 165 +++++++++++
|
||||
docs/specs/491-optimize-undo-pair-recovery.md | 385 ++++++++++++++++++++++++++
|
||||
docs/specs/README.md | 1 +
|
||||
scripts/mutation-gate.mjs | 87 ++++++
|
||||
tests_backend/test_ha_import_export.py | 31 +++
|
||||
tests_backend/test_ha_websocket.py | 357 ++++++++++++++++++++++++
|
||||
16 files changed, 1461 insertions(+), 141 deletions(-)
|
||||
```
|
||||
|
||||
Изменение backend-only (класс A: `custom_components/houseplan/*.py`; класс B:
|
||||
`scripts/mutation-gate.mjs`, `tests_backend/**`; класс C: `docs/**`). `src/**`
|
||||
не тронут — фронтенд-гейты (typecheck, smoke, golden, check-docs) вне
|
||||
обязательного набора для этого диффа.
|
||||
|
||||
4 коммита на ветке, все с `Issue: #491`; коммит `ba52a10f` (`User-Visible: yes`)
|
||||
несёт весь код и правки обоих changelog в одном коммите — трейлеры и правило
|
||||
«документация в том же коммите» соблюдены.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
### Дешёвые гейты — прогнаны сам, на `4c08b8ab`
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| JS unit | `npm test` | **2318 passed, 1 skipped, 0 failed** |
|
||||
| Build + сверка бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | зелёный, бандл побайтово совпадает (ожидаемо: `src/**` не менялся) |
|
||||
| `no-new-any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | «Новых any нет» (0 строк в 0 файлах — diff не по TS) |
|
||||
| `smoke-select` | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет... браузер-smoke этим диффом не выбираются» — прогон смоков не нужен |
|
||||
| `process-gate` | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» |
|
||||
| Синтаксис Python | `python3 -m py_compile custom_components/houseplan/{store,websocket_api,__init__}.py` | OK |
|
||||
| Python pure-subset | `python3 -m pytest tests_backend -q` (после `pip install voluptuous==0.15.2`) | 395 passed, 4 skipped — **не проверяет новый код**: `test_ha_*.py` пропущен, `conftest.py` игнорирует эти файлы без `homeassistant` |
|
||||
|
||||
Не прогонялись и почему:
|
||||
- `golden:verify`, `check-docs.mjs`, browser-smoke — не нужны: `src/**` не
|
||||
менялся, видимый рендер не меняется, `smoke-select` подтвердил пустой выбор.
|
||||
- `npm run invariants` — диффа геометрии/ссылок (`layout`, `marker.space`,
|
||||
`open_spans`, записи толщины) нет; меняется только протокол персистентности
|
||||
уже существующих config/layout документов, не их геометрическое содержимое.
|
||||
- `python -m pytest tests_backend -q` **с реальным Home Assistant** —
|
||||
недоступно в этом окружении. `tests_backend/requirements.txt` пинует
|
||||
`pytest-homeassistant-custom-component==0.13.357`, который требует
|
||||
Python **>=3.14**; в песочнице стоит Python 3.12.3, и других версий на
|
||||
машине нет (проверено — `python3.13`/`python3.14` отсутствуют,
|
||||
`.venv-backend` не создан). Установка `pip install -r
|
||||
tests_backend/requirements.txt` падает на резолвере с этой причиной.
|
||||
Все AC1–AC9 этой задачи доказываются исключительно HA-harness тестами —
|
||||
этот гейт я не смог исполнить и не подтверждаю его результат экспериментально.
|
||||
|
||||
### Зелёный CI на этом SHA
|
||||
|
||||
По условиям раунда — не найден. Разработчик в хендоффе ссылается на
|
||||
`exact-SHA CI 98c1923e` и «полный HA backend harness в WSL: 728 passed» — оба
|
||||
прогона сделаны **до** ребейза на `dev` (конфликт был только в
|
||||
`docs/specs/README.md`, попытка слияния без ревью отклонена ботом,
|
||||
issue-комментарий от `2026-09-08T22:06:57Z`). Ни тот прогон CI, ни WSL-прогон
|
||||
не относятся к точному SHA `4c08b8ab`, который является материалом этого
|
||||
ревью. Ребейз, по описанию владельца, тронул только строку в
|
||||
`docs/specs/README.md` (список ТЗ, класс C) — не код; я не могу
|
||||
пере-проверить это побайтово, потому что предребейзный SHA `98c1923e` уже не
|
||||
резолвится локально (обычное дело после ребейза, PROCESS §2.10), но
|
||||
`git diff origin/dev...HEAD` для файлов класса A/B, который я разбирал ниже,
|
||||
не содержит ничего, что выглядело бы как след мержа/конфликта.
|
||||
|
||||
**Вывод:** ни один AC этой задачи не подтверждён исполнением HA-harness ни
|
||||
мной, ни зелёным CI на этом точном SHA. Все AC1–AC10 ниже доказаны
|
||||
**чтением, не исполнением** — с указанием, что именно я проследил по коду и
|
||||
какому сценарию из существующих тестов это соответствует.
|
||||
|
||||
## Разбор по коду (проверено чтением)
|
||||
|
||||
Прослежены построчно: `custom_components/houseplan/store.py` (новые
|
||||
`ResolvedStorePair`, `_pending_target`, `async_converge_store_pair`,
|
||||
`async_resolve_pending_pair`), `custom_components/houseplan/websocket_api.py`
|
||||
(`_resolved_write_pair`, `PairCommitFailure`, `_persist_pair_intent`,
|
||||
`_converge_pair`→`_commit_pair`, все точки вызова в `ws_import_apply`,
|
||||
`ws_layout_set/update/delete`, `ws_geometry_repair`, `ws_config_set`,
|
||||
`ws_space_delete`, `ws_plan_optimize`, `ws_plan_optimize_undo`) и
|
||||
`custom_components/houseplan/__init__.py` (перенос recovery до миграции
|
||||
square-canvas).
|
||||
|
||||
Ключевые находки чтения (все — «работает корректно», не дефекты):
|
||||
|
||||
- **Разрешение конфликта write fence vs auth.** Во всех восьми writer'ах
|
||||
`_check_write`/`_runtime` вызываются **до** `_resolved_write_pair` —
|
||||
предположение ТЗ №5 («проверка permissions остаётся до fence») выполнено
|
||||
(проверено на `ws_layout_set` L710, `ws_config_set` L1588,
|
||||
`ws_plan_optimize` L1963 и т.д.).
|
||||
- **Совместимость со старым форматом pending.** `async_converge_store_pair`
|
||||
ветвится по `isinstance(pending.get("final_metadata"), dict)`. Ветка
|
||||
`replace_metadata=False` — дословно перенесённая из старого
|
||||
`custom_components/houseplan/__init__.py` (был: `if not replace_metadata
|
||||
and not pending.get("clear_backup") and "optimize_backup" in lay_stored: ...`)
|
||||
логика сохранения backup по `clear_backup`; существующий
|
||||
`test_setup_recovers_exact_optimize_storage_roundtrip_pair`
|
||||
(`tests_backend/test_ha_import_export.py:2282`, не тронут этим диффом)
|
||||
по-прежнему использует старый формат (`clear_backup: False`, без
|
||||
`final_metadata`) и по коду обязан пройти тот же путь — AC9 для setup
|
||||
подтверждён без изменений в самом тесте, только в реализации, которая
|
||||
теперь общая.
|
||||
- **Идемпотентность recovery.** Для нового формата (`replace_metadata=True`)
|
||||
запись config пропускается, если `config_data.get("config") ==
|
||||
target_config and config_rev == target_config_rev`; запись layout
|
||||
выполняется всегда (это и есть операция, которая в последний момент снимает
|
||||
`optimize_pending`) — соответствует §8 ТЗ («target уже записан в обе
|
||||
половины... повтор удаляет intent без нового смыслового состояния»).
|
||||
- **Постоянный порядок операций в setup** (`__init__.py`): resolver теперь
|
||||
вызывается **до** square-canvas миграции (раньше — после), с явным
|
||||
комментарием о причине; после миграции `optimize_revs` пересчитывается
|
||||
свежим чтением обоих stores перед `hass.bus.async_fire`, так что событие не
|
||||
описывает уже устаревшую (домиграционную) ревизию.
|
||||
- **`_commit_pair`** — прямое переименование бывшего `_commit_import_pair`
|
||||
(intent → converge → retry once → rollback intent → converge rollback →
|
||||
`PairCommitFailure`), протокол не менялся, только обобщено имя и убраны
|
||||
специфичные для импорта строки лога; поведение для Import/`space/delete`
|
||||
не отличается от `dev` кроме сообщения в логе.
|
||||
- **Мутационный трейс AC6 вручную** (пояснение ниже, в разделе находок):
|
||||
прочитан код `_resolved_write_pair`, при отказе `async_resolve_pending_pair`
|
||||
функция шлёт `commit_failed` и возвращает `None`; вызывающий writer делает
|
||||
ранний `return`. Гипотетическая мутация «проглотить исключение и продолжить
|
||||
со свежим (не сведённым) состоянием» превратила бы
|
||||
`test_issue_491_failed_fence_blocks_point_write_and_keeps_intent` в
|
||||
проходящий *успешный* ответ вместо `commit_failed` — то есть тест по факту
|
||||
умеет её ловить. Это прослежено по коду, не исполнено.
|
||||
|
||||
Дефектов, ломающих AC1–AC5, AC7–AC10, чтением не найдено.
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе, чинится в этой же задаче) — 1
|
||||
|
||||
**AC6 не имеет названного мутанта, хотя защита живёт в продуктовом коде и
|
||||
проверяется исключительно дорогим HA-harness гейтом.**
|
||||
|
||||
- Файл: `custom_components/houseplan/websocket_api.py`, функция
|
||||
`_resolved_write_pair` (обработка исключения `async_resolve_pending_pair` →
|
||||
`commit_failed`, без записи и без удаления pending/backup).
|
||||
- Доказательство AC6 в хендоффе: `test_issue_491_failed_fence_blocks_point_write_and_keeps_intent`.
|
||||
Третий столбец таблицы «чем краснеет» в хендоффе — «exact Store pair и
|
||||
pending сравниваются до/после»: это описание проверок самого теста, а не
|
||||
название мутации/снятой защиты/отдельной отрицательной пробы с
|
||||
результатом прогона.
|
||||
- PROCESS.md §2.7: «Мутант ... обязателен, когда защита живёт в продуктовом
|
||||
коде и проверяется дорогим гейтом (смок, бэкенд, golden): там ревьюер не
|
||||
воспроизведёт отрицательный прогон второй раз... Пустой третий столбец —
|
||||
находка Medium, а не примечание». AC6 — ровно такая защита (backend,
|
||||
дорогой гейт, я сам не смог исполнить HA-harness в этом окружении и не могу
|
||||
подтвердить «падает» без названного мутанта — см. раздел «Чего не
|
||||
проверял»).
|
||||
- Сценарий отказа без исправления: если протокол recovery когда-нибудь
|
||||
сломают правкой рядом (например, вернут `return ResolvedStorePair(...)`
|
||||
вместо `return None` в except-блоке), ни один существующий мутант в
|
||||
`scripts/mutation-gate.mjs` этого не поймает — 4 добавленных мутанта
|
||||
(`pair-recovery-config-writer-skips-fence`,
|
||||
`pair-recovery-point-writer-skips-fence`,
|
||||
`optimize-skips-pair-retry-rollback`,
|
||||
`optimize-undo-skips-pair-retry-rollback`) целятся в отсутствие вызова
|
||||
fence, а не в то, что происходит, когда fence **сам возвращает ошибку**.
|
||||
- Что требуется: мутант в `scripts/mutation-gate.mjs`, патчащий
|
||||
`_resolved_write_pair` так, чтобы отказ `async_resolve_pending_pair`
|
||||
игнорировался и writer продолжал со свежим (не гарантированно сведённым)
|
||||
состоянием, с guard на `test_issue_491_failed_fence_blocks_point_write_and_keeps_intent`
|
||||
(или новый более специфичный тест) и подтверждённым результатом прогона —
|
||||
либо эквивалентная запись «снял защиту руками, прогнал, вот вывод» в
|
||||
документации задачи. Без High-находок это влечёт жёлтый вердикт раунда;
|
||||
находка в скоупе issue #491 (сам мутационный гейт для этой задачи, п.5
|
||||
«HA-harness fault-injection тесты» из раздела 4 ТЗ) и правится в этой же
|
||||
ветке, отдельный issue не заводится.
|
||||
|
||||
### Low — 0
|
||||
|
||||
Не найдено.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **AC1/AC2 (Optimize/Undo — общий retry/rollback).** Прочитаны
|
||||
`ws_plan_optimize`/`ws_plan_optimize_undo` целиком: обе теперь строят
|
||||
`pending`/`rollback` с `final_metadata` и идут через `_commit_pair`, тот же
|
||||
примитив, что Import/`space/delete`. Тесты
|
||||
`test_issue_491_optimize_failure_restores_before_pair`,
|
||||
`test_issue_491_optimize_undo_failure_restores_pre_undo_pair`,
|
||||
`test_issue_491_optimize_fail_after_final_write_is_success` монтируют
|
||||
реальный `monkeypatch` на `Store.async_save` (не подмену чистой функции) и
|
||||
сверяют **точное** содержимое обоих store после отказа — структурно
|
||||
корректный дизайн теста, ловит и «до записи», и «после durable записи»
|
||||
сбой. Названные мутанты `optimize-skips-pair-retry-rollback`,
|
||||
`optimize-undo-skips-pair-retry-rollback` целятся именно в замену
|
||||
`_commit_pair` на голый `_persist_pair_intent + _converge_pair` — точное
|
||||
попадание в защищаемую строку.
|
||||
- **AC3/AC4 (ordinary writers резолвят pending до CAS).** Все шесть
|
||||
ordinary-путей (`config/set`, `layout/set`, `layout/update`,
|
||||
`layout/delete`, `geometry/repair`) вызывают `_resolved_write_pair` до
|
||||
чтения revision/CAS; `layout/update` — единственный без CAS — применяет
|
||||
дельту к `resolved.layout_data` (восстановленному), не к сырому. Мутанты
|
||||
`pair-recovery-config-writer-skips-fence` и
|
||||
`pair-recovery-point-writer-skips-fence` целятся именно в удаление вызова
|
||||
fence на этих двух путях.
|
||||
- **AC5 (paired writers не стартуют вторую пару поверх первой).**
|
||||
Общий `_resolved_write_pair` вызывается идентично во всех четырёх paired
|
||||
writer'ах (`import/apply`, `space/delete`, `plan/optimize`,
|
||||
`plan/optimize_undo`) — не дублированная логика, а один и тот же вызов.
|
||||
Для `import/apply` и `space/delete` есть прямые тесты
|
||||
(`test_issue_491_import_apply_fences_an_older_pending_pair`,
|
||||
`test_issue_491_space_delete_fences_before_pair_revisions`); для
|
||||
`plan/optimize`/`plan/optimize_undo` — только чтение кода (тот же вызов на
|
||||
той же позиции), что соответствует принятому в ТЗ доказательству («тесты и
|
||||
чтение кода общего входа»).
|
||||
- **AC7 (setup завершает недоделанное).** `__init__.py` использует тот же
|
||||
`async_resolve_pending_pair`; порядок операций (resolver → geometry
|
||||
migration → пересчёт revs → событие) не даёт события со старой ревизией.
|
||||
Существующий `test_setup_recovers_durable_import_pair` (не изменён, но
|
||||
проходит по общему коду) и `test_setup_recovers_exact_optimize_storage_roundtrip_pair`
|
||||
закрывают одновременно новый и legacy формат pending.
|
||||
- **AC8 (one-deep семантика).** `_discard_optimizer_snapshot` по-прежнему
|
||||
вызывается в конце `config/set` после успешной обычной правки (инвалидирует
|
||||
backup); `layout/set`/`layout/update` по-прежнему передают
|
||||
`remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)`. Логика инвалидации backup
|
||||
обычным edit'ом не менялась.
|
||||
- **AC9 (форматы и совместимость).** Persisted-схема не меняется: новых
|
||||
ключей нет, `final_metadata`/`kind`/`clear_backup` — уже существовавшие
|
||||
поля, используемые как общий протокол. Legacy pending без `final_metadata`
|
||||
обрабатывается веткой совместимости (см. выше).
|
||||
- **AC10 (документация).** `docs/ARCHITECTURE.md`,
|
||||
`docs/CONFIG-COMPATIBILITY.md`, `docs/TESTING.md`,
|
||||
`docs/USER-GUIDE(.ru).md` описывают ровно реализованный протокол (write
|
||||
fence до CAS/no-op, retry→rollback, порядок setup, отсутствие новых
|
||||
persisted-полей) — сверено построчно с кодом, расхождений нет.
|
||||
`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` в одном коммите с кодом
|
||||
(`ba52a10f`, `User-Visible: yes`).
|
||||
- **Трейлеры и процесс.** 4 коммита, каждый с `Issue: #491`; ветка
|
||||
`issue/491-optimize-undo-pair-recovery`; `process-gate.mjs` зелёный
|
||||
локально.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- **Исполнение HA-harness backend тестов** (`test_ha_*.py`,
|
||||
включая все 8 новых `test_issue_491_*`) — недоступно в этом окружении:
|
||||
`pytest-homeassistant-custom-component==0.13.357` требует Python ≥3.14,
|
||||
доступен только 3.12.3, других интерпретаторов на машине нет.
|
||||
Соответственно не проверены исполнением и 4 новых мутанта в
|
||||
`scripts/mutation-gate.mjs` (`node scripts/backend-test-guard.mjs ...`
|
||||
тоже требует HA). Все выводы по AC1–AC9 в разделе выше — чтение кода и
|
||||
сопоставление с существующими (не тронутыми) тестами, не запуск.
|
||||
- **Зелёный CI на точном SHA `4c08b8ab`** — не существует на момент
|
||||
ревью (условие раунда). Прогон, на который ссылается хендофф разработчика,
|
||||
сделан на предребейзном SHA `98c1923e`, недоступном для сверки.
|
||||
- **Golden/визуальные гейты, браузерные smoke, `check-docs`,
|
||||
`model-invariants`** — сознательно не прогонялись, обоснование в разделе
|
||||
«Как проверялось» (нет `src/**`, нет геометрии, `smoke-select` вернул пустой
|
||||
список).
|
||||
- **Ручное тестирование в HA** — вне цикла по PROCESS §2; не проводилось
|
||||
никем.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Один Medium в скоупе задачи (отсутствие мутационного свидетеля для AC6),
|
||||
High нет. По PROCESS §2.7/§4 это жёлтый вердикт: цикл возврата автору, без
|
||||
отдельного issue — правка (мутант или эквивалентная запись прогона со снятой
|
||||
защитой) делается в той же ветке `issue/491-optimize-undo-pair-recovery`.
|
||||
|
||||
Основная реализация (общий crash-resumable commit-протокол, write fence,
|
||||
setup recovery, обратная совместимость) при чтении кода выглядит корректной
|
||||
и хорошо согласованной с ТЗ; дешёвые гейты зелёные. Возврат вызван узким
|
||||
процессным требованием к доказательству одного защитного AC, а не сомнением
|
||||
в самом протоколе.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/491-optimize-undo-pair-recovery`, коммит `4c08b8abfca4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `697d7e922c8b91078551d280f65add13c92cb79f`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 697d7e922c8b
|
||||
```
|
||||
- ТЗ `docs/specs/491-optimize-undo-pair-recovery.md`, блоб `c73fde225d4f284d917879b5c4a7a54d1ff7994e`
|
||||
```
|
||||
git log --all --find-object=c73fde225d4f284d917879b5c4a7a54d1ff7994e -- docs/specs/491-optimize-undo-pair-recovery.md
|
||||
```
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
Reference in New Issue
Block a user