diff --git a/docs/specs/403-area-relocation-safety.md b/docs/specs/403-area-relocation-safety.md new file mode 100755 index 00000000..71facc51 --- /dev/null +++ b/docs/specs/403-area-relocation-safety.md @@ -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). +- Скриншоты не меняются.