# ТЗ #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). - Скриншоты не меняются.