13 KiB
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:
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):
_persistDevicePlacementс неканонической позицией →_layout[deviceId]равен отправленному, отпечаток совпадает (AC1).- Ветка удаления → ключ удалён локально, отпечаток пересчитан (AC5).
- Канонизация-тождество (позиция уже каноническая) → отпечаток не меняется, лишней записи нет.
Browser smoke (demo/smoke_device_position_history.mjs, правка фикстуры):
- Перемещение →
_reloadLayoutOnly()с честным серверным слепком →canUndo === true(AC2). - Перемещение → полный
_adoptStructuralResponses(reconnect) →canUndo === true(AC3). - Сервер отвечает другой позицией → история очищена,
_layoutзаменён (AC4). - Удаление позиции → честный reload →
canUndoостаётсяtrue, ключ не возвращается (AC5б). - Запись «в полёте» (
_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).- Скриншоты не меняются.