docs: specify area relocation safety (#403)

User-Visible: no
Issue: #403
This commit is contained in:
Codex
2026-08-31 18:17:00 +03:00
parent 2fae84af58
commit 1f9d9014c9
+200
View File
@@ -0,0 +1,200 @@
# ТЗ #403 — Переезд area не теряет ручную расстановку и не гасит чужую историю
- Issue: https://github.com/Matysh/houseplan-card/issues/403
- Приоритет: P1, bug; полный трек — класс A, две находки на одной поверхности
(свежий код #126)
- Ревизия: 1 (2026-08-31)
## Сценарий
Хозяин расставил маркеры по плану руками — это единственные данные, которые он
вводил сам. В 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:5056-5058`:
```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
Новых строк нет, если выбран путь с существующей меткой внимания. Если решение
потребует отдельного уведомления о потере позиции — ключ добавляется во все
четыре словаря.
## Критерии приёмки
- **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` остаётся зелёным без
правок его утверждений.
## План автотестов
**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).
- Скриншоты не меняются.