docs: specify the device position echo fix (#397)

User-Visible: no
Issue: #397
This commit is contained in:
Codex
2026-08-31 02:10:49 +03:00
parent a25de7383f
commit 9fcbb64633
+174
View File
@@ -0,0 +1,174 @@
# ТЗ #397 — Undo позиций: своё же эхо не должно сбрасывать историю
- Issue: https://github.com/Matysh/houseplan-card/issues/397
- Приоритет: P1, bug; полный трек — класс A (продуктовый код) плюс правка
подстроенного доказательства AC10 ТЗ #74
- Ревизия: 1 (2026-08-31)
## Сценарий
Хозяин раскладывает маркеры устройств по плану: подвинул лампу, подвинул
датчик, передумал — Ctrl+Z. История на 50 шагов, обещанная #74, должна
пережить обычный фон работы карточки: переподключение к Home Assistant после
сна планшета, обновление вкладки, перезагрузку самого HA. Сейчас переживает
не всегда — и не потому, что кто-то менял план из другого окна.
## Что человек увидит до и после
**До**: после перемещения маркера и любого события, приводящего к перечитыванию
layout (reconnect, вторая вкладка, `houseplan/layout/get` по таймеру), кнопки
Undo/Redo молча гаснут — история очищена, хотя менял план он сам и один.
**После**: собственная запись перестаёт выглядеть чужой; история гаснет только
тогда, когда позиции действительно изменил кто-то другой.
## Проблема и контракты
### (1) B3 — канонический ответ не возвращается в локальный layout
`_persistDevicePlacement` (`src/houseplan-card.ts:5226-5258`) отправляет на
сервер `canonicalizePosition(this._layout[deviceId])`, но сам `_layout`
оставляет как есть, а следом (`:5217`) фиксирует
`_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-путь).
- **AC4**. Чужое изменение по-прежнему чистит историю: сервер отвечает
позицией, отличной от отправленной, → `canUndo === false`, `_layout`
заменён. Доказательство: тот же смок, обратная ветка.
- **AC5**. Удаление позиции (`placement === null`) подчиняется AC1–AC4:
локальный ключ удалён до фиксации отпечатка, эхо удаления историю не чистит.
- **AC6**. Смок AC10 краснеет на коде до фикса (1). Доказательство: прогон
переписанного смока против `git stash` реализации, зафиксирован в документе
ревью.
- **AC7**. Позиции, отправленные и ещё не подтверждённые (`_sentPos`),
продолжают побеждать ответ сервера при слиянии — поведение `_reloadLayoutOnly`
в этой части не меняется.
## План автотестов
**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).
**Мутант** (`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).
- Скриншоты не меняются.