mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
docs: specify area relocation safety (#403)
User-Visible: no Issue: #403
This commit is contained in:
Executable
+200
@@ -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).
|
||||
- Скриншоты не меняются.
|
||||
Reference in New Issue
Block a user