16 KiB
Executable File
ТЗ #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 тоже ничего не знает.
Контракт: удаление сохранённой точки и продвижение провенанса — одна операция с точки зрения пользователя, и её незавершённость не имеет права стоить ему данных. Допустимы два исхода, выбрать при реализации:
- Запись первой: конфиг сохраняется до удаления layout; точка удаляется только после подтверждённой записи. Тогда отказ вообще ничего не меняет.
- Восстановление при отказе: если запись не прошла, позиция
восстанавливается и локально, и на сервере (
_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 держат свои фикстуры):
- Переезд с одним отказом
config/set→ позиция на месте ИЛИ устройство вnew_device_ids; молчаливая потеря краснеет (AC1). - Переезд без отказа → точка удалена, снапшот продвинут, метка внимания поставлена (AC2).
- Задержанный ответ сервера → старая точка не выигрывает (AC3).
- Отказ с
code: 'conflict'→ поведение AC1 (AC4). - Драг маркера 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).- Скриншоты не меняются.