mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
docs: specify the device position echo fix (#397)
User-Visible: no Issue: #397
This commit is contained in:
Executable
+174
@@ -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).
|
||||
- Скриншоты не меняются.
|
||||
Reference in New Issue
Block a user