Files
houseplan-card/docs/specs/397-device-position-echo.md
T
2026-08-31 02:21:20 +03:00

191 lines
13 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #397 — Undo позиций: своё же эхо не должно сбрасывать историю
- Issue: https://github.com/Matysh/houseplan-card/issues/397
- Приоритет: P1, bug; полный трек — класс A (продуктовый код) плюс правка
подстроенного доказательства AC10 ТЗ #74
- Ревизия: 2 (2026-08-31) — по SPEC-REVIEW-397-r1 (Medium: доказательства AC5/AC7)
## Сценарий
Хозяин раскладывает маркеры устройств по плану: подвинул лампу, подвинул
датчик, передумал — Ctrl+Z. История на 50 шагов, обещанная #74, должна
пережить обычный фон работы карточки: переподключение к Home Assistant после
сна планшета, обновление вкладки, перезагрузку самого HA. Сейчас переживает
не всегда — и не потому, что кто-то менял план из другого окна.
## Что человек увидит до и после
**До**: после перемещения маркера и любого события, приводящего к перечитыванию
layout (reconnect, вторая вкладка, `houseplan/layout/get` по таймеру), кнопки
Undo/Redo молча гаснут — история очищена, хотя менял план он сам и один.
**После**: собственная запись перестаёт выглядеть чужой; история гаснет только
тогда, когда позиции действительно изменил кто-то другой.
## Проблема и контракты
### (1) B3 — канонический ответ не возвращается в локальный layout
`_persistDevicePlacement` (`src/houseplan-card.ts:5226-5261`) отправляет на
сервер `canonicalizePosition(this._layout[deviceId])`, но сам `_layout`
оставляет как есть, а следом (`:5253`) фиксирует
`_layoutContentFingerprint = contentFingerprint(this._layout)` — по
неканоническому слепку. Прежний `_persistLayout` этой дыры не имел:
`v1.69.0:src/houseplan-card.ts:4776` писал `this._layout = { ...this._layout,
[id]: pos }` до отправки.
Канонизация — не тождество. Проверено на собранном
`test-build/coordinate-canonicalization.js`: расходятся 39 из 115 координат
вида `px/800*0.997+0.0013` и 1599 из 1600 узлов решётки; пример —
`0.024999999999999942 → 0.025`.
Дальше расхождение читают два независимых пути:
- `_reloadLayoutOnly` (`:4904-4935`) сливает ответ сервера с `_sentPos`, берёт
`contentFingerprint(merged)` и сравнивает с `contentFingerprint(this._layout)`
— не с сохранённым `_layoutContentFingerprint`, а с текущим слепком, то есть
с неканоническим;
- `_adoptStructuralResponses` (`:4203-4260`) сравнивает отпечаток ответа с
`_layoutContentFingerprint`, зафиксированным по неканоническому слепку.
В обоих случаях расхождение трактуется как «изменено снаружи»:
`_devicePositionHistory.clear()`, `_cancelDeviceDrag()`, подмена `_layout`.
Это нарушает AC10 ТЗ #74 — «собственное эхо не чистит стек».
**Контракт**: после успешной записи позиции локальный `_layout` содержит ровно
то значение, которое ушло на сервер, и `_layoutContentFingerprint` посчитан по
нему. Ответ сервера, совпадающий с отправленным, историю не трогает.
Удаление позиции (`houseplan/layout/delete`, ветка `placement === null`)
подчиняется тому же правилу: локально ключ удаляется до фиксации отпечатка.
### (2) M1 — смок, доказывающий AC10, не может упасть
`demo/smoke_device_position_history.mjs:235-237`:
```js
serverLayout = structuredClone(c._layout);
await c._reloadLayoutOnly();
out.sameContentReloadKeepsHistory = c._devicePositionHistory.canUndo;
```
Фикстура присваивает серверному слепку локальный — то есть руками устраняет
именно то расхождение, которое возникает в бою: fake-WS кладёт в `serverLayout`
то, что пришло по проводу (каноническое), а `_layout` остаётся неканоническим.
Проверка зелёная при любом состоянии кода.
**Контракт**: серверный слепок в фикстуре формируется только из того, что
реально ушло по WS. Смок обязан **краснеть** на коде до фикса (1) и зеленеть
после — это и есть доказательство AC10, а не его имитация.
## Скоуп / не-скоуп
**В скоупе**: `_persistDevicePlacement` и точки сравнения отпечатков в
`src/houseplan-card.ts`, фикстура `demo/smoke_device_position_history.mjs`,
юниты и мутант.
**Не в скоупе**: сама механика истории (глубина 50, что считается шагом,
клавиатурные сочетания) — принята в #74 и не пересматривается; канонизация
координат (`coordinate-canonicalization.ts`) — контракт с бэкендом, не
меняется; логика `_sentPos` для конкурентных окон.
## UX
Видимых изменений нет, кроме того, что Undo/Redo перестают гаснуть без причины.
## Модель данных и миграция
Формат layout не меняется. На сервер уходит то же каноническое значение, что и
сегодня; меняется только локальная копия, которая теперь ему равна. Миграции
нет, откат безопасен.
## i18n
Новых строк нет.
## Критерии приёмки
- **AC1**. После `_persistDevicePlacement` значение в `_layout[deviceId]`
побайтово равно отправленному на сервер, а `_layoutContentFingerprint`
посчитан по нему. Доказательство: юнит с перехватом `callWS`.
- **AC2**. Ответ сервера, совпадающий с отправленным, не чистит историю:
после перемещения маркера и `_reloadLayoutOnly()` (сервер отвечает тем, что
получил) `canUndo` остаётся `true`. Доказательство: смок с честной
фикстурой.
- **AC3**. Тот же инвариант для `_adoptStructuralResponses` (полная
перезагрузка конфига и layout, reconnect-путь). Доказательство: смок,
пункт 5 плана — перемещение, затем полная перезагрузка с честным серверным
слепком, `canUndo` остаётся `true`.
- **AC4**. Чужое изменение по-прежнему чистит историю: сервер отвечает
позицией, отличной от отправленной, → `canUndo === false`, `_layout`
заменён. Доказательство: тот же смок, обратная ветка.
- **AC5**. Удаление позиции (`placement === null`) подчиняется AC1–AC4 в обеих
половинах. (а) Локальный ключ удалён до фиксации отпечатка — доказательство:
юнит, пункт 2 плана. (б) Эхо удаления не чистит историю — доказательство:
смок, пункт 7 плана: удаление позиции, затем честный reload, `canUndo`
остаётся `true`. Вторая половина отдельная не для симметрии: удаление идёт
другой веткой (`houseplan/layout/delete`, `pending = null`), и запись в
`_layout` там снимает ключ, а не заменяет значение — доказательство первой
половины про вторую ничего не говорит.
- **AC6**. Смок AC10 краснеет на коде до фикса (1). Доказательство: прогон
переписанного смока против `git stash` реализации, зафиксирован в документе
ревью.
- **AC7**. Позиции, отправленные и ещё не подтверждённые (`_sentPos`),
продолжают побеждать ответ сервера при слиянии — поведение
`_reloadLayoutOnly` в этой части не меняется. Доказательство: смок, пункт 8
плана — пока запись «в полёте», сервер отвечает СТАРОЙ позицией, и после
слияния в `_layout` остаётся отправленная, а не серверная. Инвариант назван
явно, потому что фикс правит соседний код (порядок записи в `_layout` и
регистрации в `_sentPos`): без своего теста он ломается молча.
## План автотестов
**Unit** (`test/device-position-persist.test.mjs`):
1. `_persistDevicePlacement` с неканонической позицией → `_layout[deviceId]`
равен отправленному, отпечаток совпадает (AC1).
2. Ветка удаления → ключ удалён локально, отпечаток пересчитан (AC5).
3. Канонизация-тождество (позиция уже каноническая) → отпечаток не меняется,
лишней записи нет.
**Browser smoke** (`demo/smoke_device_position_history.mjs`, правка фикстуры):
4. Перемещение → `_reloadLayoutOnly()` с честным серверным слепком →
`canUndo === true` (AC2).
5. Перемещение → полный `_adoptStructuralResponses` (reconnect) →
`canUndo === true` (AC3).
6. Сервер отвечает другой позицией → история очищена, `_layout` заменён (AC4).
7. Удаление позиции → честный reload → `canUndo` остаётся `true`, ключ не
возвращается (AC5б).
8. Запись «в полёте» (`_sentPos` не пуст), сервер отвечает старой позицией →
после слияния в `_layout` отправленная позиция, история цела (AC7).
**Мутант** (`scripts/mutation-gate.mjs`):
- `device-echo-keeps-local-noncanonical`: убрать запись канонического значения
обратно в `_layout` → смок AC2 красный.
## Риски
- **Двойная запись `_layout`.** Присваивание до отправки создаёт лишний
ре-рендер. Смягчение: писать только при фактическом отличии от текущего
значения (сравнение по содержимому), юнит на «каноническая позиция не
вызывает лишнего обновления».
- **Гонка с `_sentPos`.** Локальная запись не должна перебивать логику
«карточка — авторитет до подтверждения». Смягчение: AC7 фиксирует
существующее поведение слияния; порядок — сначала локальная запись, потом
регистрация в `_sentPos`, как и сегодня.
- **Смок станет красным до фикса.** Это и есть цель (AC6), но порядок
коммитов важен: правка фикстуры и фикс кода едут вместе, иначе `dev`
краснеет.
## Откат
Одно присваивание и одна строка фикстуры. Формат данных не затронут, поэтому
откат не оставляет следов у пользователя.
## Release-артефакты
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт про то, что история
Undo позиций больше не гаснет после переподключения (User-Visible).
- Скриншоты не меняются.