Files
houseplan-card/docs/specs/403-area-relocation-safety.md
T
2026-09-01 15:08:22 +00:00

225 lines
16 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #403 — Переезд area не теряет ручную расстановку и не гасит чужую историю
- Issue: https://github.com/Matysh/houseplan-card/issues/403
- Приоритет: P1, bug; полный трек — класс A, две находки на одной поверхности
(свежий код #126)
- Ревизия: 3 (2026-09-01; техническое уточнение AC7 при реализации)
## Сценарий
Хозяин расставил маркеры по плану руками — это единственные данные, которые он
вводил сам. В Home Assistant он переименовывает area у лампы. Карточка честно
переносит маркер в новую комнату, но запись конфига в этот момент не проходит
(конфликт из второй вкладки, отказ бэкенда, обрыв связи). Позиция, которую он
ставил, исчезает — насовсем и молча. Ни маркера «посмотри сюда», ни сообщения:
лампа просто оказывается в центре новой комнаты, будто её никогда не двигали.
Второе, мельче: любой такой переезд — даже успешный и даже одного устройства —
гасит кнопки Undo/Redo для **всех** маркеров.
## Что человек увидит до и после
**До**: (1) при неудачной записи конфига ручная позиция переехавшего маркера
теряется без следа и без метки внимания; (2) переезд любого устройства
очищает весь стек Undo позиций, молча.
**После**: позиция либо сохраняется, либо устройство помечено как требующее
внимания; история остаётся у тех маркеров, которых переезд не касался.
## Проблема и контракты по пунктам
### (1) C2 — отказ записи конфига теряет позицию
Порядок в `src/houseplan-card.ts:5160-5250`: сначала для каждого переезжающего
устройства выполняется `_persistDevicePlacement(id, null)` (удаление
сохранённой точки), затем одной записью сохраняется новый снапшот и список
внимания.
Ветка отказа есть у **удаления** (`:5183`): `applyDevicePlacement(before)`
возвращает позицию локально. У отказа **записи конфига** (`:5223-5248`) её нет:
восстанавливаются `marker_area_snapshot` и `new_device_ids`, а layout остаётся
удалённым.
**Воспроизведено исполнением** (браузер, реальная карточка; устройство
`device:d_light1` с ручной позицией, снапшот с прежней area, первый `config/set`
отказывает):
```
houseplan/layout/delete → успех
houseplan/config/set → отказ
layoutHasDevice: false ← позиция удалена и не восстановлена
snapshotAreaNow: kitchen ← снапшот откачен
attention: false ← метки внимания нет
```
Дальше это самовоспроизводится: снапшот откачен на прежнюю area, поэтому
следующий authoritative-проход снова решает «relocate», снова удаляет (удалять
нечего) и снова пишет конфиг. Позиция не возвращается ни на одном витке.
Отдельная ветка того же корня — `conflict` (`:5245`): она перечитывает конфиг
с сервера, но про потерянный layout тоже ничего не знает.
**Контракт**: удаление сохранённой точки и продвижение провенанса — одна
операция с точки зрения пользователя, и её незавершённость не имеет права
стоить ему данных. Допустимы два исхода, выбрать при реализации:
1. **Запись первой**: конфиг сохраняется до удаления layout; точка удаляется
только после подтверждённой записи. Тогда отказ вообще ничего не меняет.
2. **Восстановление при отказе**: если запись не прошла, позиция
восстанавливается и локально, и на сервере (`_persistDevicePlacement(id,
before)`), а не только в памяти.
Если после всех попыток позиция всё же утрачена (например, восстановление тоже
отказало), устройство обязано попасть в `new_device_ids` — обещание §3.4/AC9
ТЗ #126. Молчаливая потеря запрещена в любом исходе.
Порядок «delete-first» был выбран в #126 сознательно (`:5142-5146`: «Layout
deletion is deliberately completed before provenance advances»), чтобы
устаревшая точка не выиграла между отрисовкой реестра и подтверждением
сервера. Реализация обязана сохранить это свойство: исход (1) допустим только
если гонка «старая точка побеждает» остаётся невозможной, иначе берётся (2).
### (2) M1 — переезд одного устройства гасит историю всех
`src/houseplan-card.ts:5062-5065`:
```ts
this._areaRelocationIds = new Set(areaRelocations.relocateIds);
if (this._areaRelocationIds.size) {
this._cancelDeviceDrag();
this._devicePositionHistory.clear();
```
Стек очищается целиком при непустом наборе переезжающих — независимо от того,
чьи записи в нём лежат, и молча, тогда как у соседнего класса событий
уведомление есть (`history.device_stale`). Повторяется на каждом
authoritative-rebuild, пока набор непуст: при read-only клиенте или после
неудачного удаления — на каждом тике.
**Воспроизведено** тем же прогоном: переезжало одно устройство, `canUndo`
после него `false`.
**Контракт**: из истории удаляются записи переехавших устройств; записи
остальных остаются. Если по какой-то причине приходится очищать больше,
пользователь получает то же уведомление, что и в остальных случаях устаревания
истории.
## Скоуп / не-скоуп
**В скоупе**: `_syncAreaRelocations` и обработка отказов в
`src/houseplan-card.ts`, сужение очистки истории позиций, смоки и мутанты.
**Не в скоупе**: сам резолвер `src/device-area-relocation.ts` (его решения
верны — проверено прогоном), критерии переезда и формат снапшота (#126),
механика истории Undo (#74/#397), гигиена снапшотов исчезнувших устройств
(#406 «г»).
## UX
Видимого оформления не меняем. Меняется поведение при отказе: маркер остаётся
там, где его поставил хозяин, либо получает уже существующую метку внимания.
## Модель данных и миграция
Формат `marker_area_snapshot`, `new_device_ids` и layout не меняется. Миграции
нет: правится только порядок операций и обработка отказа.
## i18n
Новых строк нет, если выбран путь с существующей меткой внимания. Если решение
потребует отдельного уведомления о потере позиции — ключ добавляется во все
четыре словаря.
## Touch, View и kiosk
Touch-контракт не меняется. Правка не затрагивает drag/tap-жесты, pointer-
обработчики, DOM или рендер; история редактора устройств сохраняет действующий
best-effort контракт на touch из `docs/TOUCH-SUPPORT.md` и раздела 6
`docs/USER-GUIDE.ru.md`.
В View и kiosk результат AC1/AC2 наблюдаем — маркер сохраняет корректную
позицию либо получает существующую отметку внимания, — но новых действий или
жестов не появляется. Гарантированный touch-first контракт View/kiosk не
ослабляется: на сенсорном клиенте итоговое положение и отметка должны быть теми
же, что на desktop.
## Производительность
Влияния на производительность и действующие бюджеты нет. Исправление не
добавляет циклов, подписок, постоянных вычислений или сетевых вызовов сверх уже
выполняемых `_syncAreaRelocations` / `_writeConfig`; выборочная очистка работает
по существующему стеку не более чем из 50 команд. Нового performance-профиля не
требуется.
## Критерии приёмки
- **AC1**. Отказ `config/set` во время переезда не оставляет устройство без
сохранённой позиции: либо позиция на месте (локально и на сервере), либо
устройство попало в `new_device_ids`. Доказательство: браузерный смок с
одним отказом `config/set`, проверяющий оба состояния явно.
- **AC2**. Успешный переезд по-прежнему работает: точка удалена, снапшот
продвинут, устройство помечено как требующее внимания. Доказательство: тот
же смок, ветка без отказа.
- **AC3**. Свойство delete-first сохранено: устаревшая сохранённая точка не
может выиграть между отрисовкой реестра и подтверждением сервера.
Доказательство: смок с задержанным ответом сервера — маркер не возвращается
в прежнюю комнату.
- **AC4**. Ветка `conflict` (правка из второй вкладки) подчиняется AC1.
Доказательство: смок с ошибкой `code: 'conflict'`.
- **AC5**. Переезд одного устройства не гасит историю другого: после
перетаскивания маркера A и переезда маркера B кнопка Undo для A активна.
Доказательство: смок.
- **AC6**. Записи переехавшего устройства из истории удалены — Undo не вернёт
его в комнату, которой у него больше нет. Доказательство: тот же смок.
- **AC7**. Существующее успешное поведение #126 не сломано: ручная перестановка
после переезда переживает три rebuild-тика, delete-first echo не воскрешает
точку. Доказательство: `demo/smoke_area_relocation.mjs` остаётся зелёным;
единственное прежнее утверждение `failedConfigRetryable`, которое прямо
требовало состояние исходного бага (`layout` уже удалён после отказа
`config/set`), заменяется witness нового AC1 — позиция восстановлена, а
повтор остаётся возможен. Остальные утверждения смока не меняются.
## План автотестов
**Browser smoke** (новый файл — существующие смоки #126 держат свои фикстуры):
1. Переезд с одним отказом `config/set` → позиция на месте ИЛИ устройство в
`new_device_ids`; молчаливая потеря краснеет (AC1).
2. Переезд без отказа → точка удалена, снапшот продвинут, метка внимания
поставлена (AC2).
3. Задержанный ответ сервера → старая точка не выигрывает (AC3).
4. Отказ с `code: 'conflict'` → поведение AC1 (AC4).
5. Драг маркера A, переезд маркера B → `canUndo` для A остаётся `true` (AC5);
записи B из истории удалены (AC6).
**Мутанты** (`scripts/mutation-gate.mjs`):
- `area-relocation-loses-position-on-refusal`: убрать восстановление/пометку →
смок AC1 красный.
- `area-relocation-clears-whole-history`: вернуть `clear()` на весь стек →
смок AC5 красный.
## Риски
- **Смена порядка операций ломает delete-first.** Исход (1) переставляет
запись перед удалением, а именно этого #126 избегал. Смягчение: AC3
проверяет исходное свойство напрямую; если оно не удерживается — берётся
исход (2), где порядок не меняется вовсе.
- **Восстановление на сервере тоже может отказать.** Тогда потеря неизбежна.
Смягчение: этот случай обязан оставлять метку внимания — то есть AC1
формулируется как «позиция ИЛИ метка», а не «позиция всегда».
- **Сужение очистки истории оставит записи, ссылающиеся на исчезнувшую
комнату.** Смягчение: AC6 требует удалять записи именно переехавших, а не
просто «очищать меньше».
## Откат
Обе правки локальны: обработка отказа в одном методе и фильтр в одной строке
очистки истории. Формат данных не затрагивается.
## Release-артефакты
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт о том, что расстановка
маркеров переживает неудачную запись при смене area (User-Visible: yes).
- Скриншоты не меняются.