mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
191 lines
13 KiB
Markdown
Executable File
191 lines
13 KiB
Markdown
Executable File
# ТЗ #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).
|
||
- Скриншоты не меняются.
|