From 9fcbb64633e495af55faada0a964c8c72cd59b7b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 02:10:49 +0300 Subject: [PATCH] docs: specify the device position echo fix (#397) User-Visible: no Issue: #397 --- docs/specs/397-device-position-echo.md | 174 +++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100755 docs/specs/397-device-position-echo.md diff --git a/docs/specs/397-device-position-echo.md b/docs/specs/397-device-position-echo.md new file mode 100755 index 00000000..a3412321 --- /dev/null +++ b/docs/specs/397-device-position-echo.md @@ -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). +- Скриншоты не меняются.