mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Полевая проверка основного View по #560: шесть обезличенных планов корпуса и цепочка import → Optimize → Optimize → Resize → сохранение с численными оракулами, семь household-путей с независимыми оракулами в двух ширинах карточки, аудит доступности с измеренными числами. Продуктовых правок нет: находки заведены отдельно (#564, #565, #566). Issue: #560 User-Visible: no
202 lines
16 KiB
Markdown
202 lines
16 KiB
Markdown
# Полевая проверка View, аудит доступности и геометрический корпус (#560)
|
||
|
||
Дерево проверки: `dev` на 13.09.2026 (после #556). Всё, помеченное
|
||
«проверено исполнением», снято прогоном на этом дереве; всё остальное
|
||
помечено как чтение или арифметика.
|
||
|
||
## 0. Что было и чего не было
|
||
|
||
Было: чтение реальной установки владельца (только чтение, по его прямому
|
||
разрешению), синтетический стенд `demo/` в двух ширинах, узловые тесты модели.
|
||
|
||
**Не было, и это не оговорка задним числом:** добровольцев кроме владельца,
|
||
физического Companion на телефоне/планшете, записи в реальную установку,
|
||
проверки со скринридером и с симуляцией цветовой слепоты. Пункты, которые без
|
||
этого не проверяются, названы ниже гипотезами, а не находками.
|
||
|
||
## 1. Профиль реального плана (чтение, 13.09.2026)
|
||
|
||
Модель v10, `rev` 2740, интеграция 1.76.0-beta.1.
|
||
|
||
| что | сколько |
|
||
|---|---|
|
||
| пространств / комнат | 5 / 37 |
|
||
| записей толщины / сегментов стен | 121 / — |
|
||
| проёмов / перегородок / колонн | 35 / 7 / 2 |
|
||
| маркеров | 156 |
|
||
| шаг сетки по пространствам | 5, 1, 30, 1, 1 см |
|
||
| толщины стен в одном пространстве | 20/22/28/29/33 · 20/30/40 · 4/15/17/33 · 10/15 |
|
||
| минимальное ребро комнаты | 5 см (шаг 1 см) и 10 см (шаг 5 см) |
|
||
| T-узлов по пространствам | 7 / 6 / 3 / 2 / 0 |
|
||
| X-узлов (степень ≥ 4) | **ни одного** |
|
||
| координат всего / с шумом ближе 1e-4 шага | 1010 / 23 |
|
||
| почти осевых стен (`classifyNearAxisSegment`) | 0 |
|
||
| проёмов, чей край лежит ровно в вершине комнаты | 1 |
|
||
| записей `layout` на УДАЛЁННЫЕ пространства | 4 (на 2 пространства) |
|
||
| ссылок на сущности HA в маркерах и проёмах | 42 |
|
||
| из проверенных 25 ссылок не существует в HA | 1 |
|
||
|
||
Инварианты модели на этом конфиге чистые: `node scripts/model-invariants.mjs`
|
||
сообщает «ссылки разрешимы, записи толщины находятся».
|
||
|
||
**Геометрия владельца в репозиторий не попала.** Фикстуры корпуса
|
||
синтетические и воспроизводят перечисленные условия числами. Это и есть
|
||
обезличивание: в репозитории нет ни плана дома, ни имён комнат, ни
|
||
идентификаторов сущностей.
|
||
|
||
## 2. Household-сценарии
|
||
|
||
Стенд: `demo/smoke_household_journeys.mjs`, штатная ширина карточки 780 px
|
||
(настенный планшет) и 390 px (узкая колонка телефона). У каждого пути назван
|
||
независимый оракул — не «класс появился», а вызов сервиса, доступное имя или
|
||
содержимое модели.
|
||
|
||
| путь | вход | оракул | результат |
|
||
|---|---|---|---|
|
||
| J1 «экран проснулся» | вкладка, не грузившая редакторский чанк | `_booting === false`, `.stage` видима, комнат 4, устройств 10 | пройден |
|
||
| J2 «где протечка» | датчик протечки уходит в `on` | доступное имя называет состояние (`Sink leak sensor, Alarm, …`), `data-state="alarm"` | пройден, см. F4 |
|
||
| J3 «сколько в спальне» | температурный маркер со значением | значение попадает в `aria-label` (`Temperature: 22.4°`), 7 из 10 маркеров несут число в имени | пройден |
|
||
| J4 «выключить свет с клавиатуры» | Tab до маркера, затем Enter | вызван `light.turn_off`; кольцо фокуса `#0C82F0` против `transparent` у остальных | пройден; до первого маркера 15 остановок Tab |
|
||
| J5 «переключить этаж» | клик по вкладке пространства | `_space` сменился, набор комнат перерисован, активная вкладка соответствует пространству | пройден, см. F3 |
|
||
| J6 «вернулся после обновления» | конфиг приходит заново | выбранное пространство сохранено, план и устройства нарисованы | пройден |
|
||
| J7 «нажать нужный светильник» | пять маркеров с шагом 0,0333 — самая плотная расстановка из поля | в центре ВИДИМОГО маркера нажатие достаётся ему же | 780 px — пройден; **390 px — не пройден, F1** |
|
||
|
||
## 3. Аудит доступности текущего View (проверено исполнением)
|
||
|
||
Что уже сделано правильно и должно остаться:
|
||
|
||
- маркер устройства с действием — `role="button"`, `tabindex="0"`, осмысленное
|
||
`aria-label` с названием, состоянием и значением;
|
||
- Enter и Space на маркере действительно вызывают действие (`_keyDevice` →
|
||
`_clickDevice` → `light.turn_off` в прогоне);
|
||
- фокус видим: `:focus-visible` красит кольцо `#0C82F0` (у остальных
|
||
`transparent`), скриншот вокруг маркера меняется;
|
||
- зона нажатия увеличена сознательно: `.dev::before` — круг
|
||
`max(44px, --device-shell-size)`, то есть 44 px при любом размере маркера.
|
||
Это выше минимума WCAG 2.5.8 (24 px) — и именно это порождает F1 при
|
||
плотной расстановке.
|
||
|
||
Измеренные проблемы — F1…F5 ниже.
|
||
|
||
## 4. Геометрический корпус
|
||
|
||
`test/fixtures/560-corpus/` — шесть планов; `test/geometry-corpus.test.mjs` —
|
||
26 проверок; `demo/smoke_geometry_corpus.mjs` — браузерная половина.
|
||
|
||
| фикстура | условие | откуда взято |
|
||
|---|---|---|
|
||
| `c1-short-edges` | уступ в два шага (10 см при шаге 5 см) | минимальное ребро поля |
|
||
| `c2-t-junction-thicknesses` | T-узел, где сходятся три разные толщины | 7 T-узлов и 5 толщин в поле |
|
||
| `c3-x-junction` | четыре комнаты в одной точке | **синтетика: в поле X-узлов нет** |
|
||
| `c4-opening-at-vertex` | край проёма ровно в вершине комнаты | один такой проём в поле |
|
||
| `c5-node-noise-near-axis` | шум у узла плюс честно наклонная стена | 23 шумных координаты в поле |
|
||
| `c6-stale-layout` | `layout` на удалённое пространство | 4 таких записи в поле |
|
||
|
||
Цепочка на каждом плане: импорт → инварианты → Optimize → инварианты →
|
||
Optimize → Resize → Optimize → сохранение и перечитывание. Оракулы численные:
|
||
|
||
- нарушений инвариантов ровно столько, сколько заявлено (для c1–c5 — ноль);
|
||
- Optimize не меняет вход (immutability, как в #281);
|
||
- приведённый план Optimize не трогает (`changed === false`), а второй проход
|
||
не двигает ничего никогда — фикс-пойнт #477;
|
||
- записи толщины целы (`checkWallRecordsPreserved`);
|
||
- ключи записей канонические (`wallKey` пересчитывается независимо);
|
||
- **наклонная стена не выпрямляется**: `classifyNearAxisSegment` не относит
|
||
наклон 1 шаг на 60 к почти осевым, и Optimize оставляет её как есть. Это
|
||
прямая проверка приёмки «корректная планировка не исправляется ради удобства
|
||
рендера»; шум у узла, наоборот, снимается — и это правильно.
|
||
|
||
Рендер эталоном не является: браузерная половина проверяет присутствие и число
|
||
узлов (комнаты, проём, заливка стен), попадание геометрии в `viewBox` и
|
||
сохранность набора толщин после ШТАТНОГО ресайза — то, что чистыми функциями
|
||
не проверяется, потому что перенос записей толщины делает хост карточки
|
||
(`project()` в `ResizeController.move`), а не `resize.ts`.
|
||
|
||
**Граница узловой половины названа прямо:** вход её шага Resize — «рёбра
|
||
сдвинуты, записи толщины на прежних координатах», состояние, которого штатный
|
||
writer не создаёт. Поэтому там проверяется сходимость Optimize за ≤ 2 прохода,
|
||
а не «за один».
|
||
|
||
## 5. Находки
|
||
|
||
### F1 (High) — в узкой колонке нажатие достаётся соседнему маркеру
|
||
|
||
Проверено исполнением на осевшей геометрии (замер повторяется до двух
|
||
одинаковых подряд — правило #533).
|
||
|
||
При ширине карточки 390 px маркеры имеют размер 10,84 px, а расстояние между
|
||
центрами при самой плотной расстановке поля (шаг 0,0333) — 11,8 px. Зона
|
||
нажатия каждого маркера — круг 44 px (`.dev::before`, `pointer-events: auto`,
|
||
`z-index: 3`), то есть ±22 px от центра. Круг соседа накрывает центр маркера
|
||
целиком, и выигрывает тот, кто выше по порядку.
|
||
|
||
Что измерено: центр `dense1` (203; 461,08) принадлежит `dense2`
|
||
(`elementFromPoint` и реальный `page.mouse.click` — обоими). Для пяти маркеров
|
||
владельцы центров: `dense2, dense3, dense4, dense5, dense5`. То есть **ни один
|
||
из первых четырёх маркеров нельзя нажать в его собственном центре**.
|
||
|
||
Порог: расхождение начинается, когда расстояние между центрами меньше 22 px
|
||
(половина фиксированного круга). На 780 px тот же план даёт 23,6 px между
|
||
центрами — проверка проходит, но запас 1,6 px.
|
||
|
||
Почему это важно: J3 «безопасно действовать» — на телефоне пользователь
|
||
включает не тот свет, и никакого признака ошибки нет.
|
||
|
||
### F2 (Medium) — подписи комнат: контраст 2,01:1 при норме 4,5:1
|
||
|
||
Тёмная тема по умолчанию: цвет подписи `rgb(85,96,108)` на фоне стейджа
|
||
`rgb(28,37,48)`, `opacity: 0.8`, размер 12,05 px. Контраст по чистому цвету
|
||
2,41:1, с учётом смешивания полупрозрачности — **2,01:1**. WCAG 1.4.3 (AA) для
|
||
такого размера требует 4,5:1.
|
||
|
||
Механизм: цвет подписи — это цвет комнаты (`disp.color`), а прозрачность —
|
||
`min(1, room_opacity + 0.25)`. Отдельного токена для текста плана нет, поэтому
|
||
подпись нельзя сделать читаемой, не перекрасив сами комнаты.
|
||
|
||
### F3 (Medium) — активная вкладка этажа не имеет программного состояния
|
||
|
||
`<button class="tab active">` не несёт ни `aria-current`, ни `aria-pressed`,
|
||
ни `aria-selected` (проверено чтением атрибутов в прогоне: все три `null`), а
|
||
полоса вкладок — обычный `div`, не `tablist`. Какой этаж открыт, сообщается
|
||
только классом, то есть видом. WCAG 4.1.2.
|
||
|
||
### F4 (Low) — состояние в доступном имени повторяется дважды
|
||
|
||
`Sink leak sensor, Alarm, Alarm, LQI 117, medium signal` — слово состояния
|
||
попадает в имя из двух источников. Скринридер произносит его дважды.
|
||
|
||
### F5 (Low) — подсказка с названием и метриками не показывается при фокусе
|
||
|
||
Один и тот же узел подсказки: при `pointerover` — `display: block` и текст
|
||
«Ceiling light / Smart Bulb E27», при клавиатурном фокусе — `display: none`.
|
||
Данные не теряются для скринридера (они есть в `aria-label`), но зрячий
|
||
пользователь клавиатуры их не получает.
|
||
|
||
### F6 (Low) — записи `layout` на удалённые пространства переживают Optimize
|
||
|
||
Проверено на `c6-stale-layout`: Optimize видит их (`positionsUnresolved: 2`),
|
||
но оставляет, и `npm run invariants` на экспорте пользователя сообщает о них
|
||
как о нарушениях. В поле таких записей четыре на два удалённых пространства.
|
||
Вреда пользователю нет; цена — ложный сигнал при будущем разборе.
|
||
|
||
## 6. Гипотезы UX (не находки, проверять с людьми)
|
||
|
||
- **U1.** Проём, чей `contact` удалён из HA, рисуется ОТКРЫТЫМ:
|
||
`openingAmount` при отсутствующем состоянии возвращает 1 для дверей и
|
||
калиток (для окон 0). Это отказ в безопасную сторону, но признака «состояние
|
||
неизвестно» нет. В поле ровно один такой случай: калитка ссылается на
|
||
`binary_sensor`, которого в HA больше не существует (404) — то есть на плане
|
||
владельца калитка нарисована открытой постоянно.
|
||
- **U2.** До первого маркера на плане 15 остановок Tab: сначала весь хром.
|
||
- **U3.** Состояние маркера зрительно передаётся заливкой (`#F0A00C` против
|
||
светлой/тёмной) при одинаковой иконке и размере. Светимости различаются
|
||
сильно, поэтому нарушения 1.4.1 здесь скорее нет, но без проверки с
|
||
симуляцией цветовой слепоты это гипотеза.
|
||
|
||
## 7. Что заведено отдельно
|
||
|
||
Продуктовые правки в этой задаче не делались — она research/tests-only.
|
||
F1, F2–F5 и F6 заведены отдельными issue со ссылкой на #560; свидетель к
|
||
каждой приедет вместе с правкой, а не поселится здесь зелёной проверкой,
|
||
узаконивающей дефект.
|