docs: review document for #205

Issue: #205
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-19 20:55:38 +03:00
committed by Sergey Matyunin
parent 56e01148f8
commit edf532e217
+253
View File
@@ -0,0 +1,253 @@
# CODE-REVIEW-205-r1 — продолжение следа пылесоса после короткой остановки
- Issue: [#205](https://github.com/Matysh/houseplan-card/issues/205)
- Этап: `code` (PROCESS.md §2.7)
- Диапазон: `origin/dev...HEAD`, `origin/dev` = `ad8e7a5`, `HEAD` = `11b0283`
(ветка `issue/205-vacuum-trail-grace`, detached `HEAD`); merge-base — `f287bdd`
- ТЗ: [`docs/specs/205-vacuum-trail-resume-grace.md`](../specs/205-vacuum-trail-resume-grace.md),
ревью ТЗ зелёное — [`SPEC-REVIEW-205-r1.md`](SPEC-REVIEW-205-r1.md)
- Цикл: **r1/4**
- Ревьюер: Claude, свежая сессия, без переписки с автором реализации
- Вердикт: **зелёный**
## Скоуп ревью
Три коммита диапазона:
1. `f3472de` «docs: specify vacuum trail resume grace» — ТЗ (класс C), уже
зелёное ревью ТЗ (`SPEC-REVIEW-205-r1.md`), не пересматриваю содержание
заново, только сверяю, что реализация ему соответствует.
2. `c47a6bc` «docs: review document for #205» — сам документ ревью ТЗ (класс C).
3. `11b0283` «fix: resume vacuum trails after short stops» — единственный
продуктовый коммит (класс A/B), `Issue: #205`, `User-Visible: yes`, 11 файлов:
`custom_components/houseplan/trails.py`, `demo/smoke_vacuum.mjs`,
`scripts/mutation-gate.mjs`, `scripts/trail-resume-test-guard.mjs` (новый),
`tests_backend/test_trails.py`, `tests_backend/test_trail_recorder.py`,
`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`, `docs/STATUS.md`,
`docs/TESTING.md`, `docs/USER-GUIDE.ru.md`, `docs/VACUUM.md`.
`src/**`, i18n и `dist/**`/сгенерированные копии бандла не тронуты — согласовано
со спекой («фронтенд не требует правок», подтверждено ещё на этапе spec-review
чтением `_renderVacuums`). Прочитано целиком: `docs/SCOPE.md`, `AGENTS.md`,
`PROCESS.md`, тело issue #205 и все 6 комментариев (аналитика Q1–Q4,
«ТЗ готово», вердикт spec-review, хендофф реализации, занятие слота S7),
`docs/specs/205-vacuum-trail-resume-grace.md`, `SPEC-REVIEW-205-r1.md`,
`docs/VACUUM.md` целиком, весь diff `custom_components/houseplan/trails.py`
(построчно, не выборочно), оба изменённых тестовых файла целиком, оба скрипта
mutation-gate/guard, все изменения в changelog/`STATUS.md`/`TESTING.md`/
`USER-GUIDE.ru.md`/`VACUUM.md`.
## Как проверялось
Зависимости и Chromium уже установлены средой ревью; `npm ci` не запускался.
Backend Python в этой среде без `pytest`/`voluptuous` — оба установлены
`pip install` перед прогоном (сеть доступна), чтобы гейт был реальным
исполнением, а не пропуском.
| Гейт | Команда | Результат |
|---|---|---|
| Типы | `npx tsc --noEmit` | green |
| Unit | `npm test` | **912/912** 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` | green, обе копии байт-в-байт совпадают (diff не трогал `src/**`, пересборка ничего не меняет) |
| Whitespace | `git diff --check origin/dev...HEAD` | чисто, без предупреждений |
| Backend — targeted | `python -m pytest tests_backend/test_trails.py tests_backend/test_trail_recorder.py -q` | **37 passed** |
| Backend — полный pure/stub набор | `python -m pytest tests_backend -q` (после установки `pytest`+`voluptuous`) | **172 passed**, совпадает с числом из хендоффа автора; `test_ha_*.py` собираются и включены в счёт (HA-пакет не установлен в среде ревью, но conftest не молчаливо их проигнорировал — они реально прошли в этом прогоне за счёт остального стека; полный HA-harness всё равно канон только на Linux CI, см. «Чего не проверял») |
| Целевой смок (назван в AC8) | `node demo/smoke_vacuum.mjs` | `OK` |
| Mutation guard (AC10), точечно | `node scripts/mutation-gate.mjs --id=vacuum-trail-resume-disabled` | `ok чистый прогон` → `ok vacuum-trail-resume-disabled: тест покраснел, как обязан` → `поймано 1 из 1` |
| Mutation gate, полный набор (дёшево, `~0.07s`, весь список уже мал) | `node scripts/mutation-gate.mjs --check` | все 36 мутантов пойманы, включая новый |
Не прогонялись (соразмерность гейта, PROCESS.md §8): полный набор из 127
браузерных смоков (diff не задевает рендер, canvas, редакторы — только один
targeted smoke, названный в AC8, и он прогнан), `npm run golden:verify`
(visual-diff не может измениться: diff не трогает ни один файл рендера, путь
проверяется численно по SVG `d`/points, как и оговорено в разделе 10 ТЗ),
performance-профили (в AC не названы, изменение O(1) на точку, асимптотика не
меняется). Полный HA-harness backend — канонически только Linux CI
(`AGENTS.md`: `.venv-backend` недоступен в среде ревью).
### Проверка дисциплины «тест умеет падать»
Для AC10 — не поверил заявлению автора «mutant caught (1/1)», а повторил сам:
`node scripts/mutation-gate.mjs --id=vacuum-trail-resume-disabled` действительно
патчит `resumed = bool(cur and can_resume_trail_run(cur, map_id, now))` →
`resumed = False` и заново гоняет `scripts/trail-resume-test-guard.mjs`
(таргетированный `pytest -k "resume or short_available"` по обоим backend-файлам);
гейт подтверждает, что при отключённом resume фокусный набор действительно
краснеет, а на чистом дереве — зелёный. Это прямое эмпирическое доказательство,
что новые тесты чувствительны к самому фиксу, а не тавтологичны.
## Построчная проверка кода против контракта ТЗ (раздел 6)
Читал `custom_components/houseplan/trails.py` целиком, не только diff.
- **Пункт 1 (resume при совпадении map_id и `0 ≤ now-ended ≤ 1800`).**
`can_resume_trail_run` проверяет `run.get("map_id") == map_id` до всего
остального; при совпадении и `elapsed` в допустимом диапазоне `on_point`
ставит `cur["ended"] = None` и не создаёт `previous` (условие
`cur.get("ended") is not None or cur.get("map_id") != map_id` после этого
ложно) — точное соответствие.
- **Пункт 2 (граница 30:00 включительно, эпсилон выше — новый run).**
`elapsed <= TRAIL_RESUME_GRACE_S` — нестрогое неравенство, `1800.0` резолвится
в `True`; `1800.000001` — в `False`. Подтверждено исполнением
`test_resume_grace_is_inclusive_then_rotates_after_epsilon`.
- **Пункт 3 (смена карты — всегда жёсткая граница).** `can_resume_trail_run`
возвращает `False` при несовпадении `map_id` независимо от `elapsed`;
дальше сработает обычная ветка ротации. Подтверждено
`test_resume_never_crosses_maps_and_malformed_timestamps_fail_closed`.
- **Пункт 4 (дубликат первой точки после resume: `changed=True`, точка не
добавляется).** `pts and pts[-1] == [x,y]: return resumed` — если resume
произошёл, `resumed=True` возвращается даже без добавления точки; если нет —
`False`, как раньше. Подтверждено
`test_ended_run_resumes_within_grace_and_duplicate_is_a_change`.
- **Пункт 5 (malformed/отрицательное/nan/inf/bool не даёт resume, safely новый
run).** Явные проверки `isinstance(ended, bool)` (bool — подкласс int,
исключён явно), `not isinstance(ended, (int, float))`, тот же контроль для
`now`, и `math.isfinite(elapsed)` после вычитания — отсекает `nan`/`inf`
и переполнения. Подтверждено 4 кейсами (`"bad"`, `True`, `nan`, `inf`) в
`test_resume_never_crosses_maps_and_malformed_timestamps_fail_closed` плюс
отдельным clock-rollback кейсом в
`test_resume_fails_closed_on_clock_rollback_and_survives_restart_shape`
(`ended=1000.0`, `now=900.0` → `elapsed=-100` → `0 <= elapsed` ложно).
- **Пункт 6 (idempotent `end_run`, timestamp первого stop не сдвигается).**
`end_run` изменился с `if cur and not cur.get("ended")` на
`if cur and cur.get("ended") is None` — при повторном вызове на уже
завершённом run условие ложно, `ended` не перезаписывается. Подтверждено
`test_repeated_short_stops_keep_all_points_and_existing_previous` (три цикла
dock/pause, `ended` не двигается) и recorder-тестом
`test_short_available_stops_resume_one_run_and_neutral_states_do_not_shift_window`.
- **Пункт 7 (`unavailable`/`unknown`/missing нейтральны, не двигают окно).**
Код `_sample` здесь не менялся — ветка `continue` для этих состояний стоит
до вызова `end_run` (`if not st_vac or st_vac.state in ("unavailable",
"unknown"): continue`), заявление ТЗ «уже нейтрально» подтверждено чтением, а
не переоткрыто. Recorder-тест подтверждает и то, что после этих состояний
`ended` того же значения, что до них.
- **Пункт 8 (единый контракт для любого available non-moving state).**
`can_resume_trail_run` не смотрит на конкретное значение `state` вообще —
оно уже отфильтровано в `_sample` до вызова `end_run`/`on_point`, поэтому
единство контракта следует из самой структуры кода, а не из перечисления
случаев. Подтверждено параметризованным
`test_any_available_nonmoving_state_uses_the_same_grace_contract` по пяти
состояниям (`paused`, `idle`, `error`, `washing`, `docked`) — то есть даже
вендорские атрибуты типа `washing` (явно вне scope, раздел 5 ТЗ) корректно
идут по общему пути, а не игнорируются по имени.
Побочная проверка: изменение `end_run`/`on_point` с truthy-проверок (`not
cur.get("ended")`, `cur.get("ended")`) на `is None`/`is not None` меняет
поведение только для гипотетического `ended == 0` (реальные epoch-timestamps
никогда не бывают нулевыми) — это исправление, не регресс: `0` — валидный,
хоть и нереалистичный, timestamp окончания, и `is None` корректно отличает
«run активен» от «run завершён в момент 0».
## Проверка AC1–AC11
| AC | Статус | Как доказано |
|---|---|---|
| AC1 | Доказан | `test_ended_run_resumes_within_grace_and_duplicate_is_a_change`, `test_short_available_stops_resume_one_run_and_neutral_states_do_not_shift_window` — исполнены. |
| AC2 | Доказан | `test_resume_grace_is_inclusive_then_rotates_after_epsilon` — исполнен, граница явная (`+0`/`+0.001`). |
| AC3 | Доказан | `test_resume_never_crosses_maps_...` (хвост про `floor-a`/`floor-b`) — исполнен. |
| AC4 | Доказан | `test_ended_run_resumes_within_grace_and_duplicate_is_a_change` — исполнен, проверяет и `points`, и возвращаемое значение. |
| AC5 | Доказан | `test_repeated_short_stops_keep_all_points_and_existing_previous` — исполнен, три цикла, `previous is previous` (не тронут). |
| AC6 | Доказан | `test_short_available_stops_...` (unknown/missing) + `test_any_available_nonmoving_state_...` — исполнены. |
| AC7 | Доказан | `test_resume_fails_closed_on_clock_rollback_and_survives_restart_shape` — конструирует `TrailBook` из «сохранённого» словаря (форма Store после рестарта), проверяет resume и rollback. |
| AC8 | Доказан | `node demo/smoke_vacuum.mjs` — исполнен, зелёный; отдельно прочитан рендер-путь (`showCur`/`srvCur` не зависят от `ended`), поэтому `always`-часть AC8 достаточно покрыта уже существующим (не новым) сценарием в том же смоке — новый код не меняет эту ветку рендера вовсе (см. ниже). |
| AC9 | Доказан | Полный прогон `tests_backend/test_trails.py` + `test_trail_recorder.py` (37 тестов) включает нетронутые `test_map_switch_mid_run_starts_a_new_run`, `test_cap_decimates_but_keeps_the_freshest_point`, `test_junk_store_data_tolerated`, source-health/two-floor/decimation/delete-тесты в `test_trail_recorder.py` — все зелёные, без изменений в самих тестах. |
| AC10 | Доказан | `node scripts/mutation-gate.mjs --id=vacuum-trail-resume-disabled` — мутант пойман, гайд-скрипт исполнен, не просто заявлен. |
| AC11 | Доказан | Таблица гейтов выше — typecheck/unit/build/targeted smoke/pytest все зелёные, исполнены в этой сессии, не приняты на слово. |
Проверка «AC8 не требует правки always-ветки» — не догадка: `_renderVacuums`
(`src/houseplan-card.ts:16664-16681`) вычисляет `showCur` только по
`trail_mode`/`moving`, а `srvCur` фильтрует `srv.current` только по `map_id` и
наличию `points`; поле `ended` нигде не читается фронтендом (`grep -n "ended"
src/houseplan-card.ts src/vacuum.ts` — единственные совпадения относятся к
локальному `endedTs` рантайм-буфера, не к серверному payload). Значит, стиль
current/previous в `always` не может отличаться между «обычным активным
current» и «только что возобновлённым current» — новый смок-сценарий
(`srvResumedCurrentKeepsEarlierPoints`) специально нацелен на `cleaning`-режим,
где риск реален (скрыть/показать переключается по `moving`), а не дублирует
уже существующий `always`-сценарий того же файла — читал оба сценария рядом,
пересечения по риску нет.
## Находки
Ни одной **High**. Ни одной **Medium**. Ни одной **Low**.
Мелкое стилистическое наблюдение — `resumed = bool(cur and
can_resume_trail_run(...))`: обёртка `bool()` не меняет поведение
(`cur and X` уже даёт `None`/`bool`, и оба варианта корректно приводятся к
`False`/значению в `if`/`return`), просто фиксирует тип явно для функции,
объявленной как `-> bool`. Не поведенческий дефект, не блокирует и не стоит
отдельной записи как Low — это осознанный стиль, а не небрежность.
## Что проверено и признано корректным
- **Причина и контракт ТЗ реализованы буквально** — каждый из 8 пунктов
раздела 6 сверен построчно с кодом и подтверждён исполнением
соответствующего теста (см. таблицу выше), не принят на слово ни разу.
- **Scope/non-scope соблюдены.** Diff не трогает `MOVING_STATES`, `trail_mode`,
vendor-атрибуты (`washing`/`drying`/`emptying` использованы только как
тестовое значение состояния, а не как отдельная ветка кода), source health
monitor, calibration, decimation/cap механику, Store version/shape. Новая
константа `TRAIL_RESUME_GRACE_S` — module-level, без конфигурации, как и
зафиксировано в «принятых предположениях» ТЗ (раздел 13, пункт 2).
- **Тесты умеют падать, не только показывают зелёное.** Собственноручно
воспроизвёл mutation-guard (AC10) и убедился, что таргетированный набор
краснеет при отключённом resume и зеленеет на реальном коде — не поверил
комментарию автора «mutant caught (1/1)».
- **Гейты воспроизведены самостоятельно**, не переписаны из хендоффа:
typecheck, 912/912 unit, сборка+синхронные бандлы, 172 backend pure/stub
теста (после установки отсутствовавших в среде `pytest`/`voluptuous`),
целевой смок, полный (дешёвый) прогон mutation-gate. Совпадение чисел с
отчётом автора (912, 172, 1/1) — не причина не перепроверять, а
independent confirmation того же результата.
- **Документация и трейлеры.** `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md`
правлены в том же коммите `11b0283`, что и код (`User-Visible: yes`) —
соответствует правилу №10/PROCESS.md §2.6. `docs/VACUUM.md` и
`docs/USER-GUIDE.ru.md` описывают контракт словами, совпадающими с ТЗ, без
придуманной терминологии. `docs/TESTING.md` называет конкретные файлы
доказательства. Ветка `issue/205-vacuum-trail-grace` соответствует
трейлерам `Issue: #205`. `git diff --check` чист.
- **Не расширяет скоуп.** Единственный продуктовый файл —
`custom_components/houseplan/trails.py`; изменение минимально (одна новая
функция, три изменённых условия), никаких «раз уж я здесь» правок в
соседних областях (calibration, source health, decimation) не найдено при
построчном чтении всего файла.
## Чего не проверял
- **Полный набор из 127 браузерных смоков** — не запускал; diff не касается
canvas/редакторов/other rendering surfaces, единственный релевантный смок
(`smoke_vacuum.mjs`, назван в AC8) исполнен явно. Сужение гейта осознанное
(PROCESS.md §8), не молчаливый пропуск.
- **`npm run golden:verify`** — не запускал; diff не меняет ни один файл
рендера/геометрии/стилей, path continuity в AC8 доказывается численно через
SVG `d`/points, не пикселями, как и предписывает раздел 10 ТЗ.
- **Полный HA-harness backend (`test_ha_*.py` с реальным Home Assistant)** —
в среде ревью нет `.venv-backend`/пакета `homeassistant`; канонический
прогон — Linux CI на точном SHA (AGENTS.md, PROCESS.md §8). Косвенно эти
файлы всё же собрались и прошли в общем прогоне `pytest tests_backend -q`
этой сессии (172 passed), но не считаю это заменой CI-гейта — Home
Assistant в этой среде не установлен, только его тестовые фикстуры без
реального рантайма.
- **Perf-профили** — не названы в AC, изменение алгоритмически O(1) на точку
(одна дополнительная проверка условия), асимптотика решения не меняется.
- **Живой робот/симулятор демо-стенда** — вне цикла код-ревью без ручного
тестирования (PROCESS.md §2 — фазы тестирования нет по конструкции
процесса); задача доказывается автотестами, что и сделано.
- **Возможное визуальное склеивание двух реальных уборок в пределах 30 минут**
— явно принятый владельцем trade-off (issue, комментарий аналитики Q3,
раздел 6 ТЗ «неизбежный trade-off принят владельцем»), не дефект этой
реализации.
## Вердикт
**Зелёный · цикл r1/4 · High: 0 · Medium: 0 → нет новых issue.**
Реализация — узкая, точная правка `TrailBook.on_point`/`end_run` плюс одна
новая чистая функция `can_resume_trail_run`, буквально соответствующая
восьмипунктовому контракту ТЗ раздела 6. Каждый пункт контракта и каждый из
AC1–AC11 подтверждён самостоятельным исполнением тестов и гейтов в этой
сессии (не принят на слово из хендоффа автора), включая прямую проверку, что
mutation-guard AC10 реально красный при отключённом фиксе. Документация и оба
changelog обновлены в том же коммите, что и код, трейлеры корректны, скоуп не
расширен. Следующий статус — очередь на пре-релиз (`S8-merged` после мёржа в
`dev`, PROCESS.md §2.7 / AGENTS.md «Do not merge into `dev` by hand»).