Files
houseplan-card/docs/specs/397-device-position-echo.md
2026-08-31 02:21:20 +03:00

13 KiB
Executable File
Raw Permalink Blame History

ТЗ #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:

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, правка фикстуры):

  1. Перемещение → _reloadLayoutOnly() с честным серверным слепком → canUndo === true (AC2).
  2. Перемещение → полный _adoptStructuralResponses (reconnect) → canUndo === true (AC3).
  3. Сервер отвечает другой позицией → история очищена, _layout заменён (AC4).
  4. Удаление позиции → честный reload → canUndo остаётся true, ключ не возвращается (AC5б).
  5. Запись «в полёте» (_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).
  • Скриншоты не меняются.