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

16 KiB
Executable File
Raw Permalink Blame History

ТЗ #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:

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).
  • Скриншоты не меняются.