diff --git a/docs/specs/406-beta2-polish.md b/docs/specs/406-beta2-polish.md new file mode 100644 index 00000000..eba7c5f7 --- /dev/null +++ b/docs/specs/406-beta2-polish.md @@ -0,0 +1,304 @@ +# ТЗ #406 — Полиш v1.70.0-beta.2: мёртвые строки, роль диалога, непокрытая ветка, растущий снапшот + +- Issue: https://github.com/Matysh/houseplan-card/issues/406 +- Приоритет: P3, polish; полный трек — четыре несвязанные поверхности (словари + i18n, `hp-dialog`, снапшоты переезда area, приёмка скриншотов), критерий + «одна поверхность» для `small` не выполняется — прецеденты #385, #400 +- Ревизия: 1 (2026-08-31) + +## Сценарий + +Пять мелочей, оставшихся после крупной работы #32 и #126. Переводчик +поддерживает строки, которых никто не показывает. Незрячий пользователь слышит +заголовок диалога удаления, но не слышит, что именно исчезнет. Смоки проверяют +запасную ветку диалога, а не ту, что работает у людей. Снапшот переезда копит +записи об устройствах, которых давно нет. Повторная приёмка скриншотов стирает +след того, зачем их перерисовывали. + +## Что человек увидит до и после + +**До**: (1) 28 строк в четырёх словарях никому не показываются, но их переводят +и поддерживают; (2) скринридер объявляет диалог удаления как обычный `dialog` и +не читает текст последствий; (3) HA-ветка диалога не покрыта ни одним смоком — +проверяется только запасная; (4) записи снапшота исчезнувших устройств живут +вечно; (5) повторная приёмка кадров затирает список «что меняли». +**После**: словари содержат только используемое, и это проверяется гейтом; +подтверждение разрушающего действия объявляется как `alertdialog` вместе с +последствиями; смок ходит по обеим веткам диалога; снапшот убирает записи +устройств, которых достоверно нет; след приёмки не теряется. + +## Проблема и контракты по пунктам + +### (а) Мёртвые строки в словарях + +Сверка ключей `src/i18n/en.json` с литералами в `src/*.ts` (динамические +шаблоны вида `` `furn.cat_${id}` `` учтены — их 48 семейств): + +| Ключей в словаре | Использовано литералом | Не используется никак | +|---|---|---| +| 1201 | 937 | **30** | + +Из тридцати семь — предмет issue (`confirm.delete_draft`, +`confirm.delete_draft_segment`, `confirm.delete_plan`, `confirm.delete_room`, +`confirm.delete_space`, `confirm.remove_marker`, `confirm.unlock`): это +односоставные строки браузерного `confirm()`, которые #32 заменил парами +`…_title` + `…_body`. + +Остальные двадцать три — тот же узор в других семействах: + +- 13 ключей `*.help.aria` (`marker.*`, `space.*`, `gs.*`, `device_inbox.*`) — + задел, не подключённый ни одним потребителем; +- `marker.display_hint`, `marker.display_hint_icon` — вытеснены + `marker.display_hint_badge/_icon_ripple/_value/_static_icon` + (`houseplan-card.ts:404-407`); +- `history.delete_room` — вытеснен парой `history.delete_room_keep_walls` / + `_with_walls` (`houseplan-editor-runtime.ts:5953`); +- `markup.delete` — вытеснен `markup.delete_room` (`:11877`); +- `title.markup`, `history.partition_add` — остатки прежнего инструмента стен. + +**Контракт**: словарь содержит только то, что кто-то показывает. Проверяется +гейтом, который строит множество литералов и динамических префиксов из `src/` +и объявляет остальное мёртвым. + +**Скоуп сознательно шире тела issue, и вот почему**: гейт нельзя ввести +наполовину. Если он судит весь словарь — он покраснеет на всех тридцати; если +только на `confirm.*` — он не поймает следующий такой ключ, а именно за этим +его и заводят. Поэтому в задаче удаляются все доказанно мёртвые ключи, а не +только семь. Временный список исключений не заводится: это долг, который никто +не разберёт. + +**Известная коллизия, которую обязана разрешить реализация**: +`test/unified-wall-tool-source.test.mjs:32` **требует наличия** +`history.partition_add` в трёх словарях (`assert.equal(typeof locale[key], +'string')`), хотя в `src/` он не используется. Одно из двух: либо ключ живой и +реализация показывает потребителя, либо он удаляется вместе с этой строкой +теста. Молча оставить нельзя — гейт и тест будут противоречить друг другу. + +`test/i18n.test.mjs` мёртвые ключи не ловит по устройству: он проверяет паритет +словарей, непустоту значений и совпадение плейсхолдеров — то есть что словари +одинаковы, а не что ключи нужны. + +### (б) Роль диалога и текст последствий + +`src/hp-dialog.ts:459-462`, запасная ветка: + +```html + +``` + +`aria-describedby` в файле не встречается ни разу. Скринридер объявит заголовок +и не объявит предложение, ради которого диалог и показывают («Устройство +исчезнет с плана вместе с настройками»). + +**Контракт**: диалог, требующий решения о разрушающем действии, объявляется как +`alertdialog`, и его текст последствий связан с диалогом через +`aria-describedby`. Роль **выбирается, а не меняется глобально**: `hp-dialog` +несёт и обычные диалоги (маркер, калибровка пылесоса), для которых `alertdialog` +неверен — эта роль обязывает screen reader прервать чтение. + +Практически: `hp-dialog` получает признак вида (destructive / обычный), который +проставляет `hp-confirm` для своих запросов; обе ветки — HA и запасная — +объявляют роль одинаково. + +### (в) HA-ветка диалога не покрыта + +`hp-dialog.ts:240`: `this._useHaDialog = !!customElements.get('ha-dialog')`. +Единственный смок во всём `demo/`, определяющий `ha-dialog`, — +`smoke_free_walls.mjs:20-22`, и он же на `:16` глушит `_confirmDanger`, то есть +диалог подтверждения там не рисуется. В смоках подтверждений +(`smoke_danger_confirmation.mjs`, `smoke_danger_confirm_branches.mjs`) слова +`ha-dialog` нет. + +Итог: покрыта только запасная ветка, существующая ради standalone-демо, а не +та, что работает у пользователя. + +**Контракт**: инварианты подтверждения (диалог показан, «Отмена» резолвит +`false`, начальный фокус на «Отмена», Esc отменяет) проверяются на **обеих** +ветках. Прецедент стаба `ha-dialog` уже написан и лежит в соседнем файле. + +### (г) Снапшот копит записи исчезнувших устройств + +`applyAreaRelocationResolution` (`device-area-relocation.ts`) итерирует только +по `resolution.decisions`, а решения строятся по текущему списку устройств +карточки. Устройство, пропавшее из Home Assistant и не имеющее маркера, в +decisions не попадает — его запись `marker_area_snapshot[id]` не будет ни +обновлена, ни удалена никогда. Единственная уборка — +`removeMarkerAreaSnapshots` при явном редактировании или удалении маркера +(`houseplan-editor-runtime.ts:8326, 8448`), то есть по действию человека. + +**Контракт**: запись снапшота живёт, пока живёт устройство или маркер, к +которому она относится. Запись, не соответствующая ни одному из них, +убирается — **но только когда реестр авторитетен**. Это условие не +факультативное: во время перезапуска Home Assistant или до первой полной +загрузки реестра устройства временно отсутствуют, и уборка «по факту +отсутствия» стёрла бы законные записи — ровно тот класс потери, который чинит +#403. + +**Побочный пункт того же места**: `markerAreaSnapshotOf` режет вход +`.slice(0, MARKER_AREA_SNAPSHOT_LIMIT)` (20 000) при **чтении**. Порядок ключей +— порядок вставки, поэтому при переполнении молча отбрасываются самые свежие +записи, а не самые старые: мусор вытесняет живое. Правило переворачивается +заодно — при обрезке сохраняются последние записи. + +### (д) Затирание следа приёмки + +`scripts/docs-accept.mjs:145` пишет `declared: [...decision.replace]`. Модельный +прогон повторной приёмки неизменённого набора: + +``` +отказ: НЕТ | replace=[] | witnesses=10 | floor=1 +→ в манифест уйдёт acceptance.declared = [] +``` + +Безобидный повтор `docs:accept` проходит успешно и стирает единственную запись +о том, зачем кадры перерисовывались. + +**Контракт**: приёмка, ничего не заменившая, не переписывает историю +предыдущей. Свежие числа (`witnesses`, `floor`) обновляются, список +`declared` при пустом наборе замен сохраняется. + +## Скоуп / не-скоуп + +**В скоупе**: удаление доказанно мёртвых ключей из четырёх словарей и гейт, +который не даёт им вернуться; роль и `aria-describedby` в `hp-dialog` и +`hp-confirm`; смок HA-ветки подтверждения; уборка снапшотов при авторитетном +реестре и порядок обрезки; сохранение `acceptance.declared`. + +**Не в скоупе**: подключение `*.help.aria` к интерфейсу (если задел нужен — +отдельный issue; строки восстанавливаются из истории); содержимое и оформление +диалога (#32); переезд area и его отказы (#403); порог свидетелей (#405 — +соседняя строка того же файла, другой контракт); рост `known_devices` — тот же +класс, но своя поверхность. + +## UX + +Оформление не меняется. Меняется то, что слышит пользователь скринридера: +подтверждение разрушающего действия объявляется как оповещение и читается +вместе с последствиями. Терминология берётся из `docs/USER-GUIDE.ru.md`, новых +формулировок не вводится. + +## Модель данных и миграция + +Формат `marker_area_snapshot` не меняется — меняется момент уборки записей. +Миграции нет: запись, оставшаяся от исчезнувшего устройства, исчезает при +первом авторитетном проходе. + +Удаление ключей словаря — не миграция конфига: ключи не хранятся в +пользовательских данных. + +## i18n + +Новых строк нет; тридцать (по числу мёртвых ключей) удаляются из каждого из +четырёх словарей. Паритет сохраняется — удаление синхронное во всех четырёх. + +## Критерии приёмки + +- **AC1**. Ни один ключ словаря не остаётся без потребителя: гейт строит + множество литералов и динамических префиксов из `src/` и краснеет на любом + ключе, не покрытом ни тем, ни другим. Доказательство: гейт зелёный после + чистки. +- **AC2**. **Отрицательный прогон обязателен**: возвращённый в словарь мёртвый + ключ роняет гейт. Доказательство: мутант через штатный раннер. +- **AC3**. Гейт не обвиняет динамические ключи: `furn.cat_*`, `furn.sym_*`, + `wall_model.reason.*`, `resize.disabled.*`, `junction.limit_*`, `decor.*` и + прочие 48 семейств остаются зелёными. Доказательство: гейт зелёный на текущем + словаре после удаления только доказанно мёртвых. +- **AC4**. Коллизия с `test/unified-wall-tool-source.test.mjs` разрешена явно: + либо показан потребитель `history.partition_add`, либо ключ удалён вместе со + строкой теста. Доказательство: оба гейта зелёные одновременно. +- **AC5**. Паритет словарей сохранён: `test/i18n.test.mjs` зелёный без правок + его утверждений, в четырёх словарях одинаковый набор ключей. +- **AC6**. Подтверждение разрушающего действия объявляется как `alertdialog`, а + текст последствий связан через `aria-describedby`. Доказательство: смок + читает атрибуты у настоящего диалога. +- **AC7**. Обычные диалоги (маркер, калибровка) остались `dialog`. + Доказательство: тот же смок, вторая проверка. +- **AC8**. Инварианты подтверждения проверены на **обеих** ветках: диалог + показан, «Отмена» резолвит `false`, начальный фокус на «Отмена», Esc + отменяет. Доказательство: смок подтверждений со стабом `ha-dialog` (прецедент + `smoke_free_walls.mjs:20-22`) плюс существующий прогон запасной ветки. +- **AC9**. Запись снапшота исчезнувшего устройства убирается при авторитетном + реестре. Доказательство: смок — устройство пропадает из `_devices`, после + прохода записи в `marker_area_snapshot` нет. +- **AC10**. При неавторитетном реестре записи **не** трогаются. Доказательство: + тот же смок, ветка `authoritative: false` — запись на месте. +- **AC11**. При обрезке снапшота сверх лимита сохраняются последние записи, а + не первые. Доказательство: юнит на `markerAreaSnapshotOf` с входом больше + лимита. +- **AC12**. Повторная приёмка неизменённого набора не затирает + `acceptance.declared`. Доказательство: тест на функции приёмки — + `replace: []` оставляет прежний список. +- **AC13**. Штатная приёмка изменённого кадра по-прежнему записывает свой + список. Доказательство: существующие тесты `test/docs-acceptance.test.mjs` + зелёные без правок их утверждений. +- **AC14**. Бюджет initial не растёт: удаление тридцати строк из словарей + уменьшает его, роль и `aria-describedby` добавляют единицы байт. + Доказательство: `npm run bundle:budget` до и после. + +## План автотестов + +**Гейт** (`test/i18n-dead-keys.test.mjs`, новый): + +1. Множество ключей минус литералы минус динамические префиксы — пусто (AC1, + AC3). + +**Browser smoke** (`demo/smoke_danger_confirm_branches.mjs` — дополнение; файл +уже создан #402 и держит свои фикстуры): + +2. Роль и `aria-describedby` у подтверждения (AC6), роль у обычного диалога + (AC7). +3. Те же инварианты со стабом `ha-dialog` (AC8). + +**Browser smoke** (`demo/smoke_area_relocation.mjs` — дополнение): + +4. Устройство исчезло из `_devices`, реестр авторитетен → запись убрана (AC9). +5. То же при `authoritative: false` → запись на месте (AC10). + +**Юниты**: + +6. `markerAreaSnapshotOf` на входе больше лимита — сохранены последние (AC11). +7. Приёмка с `replace: []` — прежний `declared` сохранён (AC12). + +**Мутанты** (`scripts/mutation-gate.mjs`): + +- `i18n-dead-key-returns`: вернуть `confirm.unlock` в словари → гейт краснеет; +- `confirm-dialog-loses-alertdialog`: вернуть `role="dialog"` подтверждению → + смок AC6 краснеет; +- `area-snapshot-cleanup-ignores-authority`: убрать условие авторитетности → + смок AC10 краснеет. + +Третий мутант важнее первых двух: он проверяет не наличие уборки, а её +осторожность — то есть ровно то свойство, потеря которого стоит пользователю +данных. + +## Риски + +- **Гейт мёртвых ключей даст ложные обвинения на будущих динамических ключах.** + Разработчик, написавший `` _t(`new_family.${x}`) ``, получит красный гейт на + все ключи семейства. Смягчение: гейт извлекает префиксы из исходников + автоматически (48 семейств уже покрыты), а сообщение об отказе называет + ключ и подсказывает обе законные дороги — использовать или удалить. +- **`alertdialog` меняет поведение скринридера сильнее, чем кажется**: он + прерывает текущее чтение. Смягчение: роль назначается только подтверждениям + разрушающих действий, обычные диалоги проверяются отдельным AC. +- **Уборка снапшотов может стереть законные записи.** Это ровно тот класс, что + чинит #403. Смягчение: уборка только при авторитетном реестре, AC10 + проверяет обратный случай напрямую, мутант закреплён. +- **Удаление 13 ключей `*.help.aria` окажется преждевременным**, если задел + планировался к подключению. Смягчение: восстановление из истории тривиально, + а поддерживать непоказываемый перевод в четырёх языках дороже, чем вернуть + строки в тот день, когда для них появится потребитель. + +## Откат + +Пять правок независимы и откатываются по отдельности: удаление ключей — +обратным коммитом, роль и `aria-describedby` — одной строкой каждая, уборка +снапшотов — снятием условия, сохранение `declared` — возвратом прежнего +выражения. + +## Release-артефакты + +- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт о том, что подтверждение + разрушающего действия объявляется скринридером вместе с последствиями + (User-Visible: yes). Остальные четыре пункта — внутренние. +- Скриншоты не меняются: диалог в статике не открыт.