mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
225 lines
16 KiB
Markdown
Executable File
225 lines
16 KiB
Markdown
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 тоже ничего не знает.
|
||
|
||
**Контракт**: удаление сохранённой точки и продвижение провенанса — одна
|
||
операция с точки зрения пользователя, и её незавершённость не имеет права
|
||
стоить ему данных. Допустимы два исхода, выбрать при реализации:
|
||
|
||
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`:
|
||
|
||
```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
|
||
|
||
Новых строк нет, если выбран путь с существующей меткой внимания. Если решение
|
||
потребует отдельного уведомления о потере позиции — ключ добавляется во все
|
||
четыре словаря.
|
||
|
||
## 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).
|
||
- Скриншоты не меняются.
|