mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
docs(process): ролевые конспекты, замер входа, Snapshot генерируется, TESTING.md разделён
Вход агента до первого файла кода стоил ≈ 26 700 слов (аудит 22.09). - docs/process/AUTHOR.md и REVIEWER.md — выжимки PROCESS.md: каждый пункт ссылается на раздел канона, ключевые формулировки дословные; test/process-digests.test.mjs сверяет якоря, ссылки и правила. - scripts/entry-cost.mjs — маршрут чтения по роли и бюджет (автор ≤ 12 000 слов, AC1); AGENTS.md «Read this first» называет те же маршруты. - docs/STATUS.md: блок Snapshot генерирует scripts/status-snapshot.mjs (версии — release-contract, счётчики — inventory, теги — git); feature surface и ранние milestones перенесены дословно в docs/STATUS-FEATURES.md. - docs/TESTING.md — действующая инструкция (684 строки, AC3); ручные чек-листы и приложения по issue перенесены дословно в docs/testing-notes/ с индексом и тестом на полноту. - Промпт ревьюера в process.yml читает конспект вместо пересказа правил; машинные требования (строка вердикта, REVIEW_DOC, запрет fetch, таблица «чем краснеет», разделы повторного раунда) сохранены и закреплены тестом. - PROCESS.md: правила не менялись; добавлены ссылка на конспекты в шапке и уточнение в §10.4, что ревьюер конвейера читает конспект. - 7 мутантов в реестре. Issue: #634 User-Visible: no
This commit is contained in:
+75
-124
@@ -978,64 +978,52 @@ jobs:
|
||||
|
||||
${{ needs.prepare.outputs.spec_body_changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', needs.prepare.outputs.spec_body_doc, needs.prepare.outputs.spec_body_recorded) || '' }}
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||||
перепроверял AC, которых правка не касалась: r2 по #150 стоил
|
||||
полного прогона ради одной строки в тестовой фикстуре.
|
||||
|
||||
Порядок для r2 и дальше:
|
||||
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
|
||||
на котором он получен. SHA в вердикте не назван — это находка;
|
||||
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
|
||||
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
|
||||
3. по каждой находке предыдущего раунда покажи, чем именно она
|
||||
закрыта: строка кода или текста, а не заявление автора;
|
||||
4. заново проверяй только те AC, чьё доказательство дельта
|
||||
задевает. Остальные наследуй;
|
||||
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
|
||||
принято без повторной проверки, со ссылкой на документ того
|
||||
раунда и SHA, на котором вывод получен. Без этого перечня
|
||||
сокращение — молчаливое доверие, а такой тихий успех уже
|
||||
дважды стоил дня (#171, #207).
|
||||
|
||||
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
|
||||
вперёд dev (после ребейза это другой код, §7.2), смена контракта
|
||||
поведения, задета новая подсистема, либо объём дельты сопоставим с
|
||||
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
|
||||
|
||||
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
|
||||
способна сломать AC, который предыдущий раунд признал выполненным —
|
||||
так появилась регрессия #102. Поэтому граница не «только находки», а
|
||||
«находки плюс всё, до чего дотягивается дельта».
|
||||
Правила ревью в этом промпте не повторяются (#634): их канон —
|
||||
PROCESS.md, выжимка для ревьюера — docs/process/REVIEWER.md, где
|
||||
каждый пункт ссылается на свой раздел канона. Ниже — только то, что
|
||||
относится к этому прогону, и требования, которые нельзя пропустить.
|
||||
|
||||
Прочитай в этом порядке, прежде чем судить:
|
||||
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
|
||||
ограничитель: «features are built, improved and accepted only
|
||||
if they serve a job listed here». Первый вопрос к задаче —
|
||||
какую строку Core user jobs она закрывает.
|
||||
2. AGENTS.md и PROCESS.md — процесс, классы изменений, трейлеры,
|
||||
лимит циклов, формат вердикта.
|
||||
3. Тело issue #${{ github.event.issue.number }} и все комментарии.
|
||||
4. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
|
||||
2. docs/process/REVIEWER.md — обязанности ревьюера: позиция,
|
||||
ревью ТЗ, код-ревью, объём гейтов, повторный раунд, находки и
|
||||
вердикт. Раздел PROCESS.md по ссылке открывай, когда пункт
|
||||
касается твоего решения; при расхождении прав PROCESS.md. Если
|
||||
файла в материале нет — читай PROCESS.md §2.4, §2.7, §2.10,
|
||||
§4, §7.2, §8, §12. Задача правит сам конвейер, гейты или
|
||||
процесс — PROCESS.md целиком, §10 в первую очередь.
|
||||
3. AGENTS.md — классы изменений, трейлеры, гейты, формат вердикта.
|
||||
4. Тело issue #${{ github.event.issue.number }} и все комментарии.
|
||||
5. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
|
||||
терминология интерфейса берётся оттуда, а не изобретается.
|
||||
5. Канонический документ затронутой подсистемы: docs/SUN.md,
|
||||
6. Канонический документ затронутой подсистемы: docs/SUN.md,
|
||||
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
|
||||
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.10, issue #214): найди вердикт и материал
|
||||
предыдущего раунда (блок «Материал раунда» его документа), объяви
|
||||
дельту `git diff <тот SHA>..HEAD` (для spec — дифф тела issue или
|
||||
файла ТЗ), по каждой находке покажи, чем именно она закрыта —
|
||||
строка кода или текста, а не заявление автора, — и заново проверяй
|
||||
только AC, чьё доказательство дельта задевает. Разбор остаётся
|
||||
ПОЛНЫМ, если дельта не локальна: ребейз на ушедший вперёд dev,
|
||||
смена контракта, новая подсистема, объём сопоставим с задачей.
|
||||
Сомневаешься — разбирай полностью и скажи почему. Сокращается
|
||||
объём РАЗБОРА, а не строгость.
|
||||
|
||||
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
|
||||
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
|
||||
есть у старой задачи, он и есть материал, новые не создаются.
|
||||
Проверь обязательные разделы §7.1, однозначность каждого AC и
|
||||
указание способа доказательства. Отдельно проверь, что автор не
|
||||
выдал догадку за решение: утверждение о поведении, которого нет ни
|
||||
в одном документе и которое не помечено как предположение, —
|
||||
указание способа доказательства. Утверждение о поведении, которого
|
||||
нет ни в одном документе и которое не помечено как предположение, —
|
||||
замечание. Не бывает сложной задачи без единого открытого вопроса.
|
||||
|
||||
Владельцу задаются только продуктовые вопросы: что человек видит или
|
||||
делает и каков объём видимых изменений в этом issue. Технический
|
||||
вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и
|
||||
решаешь по существу в своём вердикте.
|
||||
Технический вопрос, вынесенный владельцу, — тоже замечание: ты его
|
||||
снимаешь и решаешь по существу в своём вердикте.
|
||||
|
||||
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
|
||||
и `git diff origin/dev...HEAD`. **Материал ревью — ровно
|
||||
@@ -1044,95 +1032,60 @@ jobs:
|
||||
привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с
|
||||
ним. Если автор в issue называет более новый коммит, которого в
|
||||
материале нет, — это находка «материал не был запушен до метки», а не
|
||||
повод подтянуть его самому (#437 r3→r4 стоил лишнего раунда именно так,
|
||||
#499). Ручного тестирования в цикле нет,
|
||||
повод подтянуть его самому (#499). Ручного тестирования в цикле нет,
|
||||
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
|
||||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||||
умеет падать, либо разобран по коду с явной записью «проверено
|
||||
чтением, не исполнением». «Verified» без названной команды и её
|
||||
результата доказательством не является. Зависимости уже установлены
|
||||
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
|
||||
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
|
||||
changelog в том же коммите.
|
||||
чтением, не исполнением». Для защитного AC (валидация, гард, лимит,
|
||||
отказ, инвариант) в документе обязательна строка таблицы
|
||||
«AC · чем доказан · чем краснеет» с результатом прогона; пустой
|
||||
третий столбец — находка Medium (§2.7, #435). «Verified» без
|
||||
названной команды и её результата доказательством не является.
|
||||
Проверь трейлеры Issue и User-Visible, при User-Visible: yes — правки
|
||||
в оба changelog в том же коммите. Если дифф меняет величину, видимую
|
||||
пользователю, назови прямо: какое число видно дважды и один ли у
|
||||
него источник (§8).
|
||||
|
||||
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
**Объём гейтов соразмерен задаче** (PROCESS.md §8): полные наборы —
|
||||
предрелизный гейт, а не гейт ревью.
|
||||
|
||||
${{ needs.prepare.outputs.validated_note }}
|
||||
|
||||
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
|
||||
и в повторном раунде тоже: код изменился, а стоят они минуты:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
|
||||
`src/**`: отпечаток скриншотов документации считается по всему
|
||||
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
|
||||
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
|
||||
с красным job `docs` до следующей задачи (#237).
|
||||
|
||||
Если diff трогает геометрию или ссылки на неё — рёбра комнат,
|
||||
записи толщины, `layout`, `marker.space`, `open_spans` — обязательны
|
||||
инварианты модели (#254): `npm test` уже гоняет их на всех моделях
|
||||
проекта, а на конкретной конфигурации они проверяются командой
|
||||
`npm run invariants -- --config <экспорт или ответ config/get>`.
|
||||
Три вопроса, на которые они отвечают, и все три уже стоили
|
||||
продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли
|
||||
каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу
|
||||
решёточного ребра (#258, #259). Последний сравнивает строки без
|
||||
допусков: сдвиг ключа на один шаг решётки равен допуску первых двух,
|
||||
поэтому они на нём промахиваются. Если задача меняет геометрию, а
|
||||
инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь.
|
||||
|
||||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
|
||||
что печатает `node scripts/smoke-select.mjs --base <base> --head <head>`.
|
||||
Сколько их всего — считает `ls demo/smoke_*.mjs | wc -l`; вшитое
|
||||
в этот текст число трижды расходилось с деревом, поэтому его
|
||||
здесь больше нет. Прогон всех уместен только когда задача
|
||||
действительно задевает всё. Выбирать по теме недостаточно: регресс #234 поймал
|
||||
`smoke_wall_junctions`, который по названию про стыки стен, а не
|
||||
про толщину отрезка. Инструмент печатает три вида ответа, и они
|
||||
разные: «прямое совпадение» — смок называет изменённый символ,
|
||||
«зарегистрированная связь» — смок проверяет следствие контракта,
|
||||
не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не
|
||||
разрешение ничего не прогонять. Вывод инструмента прикладывается
|
||||
к комментарию ревью вместе с решением по каждой строке: прогнал
|
||||
либо не прогнал и почему. Слабые связи (одно распространённое
|
||||
имя) — повод посмотреть, а не обязанность прогонять;
|
||||
- `npm run golden:verify` — если diff может изменить видимый
|
||||
результат: рендер, геометрия, стили, слои;
|
||||
- `python -m pytest tests_backend -q` — если тронут
|
||||
`custom_components/**/*.py`;
|
||||
- performance-профили — если названы в AC либо тронуты
|
||||
чувствительные к перфу пути.
|
||||
|
||||
**Одно число — один источник.** Если дифф добавляет или меняет
|
||||
величину, видимую пользователю, назови в отчёте прямо: какое число
|
||||
видно дважды (превью против записи, подпись против площади,
|
||||
подсветка инструмента против сохранённого значения) и один ли у него
|
||||
источник. Три дефекта подряд имели именно эту причину — #234, #233 и
|
||||
способ, которым #234 обнаружили. Механическая часть закреплена
|
||||
тестом `test/single-source-numbers.test.mjs`, смысловая — твоя.
|
||||
|
||||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||||
тем тестам, которые ты прогонял.
|
||||
и в повторном раунде тоже: `npx tsc --noEmit`, `npm test`,
|
||||
`npm run build` со сверкой трёх копий бандла, плюс
|
||||
`node scripts/check-docs.mjs`, если diff трогает `src/**`.
|
||||
Зависимости уже установлены workflow, Chromium тоже — `npm ci`
|
||||
выполнять не нужно. По диффу и AC: браузерные смоки — названные в AC
|
||||
плюс вывод `node scripts/smoke-select.mjs --base <base> --head <head>`,
|
||||
приложенный к комментарию с решением по каждой строке: прогнал либо
|
||||
не прогнал и почему. Три вида ответа инструмента разные: «прямое
|
||||
совпадение», «зарегистрированная связь», «НЕОПРЕДЕЛЁННОСТЬ» — связь
|
||||
не доказана, и это не разрешение ничего не прогонять; слабые связи —
|
||||
повод посмотреть, а не обязанность прогонять;
|
||||
`npm run golden:verify` при видимом изменении;
|
||||
`python -m pytest tests_backend -q` при правке
|
||||
`custom_components/**/*.py`; инварианты модели
|
||||
`npm run invariants -- --config <экспорт>` при правке геометрии или
|
||||
ссылок на неё (#254) — задача меняет геометрию, а инварианты в
|
||||
отчёте не названы, это непрогнанный гейт, а не мелочь;
|
||||
performance-профили, если названы в AC. Дисциплина «тест должен
|
||||
уметь падать» не отменяется, но применяется к тем тестам, которые
|
||||
ты прогонял.
|
||||
|
||||
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
|
||||
почему.** Это условие честности такого сужения: непрогнанный гейт
|
||||
становится видимым решением, а не молчаливым пропуском. Раздел «чего
|
||||
не проверял» в документе ревью — не формальность, а главный его
|
||||
раздел на коротких задачах.
|
||||
почему.** Раздел «чего не проверял» в документе ревью — не
|
||||
формальность, а главный его раздел на коротких задачах.
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
|
||||
же — без High это жёлтый вердикт и возврат автору, отдельный issue
|
||||
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
|
||||
обслуживание issue дороже правки на месте); Low либо правится,
|
||||
либо снимается с записью. Жёлтый вердикт допустим и при полностью
|
||||
выполненных AC, если изменение не решает заявленный сценарий или
|
||||
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
|
||||
отменяет AC и не даёт права менять скоуп.
|
||||
НЕ заводится (#202); Low либо правится, либо снимается с записью.
|
||||
Жёлтый вердикт допустим и при полностью выполненных AC, если
|
||||
изменение не решает заявленный сценарий или ухудшает смежный.
|
||||
Продуктовое рассуждение расширяет вопросы, но не отменяет AC и не
|
||||
даёт права менять скоуп.
|
||||
|
||||
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
|
||||
поведения, который в этой ветке чинить нельзя) заведи отдельным
|
||||
@@ -1142,13 +1095,11 @@ jobs:
|
||||
|
||||
Напиши полный документ ревью в файл, путь которого лежит в
|
||||
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
|
||||
|
||||
Почему не в docs/reviews: документ там был некоммитнутым файлом того
|
||||
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
|
||||
#220 три раунда подряд документ исчезал — восстановление дерева
|
||||
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
|
||||
ревью, потому что он untracked. В репозиторий его положит шаг
|
||||
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
|
||||
Не в docs/reviews: восстановление дерева после проверки «умеет ли
|
||||
тест падать» (`git checkout -- .`, `git clean -fd`) сносит
|
||||
untracked-файл, и на #220 документ так исчезал три раунда подряд.
|
||||
В репозиторий его положит шаг публикации, взяв из REVIEW_DOC;
|
||||
тебе трогать docs/reviews не нужно.
|
||||
|
||||
В самом репозитории не создавай файлов вообще: любые изменения в
|
||||
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
|
||||
|
||||
@@ -22,11 +22,22 @@ For work that changes visible behaviour, also read `docs/USER-GUIDE.ru.md` —
|
||||
interface wording comes from there and is not invented, or the UI starts speaking
|
||||
developer.
|
||||
|
||||
Then `PROCESS.md` (the full process), `docs/STATUS.md` (where the release line
|
||||
is), and for non-trivial changes `docs/ARCHITECTURE.md` plus the canonical
|
||||
document of the subsystem you touch: `SUN.md`, `LIGHT.md`, `CANVAS.md`,
|
||||
`WALL-THICKNESS.md`, `UX-MODES.md`, `CONFIG-COMPATIBILITY.md`,
|
||||
`TOUCH-SUPPORT.md`.
|
||||
**Reading order by role** (#634). `node scripts/entry-cost.mjs` measures each
|
||||
route and `test/entry-cost.test.mjs` keeps this list equal to its routes:
|
||||
|
||||
- author (analysis, spec, implementation, infrastructure): `docs/SCOPE.md` →
|
||||
`AGENTS.md` → `docs/process/AUTHOR.md` → `docs/STATUS.md`;
|
||||
- reviewer (spec or code): `docs/SCOPE.md` → `AGENTS.md` →
|
||||
`docs/process/REVIEWER.md`, then the issue body and its comments;
|
||||
- changing the pipeline, the gates or the process itself: `docs/SCOPE.md` →
|
||||
`AGENTS.md` → `PROCESS.md` → `docs/STATUS.md`.
|
||||
|
||||
The two digests quote and link `PROCESS.md` section by section; it stays the
|
||||
only complete canon and wins any disagreement, so open the linked section
|
||||
whenever a digest line governs your current step. For non-trivial changes add
|
||||
`docs/ARCHITECTURE.md` plus the canonical document of the subsystem you touch:
|
||||
`SUN.md`, `LIGHT.md`, `CANVAS.md`, `WALL-THICKNESS.md`, `UX-MODES.md`,
|
||||
`CONFIG-COMPATIBILITY.md`, `TOUCH-SUPPORT.md`.
|
||||
|
||||
Standard commands live in `package.json` scripts, `CONTRIBUTING.md` and
|
||||
`docs/DEVELOPMENT.md`.
|
||||
|
||||
+11
-2
@@ -12,6 +12,14 @@
|
||||
> репозитории: до августа 2026 канон лежал только в папке владельца, и свежий клон
|
||||
> его не содержал вовсе.
|
||||
>
|
||||
> **Ролевые конспекты** (#634): `docs/process/AUTHOR.md` и
|
||||
> `docs/process/REVIEWER.md` — выжимки этого файла со ссылками на его разделы.
|
||||
> Автор и ревьюер входят через них (порядок чтения по роли — `AGENTS.md`) и
|
||||
> открывают раздел канона, когда пункт конспекта касается текущего шага.
|
||||
> Правил конспекты не добавляют; при расхождении побеждает этот файл. Ссылки и
|
||||
> ключевые формулировки конспектов сверяет `test/process-digests.test.mjs`,
|
||||
> поэтому правка формулировки здесь правит и конспект тем же коммитом.
|
||||
>
|
||||
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> документации с GitHub побеждает GitHub. Этот файл — единственный полный канон
|
||||
@@ -987,8 +995,9 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
|
||||
Текущая техническая реализация независимого ревьюера —
|
||||
`anthropics/claude-code-action`; это деталь автоматизации, а не закрепление роли
|
||||
или вида задач за Claude. Ревьюер читает `docs/SCOPE.md`, `AGENTS.md`, этот
|
||||
документ и тело issue, публикует разбор комментарием, заводит issue на
|
||||
или вида задач за Claude. Ревьюер читает `docs/SCOPE.md`, `AGENTS.md`,
|
||||
конспект `docs/process/REVIEWER.md` с разделами этого документа по его ссылкам
|
||||
(#634) и тело issue, публикует разбор комментарием, заводит issue на
|
||||
Medium-находки вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки
|
||||
задачи и возвращает вердикт структурированным JSON. **Метку переставляет отдельная
|
||||
детерминированная стадия по вердикту, а не модель.**
|
||||
|
||||
+3
-2
@@ -714,5 +714,6 @@ mode before dispatching synthetic gestures, it must also wait for the observable
|
||||
mode-transition and viewport-refit state to settle, because the stage can finish
|
||||
its physical resize after the Lit update (#460).
|
||||
|
||||
When adding a checklist line marked `[auto: ...]` in docs/TESTING.md, add the
|
||||
failing check in the same commit — that is what the marker now promises.
|
||||
When adding a checklist line marked `[auto: ...]` in docs/TESTING.md or its
|
||||
appendices in docs/testing-notes/, add the failing check in the same commit —
|
||||
that is what the marker now promises.
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
# Feature surface and milestones
|
||||
|
||||
> Moved verbatim from `docs/STATUS.md` by #634 so that the session entry
|
||||
> route stays small (`node scripts/entry-cost.mjs`). The documentation policy
|
||||
> of `STATUS.md` applies here unchanged: a user-visible feature gets its
|
||||
> bullet in the same commit as the behaviour.
|
||||
|
||||
## Current feature surface (since the 2026-07-17 snapshot)
|
||||
|
||||
- **Configurable summary overlay** (#437, merged into `dev`): a two-part
|
||||
View control opens one adaptive form or toggles a browser-local preference.
|
||||
Shared ordered blocks can show readable HA entity states and three system
|
||||
values; local icon/text scaling applies to ordinary and kiosk View without
|
||||
changing editor geometry. The overlay is a screen-space layer on the right
|
||||
or bottom, respects native HA mobile mode, and temporarily hides in a small
|
||||
card. Shared saves remain revision-checked; read-only and kiosk users receive
|
||||
only the local controls.
|
||||
#505 aligns its split control, compact overlay and wide settings dialog with
|
||||
the designer reference. Both entry and exit animate; size controls are absent
|
||||
from this form without changing saved scales, and mobile visibility is
|
||||
disabled under draft local-off. The GitHub issue is the source of workflow
|
||||
status; `docs/design/505-summary-panel/` holds the reference and visual evidence
|
||||
instructions, not a separate backlog.
|
||||
- **Presence radars (#485 Stage 1, merged into `dev`):** an optional marker-owned,
|
||||
change-aware v1 configuration binds exact HA sources for recognized ESPHome
|
||||
LD2450 or explicit Cartesian, polar, range, zone-state and presence-only
|
||||
profiles. The backend owns freshness, pairing, projection, room clipping,
|
||||
ACL-filtered bounded frames and teardown. The lazy device editor owns source
|
||||
inspection and physical two-point setup; View receives only normalized live
|
||||
frames. Raw observations, calibration samples and the short target trail are
|
||||
session-only and excluded from config, exports and support data.
|
||||
|
||||
- **Primary sidebar panel + optional cards** (#486): the integration registers
|
||||
`/houseplan` for every signed-in user after backend initialization. Its own HA
|
||||
app bar wraps the same `houseplan-card`, so spaces, permissions, actions and
|
||||
editor lifecycle stay shared; only the duplicated product title and outer
|
||||
dashboard-card chrome are removed. Failure to register the panel is isolated
|
||||
and reported in System Health. The optional full dashboard card requests full
|
||||
width in Sections by default; explicit HA sizing remains authoritative.
|
||||
- **Three editors + View**: Plan / Devices / Background (decor layer v1.33) as
|
||||
tabs with an X to close; View is the default; only the last space persists,
|
||||
while reload/return from another HA route always starts in View (#93).
|
||||
**Kiosk mode** (v1.41.0): `kiosk: true` — no
|
||||
header/editors, swipe between spaces, double-tap zoom reset, `cycle: N`
|
||||
carousel, per-screen size multipliers in localStorage. Discrete wheel,
|
||||
button, fit/home and kiosk-reset camera commands use one short retargetable
|
||||
transition; direct pan/pinch and reduced motion remain immediate (#82).
|
||||
- **Independent Glow overlay** (#55/#19, refined locally by #61/#65–#67): dark-room
|
||||
base is used only when the effective fill resolver returns `null`, including
|
||||
dynamic modes without usable data; resolved LQI/light/temp/custom colors keep
|
||||
their exact alpha while per-source pools remain visible. Pools use
|
||||
additive screen blending only after a cached real-pixel browser probe and
|
||||
otherwise fall back to normal composition; legacy `fill_mode: glow` remains
|
||||
losslessly readable/migratable. Marker roles are Auto/Always/Never; colour
|
||||
can remain live, be overridden with live brightness, or be fixed together
|
||||
with brightness. One perceptual alpha resolver gives every source the alpha at
|
||||
the centre of its pool; `GLOW_FALLOFF` then spends it over the whole radius.
|
||||
Per-source radius, doorway sight lines and transitive open boundaries remain —
|
||||
all three now fall out of the visibility region instead of separate layers.
|
||||
- **Custom room fill** (#56, space UX refined locally by #64): the space dialog
|
||||
uses persistent user color/opacity as its ordinary first/default fill instead
|
||||
of a redundant None option; a room keeps None as an explicit inherited-fill
|
||||
suppression. Effective inheritance is room → space → safe documented default;
|
||||
room reset restores space inheritance, and full/static renderers share the
|
||||
same projection.
|
||||
- **Universal device toggle** (#94, v1.62.0-beta.3): one `Toggle state` option is visible
|
||||
for every marker and resolves the exact binding, functional device role or
|
||||
explicitly configured controls. Hint, click, confirmation and cover
|
||||
presentation share one result; partial groups call only the shown available
|
||||
subset, stale controls never fall back, secure targets are no-op, and legacy
|
||||
`cover`/absent light defaults round-trip losslessly. **Lights toggle by
|
||||
default** (v1.39); other devices still default to the House Plan card.
|
||||
- **Contextual Zigbee topology** (#54, refined by #457 and #464 on current dev): an
|
||||
opt-in admin-only mouse hover shows direct neighbours without scanning. A
|
||||
deterministic per-provider uplink tree now adds arrows towards the
|
||||
coordinator while keeping non-route neighbour lines; a short bubble names a
|
||||
remote space or explains that the next device/coordinator is not on the plan.
|
||||
Active topology is above room names and unrelated markers, exact local
|
||||
endpoints remain above it, and unknown-LQI dashes have a dark contrast
|
||||
casing. Touch, kiosk, editors and the static card remain unchanged.
|
||||
- **Plan geometry**: polyline split (v1.32), island rooms w/ evenodd holes
|
||||
(v1.34), smart guides + 45° angle badge (v1.40), opening hover preview.
|
||||
Openings support doors, windows and compact wide gates; gates retain door
|
||||
contact/lock/Glow semantics but use two leaves opening only 10° outwards.
|
||||
- **Canonical wall model** (#282, #306, #478, current dev): Walls and Thickness
|
||||
accept `0..100 cm` for contour and independent segments. Model v10
|
||||
migrates legacy virtual spans into stable `cm:0` atoms; a per-space
|
||||
dashed/solid selector controls both line style and Glow/sun transmission.
|
||||
It also converts persisted unfinished contours to ordinary partitions; new
|
||||
wall-chain segments are partitions from the first accepted click and chain
|
||||
state is session-only. A finished chain losslessly merges/reconciles its own
|
||||
seed component and finished-chain Undo/Redo restores the same fixed point;
|
||||
room Delete/Merge owns direct and vacuum room-reference cleanup (#477).
|
||||
Zero walls have no body, area or opening host, and
|
||||
there is no Boundary tool.
|
||||
- **Rooms**: room cards with metrics (temp/hum/lqi/light "1 of 3") and
|
||||
proportional resize (v1.31); link icon to the HA area (v1.40.1, room taps
|
||||
removed). **New-device red dot** (v1.29), lock action button (v1.30).
|
||||
- **Dialog UX**: binding radios + entities checkbox + search dropdown
|
||||
(v1.38.0); tap actions simplified to Device card / more-info / Toggle,
|
||||
right-click → more-info (v1.38.1); Esc closes every dialog (v1.30.4).
|
||||
Since #607, rejecting the real HA close button after an unsaved-changes
|
||||
prompt explicitly reconciles the nested modal before reopening it; the same
|
||||
Device, Room, Space or General-settings draft remains interactive. #609 keeps
|
||||
every shared settings form on the reviewed 560 px canvas and single HA-owned
|
||||
scroller in the authentic Home Assistant branch; at 480 px and below the
|
||||
form is edge-to-edge fullscreen, while generic dialogs retain their previous
|
||||
sizing.
|
||||
- **Room settings, tier 3** (v1.42.0): per-room fill/temp-source/label sizes;
|
||||
the settings button sits at the room's VISUAL centre (inscribed circle +
|
||||
centroid pull), icon-derived size, zooms with the plan (v1.51.0).
|
||||
- **Files & plans** (v1.44–v1.50): signed content urls with sandbox CSP,
|
||||
copy-on-write plan files, "already uploaded" picker + explicit delete
|
||||
(v1.47.0), store quotas instead of any age-based deletion (v1.49.0),
|
||||
nothing is ever deleted on an inference (docs/SCOPE.md rule).
|
||||
- **Square canvas** (v1.48.0) with a crash-safe two-store migration
|
||||
(geom_pending, v1.50.0) and an explicit geometry/repair command (v1.50.1);
|
||||
content-fit default zoom with devices as content, zoom out to 0.4×,
|
||||
measured stage height (v1.49–v1.50.2).
|
||||
- **Explicit hide flags** (v1.51.0, docs/FILTERING.md): per-device
|
||||
`marker.hidden` seeded once from the old filter and controlled by the
|
||||
bottom-left "Hide" / "Show" action in the device dialog; local
|
||||
"Show hidden" ghosts; hidden counts toward room LQI on both cards, casts
|
||||
no light (v1.51.1).
|
||||
- **True plan deletion** (v1.60.0-beta.1, 2026-08-07): confirmed Delete beside Hide/Show;
|
||||
a minimal `marker.removed` tombstone prevents auto-rediscovery but exposes
|
||||
the binding to Add. Deleted devices are absent from every plan aggregate and
|
||||
linked marker presentation; layout/files/trails are cleaned and stale layout
|
||||
writes are rejected. Exact contact/lock references owned by architectural
|
||||
openings remain active without restoring the standalone marker; live text
|
||||
and other marker controls retain the older re-add-to-reactivate contract.
|
||||
- **Yellow = working right now** (v1.51.0): climate uses `hvac_action` when
|
||||
available and falls back to a current advertised non-off HVAC mode only when
|
||||
the integration omits the action; service switches can no longer become
|
||||
primary, and glow pool and icon share one condition. Editor gestures on touch
|
||||
(pinch/pan) landed the same release.
|
||||
- **Unified device status/activity** (v1.59.0-beta.10; pulse pipeline #98 local): four display
|
||||
modes (Icon + state / Icon + state and activity / Value + state / Always static icon),
|
||||
one semantic resolver for yellow
|
||||
actual work, orange open/unlocked, unavailable and always-red alarms;
|
||||
activity projects to three finite waves for a short event or one continuous
|
||||
pulse for presence, mechanical travel and running. Always-static deliberately suppresses every state-driven visual,
|
||||
satellite badge and live vacuum overlay while leaving hover, actions, Glow and
|
||||
controls intact. Legacy Ripple-only migrates to Icon + activity on the next save.
|
||||
- **Passive media lifecycle** (v1.60.0-beta.1): every `media_player`,
|
||||
regardless of model, stays neutral while powered or playing and fades to
|
||||
the existing unavailable appearance on explicit `off`; transport playback
|
||||
is no longer classified as yellow actual work and no new visual state is
|
||||
introduced.
|
||||
- **Unified Background editor** (v1.60.0-beta.1): typed decor model,
|
||||
physical cm/in strokes and text size, independent contour/fill opacity, decor+room smart
|
||||
magnet, common selection/resize/rotate frame for every decor kind, numeric
|
||||
geometry properties, and shared 50-command Undo/Redo. The plan image now has
|
||||
an exclusive tool, 0.5 editor de-emphasis elsewhere, independent axes,
|
||||
rotation, numeric properties and rotated content bounds. Legacy `width` and
|
||||
`plan_scale` remain read-compatible and migrate only through explicit plan
|
||||
optimisation. Furniture properties also expose the symbol itself. See
|
||||
`DECOR-EDITOR.md`.
|
||||
- **v1.59.0-rc.1** (2026-08-06): whole-plan lossless optimization with an
|
||||
atomic config+layout commit and safe undo; the yellow actual-work plate is
|
||||
retained alongside source glow; all Background objects have Select-mode
|
||||
double-click properties; View hover highlights every room and reports its
|
||||
clean-floor area. Audit fixes add eager activity baselines/source resets,
|
||||
lossless legacy live-text editing, exact wall-fragment endpoints/compaction,
|
||||
editor-visible virtual walls and prerelease-discovery reporting in CI.
|
||||
- **v1.59.0-rc.2** (2026-08-06): Plan actions have one meaning each; Room
|
||||
outline names the closed-contour tool; one named 50-command Undo/Redo stack
|
||||
covers committed plan geometry; positional placement is always grid-bound.
|
||||
Glow and toast overlays no longer steal room/tool pointers, View hover covers
|
||||
shared thick walls, device Hide/Show is explicit, the static no-op aspect
|
||||
field is gone, and the new user guide/product audit replaces archived docs.
|
||||
- **v1.59.0** (2026-08-06): The stable 1.59 line includes all beta/RC work.
|
||||
Room hover follows clean-floor wall faces, including nested contours and
|
||||
projected opening gaps. Thick-wall rendering unions each room's own wall
|
||||
ring, so one room's floor cannot erase another room's wall or leave white
|
||||
slivers at complex crossings.
|
||||
- **v1.59.1** (2026-08-06): device markers resolve a semantic entity role
|
||||
instead of trusting registry order; one light-source resolver now drives
|
||||
Glow, Light fill, room statistics, marker feedback and controls. Glow uses
|
||||
0.7 opacity. Current dev keeps external `controls` non-spatial: they still
|
||||
drive group state, but only a real lamp marker or explicit `is_light` marker
|
||||
can place a Glow pool. Compacted real walls retain their correct inner face
|
||||
and body at real/virtual T-junctions.
|
||||
- **v1.59.2** (2026-08-07): every modal uses the shared `hp-dialog` shell,
|
||||
backed by Home Assistant's `ha-dialog` with a native demo fallback. Titles,
|
||||
modal semantics, initial focus, focus trapping, Escape and restore focus are
|
||||
consistent across all editors, nested dialogs and dialog replacement flows.
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
- **v1.10.0** — audit & refactor: asyncio.Lock around all store writes (race fix, atomic
|
||||
`expected_rev`), point `layout/update` instead of full `layout/set` (anti last-writer-wins),
|
||||
new `layout/delete`, `safeUrl()` XSS guard, `fetchWithAuth`, KEY_HASS, streaming upload cap,
|
||||
card split into modules (`styles.ts` / `types.ts` / `devices.ts`), dynamic spaces in the GUI
|
||||
editor, dead code removed.
|
||||
- **v1.11.0** — full English translation + en/ru UI localization.
|
||||
- **v1.11.1** — brand images inside the integration; CI fully green for the first time.
|
||||
- **v1.11.2** — Description textarea fix in the device dialog.
|
||||
- **v1.12.0** — Quality Scale conformance: runtime_data, test-before-setup, unloading,
|
||||
single_config_entry, Store migrations hook, diagnostics, repairs, system health,
|
||||
uninstall cleanup, HA-harness tests in CI, quality_scale.yaml.
|
||||
- **v1.13.0** — universality: floors-import wizard, editable icon rules (+device_class
|
||||
fallback), tap actions with a security model, i18n dictionaries in JSON, light-theme pass.
|
||||
- **v1.13.1** — distribution: synthetic-home demo GIF in the README, issue templates,
|
||||
CONTRIBUTING.md, Discussions. Forum/Reddit drafts are in the user folder
|
||||
(`posts_drafts.md`) awaiting manual posting.
|
||||
- **v1.13.2** — audit round 3: buildDevices unit-test suite, multi-placeholder t(),
|
||||
conflict resync in _saveConfigNow, pointercancel long-press fix, repairs re-check
|
||||
on config save (repairs.py).
|
||||
- **v1.13.3** — privacy: legacy real-house assets/ removed; README screenshots synthetic.
|
||||
- **v1.14.0** — per-space display settings (borders/names/color/opacity/fills),
|
||||
draggable room labels, hand-drawn spaces (no image required), demo/ harness in-repo,
|
||||
docs/TESTING.md manual checklist (update with every functional change!).
|
||||
- **v1.15.0** — temperature room fill (blue/green/yellow) with editable comfort bounds.
|
||||
- **v1.15.1** — display-settings UX: radio fill selector, inline compact bounds
|
||||
(with the Number('')→0 bound-collapse bug fixed), avg room temp in the tooltip,
|
||||
darken-on-hover, wider space dialog.
|
||||
- **v1.15.2** — fix: average room temperature (fill + tooltip) counted non-thermometers
|
||||
(fridges/TRVs/chip `*_device_temperature`/diagnostic); now thermometer/air-monitor only.
|
||||
- **v1.15.3** — fix: device icon badge sat 1 px off its anchor (content-box + 1 px
|
||||
border); `box-sizing: border-box` centres it exactly on the device point.
|
||||
- **v1.15.4** — fix: real `ha-icon` (block + big line-height) put the glyph ~1.8 px low;
|
||||
`.dev ha-icon` is now a zero-line-height flex box. Reverted v1.15.3 border-box (shrank the
|
||||
badge). Verified live. Demo stub made faithful so the smoke guards it.
|
||||
- **v1.15.5** — fix: room hover was always grey; legacy overlay/yard hover rules scoped
|
||||
with `:not(.styled)` so filled rooms darken their fill, unfilled ones grey.
|
||||
- **v1.15.6** — room hover also reveals the border (stroke colour kept, hidden via
|
||||
opacity) even when borders are off.
|
||||
- **v1.16.0** — NEW read-only `houseplan-space-card` (static single-space schematic,
|
||||
pointer-events:none, deep-link button) + `#space=<id>` deep-link in the full card; shared
|
||||
space-geometry/space-render + module-level config cache (config-store).
|
||||
- **v1.16.1** — space-card renders room fills as configured on the full card (snapshot),
|
||||
no longer omitted; +shared areaLqi().
|
||||
- **v1.17.x** — entity markers get auto icon/temp (issue #1); correct resource URL documented
|
||||
(issue #2); humidity badge (gated on device_class, not the icon).
|
||||
- **v1.18.x** — live ruler while drawing rooms (metres / feet+inches) + per-space scale
|
||||
`cell_cm`; visibility fix (the badge lived in the markup-hidden devlayer).
|
||||
- **v1.19.0** — a line is never an entity of its own: walls derived from room outlines
|
||||
(`roomEdges`), unfinished contours persist nothing, Erase tool removed, `space.segments`
|
||||
stripped on save.
|
||||
- **v1.20.0** — rooms may not overlap (strictly-inside clicks refused, overlapping contours
|
||||
refused at close; shared walls stay legal).
|
||||
- **v1.21.x** — merge & split rooms (boolean geometry via polyclip-ts; merge = union collapses
|
||||
to one hole-free outline; split = wall-to-wall chord, bigger part keeps identity) + UX fixes.
|
||||
- **v1.22.0** — presence ripples (badge/ripple/icon_ripple + colour/size, `isActiveState`),
|
||||
per-device icon size/rotation (`--dev-size`), one-click HACS badge. NOTE: sources were lost
|
||||
in a sandbox reset after deploy and restored from conversation patches — push immediately
|
||||
after building, never wait for verification.
|
||||
- **v1.23.0** — doors & windows: "Opening" markup tool (snap onto derived walls, absolute
|
||||
coords), length in real cm, contact sensor + lock, animated leaf/arc, padlock badge, status
|
||||
card; lock never toggled from the plan.
|
||||
- **v1.23.1** — openings UX: hover outline + grab cursor, drag along walls (angle normalized
|
||||
to [-90,90) so the hinge never flips), click=status / double-click=properties, thicker hit
|
||||
strip. Release v1.23.1 published on GitHub (covers v1.22.0–v1.23.1).
|
||||
+23
-249
@@ -17,11 +17,28 @@ change must pass through a published beta/RC before stable. Stable release
|
||||
commits are promotion-only (versions, generated bundles and release/changelog
|
||||
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
## Snapshot (2026-09-23)
|
||||
## Snapshot
|
||||
|
||||
Everything computable from the tree and git; regenerate, never edit by hand
|
||||
(#634). Workflow status is not here — it lives only in issue labels.
|
||||
|
||||
<!-- status-snapshot:begin — generated by `node scripts/status-snapshot.mjs --write`; do not edit by hand -->
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Generated | 2026-09-24 — rerun `node scripts/status-snapshot.mjs` for the current tree |
|
||||
| Version | **1.78.0-beta.1** in all 7 version sources (`scripts/release-contract.mjs`) |
|
||||
| Latest stable tag | `v1.77.0` |
|
||||
| Latest prerelease tag | `v1.78.0-beta.1` |
|
||||
| Tests | Node unit 2868 · pure backend 381 · HA-harness backend 292 · browser smokes 263 (`npm run inventory`) |
|
||||
<!-- status-snapshot:end -->
|
||||
|
||||
## Standing state and decisions
|
||||
|
||||
Prose kept by hand: decisions and the state they explain. Update a row in the
|
||||
same commit as the change it describes.
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.78.0-beta.1** everywhere (manifest, const.py, package.json, package-lock in both places, CARD_VERSION in the card and the editor runtime) |
|
||||
| Current local cycle | **Beta v1.78.0-beta.1 candidate** — prepared from the exact integrated `dev` tree. It combines settings-dialog reliability and validation (#607, #608, #609, #610, #614), safer import/export and storage (#611, #625), read-only and touch fixes (#612, #613), the dishwasher palette icon (#640), and the associated validation/release infrastructure. `main` remains on stable v1.77.0. |
|
||||
| Hidden Alpha Stage | #89 Stage 1 ships in v1.63.0-beta.1, #122 Stage 2 in v1.64.0 and #160 Stage 3 in v1.73.0-beta.1; #570 adds the Stage 4 designer handoff and #583 refines it on `dev`: only the building ambient shadow remains, door/gate volumes pivot on the selected host face without strokes, and devices/lock badges receive deterministic mutual collision correction while room labels remain passive below them. The same hidden `iso` view uses a fixed 0°/20° camera and scale-aware wall height 84, low screen-facing device/room/lock overlays without tethers or ground dots, neutral full-height window frames with blue glass, and bounded theme materials. Since #448 the experiment is enabled only through the single indefinite browser-local `hp_alpha` gate; it is not expiring and has no per-stage key. Flat remains default; editors, `houseplan-space-card`, floor effects, stored coordinates and HA actions remain unchanged. Stage 4 stays internal and is absent from public changelog/user documentation. |
|
||||
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the stable release is produced by `release.yml` (`workflow_dispatch` on `main` with the tag) — the only publisher of installable assets since #540: gates on the exact SHA (Validate, Full Performance, E2E on the candidate commit), one build, `houseplan.zip` archived from the committed tree, `SHA256SUMS`, draft → publish → read-back verification; a release published by hand in the GitHub form is turned back into a draft and walked through the same path, and a re-dispatch on a public tag is a repair that adds only missing assets. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
@@ -39,253 +56,10 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||
| Product scope | `docs/SCOPE.md` is the feature guard rail; `docs/TOUCH-SUPPORT.md` is the input-support contract — check both before accepting interaction work |
|
||||
|
||||
## Current feature surface (since the 2026-07-17 snapshot)
|
||||
|
||||
- **Configurable summary overlay** (#437, merged into `dev`): a two-part
|
||||
View control opens one adaptive form or toggles a browser-local preference.
|
||||
Shared ordered blocks can show readable HA entity states and three system
|
||||
values; local icon/text scaling applies to ordinary and kiosk View without
|
||||
changing editor geometry. The overlay is a screen-space layer on the right
|
||||
or bottom, respects native HA mobile mode, and temporarily hides in a small
|
||||
card. Shared saves remain revision-checked; read-only and kiosk users receive
|
||||
only the local controls.
|
||||
#505 aligns its split control, compact overlay and wide settings dialog with
|
||||
the designer reference. Both entry and exit animate; size controls are absent
|
||||
from this form without changing saved scales, and mobile visibility is
|
||||
disabled under draft local-off. The GitHub issue is the source of workflow
|
||||
status; `docs/design/505-summary-panel/` holds the reference and visual evidence
|
||||
instructions, not a separate backlog.
|
||||
- **Presence radars (#485 Stage 1, merged into `dev`):** an optional marker-owned,
|
||||
change-aware v1 configuration binds exact HA sources for recognized ESPHome
|
||||
LD2450 or explicit Cartesian, polar, range, zone-state and presence-only
|
||||
profiles. The backend owns freshness, pairing, projection, room clipping,
|
||||
ACL-filtered bounded frames and teardown. The lazy device editor owns source
|
||||
inspection and physical two-point setup; View receives only normalized live
|
||||
frames. Raw observations, calibration samples and the short target trail are
|
||||
session-only and excluded from config, exports and support data.
|
||||
|
||||
- **Primary sidebar panel + optional cards** (#486): the integration registers
|
||||
`/houseplan` for every signed-in user after backend initialization. Its own HA
|
||||
app bar wraps the same `houseplan-card`, so spaces, permissions, actions and
|
||||
editor lifecycle stay shared; only the duplicated product title and outer
|
||||
dashboard-card chrome are removed. Failure to register the panel is isolated
|
||||
and reported in System Health. The optional full dashboard card requests full
|
||||
width in Sections by default; explicit HA sizing remains authoritative.
|
||||
- **Three editors + View**: Plan / Devices / Background (decor layer v1.33) as
|
||||
tabs with an X to close; View is the default; only the last space persists,
|
||||
while reload/return from another HA route always starts in View (#93).
|
||||
**Kiosk mode** (v1.41.0): `kiosk: true` — no
|
||||
header/editors, swipe between spaces, double-tap zoom reset, `cycle: N`
|
||||
carousel, per-screen size multipliers in localStorage. Discrete wheel,
|
||||
button, fit/home and kiosk-reset camera commands use one short retargetable
|
||||
transition; direct pan/pinch and reduced motion remain immediate (#82).
|
||||
- **Independent Glow overlay** (#55/#19, refined locally by #61/#65–#67): dark-room
|
||||
base is used only when the effective fill resolver returns `null`, including
|
||||
dynamic modes without usable data; resolved LQI/light/temp/custom colors keep
|
||||
their exact alpha while per-source pools remain visible. Pools use
|
||||
additive screen blending only after a cached real-pixel browser probe and
|
||||
otherwise fall back to normal composition; legacy `fill_mode: glow` remains
|
||||
losslessly readable/migratable. Marker roles are Auto/Always/Never; colour
|
||||
can remain live, be overridden with live brightness, or be fixed together
|
||||
with brightness. One perceptual alpha resolver gives every source the alpha at
|
||||
the centre of its pool; `GLOW_FALLOFF` then spends it over the whole radius.
|
||||
Per-source radius, doorway sight lines and transitive open boundaries remain —
|
||||
all three now fall out of the visibility region instead of separate layers.
|
||||
- **Custom room fill** (#56, space UX refined locally by #64): the space dialog
|
||||
uses persistent user color/opacity as its ordinary first/default fill instead
|
||||
of a redundant None option; a room keeps None as an explicit inherited-fill
|
||||
suppression. Effective inheritance is room → space → safe documented default;
|
||||
room reset restores space inheritance, and full/static renderers share the
|
||||
same projection.
|
||||
- **Universal device toggle** (#94, v1.62.0-beta.3): one `Toggle state` option is visible
|
||||
for every marker and resolves the exact binding, functional device role or
|
||||
explicitly configured controls. Hint, click, confirmation and cover
|
||||
presentation share one result; partial groups call only the shown available
|
||||
subset, stale controls never fall back, secure targets are no-op, and legacy
|
||||
`cover`/absent light defaults round-trip losslessly. **Lights toggle by
|
||||
default** (v1.39); other devices still default to the House Plan card.
|
||||
- **Contextual Zigbee topology** (#54, refined by #457 and #464 on current dev): an
|
||||
opt-in admin-only mouse hover shows direct neighbours without scanning. A
|
||||
deterministic per-provider uplink tree now adds arrows towards the
|
||||
coordinator while keeping non-route neighbour lines; a short bubble names a
|
||||
remote space or explains that the next device/coordinator is not on the plan.
|
||||
Active topology is above room names and unrelated markers, exact local
|
||||
endpoints remain above it, and unknown-LQI dashes have a dark contrast
|
||||
casing. Touch, kiosk, editors and the static card remain unchanged.
|
||||
- **Plan geometry**: polyline split (v1.32), island rooms w/ evenodd holes
|
||||
(v1.34), smart guides + 45° angle badge (v1.40), opening hover preview.
|
||||
Openings support doors, windows and compact wide gates; gates retain door
|
||||
contact/lock/Glow semantics but use two leaves opening only 10° outwards.
|
||||
- **Canonical wall model** (#282, #306, #478, current dev): Walls and Thickness
|
||||
accept `0..100 cm` for contour and independent segments. Model v10
|
||||
migrates legacy virtual spans into stable `cm:0` atoms; a per-space
|
||||
dashed/solid selector controls both line style and Glow/sun transmission.
|
||||
It also converts persisted unfinished contours to ordinary partitions; new
|
||||
wall-chain segments are partitions from the first accepted click and chain
|
||||
state is session-only. A finished chain losslessly merges/reconciles its own
|
||||
seed component and finished-chain Undo/Redo restores the same fixed point;
|
||||
room Delete/Merge owns direct and vacuum room-reference cleanup (#477).
|
||||
Zero walls have no body, area or opening host, and
|
||||
there is no Boundary tool.
|
||||
- **Rooms**: room cards with metrics (temp/hum/lqi/light "1 of 3") and
|
||||
proportional resize (v1.31); link icon to the HA area (v1.40.1, room taps
|
||||
removed). **New-device red dot** (v1.29), lock action button (v1.30).
|
||||
- **Dialog UX**: binding radios + entities checkbox + search dropdown
|
||||
(v1.38.0); tap actions simplified to Device card / more-info / Toggle,
|
||||
right-click → more-info (v1.38.1); Esc closes every dialog (v1.30.4).
|
||||
Since #607, rejecting the real HA close button after an unsaved-changes
|
||||
prompt explicitly reconciles the nested modal before reopening it; the same
|
||||
Device, Room, Space or General-settings draft remains interactive. #609 keeps
|
||||
every shared settings form on the reviewed 560 px canvas and single HA-owned
|
||||
scroller in the authentic Home Assistant branch; at 480 px and below the
|
||||
form is edge-to-edge fullscreen, while generic dialogs retain their previous
|
||||
sizing.
|
||||
- **Room settings, tier 3** (v1.42.0): per-room fill/temp-source/label sizes;
|
||||
the settings button sits at the room's VISUAL centre (inscribed circle +
|
||||
centroid pull), icon-derived size, zooms with the plan (v1.51.0).
|
||||
- **Files & plans** (v1.44–v1.50): signed content urls with sandbox CSP,
|
||||
copy-on-write plan files, "already uploaded" picker + explicit delete
|
||||
(v1.47.0), store quotas instead of any age-based deletion (v1.49.0),
|
||||
nothing is ever deleted on an inference (docs/SCOPE.md rule).
|
||||
- **Square canvas** (v1.48.0) with a crash-safe two-store migration
|
||||
(geom_pending, v1.50.0) and an explicit geometry/repair command (v1.50.1);
|
||||
content-fit default zoom with devices as content, zoom out to 0.4×,
|
||||
measured stage height (v1.49–v1.50.2).
|
||||
- **Explicit hide flags** (v1.51.0, docs/FILTERING.md): per-device
|
||||
`marker.hidden` seeded once from the old filter and controlled by the
|
||||
bottom-left "Hide" / "Show" action in the device dialog; local
|
||||
"Show hidden" ghosts; hidden counts toward room LQI on both cards, casts
|
||||
no light (v1.51.1).
|
||||
- **True plan deletion** (v1.60.0-beta.1, 2026-08-07): confirmed Delete beside Hide/Show;
|
||||
a minimal `marker.removed` tombstone prevents auto-rediscovery but exposes
|
||||
the binding to Add. Deleted devices are absent from every plan aggregate and
|
||||
linked marker presentation; layout/files/trails are cleaned and stale layout
|
||||
writes are rejected. Exact contact/lock references owned by architectural
|
||||
openings remain active without restoring the standalone marker; live text
|
||||
and other marker controls retain the older re-add-to-reactivate contract.
|
||||
- **Yellow = working right now** (v1.51.0): climate uses `hvac_action` when
|
||||
available and falls back to a current advertised non-off HVAC mode only when
|
||||
the integration omits the action; service switches can no longer become
|
||||
primary, and glow pool and icon share one condition. Editor gestures on touch
|
||||
(pinch/pan) landed the same release.
|
||||
- **Unified device status/activity** (v1.59.0-beta.10; pulse pipeline #98 local): four display
|
||||
modes (Icon + state / Icon + state and activity / Value + state / Always static icon),
|
||||
one semantic resolver for yellow
|
||||
actual work, orange open/unlocked, unavailable and always-red alarms;
|
||||
activity projects to three finite waves for a short event or one continuous
|
||||
pulse for presence, mechanical travel and running. Always-static deliberately suppresses every state-driven visual,
|
||||
satellite badge and live vacuum overlay while leaving hover, actions, Glow and
|
||||
controls intact. Legacy Ripple-only migrates to Icon + activity on the next save.
|
||||
- **Passive media lifecycle** (v1.60.0-beta.1): every `media_player`,
|
||||
regardless of model, stays neutral while powered or playing and fades to
|
||||
the existing unavailable appearance on explicit `off`; transport playback
|
||||
is no longer classified as yellow actual work and no new visual state is
|
||||
introduced.
|
||||
- **Unified Background editor** (v1.60.0-beta.1): typed decor model,
|
||||
physical cm/in strokes and text size, independent contour/fill opacity, decor+room smart
|
||||
magnet, common selection/resize/rotate frame for every decor kind, numeric
|
||||
geometry properties, and shared 50-command Undo/Redo. The plan image now has
|
||||
an exclusive tool, 0.5 editor de-emphasis elsewhere, independent axes,
|
||||
rotation, numeric properties and rotated content bounds. Legacy `width` and
|
||||
`plan_scale` remain read-compatible and migrate only through explicit plan
|
||||
optimisation. Furniture properties also expose the symbol itself. See
|
||||
`DECOR-EDITOR.md`.
|
||||
- **v1.59.0-rc.1** (2026-08-06): whole-plan lossless optimization with an
|
||||
atomic config+layout commit and safe undo; the yellow actual-work plate is
|
||||
retained alongside source glow; all Background objects have Select-mode
|
||||
double-click properties; View hover highlights every room and reports its
|
||||
clean-floor area. Audit fixes add eager activity baselines/source resets,
|
||||
lossless legacy live-text editing, exact wall-fragment endpoints/compaction,
|
||||
editor-visible virtual walls and prerelease-discovery reporting in CI.
|
||||
- **v1.59.0-rc.2** (2026-08-06): Plan actions have one meaning each; Room
|
||||
outline names the closed-contour tool; one named 50-command Undo/Redo stack
|
||||
covers committed plan geometry; positional placement is always grid-bound.
|
||||
Glow and toast overlays no longer steal room/tool pointers, View hover covers
|
||||
shared thick walls, device Hide/Show is explicit, the static no-op aspect
|
||||
field is gone, and the new user guide/product audit replaces archived docs.
|
||||
- **v1.59.0** (2026-08-06): The stable 1.59 line includes all beta/RC work.
|
||||
Room hover follows clean-floor wall faces, including nested contours and
|
||||
projected opening gaps. Thick-wall rendering unions each room's own wall
|
||||
ring, so one room's floor cannot erase another room's wall or leave white
|
||||
slivers at complex crossings.
|
||||
- **v1.59.1** (2026-08-06): device markers resolve a semantic entity role
|
||||
instead of trusting registry order; one light-source resolver now drives
|
||||
Glow, Light fill, room statistics, marker feedback and controls. Glow uses
|
||||
0.7 opacity. Current dev keeps external `controls` non-spatial: they still
|
||||
drive group state, but only a real lamp marker or explicit `is_light` marker
|
||||
can place a Glow pool. Compacted real walls retain their correct inner face
|
||||
and body at real/virtual T-junctions.
|
||||
- **v1.59.2** (2026-08-07): every modal uses the shared `hp-dialog` shell,
|
||||
backed by Home Assistant's `ha-dialog` with a native demo fallback. Titles,
|
||||
modal semantics, initial focus, focus trapping, Escape and restore focus are
|
||||
consistent across all editors, nested dialogs and dialog replacement flows.
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
- **v1.10.0** — audit & refactor: asyncio.Lock around all store writes (race fix, atomic
|
||||
`expected_rev`), point `layout/update` instead of full `layout/set` (anti last-writer-wins),
|
||||
new `layout/delete`, `safeUrl()` XSS guard, `fetchWithAuth`, KEY_HASS, streaming upload cap,
|
||||
card split into modules (`styles.ts` / `types.ts` / `devices.ts`), dynamic spaces in the GUI
|
||||
editor, dead code removed.
|
||||
- **v1.11.0** — full English translation + en/ru UI localization.
|
||||
- **v1.11.1** — brand images inside the integration; CI fully green for the first time.
|
||||
- **v1.11.2** — Description textarea fix in the device dialog.
|
||||
- **v1.12.0** — Quality Scale conformance: runtime_data, test-before-setup, unloading,
|
||||
single_config_entry, Store migrations hook, diagnostics, repairs, system health,
|
||||
uninstall cleanup, HA-harness tests in CI, quality_scale.yaml.
|
||||
- **v1.13.0** — universality: floors-import wizard, editable icon rules (+device_class
|
||||
fallback), tap actions with a security model, i18n dictionaries in JSON, light-theme pass.
|
||||
- **v1.13.1** — distribution: synthetic-home demo GIF in the README, issue templates,
|
||||
CONTRIBUTING.md, Discussions. Forum/Reddit drafts are in the user folder
|
||||
(`posts_drafts.md`) awaiting manual posting.
|
||||
- **v1.13.2** — audit round 3: buildDevices unit-test suite, multi-placeholder t(),
|
||||
conflict resync in _saveConfigNow, pointercancel long-press fix, repairs re-check
|
||||
on config save (repairs.py).
|
||||
- **v1.13.3** — privacy: legacy real-house assets/ removed; README screenshots synthetic.
|
||||
- **v1.14.0** — per-space display settings (borders/names/color/opacity/fills),
|
||||
draggable room labels, hand-drawn spaces (no image required), demo/ harness in-repo,
|
||||
docs/TESTING.md manual checklist (update with every functional change!).
|
||||
- **v1.15.0** — temperature room fill (blue/green/yellow) with editable comfort bounds.
|
||||
- **v1.15.1** — display-settings UX: radio fill selector, inline compact bounds
|
||||
(with the Number('')→0 bound-collapse bug fixed), avg room temp in the tooltip,
|
||||
darken-on-hover, wider space dialog.
|
||||
- **v1.15.2** — fix: average room temperature (fill + tooltip) counted non-thermometers
|
||||
(fridges/TRVs/chip `*_device_temperature`/diagnostic); now thermometer/air-monitor only.
|
||||
- **v1.15.3** — fix: device icon badge sat 1 px off its anchor (content-box + 1 px
|
||||
border); `box-sizing: border-box` centres it exactly on the device point.
|
||||
- **v1.15.4** — fix: real `ha-icon` (block + big line-height) put the glyph ~1.8 px low;
|
||||
`.dev ha-icon` is now a zero-line-height flex box. Reverted v1.15.3 border-box (shrank the
|
||||
badge). Verified live. Demo stub made faithful so the smoke guards it.
|
||||
- **v1.15.5** — fix: room hover was always grey; legacy overlay/yard hover rules scoped
|
||||
with `:not(.styled)` so filled rooms darken their fill, unfilled ones grey.
|
||||
- **v1.15.6** — room hover also reveals the border (stroke colour kept, hidden via
|
||||
opacity) even when borders are off.
|
||||
- **v1.16.0** — NEW read-only `houseplan-space-card` (static single-space schematic,
|
||||
pointer-events:none, deep-link button) + `#space=<id>` deep-link in the full card; shared
|
||||
space-geometry/space-render + module-level config cache (config-store).
|
||||
- **v1.16.1** — space-card renders room fills as configured on the full card (snapshot),
|
||||
no longer omitted; +shared areaLqi().
|
||||
- **v1.17.x** — entity markers get auto icon/temp (issue #1); correct resource URL documented
|
||||
(issue #2); humidity badge (gated on device_class, not the icon).
|
||||
- **v1.18.x** — live ruler while drawing rooms (metres / feet+inches) + per-space scale
|
||||
`cell_cm`; visibility fix (the badge lived in the markup-hidden devlayer).
|
||||
- **v1.19.0** — a line is never an entity of its own: walls derived from room outlines
|
||||
(`roomEdges`), unfinished contours persist nothing, Erase tool removed, `space.segments`
|
||||
stripped on save.
|
||||
- **v1.20.0** — rooms may not overlap (strictly-inside clicks refused, overlapping contours
|
||||
refused at close; shared walls stay legal).
|
||||
- **v1.21.x** — merge & split rooms (boolean geometry via polyclip-ts; merge = union collapses
|
||||
to one hole-free outline; split = wall-to-wall chord, bigger part keeps identity) + UX fixes.
|
||||
- **v1.22.0** — presence ripples (badge/ripple/icon_ripple + colour/size, `isActiveState`),
|
||||
per-device icon size/rotation (`--dev-size`), one-click HACS badge. NOTE: sources were lost
|
||||
in a sandbox reset after deploy and restored from conversation patches — push immediately
|
||||
after building, never wait for verification.
|
||||
- **v1.23.0** — doors & windows: "Opening" markup tool (snap onto derived walls, absolute
|
||||
coords), length in real cm, contact sensor + lock, animated leaf/arc, padlock badge, status
|
||||
card; lock never toggled from the plan.
|
||||
- **v1.23.1** — openings UX: hover outline + grab cursor, drag along walls (angle normalized
|
||||
to [-90,90) so the hinge never flips), click=status / double-click=properties, thicker hit
|
||||
strip. Release v1.23.1 published on GitHub (covers v1.22.0–v1.23.1).
|
||||
The feature surface since the 2026-07-17 snapshot and the early release
|
||||
milestones moved to [`STATUS-FEATURES.md`](STATUS-FEATURES.md) (#634): they
|
||||
are reference, not session entry. New feature-surface bullets go there, in the
|
||||
same commit as the behaviour.
|
||||
|
||||
## Where things live
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
> долгоживущие проверки («файл исчез через сутки», «пережило рестарт») на стенде
|
||||
> не живут. Рестарт HA изнутри заблокирован demo_guard.
|
||||
>
|
||||
> Источник истины — [TESTING.md](TESTING.md): формулировки пунктов и их
|
||||
> `[auto:…]`-маркеры смотреть там. Здесь каждый пункт сокращён до сути и дополнен
|
||||
> Источник истины — [TESTING.md](TESTING.md) и его приложения
|
||||
> [`testing-notes/`](testing-notes/README.md) (ручной чек-лист по поверхностям
|
||||
> перенесён туда, #634): формулировки пунктов и их `[auto:…]`-маркеры смотреть там. Здесь каждый пункт сокращён до сути и дополнен
|
||||
> строкой «Стенд: …» — где и как ткнуть именно на демо-стенде.
|
||||
>
|
||||
> **Демо-дом v2** (анонимированная планировка реального загородного дома):
|
||||
|
||||
+314
-3937
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
# Конспект для автора
|
||||
|
||||
Роли: аналитик, автор ТЗ, разработчик, автор инфраструктурной задачи
|
||||
([§6](../../PROCESS.md#6-роли)).
|
||||
|
||||
> **Это выжимка, а не канон.** Канон процесса — [`PROCESS.md`](../../PROCESS.md);
|
||||
> при расхождении побеждает он, а расхождение — issue с меткой `process`.
|
||||
> Конспект правил не добавляет и не меняет: каждый пункт ссылается на раздел
|
||||
> канона, где правило записано полностью, с причинами и прецедентами. Ссылки и
|
||||
> ключевые формулировки сверяет `test/process-digests.test.mjs`. Читать
|
||||
> раздел канона целиком, когда пункт касается текущего шага.
|
||||
|
||||
## Вход в процесс
|
||||
|
||||
- **Изменение продуктового кода без issue запрещено.** Код меняется только
|
||||
из `S5-ready` или дальше ([§1](../../PROCESS.md#1-основное-правило),
|
||||
[§3 п.1–2](../../PROCESS.md#3-правила)).
|
||||
- Классы: A — продукт (`src/**`, `custom_components/houseplan/**/*.py`,
|
||||
манифесты, i18n); B — гейты и инструменты (`test/**`, `tests_backend/**`,
|
||||
`demo/**`, `scripts/**`, `.github/**`, конфиги сборки, `package*.json`);
|
||||
C — документация; D — сгенерированное (`dist/**`,
|
||||
`custom_components/houseplan/frontend/**`, `demo/golden/baselines/**`).
|
||||
При пересечении путей D сильнее A ([§1](../../PROCESS.md#1-основное-правило)).
|
||||
- Инфраструктурная задача — ни одного файла класса A: реализация сразу в
|
||||
`issue/<NN>-<slug>`, без аналитики и ТЗ; локальные гейты зелёные, ветка
|
||||
запушена — `S7-code-review`. «В основном инфраструктурная» не бывает
|
||||
([§1](../../PROCESS.md#1-основное-правило)).
|
||||
- Ровно одна метка статуса на issue; `blocked` дополняет статус, а не
|
||||
заменяет; инфраструктурная задача до первого `S7` может быть без `S*`
|
||||
([§9](../../PROCESS.md#9-метки--канонический-статус),
|
||||
[§3 п.5](../../PROCESS.md#3-правила)).
|
||||
- Статус меняется до действия, а не после: взял — поставил метку
|
||||
([§3 п.4](../../PROCESS.md#3-правила)).
|
||||
- Автор не ревьюит своё — ни ТЗ, ни код; автор и ревьюер — разные
|
||||
агенты/сессии ([§3 п.6](../../PROCESS.md#3-правила),
|
||||
[§6](../../PROCESS.md#6-роли)).
|
||||
|
||||
## Аналитика (`S2-analysis`)
|
||||
|
||||
- Чек-лист комментарием: дубликаты, скоуп по `docs/SCOPE.md` и
|
||||
`docs/TOUCH-SUPPORT.md`, ценность, сложность и риск, приоритет, тип,
|
||||
поверхности, трек. Оценки ставятся метками сразу; молчание владельца —
|
||||
согласие; в `S3-spec` аналитик переводит сам. Останавливается аналитика
|
||||
только на конфликте со `SCOPE.md`
|
||||
([§2.2](../../PROCESS.md#22-аналитика-и-оценка)).
|
||||
- Шаблон: `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
|
||||
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Лёгкий трек `small` — путь по умолчанию: обосновывается не выбор лёгкого
|
||||
трека, а отказ от него — называется нарушенный критерий. Критерии, все
|
||||
сразу: сложность и риск ≤ 3; одна поверхность; нет миграции конфига;
|
||||
нет нового UX-контракта; нет влияния на перф и touch
|
||||
([§5](../../PROCESS.md#5-лёгкий-трек-метка-small--путь-по-умолчанию)).
|
||||
- Короткий трек `trivial`: `S2-analysis` → `S5-ready`, AC автор пишет в теле
|
||||
issue до перехода. Тип `bug`, одна поверхность, без i18n, миграции, перфа и
|
||||
touch, не больше трёх AC, и ожидаемое поведение уже зафиксировано — решать
|
||||
нечего ([§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)).
|
||||
|
||||
## ТЗ (`S3-spec`)
|
||||
|
||||
- ТЗ живёт в теле issue, раздел `## ТЗ`; файл в `docs/specs/` не создаётся
|
||||
([§2.3](../../PROCESS.md#23-тз-в-работе--написание-тз)).
|
||||
- Обязательные разделы: сценарий · что человек увидит до и после · проблема ·
|
||||
скоуп и не-скоуп · контракт поведения · UX · модель данных и миграция ·
|
||||
i18n · AC1…ACn с доказательством · план автотестов · риски · откат ·
|
||||
release-артефакты. На лёгком треке короче: проблема · контракт · AC · откат
|
||||
([§7.1](../../PROCESS.md#71-цепочка),
|
||||
[§5](../../PROCESS.md#5-лёгкий-трек-метка-small--путь-по-умолчанию)).
|
||||
- Размытое место не додумывается. Владельцу задаются только продуктовые
|
||||
вопросы — что человек видит или делает и какой объём видимых изменений
|
||||
входит в issue. Всё, чего пользователь не наблюдает, автор решает сам и
|
||||
записывает блоком «принято предположительно, поменять свободно». Смешанный
|
||||
вопрос делится ([§7.1](../../PROCESS.md#71-цепочка)).
|
||||
- Вопросы — одним комментарием, пачкой: что неясно · что изменится от ответа ·
|
||||
вариант по умолчанию. Пока ждём ответа, issue остаётся в `S3-spec` и
|
||||
получает `blocked` ([§7.1](../../PROCESS.md#71-цепочка)).
|
||||
- DoR перед `S5-ready`: зелёное ревью ТЗ; пронумерованные AC со способом
|
||||
доказательства (`unit` / `backend` / `smoke` / `golden` / «ревью кода»);
|
||||
файлы и модули; ключи i18n en + ru; миграция по
|
||||
`docs/CONFIG-COMPATIBILITY.md`; перф; touch; release-артефакты; откат; нет
|
||||
открытых продуктовых вопросов
|
||||
([§2.5](../../PROCESS.md#25-готово-к-разработке-dor)).
|
||||
- Лимит — 4 цикла ревью, на лёгком и коротком треке 2; зелёный вердикт цикла
|
||||
не тратит; исчерпание — решение владельца: разделить, отклонить, арбитраж
|
||||
([§4](../../PROCESS.md#4-лимит-циклов-ревью-4)).
|
||||
|
||||
## Реализация (`S6-in-progress`)
|
||||
|
||||
- Занятие: `Взял: <роль> · сессия <id> · ветка issue/NN-slug`; WIP — одна
|
||||
задача в разработке на исполнителя
|
||||
([§2.6](../../PROCESS.md#26-в-разработке--реализация),
|
||||
[§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры `Issue: #<NN>` и
|
||||
`User-Visible: yes|no`. `User-Visible: yes` требует правок в обоих changelog
|
||||
в том же коммите. После `cherry-pick -x` трейлеры остаются последним блоком
|
||||
([§2.6](../../PROCESS.md#26-в-разработке--реализация),
|
||||
[§3 п.10](../../PROCESS.md#3-правила)).
|
||||
- Автотесты — часть реализации: каждый AC с пометкой `unit` / `backend` /
|
||||
`smoke` / `golden` получает проверку здесь же
|
||||
([§2.6](../../PROCESS.md#26-в-разработке--реализация)).
|
||||
- Приёмка проверяет результат для человека: обычный сценарий плюс самый
|
||||
рискованный соседний, у каждого наблюдаемый oracle. Шесть классов риска
|
||||
проходятся явно: async; данные и права; геометрия; визуал; объём и
|
||||
performance; host/input. Проверка имени метода или строки исходника oracle
|
||||
не считается ([§2.6](../../PROCESS.md#26-в-разработке--реализация)).
|
||||
- Скоуп не расширяется: найденное по пути — новый issue; блокирующая находка —
|
||||
`blocked` со ссылкой ([§2.6](../../PROCESS.md#26-в-разработке--реализация),
|
||||
[§3 п.9](../../PROCESS.md#3-правила)).
|
||||
- Документация — в том же коммите, что и поведение: changelog RU+EN,
|
||||
`STATUS.md`, `DEVELOPMENT.md`, `ARCHITECTURE.md`
|
||||
([§2.6](../../PROCESS.md#26-в-разработке--реализация),
|
||||
[§3 п.11](../../PROCESS.md#3-правила)).
|
||||
- Сгенерированное не коммитится само по себе; golden принимаются только
|
||||
`npm run golden:accept -- --reviewed` по полному Linux-артефакту или
|
||||
аттестованному WSL-артефакту ([§3 п.12–13](../../PROCESS.md#3-правила)).
|
||||
- Защитный AC доказывается таблицей «чем краснеет»: AC · чем доказан · чем
|
||||
краснеет (мутация, снятая защита или отрицательная проба с результатом).
|
||||
Пустой третий столбец — находка Medium. Мутант в реестре обязателен, когда
|
||||
защита в продуктовом коде и проверяется дорогим гейтом
|
||||
([§2.7](../../PROCESS.md#27-код-ревью)).
|
||||
- Контракты по монолиту — исполнением, не regex по тексту: экспорт функции и
|
||||
вызов в `test-build`; список текстовых якорей заморожен; `npm run
|
||||
lint:unused` красит рост метрик монолита
|
||||
([§2.7](../../PROCESS.md#27-код-ревью)).
|
||||
- Одно число — один источник: величина, которую пользователь видит дважды,
|
||||
считается в одном месте ([§8](../../PROCESS.md#8-гейты)).
|
||||
- AC доказывает автотест или честное «проверено чтением, не исполнением» у
|
||||
ревьюера; «проверил локально» доказательством не является
|
||||
([§3 п.18](../../PROCESS.md#3-правила)).
|
||||
|
||||
## Гейты перед хендоффом
|
||||
|
||||
- Минимальный набор по изменённым поверхностям: `npx tsc --noEmit`,
|
||||
`npm test`, `npm run build` со сверкой копий бандла, `smoke-select` и
|
||||
целевые смоки, `no-new-any`; по диффу — `golden:verify`, `check-docs`,
|
||||
`model-invariants`, `pytest tests_backend`, junction parity. Команды —
|
||||
в каноне ([§8](../../PROCESS.md#8-гейты)); `npm run gate:small` собирает
|
||||
обязательную часть (`AGENTS.md`, «Gates»).
|
||||
- Новый код не добавляет `any`: гейт судит добавленные строки; исключение —
|
||||
`// any-ok: <конкретная причина>` на той же строке
|
||||
([§8](../../PROCESS.md#8-гейты)).
|
||||
- Любая правка `src/**` требует `node scripts/check-docs.mjs`: отпечаток
|
||||
скриншотов считается по всему фронтенду. Скриншоты снимает только CI;
|
||||
без изменения кадров — `npm run docs:accept -- --identical`
|
||||
([§8](../../PROCESS.md#8-гейты)).
|
||||
- Полные наборы — предрелизный гейт, а не гейт ревью. Упавший предрелизный
|
||||
гейт автор чинит и повторно прогоняет; повторного код-ревью нет, если
|
||||
правка не меняет контракт, не задевает новую подсистему и не правит сам
|
||||
гейт ([§8](../../PROCESS.md#8-гейты),
|
||||
[§11.4](../../PROCESS.md#114-починка-предрелизных-гейтов-без-повторного-код-ревью)).
|
||||
- Хуки ставит `npm ci`: `commit-msg` проверяет трейлеры, `pre-push` гоняет
|
||||
`scripts/process-gate.mjs` ([§10.1](../../PROCESS.md#101-хуки-которые-невозможно-забыть-поставить),
|
||||
[§10.2](../../PROCESS.md#102-что-проверяет-process-gatemjs)).
|
||||
|
||||
## Хендофф и ожидание вердикта
|
||||
|
||||
- Хендофф: `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Один хендофф — один пуш: материал пушится до метки, перед пушем
|
||||
`node scripts/process-gate.mjs --issues`; после `S7-code-review` в ветку не
|
||||
пушить до вердикта; `S7` ставится один раз на заход
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Ревью не начинается на красном коде: конвейер сам гоняет Validate с
|
||||
мутантами и возвращает красный в `S6-in-progress` без траты цикла
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Ветка приводится к `dev` до ревью, а не после: конфликт — возврат в
|
||||
`S6-in-progress` до ревью ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Автор обязан дождаться вердикта, а не заканчивать сессию:
|
||||
`node scripts/wait-verdict.mjs --issue NN`, смотреть на метку, а не на
|
||||
комментарий; при `blocked` не ждать. После прогона ревью метка меняется
|
||||
всегда; не сменилась — упал сам прогон
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Вперёд двигает только зелёный вердикт; жёлтый и красный возвращают автору.
|
||||
Medium в скоупе чинится в текущем issue, вне скоупа — отдельный issue
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев),
|
||||
[§3 п.8](../../PROCESS.md#3-правила)).
|
||||
- Зелёное ревью с неудавшимся слиянием — `S6-in-progress`: остался ребейз,
|
||||
после него снова `S7`; `S8-merged` ставится только после push в `dev`
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Issue закрывает релиз-менеджер после выпуска беты, не исполнитель
|
||||
([§2.8](../../PROCESS.md#28-закрытие-после-выпуска-беты),
|
||||
[§3 п.14](../../PROCESS.md#3-правила)).
|
||||
|
||||
## Запрещено
|
||||
|
||||
- Код без issue или из статуса раньше `S5-ready`; ТЗ после кода (кроме
|
||||
хотфикса); ревью своей работы; пятый цикл ревью; issue вместо возврата на
|
||||
правки ([§12](../../PROCESS.md#12-запрещено)).
|
||||
- Попутные правки «раз уж я здесь»; параллельные бэклоги в файлах;
|
||||
force-push в `dev`; закрытие issue до выпуска беты
|
||||
([§12](../../PROCESS.md#12-запрещено), [§3 п.17](../../PROCESS.md#3-правила)).
|
||||
- Принятие golden-эталонов ради зелёного CI; Medium, оставленные как TODO в
|
||||
документе ревью ([§12](../../PROCESS.md#12-запрещено)).
|
||||
- Аварийный хотфикс — только решением владельца, с issue в той же сессии до
|
||||
коммита ([§11.2](../../PROCESS.md#112-аварийный-хотфикс-метка-hotfix-решение-владельца)).
|
||||
@@ -0,0 +1,130 @@
|
||||
# Конспект для ревьюера
|
||||
|
||||
Роли: ревьюер ТЗ и ревьюер кода ([§6](../../PROCESS.md#6-роли)). Штатный
|
||||
ревьюер конвейера получает этот файл из промпта `.github/workflows/process.yml`;
|
||||
ручное ревью по просьбе владельца идёт по нему же.
|
||||
|
||||
> **Это выжимка, а не канон.** Канон процесса — [`PROCESS.md`](../../PROCESS.md);
|
||||
> при расхождении побеждает он, а расхождение — issue с меткой `process`.
|
||||
> Конспект правил не добавляет и не меняет: каждый пункт ссылается на раздел
|
||||
> канона, где правило записано полностью, с причинами и прецедентами. Ссылки и
|
||||
> ключевые формулировки сверяет `test/process-digests.test.mjs`.
|
||||
|
||||
## Позиция ревьюера
|
||||
|
||||
- Ревьюер ≠ исполнитель, свежая сессия без контекста реализации. Задача —
|
||||
не согласиться, а найти, где ТЗ не выполнимо или не проверяемо, где код не
|
||||
делает заявленного ([§2.4](../../PROCESS.md#24-тз-на-ревью),
|
||||
[§2.7](../../PROCESS.md#27-код-ревью), [§6](../../PROCESS.md#6-роли)).
|
||||
- Ревьюер не правит ни ТЗ, ни продуктовый код; ревью своей работы запрещено
|
||||
([§6](../../PROCESS.md#6-роли), [§12](../../PROCESS.md#12-запрещено)).
|
||||
- Первый вопрос к задаче — какую работу из `docs/SCOPE.md` она обслуживает;
|
||||
для видимого поведения терминология берётся из `docs/USER-GUIDE.ru.md`
|
||||
([§7.1](../../PROCESS.md#71-цепочка)).
|
||||
|
||||
## Ревью ТЗ
|
||||
|
||||
- Артефакт — `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, на лёгком треке —
|
||||
комментарий ([§2.4](../../PROCESS.md#24-тз-на-ревью)).
|
||||
- ТЗ живёт в теле issue, раздел `## ТЗ`; `docs/specs/` — архив до 2026-09-10
|
||||
([§2.3](../../PROCESS.md#23-тз-в-работе--написание-тз)).
|
||||
- Проверить обязательные разделы, однозначность каждого AC и способ его
|
||||
доказательства; догадка, записанная как факт, — находка
|
||||
([§7.1](../../PROCESS.md#71-цепочка),
|
||||
[§2.5](../../PROCESS.md#25-готово-к-разработке-dor)).
|
||||
- Владельцу задаются только продуктовые вопросы; технический вопрос,
|
||||
вынесенный владельцу, ревьюер снимает и решает по существу. Технический
|
||||
спор автора и ревьюера решается вердиктом, а не владельцем
|
||||
([§7.1](../../PROCESS.md#71-цепочка)).
|
||||
|
||||
## Код-ревью
|
||||
|
||||
- Артефакт — `docs/reviews/CODE-REVIEW-<tag|NN>-r<N>.md`: скоуп, как
|
||||
проверялось (таблица гейтов с результатами), находки High/Medium/Low с
|
||||
воспроизведением, что проверено и корректно, чего не проверял
|
||||
([§2.7](../../PROCESS.md#27-код-ревью)).
|
||||
- Ревьюер отвечает за полноту доказательств AC, а не заменяет их исполнение.
|
||||
Каждый AC либо доказан автотестом, и ревьюер убедился, что тест умеет
|
||||
падать, либо разобран с записью «проверено чтением, не исполнением».
|
||||
Применимые классы риска §2.6 сверяются отдельно
|
||||
([§2.7](../../PROCESS.md#27-код-ревью),
|
||||
[§2.6](../../PROCESS.md#26-в-разработке--реализация)).
|
||||
- Защитный AC доказывается таблицей «чем краснеет»: AC · чем доказан ·
|
||||
чем краснеет — мутация, снятая защита или отрицательная проба с
|
||||
результатом прогона. Пустой третий столбец — находка Medium, а не
|
||||
примечание. «Тест умеет падать» без названной мутации и её вывода
|
||||
доказательством не является ([§2.7](../../PROCESS.md#27-код-ревью)).
|
||||
- Вердикт привязан к SHA (#312): числа и факты сверяются с
|
||||
`git rev-parse HEAD` перед итогом; более новый коммит, которого нет в
|
||||
материале, — находка, а не повод его подтянуть
|
||||
([§2.7](../../PROCESS.md#27-код-ревью),
|
||||
[§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Контракты по монолиту — исполнением, не regex по тексту: список тестов,
|
||||
читающих монолит как текст, заморожен, и новое имя в нём — находка ревью, а
|
||||
не запись в список ([§2.7](../../PROCESS.md#27-код-ревью)).
|
||||
- Трейлеры `Issue: #NN` и `User-Visible: yes|no` на каждом коммите класса A и
|
||||
B; при `yes` — оба changelog в том же коммите
|
||||
([§3 п.10](../../PROCESS.md#3-правила)).
|
||||
- Одно число — один источник: ревьюер отвечает на вопрос прямо: какое число в
|
||||
этом диффе видно дважды и один ли у него источник
|
||||
([§8](../../PROCESS.md#8-гейты)).
|
||||
|
||||
## Объём гейтов
|
||||
|
||||
- Всегда: `typecheck`, `npm test`, `npm run build` со сверкой копий бандла; при
|
||||
диффе по `src/**` — ещё `node scripts/check-docs.mjs`. Зелёный Validate на
|
||||
SHA материала подтверждает дешёвые гейты ([§8](../../PROCESS.md#8-гейты)).
|
||||
- По диффу и AC: смоки — названные в AC плюс вывод
|
||||
`node scripts/smoke-select.mjs --base <base> --head <head>` с решением по
|
||||
каждой строке; `golden:verify` при видимом изменении; `pytest tests_backend`
|
||||
при правке Python; инварианты модели при правке геометрии; performance —
|
||||
если назван в AC. Полные наборы — предрелизный гейт, а не гейт ревью
|
||||
([§8](../../PROCESS.md#8-гейты)).
|
||||
- Условие честности сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему ([§8](../../PROCESS.md#8-гейты)).
|
||||
- Зелёный `pytest tests_backend` без Home Assistant скипает `test_ha_*.py` и
|
||||
ничего не доказывает — это «чего не проверял» (`AGENTS.md`, «Gates»;
|
||||
[§8](../../PROCESS.md#8-гейты)).
|
||||
|
||||
## Повторный раунд
|
||||
|
||||
- Предмет повторного раунда — дельта, а не задача целиком: найти вердикт и
|
||||
материал предыдущего раунда (блок «Материал раунда»), объявить
|
||||
`git diff <тот SHA>..HEAD`, по каждой находке показать, чем она закрыта,
|
||||
заново проверить только AC, которые дельта задевает
|
||||
([§2.10](../../PROCESS.md#210-повторный-раунд-ревью--объём-по-дельте)).
|
||||
- Если SHA не резолвится — это не находка, а обычное дело: материал ищется по
|
||||
дереву и блобу. Находка — SHA, мёртвый уже в момент публикации отчёта
|
||||
([§2.10](../../PROCESS.md#210-повторный-раунд-ревью--объём-по-дельте)).
|
||||
- Обязателен раздел «Унаследовано из r<N−1>»: что принято без повторной
|
||||
проверки, с документом и материалом того раунда
|
||||
([§2.10](../../PROCESS.md#210-повторный-раунд-ревью--объём-по-дельте)).
|
||||
- Разбор остаётся полным, если дельта не локальна: ребейз на ушедший вперёд
|
||||
`dev`, смена контракта, новая подсистема, объём сопоставим с задачей.
|
||||
Сокращается объём разбора, а не строгость
|
||||
([§2.10](../../PROCESS.md#210-повторный-раунд-ревью--объём-по-дельте)).
|
||||
- Перед разбором подсистемы — её строки в `docs/reviews/INDEX.md`
|
||||
([§2.10](../../PROCESS.md#210-повторный-раунд-ревью--объём-по-дельте)).
|
||||
|
||||
## Находки и вердикт
|
||||
|
||||
- High блокирует. Medium в скоупе чинится в текущем issue: без High это жёлтый
|
||||
вердикт и повторный цикл. Medium вне скоупа — отдельный issue со ссылкой.
|
||||
Low правится или снимается ревьюером с записью
|
||||
([§3 п.8](../../PROCESS.md#3-правила),
|
||||
[§2.7](../../PROCESS.md#27-код-ревью)).
|
||||
- Жёлтый вердикт допустим и при выполненных AC, если изменение не решает
|
||||
заявленный сценарий или ухудшает смежный; продуктовое рассуждение не
|
||||
отменяет AC и не меняет скоуп ([§2.7](../../PROCESS.md#27-код-ревью)).
|
||||
- Строка вердикта, первой строкой комментария:
|
||||
`Вердикт: зелёный/жёлтый/красный · заход r<N> · блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… · Документ: docs/reviews/…`
|
||||
(«→ #…» — только у Medium вне скоупа)
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Вперёд двигает только зелёный вердикт; жёлтый и красный возвращают автору
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Зелёный вердикт цикла не образует; лимит — 4 цикла, на лёгком и коротком
|
||||
треке 2; бюджет считается по этапу
|
||||
([§4](../../PROCESS.md#4-лимит-циклов-ревью-4),
|
||||
[§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Запрещено: Medium-находки, оставленные как TODO в документе ревью;
|
||||
ревью-документы вне репозитория ([§12](../../PROCESS.md#12-запрещено)).
|
||||
@@ -0,0 +1,111 @@
|
||||
# Приложения к TESTING.md — индекс
|
||||
|
||||
Ручные чек-листы по поверхностям и приложения по отдельным issue, перенесённые
|
||||
из [`docs/TESTING.md`](../TESTING.md) дословно (#634). Действующие правила и
|
||||
гейты — в самом `TESTING.md`. Искать по номеру issue или теме:
|
||||
`grep -n "#293" docs/testing-notes/README.md`. Полноту индекса и каждую ссылку
|
||||
сверяет `test/testing-notes-index.test.mjs`: раздел без строки здесь — красный
|
||||
тест.
|
||||
|
||||
## [Ручной чек-лист по поверхностям](core-checklist.md)
|
||||
|
||||
- [Modes (v1.25.0) ★](core-checklist.md#modes-v1250-)
|
||||
- [Onboarding ★](core-checklist.md#onboarding-)
|
||||
- [Spaces ★](core-checklist.md#spaces-)
|
||||
- [Room markup editor ★](core-checklist.md#room-markup-editor-)
|
||||
- [Devices on the plan ★](core-checklist.md#devices-on-the-plan-)
|
||||
- [Device dialog (markers) ★](core-checklist.md#device-dialog-markers-)
|
||||
- [Icon rules ★](core-checklist.md#icon-rules-)
|
||||
- [Tap actions & gestures ★](core-checklist.md#tap-actions--gestures-)
|
||||
- [Zoom / pan / labels](core-checklist.md#zoom--pan--labels)
|
||||
- [Multi-client & concurrency ★](core-checklist.md#multi-client--concurrency-)
|
||||
- [houseplan-space-card (read-only embedded)](core-checklist.md#houseplan-space-card-read-only-embedded)
|
||||
- [Doors, windows & gates (v1.23.0+)](core-checklist.md#doors-windows--gates-v1230)
|
||||
|
||||
## [Диалоги, формы и сводная панель](dialogs-and-forms.md)
|
||||
|
||||
- [Вопрос о несохранённых настройках (#610)](dialogs-and-forms.md#вопрос-о-несохранённых-настройках-610)
|
||||
- [Числовые поля со слайдером (#608)](dialogs-and-forms.md#числовые-поля-со-слайдером-608)
|
||||
- [Toggle confirmation state (#103)](dialogs-and-forms.md#toggle-confirmation-state-103)
|
||||
- [Unified color and opacity picker (#57)](dialogs-and-forms.md#unified-color-and-opacity-picker-57)
|
||||
- [Unified picker coverage for every color field (#180)](dialogs-and-forms.md#unified-picker-coverage-for-every-color-field-180)
|
||||
- [Contextual help (issues #68 and #86)](dialogs-and-forms.md#contextual-help-issues-68-and-86)
|
||||
- [Help & private feedback (#43)](dialogs-and-forms.md#help--private-feedback-43)
|
||||
- [v1.71 audit polish (#434)](dialogs-and-forms.md#v171-audit-polish-434)
|
||||
- [Надёжность сводной панели (#493)](dialogs-and-forms.md#надёжность-сводной-панели-493)
|
||||
- [Идентичность сводной панели в Masonry (#561)](dialogs-and-forms.md#идентичность-сводной-панели-в-masonry-561)
|
||||
- [Доступность основного View (#565)](dialogs-and-forms.md#доступность-основного-view-565)
|
||||
|
||||
## [Устройства и маркеры](devices.md)
|
||||
|
||||
- [Device icon design package (#179)](devices.md#device-icon-design-package-179)
|
||||
- [Dense device-marker hit ownership (#564)](devices.md#dense-device-marker-hit-ownership-564)
|
||||
- [Device marker polish and pointer modality (#212)](devices.md#device-marker-polish-and-pointer-modality-212)
|
||||
- [Device value badge (#90)](devices.md#device-value-badge-90)
|
||||
- [HA-disabled binding gate](devices.md#ha-disabled-binding-gate)
|
||||
- [HA Area marker relocation (#126)](devices.md#ha-area-marker-relocation-126)
|
||||
- [Device display preview and face parity](devices.md#device-display-preview-and-face-parity)
|
||||
- [Device icon package parity (#211)](devices.md#device-icon-package-parity-211)
|
||||
- [Device marker geometry and input polish (#213)](devices.md#device-marker-geometry-and-input-polish-213)
|
||||
- [Text marker shell shape (#217)](devices.md#text-marker-shell-shape-217)
|
||||
- [Device lock and orange foreground palette (#219)](devices.md#device-lock-and-orange-foreground-palette-219)
|
||||
- [Unified device status and pulse activity (#98)](devices.md#unified-device-status-and-pulse-activity-98)
|
||||
- [Climate temperature opt-in (dev)](devices.md#climate-temperature-opt-in-dev)
|
||||
- [Styling hooks and HA-formatted values (docs/STYLING-HOOKS.md, dev, unreleased)](devices.md#styling-hooks-and-ha-formatted-values-docsstyling-hooksmd-dev-unreleased)
|
||||
|
||||
## [Геометрия: стены, проёмы, комнаты, холст](geometry.md)
|
||||
|
||||
- [Stable wall-segment identity (#282)](geometry.md#stable-wall-segment-identity-282)
|
||||
- [Legacy draft migration and atomic wall-chain writes (#314, #478)](geometry.md#legacy-draft-migration-and-atomic-wall-chain-writes-314-478)
|
||||
- [Current-writer fixed point (#477)](geometry.md#current-writer-fixed-point-477)
|
||||
- [Resize: реальный pointer pipeline (#293)](geometry.md#resize-реальный-pointer-pipeline-293)
|
||||
- [Opening symbol centreline (#242, #250)](geometry.md#opening-symbol-centreline-242-250)
|
||||
- [Empty-space lifecycle (#113)](geometry.md#empty-space-lifecycle-113)
|
||||
- [Fixed card space (#210)](geometry.md#fixed-card-space-210)
|
||||
- [Open passage (#157)](geometry.md#open-passage-157)
|
||||
- [Independent-wall openings and structural axes (#132, #185)](geometry.md#independent-wall-openings-and-structural-axes-132-185)
|
||||
- [Independent-wall opening jamb margin (#186)](geometry.md#independent-wall-opening-jamb-margin-186)
|
||||
- [Room resize (docs/RESIZE.md)](geometry.md#room-resize-docsresizemd)
|
||||
- [Infinite canvas (docs/CANVAS.md, dev)](geometry.md#infinite-canvas-docscanvasmd-dev)
|
||||
- [«+» adds a space from anywhere (dev, unreleased)](geometry.md#-adds-a-space-from-anywhere-dev-unreleased)
|
||||
- [Honest new-space display defaults (#204, dev, unreleased)](geometry.md#honest-new-space-display-defaults-204-dev-unreleased)
|
||||
- [Wall thickness (docs/WALL-THICKNESS.md, Unreleased)](geometry.md#wall-thickness-docswall-thicknessmd-unreleased)
|
||||
- [Wall chains, partitions and columns](geometry.md#wall-chains-partitions-and-columns)
|
||||
- [Вписывание выбранной комнаты (#152)](geometry.md#вписывание-выбранной-комнаты-152)
|
||||
|
||||
## [Декор, подложка, мебель, слои](decor-and-backdrop.md)
|
||||
|
||||
- [Decor composition order (#231)](decor-and-backdrop.md#decor-composition-order-231)
|
||||
- [Custom decor images (#51)](decor-and-backdrop.md#custom-decor-images-51)
|
||||
- [Backdrop picture: move & scale (docs/BACKDROP.md, dev)](decor-and-backdrop.md#backdrop-picture-move--scale-docsbackdropmd-dev)
|
||||
- [«Already uploaded» plan picker (dev, unreleased)](decor-and-backdrop.md#already-uploaded-plan-picker-dev-unreleased)
|
||||
- [The furniture library (docs/FURNITURE.md, dev, unreleased)](decor-and-backdrop.md#the-furniture-library-docsfurnituremd-dev-unreleased)
|
||||
- [The furniture library (docs/FURNITURE.md, dev, unreleased)](decor-and-backdrop.md#the-furniture-library-docsfurnituremd-dev-unreleased-1)
|
||||
- [Large backdrops (#39, docs/specs/039-large-backdrops.md)](decor-and-backdrop.md#large-backdrops-39-docsspecs039-large-backdropsmd)
|
||||
- [Hiding layers: decor, openings, zero-thickness walls (docs/UX-MODES.md)](decor-and-backdrop.md#hiding-layers-decor-openings-zero-thickness-walls-docsux-modesmd)
|
||||
|
||||
## [Живые слои и интеграции](live-and-integrations.md)
|
||||
|
||||
- [PDF export polish (#482)](live-and-integrations.md#pdf-export-polish-482)
|
||||
- [Многоэтажный робот: карты и пространства (#162)](live-and-integrations.md#многоэтажный-робот-карты-и-пространства-162)
|
||||
- [Vacuum trail smoothing (#209)](live-and-integrations.md#vacuum-trail-smoothing-209)
|
||||
- [Live vacuums (docs/VACUUM.md)](live-and-integrations.md#live-vacuums-docsvacuummd)
|
||||
- [Sun on the plan (docs/SUN.md)](live-and-integrations.md#sun-on-the-plan-docssunmd)
|
||||
- [The text block on the plan (docs/LIVE-TEXT.md, dev, unreleased)](live-and-integrations.md#the-text-block-on-the-plan-docslive-textmd-dev-unreleased)
|
||||
- [Sun ray rim (docs/SUN.md «The rim», dev, unreleased)](live-and-integrations.md#sun-ray-rim-docssunmd-the-rim-dev-unreleased)
|
||||
- [Coming back to the tab (docs/WARM-REMOUNT.md, dev, unreleased)](live-and-integrations.md#coming-back-to-the-tab-docswarm-remountmd-dev-unreleased)
|
||||
- [Реальная raw-карта Zigbee2MQTT (#450)](live-and-integrations.md#реальная-raw-карта-zigbee2mqtt-450)
|
||||
- [Порядок слоя Zigbee-топологии (#464)](live-and-integrations.md#порядок-слоя-zigbee-топологии-464)
|
||||
- [Полнота источников радара (#545)](live-and-integrations.md#полнота-источников-радара-545)
|
||||
|
||||
## [Инфраструктура тестов и съёмки](infrastructure.md)
|
||||
|
||||
- [Issue #73 baseline and implementation (2026-08-11)](infrastructure.md#issue-73-baseline-and-implementation-2026-08-11)
|
||||
- [Lazy editor runtime and frontend asset tree (#337)](infrastructure.md#lazy-editor-runtime-and-frontend-asset-tree-337)
|
||||
- [Съёмка документации запускается с флагами детерминизма (#424)](infrastructure.md#съёмка-документации-запускается-с-флагами-детерминизма-424)
|
||||
- [Воспроизводимость съёмки документации (#410, #422)](infrastructure.md#воспроизводимость-съёмки-документации-410-422)
|
||||
|
||||
## [История прогонов и партий](history.md)
|
||||
|
||||
- [Last self-run](history.md#last-self-run)
|
||||
- [Batch 2026-08-04 (dev, unreleased)](history.md#batch-2026-08-04-dev-unreleased)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
# Декор, подложка, мебель, слои
|
||||
|
||||
> Приложение к [`docs/TESTING.md`](../TESTING.md): перенесено оттуда дословно (#634).
|
||||
> Индекс всех приложений — [`README.md`](README.md).
|
||||
|
||||
## Decor composition order (#231)
|
||||
|
||||
- [ ] All six decor kinds render in one `.decorlayer` after opaque room/data
|
||||
fill, active room-hover fill, opening tunnels and Glow-base rooms/tunnels,
|
||||
but before live Glow, sun, physical walls, opening symbols and the HTML
|
||||
device/room-label layer [auto: `smoke_decor_layer_order.mjs`,
|
||||
`smoke_glow.mjs`].
|
||||
- [ ] Pixel probes through an opaque room and a filled opening tunnel stay the
|
||||
decor colour before/after hover and over Glow base. Restoring the old DOM
|
||||
order makes those probes red [auto: `smoke_decor_layer_order.mjs`;
|
||||
mutation: `decor-restored-below-room-fills`].
|
||||
- [ ] The complete #231 golden impact set is reviewed before baseline
|
||||
acceptance. The two dedicated Light/opaque-hover and Dark/Glow-base
|
||||
scenes contain all five decor types and semantic probes in both rooms and
|
||||
the shared doorway. The three existing large-house scenes also change
|
||||
because their dense decor grid now renders above Glow-base room fills.
|
||||
Reviewed baselines are accepted only from the Linux release artifact
|
||||
[golden: `decor-over-opaque-hover-light`,
|
||||
`decor-over-glow-base-dark`, `isometric-large-warm-remount-dark`,
|
||||
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`].
|
||||
- [ ] `hide_decor`, the Background editor override and stored config remain
|
||||
unchanged; no per-object under-plan compatibility flag is introduced.
|
||||
|
||||
## Custom decor images (#51)
|
||||
|
||||
- [ ] Background has one **Image** button. PNG/JPEG/WebP/safe SVG upload opens
|
||||
the reusable palette; picking a file shows an exact one-shot preview and
|
||||
one click creates a 100 cm wide aspect-preserving object (height capped at
|
||||
200 cm), then returns to Select [unit: `decor-assets.test.mjs`; auto:
|
||||
`smoke_decor_images.mjs`].
|
||||
- [ ] Image move/continuous resize/four side handles/crossing mirrors/free
|
||||
rotation/`Shift` 45° match furniture, while placement and movement have
|
||||
no wall magnet. The complete rotated rectangle remains selectable through
|
||||
transparent pixels [auto: `smoke_decor_images.mjs`].
|
||||
- [ ] Full View and `houseplan-space-card` paint the same signed raster/SVG
|
||||
under live Glow/walls/devices and obey `hide_decor`. A missing or
|
||||
hash-mismatched file paints nothing in View and a selectable crossed
|
||||
repair placeholder only in Background [unit: `decor-assets.test.mjs`,
|
||||
`test_decor_assets.py`; auto: `smoke_decor_images.mjs`].
|
||||
- [ ] Upload rejects wrong magic, corrupt decode, oversized dimensions/files,
|
||||
forbidden namespaces/elements/attributes/URLs, DTD/entities, processing
|
||||
instructions and local-reference cycles. Responses have exact MIME,
|
||||
`nosniff` and SVG sandbox CSP [pure backend: `test_decor_assets.py`; HA
|
||||
harness: `test_ha_import_export.py` and endpoint tests].
|
||||
- [ ] Identical canonical bytes reuse one SHA-256 id. Resolve is deduplicated
|
||||
and batched at 200; deletion rechecks all spaces and refuses an in-use
|
||||
file. Export v2 has hashes but no bytes/signed URLs; v1 remains readable,
|
||||
and confirmed missing imports preserve image geometry [unit/pure/HA:
|
||||
`decor-assets.test.mjs`, `test_decor_assets.py`,
|
||||
`test_ha_import_export.py`].
|
||||
|
||||
## Backdrop picture: move & scale (docs/BACKDROP.md, dev)
|
||||
|
||||
- [ ] **The frame is there, and only there** (owner 2026-08-04): open a space
|
||||
that HAS an uploaded plan image → **Редактор подложки**. It opens on
|
||||
Select; the toolbar contains «Картинка-подложка». Select that tool: a
|
||||
dashed, rotated frame hugs the picture with four corner handles and one
|
||||
upper rotate handle. Switch to Select / Line / Rectangle / Oval / Text /
|
||||
Furniture / Erase — the frame disappears and the image is pointer-inert.
|
||||
Leave for View, Plan, Devices or kiosk — no frame anywhere. A space with
|
||||
NO image has no image tool [auto: smoke_backdrop, smoke_decor]
|
||||
- [ ] **Opacity is contextual**: under Select or any drawing/furniture/erase
|
||||
tool the image opacity is exactly 0.5; under Plan backdrop it is 1.0.
|
||||
View, Plan, Devices, kiosk and the static card always use 1.0
|
||||
[auto: smoke_backdrop, smoke_hide_layers]
|
||||
- [ ] **The corner handles are beads, not blobs** (owner 2026-08-05,
|
||||
«уменьшить в 4 раза… они постоянно гигантские»): the four dots on the
|
||||
picture's frame are small — they must not cover the picture — yet a
|
||||
finger still lands on them without aiming. Room Resize deliberately has
|
||||
wall-midpoint handles only (its former corner frame is removed); robot-map
|
||||
calibration keeps its own frame [auto: smoke_hide_layers, smoke_backdrop,
|
||||
smoke_room_resize]
|
||||
- [ ] **Dragging the picture moves the picture, and nothing else** (owner
|
||||
2026-08-04): with the «Картинка-подложка» tool (the cursor over the
|
||||
picture is a hand), grab the picture by its body and pull it aside.
|
||||
It follows the finger the whole way — it must NOT drift away from the
|
||||
cursor or jump when the toolbar changes — while the rooms, walls, doors,
|
||||
windows, devices, room names and decor stay exactly where they were.
|
||||
Release, reload the page — the picture is still where you left it
|
||||
[auto: smoke_backdrop]
|
||||
- [ ] **One-finger pan survives** (regression, DEV-B58 «таскать план при любом
|
||||
масштабе»): back on the «Выбрать» tool, drag across the middle of the
|
||||
picture — the PLANE pans, the picture does not move. Same at 50 % and
|
||||
33 % zoom [auto: smoke_backdrop, smoke_pan_any_zoom]
|
||||
- [ ] **The corners scale it evenly**: pull a corner handle. The picture grows
|
||||
and shrinks in BOTH directions at once, keeps its proportions (nothing
|
||||
is stretched), and the OPPOSITE corner does not move a pixel. Try all
|
||||
four corners; the cursor over a handle is a diagonal resize arrow. On a
|
||||
tablet the handles are big enough to hit with a finger. Repeat with
|
||||
Shift: width and height now change independently, but stay grid-bound
|
||||
[auto: smoke_backdrop]
|
||||
- [ ] **Rotation and numeric properties**: the upper handle rotates around the
|
||||
image centre in 5° steps; Shift allows any angle. Double click the image
|
||||
while its tool is active, enter width/height in m/ft and an angle, save,
|
||||
reload and compare. Esc during a live transform restores pointer-down;
|
||||
after release Ctrl+Z restores and Ctrl+Y reapplies it [manual + unit:
|
||||
backdrop.test.mjs]
|
||||
- [ ] **Live size in metres**: while dragging or scaling, a badge in the middle
|
||||
of the picture states its real size, «Ш × В», in the same units the wall
|
||||
ruler uses (metres, or feet on an imperial HA). Change the space's
|
||||
`cell_cm` in its settings and the numbers change with it. The badge
|
||||
disappears on release [auto: smoke_backdrop]
|
||||
- [ ] **Mandatory snap**: after a plain drag the picture's corner sits on a
|
||||
grid node (zoom in on the corner — it is on a crossing, not between
|
||||
two). After a corner scale one side of the picture ends on a node too;
|
||||
holding Shift produces the same snapped result [auto: smoke_backdrop]
|
||||
- [ ] **«Вернуть картинку»**: the button appears in the backdrop toolbar only
|
||||
after the picture has been moved, scaled or rotated. Press it — the picture goes
|
||||
back to centred, at its own size and zero angle; Undo restores the prior
|
||||
transform, and the button disappears at the reset state
|
||||
[auto: smoke_backdrop]
|
||||
- [ ] **NEW PAPER RULE — the sheet is the rooms** (owner 2026-08-04, changes
|
||||
the old behaviour): set a loud `bg_color` (or `daynight`) on a space
|
||||
that has BOTH a picture and drawn rooms, then shrink the picture with a
|
||||
corner handle. The opaque white/card-coloured sheet follows the ROOM
|
||||
CONTOURS — it is no longer a rectangle the size of the picture. The
|
||||
scene colour is visible around the rooms, including in the pocket of an
|
||||
L-shaped house and between detached buildings. The picture is drawn ON
|
||||
that sheet: above it, below the walls, doors, decor and devices. A space
|
||||
with a picture and NO rooms has no sheet at all, so a transparent PNG
|
||||
shows the scene through itself — deliberate
|
||||
[auto: smoke_backdrop + smoke_bg_color, still: demo/shot_backdrop.mjs]
|
||||
- [ ] **«Вписать всё» does not lose the picture**: drag the picture well away
|
||||
from the rooms (or scale it right down), leave the editor, press «Вписать
|
||||
всё». The view frames the rooms AND the picture; the "home is that way"
|
||||
arrow points at them together [auto: smoke_backdrop]
|
||||
- [ ] **The static card and the kiosk agree**: put a `houseplan-space-card` for
|
||||
the same space on a dashboard and open the kiosk view. Both draw the
|
||||
picture at the same offset, independent size and angle, with the same room-contour paper
|
||||
[auto: smoke_backdrop, smoke_render_parity]
|
||||
- [ ] **Old plans are untouched**: a space whose picture has never been moved
|
||||
renders exactly as before the update — same place, same size. Nothing is
|
||||
written to its config until the first drag [auto: unit test/backdrop.test.mjs
|
||||
+ tests_backend/test_validation.py]
|
||||
|
||||
## «Already uploaded» plan picker (dev, unreleased)
|
||||
|
||||
- [ ] **«Already uploaded» is a list, not a stripe**: in both space dialogs
|
||||
(new space and space settings, source = "I have a floor-plan image")
|
||||
press «Already uploaded» with at least five plans on the server. The box
|
||||
is a few hundred pixels tall, the first thumbnail is fully visible inside
|
||||
it, and the rest scroll. With nothing uploaded the box shows its message
|
||||
instead of clipping it. Repeat at phone width. Measure heights, do not
|
||||
trust the DOM: the rows were always there, the box was 14 px
|
||||
[auto: smoke_plan_picker]
|
||||
|
||||
## The furniture library (docs/FURNITURE.md, dev, unreleased)
|
||||
|
||||
## The furniture library (docs/FURNITURE.md, dev, unreleased)
|
||||
|
||||
- [ ] **The tool and the palette**: Background editor → **Furniture**. A panel
|
||||
opens under the bar with the symbols grouped (furniture / appliances /
|
||||
plumbing / other), every tile drawing the real symbol, and the plan stays
|
||||
visible behind it [auto: smoke_furniture]
|
||||
- [ ] **Real size through `cell_cm`**: pick the sofa, click in the middle of a
|
||||
room, then measure it against the plan's own grid — it is 2.2 m wide and
|
||||
0.9 m deep. Change the space's scale (Space settings → cm per cell) from
|
||||
5 to 10 and place a second sofa: it covers the same 2.2 m of the plan,
|
||||
i.e. half as many cells [auto: smoke_furniture + furniture.test]
|
||||
- [ ] **The size fields**: pick the bath, type 1.5 in Width, click — the piece
|
||||
is 1.5 m, not 1.7. In an imperial HA profile the same fields read and
|
||||
accept FEET, and the stored plan is unchanged when you switch back
|
||||
[auto: smoke_furniture (metric); manual for the imperial profile]
|
||||
- [ ] **The wall magnet**: click near a wall — the piece's BACK lands flat on
|
||||
it and it turns to the wall's direction (a bed's headboard against the
|
||||
wall, a toilet's cistern against it, a sofa's back against it). Holding
|
||||
**Shift** bypasses the wall magnet but still lands through the
|
||||
decor/room/grid magnet, never between grid nodes
|
||||
[auto: smoke_furniture]
|
||||
- [ ] **The magnet while dragging**: drag a placed sofa across the room to
|
||||
another wall — it turns to that wall as it arrives. Drag it back into the
|
||||
middle: it keeps the angle it had rather than snapping straight. On a
|
||||
DIAGONAL wall it lands at the wall's own angle [auto: smoke_furniture for
|
||||
the axis-aligned case; manual for a diagonal wall]
|
||||
- [ ] **One stamp per pick**: after placing, the editor is back in **Select**
|
||||
with the new piece selected, and the palette is disarmed — clicking the
|
||||
plan again does not place a second one [auto: smoke_furniture]
|
||||
- [ ] **The furniture frame**: four corner beads, four one-axis middle beads
|
||||
and a rotate handle; every bead is a quarter of its unchanged hit area.
|
||||
Ordinary decor keeps its previous five-handle frame. Grab a furniture
|
||||
bead with a finger on a tablet, aiming roughly: it is caught
|
||||
[auto: smoke_furniture]
|
||||
- [ ] **Proportional by default**: drag a corner sideways or down — the current
|
||||
ratio is preserved about the opposite corner. Hold Shift to change width
|
||||
and depth independently. Two live badges show both in metres (or feet)
|
||||
while you drag, and they match a later physical measurement. Both modes
|
||||
are continuous and can create a sub-grid size; the four middle handles
|
||||
change only width or only depth
|
||||
[auto: smoke_furniture]
|
||||
- [ ] **Crossing and mirroring**: drag a corner or middle handle through its
|
||||
fixed opposite edge — the gesture continues with positive stored size
|
||||
and the corresponding H/V mirror toggled. `Esc`/pointer cancel restores
|
||||
the complete pre-drag object [auto: smoke_furniture + furniture.test]
|
||||
- [ ] **Rotation**: the handle above the box turns the piece freely about its
|
||||
CENTRE (not a corner); Shift snaps to the nearest 45° and can be pressed
|
||||
or released during the same gesture [auto: smoke_furniture]
|
||||
- [ ] **Complete properties**: double click a piece and change its symbol,
|
||||
signed width/depth in m/ft, H/V mirror checkboxes, angle, contour
|
||||
colour/opacity and line width in cm/in. Signs and checkboxes stay in sync;
|
||||
zero/invalid size disables Save. Save keeps its centre and reloads exactly;
|
||||
Cancel changes nothing; reopening a non-first symbol selects that option
|
||||
[auto: smoke_furniture; manual]
|
||||
- [ ] **Selection tolerance**: in Select, a press up to 10 physical centimetres
|
||||
from a drawn furniture stroke selects it at any zoom/scale; an empty area
|
||||
of its bounding box does not [auto: smoke_furniture + source contract]
|
||||
- [ ] **It is only decor**: a piece takes no tap in view mode, has no entity
|
||||
and no state, and does not appear in any room aggregation. Erase removes
|
||||
it; Delete removes the selected one [auto: smoke_furniture]
|
||||
- [ ] **Old plans and old servers**: a plan saved before this release opens
|
||||
unchanged. A plan WITH furniture saved by this card and opened by an
|
||||
older integration still saves (the backend accepts any well-formed symbol
|
||||
id) [auto: tests_backend test_decor_furniture; manual for the old server]
|
||||
- [ ] **card-mod**: `[data-symbol="toilet"] { stroke: #4fc3f7; }` colours only
|
||||
the toilets [auto: smoke_furniture checks the attributes; manual for the
|
||||
rule itself]
|
||||
|
||||
## Large backdrops (#39, docs/specs/039-large-backdrops.md)
|
||||
|
||||
- [ ] A raster whose header claims ≳32 MP opens the warning dialog with real
|
||||
resolution/file/memory numbers BEFORE any decode — zero
|
||||
`createImageBitmap` calls until a choice is made
|
||||
[auto: `smoke_backdrop_guard`, `backdrop-probe.test`].
|
||||
- [ ] «Reduced copy» honours aspect and alpha: longest side 4096, PNG with
|
||||
alpha stays PNG, opaque becomes JPEG; the result flows through the
|
||||
ordinary planFile → upload path [auto: `smoke_backdrop_guard`].
|
||||
- [ ] «Keep the original» is byte-identical to the legacy path (base64
|
||||
parity) [auto: `smoke_backdrop_guard`].
|
||||
- [ ] Beyond 16384 px per side the dialog offers only Cancel; a failed or
|
||||
timed-out reduce shows the toast, leaves staging clean and never
|
||||
uploads the declined original [auto: `smoke_backdrop_guard`].
|
||||
- [ ] Corrupt/truncated headers warn without numbers; SVG bypasses the probe
|
||||
and is never rasterised [auto: `backdrop-probe.test`,
|
||||
`smoke_backdrop_guard`].
|
||||
- [ ] An EXIF-rotated JPEG (orientation 6) reduces with the rotation applied:
|
||||
the header probe reads the unrotated SOF, the decode honours
|
||||
`imageOrientation: 'from-image'`, and the reduced copy comes out
|
||||
portrait; dismissal during a running reduce is ignored and a stale flow
|
||||
never applies its result [auto: `smoke_backdrop_guard`].
|
||||
|
||||
## Hiding layers: decor, openings, zero-thickness walls (docs/UX-MODES.md)
|
||||
|
||||
- [ ] **Room names have one literal off state (#203)**: disable «Показывать
|
||||
названия» in the live space dialog, save and reopen it. Full View, kiosk,
|
||||
hidden isometric and `houseplan-space-card` contain no
|
||||
`[data-hp="room-label"]` and no legacy `text.rlabel`. Plan temporarily
|
||||
shows the same draggable HTML card and gear; returning to View hides it.
|
||||
Cancel restores the previous setting, while re-enabling names restores
|
||||
the saved card position and area icon
|
||||
[auto: `smoke_hide_room_names`, `smoke_styling_hooks`].
|
||||
- [ ] **Each renderer can fail independently**: restoring the old full-card SVG
|
||||
fallback, compact-card SVG fallback or hidden-isometric override makes
|
||||
the dedicated smoke red
|
||||
[mutation: `hidden-room-names-full-svg-fallback`,
|
||||
`hidden-room-names-compact-svg-fallback`,
|
||||
`hidden-room-names-iso-override`].
|
||||
- [ ] **«Скрыть декоративный слой»** (owner 2026-08-05): a space with lines,
|
||||
labels or furniture on it → space settings → Display → tick the box, save.
|
||||
The plan loses all of it, in View, in the Plan editor and in the Device
|
||||
editor. Open **Редактор подложки** — everything is back, editable, exactly
|
||||
where it was. Untick it: the plan looks as it did before you started
|
||||
[auto: smoke_hide_layers]
|
||||
- [ ] **«Скрыть проёмы»**: tick it on a space with doors, windows and gates. The
|
||||
symbols are gone from View and from the Device editor; the **Plan editor
|
||||
still draws them**, or the Opening tool would be editing blind. Nothing
|
||||
else changed: a lit room still spills light through a door/gate, the sun
|
||||
still comes in at the window, a door with a contact sensor still reports
|
||||
open/closed in the room card, and the resize tool still refuses to
|
||||
shorten a wall past its opening [auto: smoke_hide_layers, smoke_glow]
|
||||
- [ ] **Zero walls follow the borders switch only in View**: set a wall to
|
||||
`0 cm`, then turn **«Всегда отображать границы комнат» OFF**. Its line
|
||||
disappears with the borders and returns when enabled. Plan, Devices and
|
||||
Background editors always show the line. Dashed/solid light behaviour is
|
||||
unchanged while hidden [auto: `smoke_hide_layers`, `smoke_zero_walls`].
|
||||
- [ ] **Nothing is stored when nothing is hidden**: with both boxes unticked,
|
||||
the space's config carries no `hide_decor` / `hide_openings` at all, and
|
||||
a plan saved by an older card still opens here unchanged
|
||||
[auto: smoke_hide_layers, tests_backend test_hide_layer_settings]
|
||||
@@ -0,0 +1,391 @@
|
||||
# Устройства и маркеры
|
||||
|
||||
> Приложение к [`docs/TESTING.md`](../TESTING.md): перенесено оттуда дословно (#634).
|
||||
> Индекс всех приложений — [`README.md`](README.md).
|
||||
|
||||
## Device icon design package (#179)
|
||||
|
||||
- [ ] Pure presentation tests cover lock/unlock, exact marker-only LQI bands
|
||||
`0/40/41/179/180`, unchanged room gradient, package pulse defaults,
|
||||
semantic colors and reduced motion
|
||||
[unit: `device-presentation.test.mjs`, `device-pulse.test.mjs`].
|
||||
- [ ] Shared face tests cover shell/core DOM, four Double positions, a third
|
||||
legacy section, deterministic font fitting, full text and safe CSS color
|
||||
variables [unit: `device-face.test.mjs`].
|
||||
- [ ] The browser renders the exact shell ratio and shadow color, Light/Dark
|
||||
cores without backdrop blur, state/LQI colors, 3.6 s presence pulse,
|
||||
unavailable no-hover, full Text/Double values, 44×44 target, View and
|
||||
Device-editor keyboard paths, and no Plan tab stop
|
||||
[auto: `smoke_device_icon_design.mjs`].
|
||||
- [ ] Full plan, preview and static card preserve the same face after the DOM
|
||||
redesign; static mode, state/value and disabled-device contracts stay
|
||||
green [auto: `smoke_device_preview_parity.mjs`, `smoke_static_icon.mjs`,
|
||||
`smoke_state_value.mjs`, `smoke_disabled_device.mjs`].
|
||||
- [ ] Restoring unavailable hover, shifting the LQI boundary, restoring value
|
||||
ellipsis or bypassing `_clickDevice()` makes its guard red
|
||||
[mutation: `device-unavailable-hover-restored`,
|
||||
`device-marker-lqi-low-boundary-shifted`,
|
||||
`device-long-value-ellipsis-restored`,
|
||||
`device-keyboard-bypasses-click-path`].
|
||||
- [ ] Pre-beta golden reviews desktop/mobile Light/Dark states, combo states,
|
||||
Text, four Double positions, long and legacy values, LQI, reduced motion,
|
||||
sizes 32/56/96 and colored backgrounds. Full smoke/golden/performance
|
||||
remains a Linux release gate.
|
||||
|
||||
## Dense device-marker hit ownership (#564)
|
||||
|
||||
- [ ] Pure geometry covers visible capsule priority, invisible 44 px-floor
|
||||
overlap, nearest-core selection, stable exact ties, stadium corners and
|
||||
spatial-index locality [unit: `device-hit-owner.test.mjs`].
|
||||
- [ ] Source contracts keep painted shells globally above transparent floors,
|
||||
route hover/click/pointer lifecycle through one semantic owner and forbid
|
||||
layout reads from the pointer-move path
|
||||
[unit: `device-hit-owner-contract.test.mjs`].
|
||||
- [ ] Household J7 checks the five-marker dense column at tablet and phone
|
||||
widths: every visible centre has the same native and semantic owner, its
|
||||
click opens that marker, and a pointer sequence remains latched through
|
||||
its terminal click [auto: `smoke_household_journeys.mjs`].
|
||||
- [ ] Real browser geometry covers Icon, Text, Double and untouched legacy
|
||||
faces at all four cardinal sides/directions. In every case a painted
|
||||
point overlaps only the neighbour's invisible 44 px floor and still owns
|
||||
the native hit, semantic owner and click
|
||||
[auto: `smoke_device_hit_capsules.mjs`].
|
||||
- [ ] Replacing nearest-screen-core selection with first-input/DOM order makes
|
||||
both the pure geometry guard and the real rendered-face browser guard red
|
||||
[mutations: `dense-device-hit-falls-back-to-input-order`,
|
||||
`dense-device-hit-browser-skips-painted-priority`].
|
||||
|
||||
## Device marker polish and pointer modality (#212)
|
||||
|
||||
- [ ] Shared icon geometry applies one 0.9 visual factor after card/per-marker
|
||||
sizing, keeps the saved centre and 44×44 hit floor unchanged, and gives
|
||||
wide Text cores a radius equal to half their height
|
||||
[unit: `device-marker-polish-contract.test.mjs`; auto:
|
||||
`smoke_device_icon_design.mjs`].
|
||||
- [ ] Only a genuinely dispatched toggle/run action produces one
|
||||
`1 → .95 → 1` feedback cycle lasting 200 ms. Info, editor, confirmation
|
||||
before acceptance, unavailable/secure/no-target and cancelled gestures do
|
||||
not; reduced motion has no scale tween
|
||||
[auto: `smoke_device_icon_design.mjs`].
|
||||
- [ ] Pointer authority is isolated per card. Touch/pen and compatibility mouse
|
||||
input clear JS/CSS hover, while a later real mouse restores it only on
|
||||
fine/hover hardware; mode/space/visibility/disconnect cleanup remains
|
||||
bounded [unit: `pointer-modality.test.mjs`; auto: `smoke_feedback_v2.mjs`].
|
||||
- [ ] Pre-beta visual review covers Light/Dark desktop and touch matrices for
|
||||
ordinary, Text, Double, unavailable and vacuum markers; full golden and
|
||||
performance gates remain release work.
|
||||
|
||||
## Device value badge (#90)
|
||||
|
||||
- [ ] An untouched legacy thermometer/humidity marker remains pixel-identical;
|
||||
saving another field does not materialize `value_badge`. [auto: device-presentation]
|
||||
- [ ] Explicit on/off overrides the legacy temperature gate; zero, false and
|
||||
off remain visible, while missing/unknown/unavailable render a stable `—`. [auto: device-presentation]
|
||||
- [ ] State, every allowlisted attribute, derived LQI and `marker:<id>` light
|
||||
state resolve identically on the full plan, static space card and preview. [auto: smoke_device_preview_parity]
|
||||
- [ ] Opening the editor explicitly selects the persisted source and position,
|
||||
even when they are not the first dynamic options, without touching config. [auto: smoke_device_preview_parity]
|
||||
- [ ] Right, bottom, left and top update live in the editor; bottom stacks above
|
||||
system LQI and derived LQI suppresses the duplicate system row. [auto: device-presentation]
|
||||
- [ ] Browser bounding boxes stay inside `.previewstage` with a safe gap for
|
||||
all positions, a long value, scale ×3 and the maximum activity ring. [auto: smoke_device_preview_parity]
|
||||
- [ ] Rebind resets the source, delete leaves a missing diagnostic reference,
|
||||
and space import remaps internal refs or disables/counts external refs. [auto: test_ha_import_export]
|
||||
- [ ] `static_icon` suppresses but preserves the setting; live-state and room
|
||||
label toggles do not suppress an explicit badge. [auto: device-presentation]
|
||||
|
||||
> **Policy:** this checklist is updated **in the same commit** as any functional
|
||||
> change (like CHANGELOG.md). For a pre-release, build the production bundle and
|
||||
> run the smallest unit/smoke subset that covers its changed surfaces. Run the
|
||||
> complete local frontend, backend and smoke gates only before a stable release.
|
||||
> The exact-SHA GitHub Validate remains mandatory for publication. Items marked
|
||||
> `[manual]` are covered by unit tests or the headless smokes in `demo/` — they still
|
||||
> deserve an occasional eyeball. File every failure as a GitHub issue before fixing.
|
||||
|
||||
> **What `[manual]` means (since 2026-07-27).** A named check exists that FAILS
|
||||
> when the behaviour breaks — in `npm test` or in the smoke suite, both of which
|
||||
> run in CI on every push. Where the check lives is written next to the marker
|
||||
> (`[auto: smoke_modes]`). Before this date the marker described an intention:
|
||||
> the smoke suite printed values and always exited 0, so 96 markers guarded
|
||||
> nothing (external audit T1/T3). If you add a checklist line marked `[manual]`,
|
||||
> add the failing check in the same commit.
|
||||
|
||||
> **⚠ Rule: a new scrollable list inside a dialog is tested by GEOMETRY, never
|
||||
> by the DOM.** Any new scrolling box or `overflow` container added to a dialog
|
||||
> MUST get a smoke that measures **the container's own height and the visible
|
||||
> position of its first item** (`getBoundingClientRect`, and the item's rect
|
||||
> against the box's rect) — counting rendered rows, or asserting that the nodes
|
||||
> exist, proves nothing. The failure mode is always the same and always
|
||||
> invisible to a DOM check: a scrolling box is a flex item whose automatic
|
||||
> minimum size is zero (`min-height: auto` → 0 for an `overflow` child), and a
|
||||
> dialog body is a flex column with a height cap, so the box is the one child
|
||||
> that can be squeezed to a sliver while every row inside it renders happily.
|
||||
> It has bitten us twice already: the **target search results** in the tap
|
||||
> action dialog (v1.53.1 — 26 matching automations rendered into a 1 px
|
||||
> stripe; the smoke counted rows and passed) and the **«Already uploaded»**
|
||||
> plan picker (dev, unreleased — rows present, box 14 px tall, same story).
|
||||
> Both smokes measure heights now; write the third one that way from the start.
|
||||
|
||||
## HA-disabled binding gate
|
||||
|
||||
The source-of-truth matrix is
|
||||
`docs/superpowers/specs/2026-08-08-ha-disabled-devices-design.md` §17.
|
||||
`test/ha-binding-status.test.mjs` covers full/limited registry decisions and
|
||||
the active-only state projection. The standalone demo exposes complete
|
||||
`disabled_by` rows through both registry list WS commands plus
|
||||
`window.__setRegistryDisabled(kind, id, disabledBy)` and
|
||||
`window.__setRegistryAccess(mode)` for browser scenarios.
|
||||
|
||||
- [ ] A saved device/entity marker disappears from View, room data, Glow,
|
||||
controls, live text, openings and vacuum overlays after its registry row
|
||||
becomes disabled; config/layout remain byte-for-byte unchanged.
|
||||
- [ ] Device editor → Hidden and disabled shows a labelled grey ghost. Show is
|
||||
refused, metadata/Delete/Open in HA remain available, and the ghost is
|
||||
not draggable.
|
||||
- [ ] Reactivating the same ID restores its metadata/layout without a false
|
||||
new-device event; an explicitly user-hidden marker stays hidden.
|
||||
- [ ] A never-seen auto device disabled before discovery appears as new only
|
||||
after its first activation.
|
||||
- [ ] All disabled child entities make an otherwise active device disabled;
|
||||
one disabled auxiliary entity never suppresses active functional rows.
|
||||
- [ ] If full registry WS access is denied, positive live evidence stays
|
||||
active, an unknown binding is `unverified`, and no false disabled/orphaned
|
||||
ghost or service call is produced.
|
||||
- [ ] Two full cards plus a static space card share one registry fetch and one
|
||||
subscription pair per HA connection; registry events invalidate all of
|
||||
them without a reload.
|
||||
- [ ] `houseplanDiagnostics()` reports only redacted registry access/age/error
|
||||
and binding-status counts; it contains no names, states or marker data.
|
||||
|
||||
## HA Area marker relocation (#126)
|
||||
|
||||
- [ ] `test/device-area-relocation.test.mjs` covers direct-binding authority,
|
||||
same/cross-space transitions, conservative legacy backfill, explicit and
|
||||
composite exclusions, rebind, malformed metadata and delete-first
|
||||
provenance.
|
||||
- [ ] `test/space-geometry.test.mjs` proves both marker-position paths can
|
||||
suppress one stale saved point without changing the stored layout input.
|
||||
- [ ] `demo/smoke_area_relocation.mjs` changes an authoritative registry Area
|
||||
against the production bundle, checks one serialized layout delete plus
|
||||
config/attention persistence, and proves the read-only hosted card moves
|
||||
immediately without writes.
|
||||
- [ ] Area-provenance cleanup ignores the filtered display roster, preserves
|
||||
empty Device/Entity Registry namespaces, accepts exact live entity states
|
||||
as existence evidence, requests only one confirmation refresh and removes
|
||||
an orphan only after two distinct non-empty authoritative revisions.
|
||||
- [ ] A rejected confirmed-cleanup config write remains retryable on the next
|
||||
rebuild; the same revision, state ticks and repeated empty frames never
|
||||
become extra confirmation or a registry reload loop.
|
||||
- [ ] Backend validation and import/export tests cover the 20,000-entry bound,
|
||||
exact entry schema, same-source preservation and cross-source removal.
|
||||
|
||||
## Device display preview and face parity
|
||||
|
||||
The behaviour matrix is defined in
|
||||
`docs/superpowers/specs/2026-08-08-device-display-preview-design.md` §22.
|
||||
Pure source/value/presentation rules live in `test/device-presentation.test.mjs`.
|
||||
`demo/smoke_device_preview_parity.mjs` compares the same live fixture across
|
||||
the interactive plan, `hp-device-preview` and `houseplan-space-card`, including
|
||||
semantic classes, icon/value/badges, scale variables, provider text and the
|
||||
public binding-status hook.
|
||||
|
||||
- [ ] Every binding/display/icon/size/angle/control/temperature draft change
|
||||
updates the preview before Save; Cancel writes neither config nor layout.
|
||||
- [ ] Working, open, cover, presence, short event, transition, alarm, static,
|
||||
unavailable, media-neutral, composite-Power and `live_states: false`
|
||||
explanations match the actual face.
|
||||
- [ ] The local short-activity demo lasts 3.3 seconds and the continuous demo
|
||||
runs until stopped. Neither sends a service call; reduced motion uses a
|
||||
compact dot, and both reset immediately on binding change, real activity
|
||||
or alarm.
|
||||
- [ ] Provider metadata is cached between dialog openings and refreshed after
|
||||
registry/config-entry changes; source integrations remain separate from
|
||||
the binding provider.
|
||||
- [ ] Long provider/source/state text wraps without horizontal scroll; maximum
|
||||
marker/ripple size fits the stage and reports its preview scale.
|
||||
- [ ] Derived temperature/humidity values keep the compact plan form (`22.4°`,
|
||||
`48%`), while a direct entity value continues to use HA localization and
|
||||
units.
|
||||
|
||||
## Device icon package parity (#211)
|
||||
|
||||
The independent reference subset under
|
||||
`demo/srv/reference/device-icons/` comes directly from designer package 1.1.1;
|
||||
it is not generated from production CSS. The package archive hash and the
|
||||
owner's #219 red/green Lock/Unlock paint override are recorded in that
|
||||
directory's README.
|
||||
|
||||
- [ ] `node demo/smoke_device_icon_design.mjs` reads the SVG colors and stroke
|
||||
widths, then compares them with fresh computed styles. It also measures
|
||||
circular core/shell geometry, the real `mdi:lightbulb-spot` painted path,
|
||||
value-pill radius and 44×44 hit area at 32/56/96 px.
|
||||
- [ ] `node demo/capture_device_icon_reference.mjs` writes a two-column
|
||||
**Reference SVG / Runtime** matrix for both themes to
|
||||
`artifacts/device-icon-reference/`. Code review must inspect this artifact
|
||||
visually; a green historical golden is not proof of package parity.
|
||||
- [ ] Preview/static parity and unavailable keyboard/tap behavior remain
|
||||
covered by `smoke_device_preview_parity`, `smoke_static_icon` and
|
||||
`smoke_disabled_device` after a fresh production build.
|
||||
|
||||
## Device marker geometry and input polish (#213)
|
||||
|
||||
- [ ] `node demo/smoke_device_icon_pixel_alignment.mjs` covers core bases
|
||||
24…112 CSS px in quarter-pixel steps at DPR 1/1.25/1.5/2. DOM centres,
|
||||
isolated painted centroids/support and a deliberate 1 CSS px mutant must
|
||||
distinguish browser raster parity from a persistent offset.
|
||||
- [ ] `node demo/smoke_device_icon_design.mjs` keeps the effective 32/56/96
|
||||
geometry, uses the direct 0.55 MDI/core ratio and proves hover plus the
|
||||
configured action from the far value-capsule end at right/bottom/left/top.
|
||||
- [ ] Opening binding/registry-less/lock-action smokes preserve the secure
|
||||
no-toggle-on-plan invariant while checking compact Light/Dark
|
||||
locked/unlocked/unknown shell/core presentation.
|
||||
- [ ] `node demo/smoke_opening_entity_search.mjs` checks the real opening
|
||||
dialog: contact and lock search by friendly name/entity ID, preserved
|
||||
contact priority, visible IDs, persistent **none** option and unchanged
|
||||
`opening.contact`/`opening.lock` storage.
|
||||
- [ ] Unit presentation coverage compares marker LQI colour with the shared
|
||||
continuous `lqiColor()` across former 40/41 and 179/180 boundaries; bands
|
||||
remain semantic metadata only.
|
||||
|
||||
## Text marker shell shape (#217)
|
||||
|
||||
- [ ] `node demo/smoke_device_icon_design.mjs` checks the external Text frame,
|
||||
not only its core: a long value keeps a saturating capsule radius at
|
||||
24/32/56/96/112 px, while Icon-only remains circular and Double remains a
|
||||
capsule. The runtime mutation to `border-radius: 50%` must be rejected.
|
||||
- [ ] `device-text-shell-long-light` and `device-text-shell-long-dark` isolate a
|
||||
large `498 ppm` Text marker. Golden review must visibly confirm straight
|
||||
upper/lower middle sections rather than an ellipse.
|
||||
- [ ] `node demo/capture_device_icon_reference.mjs` includes an additional
|
||||
96 px Text row beside the normative Light/Dark `Text Default.svg`.
|
||||
|
||||
## Device lock and orange foreground palette (#219)
|
||||
|
||||
- [ ] Closed/`locked` is green `#66D17A`; open/`unlocked` is red `#F0410C`.
|
||||
The same core/stroke palette is used by ordinary lock markers and compact
|
||||
door/gate lock badges; glyph shape remains closed/open/question.
|
||||
- [ ] Every device glyph on an orange core (`on`/working and physical `open`)
|
||||
is white in Light and `#252525` in Dark. `device-icon-state-table-light`
|
||||
and `device-icon-state-table-dark` show `on` and `open` together, plus
|
||||
both lock states [unit: device-marker-polish-contract; auto:
|
||||
smoke_device_icon_design; golden: device-icon-state-table-*].
|
||||
- [ ] Alarm, hover, focus, selected, unavailable, virtual, press feedback,
|
||||
pulse, hit-area and lock actions retain their existing priority and
|
||||
behaviour [unit: device presentation/polish/pointer; visual source review].
|
||||
|
||||
## Unified device status and pulse activity (#98)
|
||||
|
||||
- [ ] The Display list contains exactly Icon + state, Icon + state and activity,
|
||||
Value + state, Always static icon, in that order. Legacy
|
||||
`display: ripple` reads and saves back as `icon_ripple`
|
||||
- [ ] Icon + dynamic plate shows state plate/morph but no ordinary activity
|
||||
effect; Icon + activity adds the semantic effect; Value keeps the
|
||||
state-coloured plate and hides ordinary activity; Always static icon
|
||||
keeps one neutral base icon and suppresses all state-driven visuals
|
||||
- [ ] Motion/vibration/sound/contact rising edges render exactly three waves
|
||||
for about 3.3 s; initial load and recovery from unknown/unavailable do
|
||||
not fake an event; a rapid retrigger restarts it
|
||||
- [ ] Occupancy/presence is one calm continuous pulse for the whole active state
|
||||
- [ ] Cover/lock/valve movement continuously pulses until the travelling state ends;
|
||||
direct terminal `closed ↔ open` / `locked ↔ unlocked` without an
|
||||
intermediate state breathes for about 3.3 s
|
||||
- [ ] Actual work (light/switch/fan/humidifier on, active climate action,
|
||||
vacuum cleaning, script running) is yellow and slowly
|
||||
breathes in Icon + activity; `automation = on` is merely enabled and
|
||||
remains neutral
|
||||
- [ ] Every `media_player` is neutral and has no running activity for `on`,
|
||||
`idle`, `playing`, `paused` and other transport states; explicit `off`
|
||||
uses the same faded treatment as `unknown`/`unavailable`. Several
|
||||
resolved media entities fade only when none is available and powered
|
||||
- [ ] Controls aggregate their targets: any working target drives both the
|
||||
yellow plate and the running effect
|
||||
- [ ] Open contact/open valve are orange; unlocked lock is red and locked lock
|
||||
is green; an open cover stays neutral because its icon morph carries that
|
||||
state
|
||||
- [ ] Unavailable suppresses ordinary activity. Alarm outranks all dynamic
|
||||
presentation, including when ordinary live states are off; `static_icon`
|
||||
deliberately hides alarm paint without suppressing service-call errors
|
||||
- [ ] `static_icon` hides temperature/humidity/LQI, RGB, value, icon morph,
|
||||
activity and live vacuum puck/trails/room highlight on the full and static
|
||||
cards; preview still names the real HA state/source and explains the static result
|
||||
- [ ] Switching a static vacuum back to a dynamic display restores applicable
|
||||
live/server trails; choosing static never deletes stored trail history
|
||||
- [ ] `value_static_icon` (#588) shows the same value as `value` — including on
|
||||
the plan itself and the space card, where sources are resolved lazily
|
||||
— while state, alarm, unavailability, RGB and activity never paint
|
||||
it; no pulse, no °/%/LQI, value badge suppressed with its setting kept,
|
||||
and the live vacuum puck, trail and route warning stay hidden
|
||||
[auto: `smoke_static_icon.mjs`, golden `device-icon-state-table-*`]
|
||||
- [ ] Activity colour and size (×2..×8) apply per device; alarm ignores them
|
||||
- [ ] Icon size ×0.5..×3 and rotation 0..355° apply per device; the
|
||||
temp/humidity badges scale with the icon
|
||||
- [ ] With OS "reduce motion" enabled, ordinary activity becomes a compact
|
||||
solid dot; alarm keeps the red plate and accessible alarm description,
|
||||
without an animated or static ring
|
||||
|
||||
## Climate temperature opt-in (dev)
|
||||
|
||||
- [ ] «Use the device's temperature sensor» (marker dialog, climate devices
|
||||
only, default OFF): current_temperature shows as the standard `.tval`
|
||||
badge and joins the room average like a thermometer; unavailable /
|
||||
missing attribute = no badge, no vote; hidden devices keep voting
|
||||
(registry-wide climate, like hidden thermometers); the tick survives
|
||||
dialog recreation [auto: smoke_climate_temp; units: test/devices.test.mjs;
|
||||
backend: tests_backend/test_validation.py (use_climate_temp)]
|
||||
|
||||
## Styling hooks and HA-formatted values (docs/STYLING-HOOKS.md, dev, unreleased)
|
||||
|
||||
- [ ] **The hooks are there and they are the config's ids**: open the plan's
|
||||
DOM (devtools → the card's shadow root) and check that a device marker
|
||||
carries `data-hp="device"`, `data-id`, `data-entity` and `data-area`; a
|
||||
room `data-hp="room"` + `data-id` + `data-area`; a door/window/gate
|
||||
`data-hp="opening"` + `data-kind`; a decor shape `data-hp="decor"` +
|
||||
`data-kind`; a visible room card `data-hp="room-label"`; a floor tab
|
||||
`data-hp="space-tab"`. The ids are the ones in your config, not DOM
|
||||
positions — they survive a reload [auto: smoke_styling_hooks]
|
||||
- [ ] **Absent is absent**: a virtual marker has NO `data-entity` at all, and a
|
||||
sub-area room (no HA area) has NO `data-area` — never the string
|
||||
«undefined» [auto: smoke_styling_hooks]
|
||||
- [ ] **A card-mod rule actually applies**: with card-mod installed, add
|
||||
`ha-card [data-hp="device"] .lqi { display: none; }` to the card — the
|
||||
signal badges disappear and nothing else moves. Then target one marker by
|
||||
`[data-entity="…"]` and confirm it is the only one affected [manual]
|
||||
- [ ] **The static card carries the same hooks**: a `houseplan-space-card`
|
||||
has `data-hp` on its rooms, visible room labels and markers (it draws no openings
|
||||
and no decor, so those are simply absent), and it needs its OWN card-mod
|
||||
block — it is a different card with its own shadow root
|
||||
[auto: smoke_styling_hooks]
|
||||
- [ ] **`ha-icon` internals stay out of reach**: a rule may style the icon HOST
|
||||
(colour, transform) but cannot reach the `<svg>` inside it. That is a
|
||||
browser rule, and it is why the hooks sit on our wrappers
|
||||
[auto: smoke_styling_hooks]
|
||||
|
||||
- [ ] **A value badge is formatted by HA**: set a numeric sensor's display
|
||||
precision in HA (Settings → the entity → Display precision) to 1 and put
|
||||
it on the plan as «value instead of icon». The badge shows the rounded
|
||||
number with YOUR decimal separator and the entity's unit — once, not
|
||||
twice — and matches what more-info shows [auto: smoke_value_format]
|
||||
- [ ] **A live decor label is formatted by HA**: the same sensor in a text
|
||||
shape reads identically; a switch shows «Включено», not `on`; an
|
||||
attribute goes through the attribute formatter (a climate's
|
||||
`current_temperature` is a number, not the climate's state)
|
||||
[auto: smoke_value_format + unit logic.test]
|
||||
- [ ] **A literal suffix stays literal**: write an attribute token followed by
|
||||
` проц.` — the suffix is part of the text, with no hidden unit override
|
||||
and no duplicate appended by the label renderer [auto: smoke_live_text]
|
||||
- [ ] **An older Home Assistant is unchanged**: on an HA without
|
||||
`formatEntityState` the badge and the label print the raw state with the
|
||||
entity's unit appended, exactly as before — nothing is blank and nothing
|
||||
throws [auto: smoke_value_format + unit logic.test]
|
||||
- [ ] **The °/% plates are untouched**: the small temperature/humidity badges
|
||||
next to an icon (and the same numbers in a room card and the tooltip)
|
||||
still read «21.5°» / «48%». They are a derived reading, not an entity
|
||||
state — deliberately ours [auto: smoke_value_format]
|
||||
|
||||
- [ ] **The text block's handles are small but still catchable**: select a
|
||||
label in the Background editor. The four corner circles and the rotate
|
||||
handle are a quarter of their old size — beads, not buttons — and the
|
||||
dashed frame no longer hides the text. Now grab one on a TABLET with a
|
||||
finger, aiming roughly at it rather than exactly: it is caught, because
|
||||
the invisible hit circle is still the old finger-sized one. Same at any
|
||||
zoom [auto: smoke_decor_text]
|
||||
@@ -0,0 +1,269 @@
|
||||
# Диалоги, формы и сводная панель
|
||||
|
||||
> Приложение к [`docs/TESTING.md`](../TESTING.md): перенесено оттуда дословно (#634).
|
||||
> Индекс всех приложений — [`README.md`](README.md).
|
||||
|
||||
## Вопрос о несохранённых настройках (#610)
|
||||
|
||||
- [ ] В русской локали четыре формы настроек показывают **Вернуться** и
|
||||
**Не сохранять**: первая кнопка сохраняет черновик, вторая закрывает
|
||||
форму без сохранения [auto: `demo/smoke_discard_copy.mjs`].
|
||||
- [ ] Заголовок и кнопка потери черновика используют
|
||||
`mdi:content-save-off-outline`; обычный warning без override сохраняет
|
||||
иконки открытого замка [auto: `demo/smoke_discard_copy.mjs`; mutation:
|
||||
`discard-confirm-action-icon-falls-back-to-lock`].
|
||||
- [ ] RU/EN/DE/FR на 320–640 px, light/dark и DPR 1/2 сохраняют одну строку,
|
||||
полный текст, безопасный autofocus и возврат к форме
|
||||
[auto: `demo/smoke_dialog_polish_603.mjs`].
|
||||
|
||||
## Числовые поля со слайдером (#608)
|
||||
|
||||
- [ ] В настройках комнаты поле размера имени принимает `120` посимвольно:
|
||||
до выхода из поля черновик и слайдер остаются на прежнем значении, после
|
||||
выхода оба показывают 120; очистка поля возвращает последнее
|
||||
подтверждённое значение [auto: `demo/smoke_range_line_draft.mjs`].
|
||||
- [ ] В настройках пространства `123` становится 125 только после завершения
|
||||
ввода; движение слайдера во время незавершённого ввода отбрасывает текст
|
||||
и сразу синхронизирует поле [auto: `demo/smoke_range_line_draft.mjs`,
|
||||
`demo/smoke_dialog_config_parity.mjs`].
|
||||
- [ ] Возврат раннего clamp на каждом `input` делает свидетель красным
|
||||
[mutation: `range-line-clamps-every-keystroke`].
|
||||
|
||||
## Toggle confirmation state (#103)
|
||||
|
||||
- [ ] Every executable `ToggleNextEffect` formats current and expected lines
|
||||
without deriving direction from the state label; `toggle` names Home
|
||||
Assistant as the authority and a no-operation intent produces no lines
|
||||
[unit: `device-toggle.test.mjs`].
|
||||
- [ ] All-off/mixed/partial groups use only executable targets for their
|
||||
active/total count and show skipped targets as a separate line
|
||||
[unit: `device-toggle.test.mjs`].
|
||||
- [ ] EN/RU confirmation renders prompt → current → expected → skipped before
|
||||
the buttons, wraps a long name at 390 px and has no horizontal scroll
|
||||
[auto: `smoke_toggle_confirmation.mjs`].
|
||||
- [ ] A state race with the same target executes the newly resolved direction;
|
||||
a changed target set makes zero service calls and shows the existing
|
||||
retry toast [auto: `smoke_toggle_confirmation.mjs`].
|
||||
- [ ] Cover, virtual-light, HA-control and Run confirmations keep their
|
||||
existing actuation/cancel contracts [auto: `smoke_cover_tap`,
|
||||
`smoke_virtual_light_toggle`, `smoke_ha_controls`, `smoke_controls`,
|
||||
`smoke_tap_run`].
|
||||
|
||||
## Unified color and opacity picker (#57)
|
||||
|
||||
- [ ] RGB↔HSV round trips stay within one RGB channel; 3/6-digit HEX input is
|
||||
normalized and invalid drafts never become persisted colors
|
||||
[unit: `color-picker.test.mjs`].
|
||||
- [ ] Every existing `hp-color-opacity` consumer receives per-card localized
|
||||
labels through the unchanged color/opacity event contract, and the shared
|
||||
component contains no native `input[type=color]`
|
||||
[unit: `color-picker.test.mjs`].
|
||||
- [ ] The localized full-width OK button is the final picker control, remains at
|
||||
least 40 CSS px high in native and fallback surfaces, and closes without a
|
||||
duplicate value event or click-through to the parent card
|
||||
[unit: `color-picker.test.mjs`, auto: `smoke_color_picker.mjs`,
|
||||
`smoke_help_affordance.mjs`].
|
||||
- [ ] Invalid HEX keeps the picker open and focused on its error even after a
|
||||
repeated OK; only new valid HEX input unlocks confirmation, while live
|
||||
color/opacity updates and the existing outside/trigger/Escape close paths
|
||||
retain their values [auto: `smoke_color_picker.mjs`].
|
||||
- [ ] At 390 px the one surface exposes hue, saturation, brightness, HEX and
|
||||
opacity without horizontal overflow; keyboard Shift+Arrow, touch pointer
|
||||
cancellation, invalid HEX recovery, Escape focus return and disabled mode
|
||||
remain safe [auto: `smoke_color_picker.mjs`].
|
||||
- [ ] The color-only Glow consumer keeps the same picker without an opacity row,
|
||||
and native/fallback floating surfaces remain mutually exclusive with help
|
||||
[auto: `smoke_help_affordance.mjs`].
|
||||
- [ ] The Hue range keeps `0…359`, step 1 and its existing input events while its
|
||||
WebKit/Blink and Gecko tracks expose the same cyclic spectrum. A dual
|
||||
theme-aware ring keeps the native thumb distinct from every hue, while
|
||||
forced-colors falls back to system track/thumb rendering [unit:
|
||||
`color-picker.test.mjs`, auto: `smoke_color_picker.mjs`].
|
||||
- [ ] The dark mobile and light desktop open-picker goldens are reviewed from the
|
||||
complete Linux artifact before a beta; baseline acceptance is not part of
|
||||
the implementation loop [golden: `decor-color-popover-mobile-ru`,
|
||||
`decor-color-popover-desktop-en`].
|
||||
|
||||
## Unified picker coverage for every color field (#180)
|
||||
|
||||
- [ ] A recursive source contract rejects every native `input[type=color]` in
|
||||
product TypeScript and fixes the complete shared-component inventory at
|
||||
13 template instances [unit: `color-picker.test.mjs`].
|
||||
- [ ] The 11 general light/temperature/LQI/Glow/wall palettes and the space room
|
||||
colour move their existing opacity into the unified picker; color and
|
||||
alpha update one parent draft atomically [unit: `color-picker.test.mjs`,
|
||||
auto: `smoke_color_picker_consumers.mjs`].
|
||||
- [ ] Global background, space background and activity ripple are color-only;
|
||||
opening/closing does not materialize an inherited/default value, and
|
||||
Default/Inherit restore `null` without adding alpha
|
||||
[auto: `smoke_color_picker_consumers.mjs`].
|
||||
- [ ] General settings keep one exclusive picker open among 12 swatches; marker
|
||||
activity colour and ripple size use separate, non-overlapping mobile rows,
|
||||
ripple size remains independent, and cancelling the space dialog writes
|
||||
no color draft [unit: `color-picker.test.mjs`, auto:
|
||||
`smoke_color_picker_consumers.mjs`].
|
||||
- [ ] The three new dialog families are reviewed from the complete Linux
|
||||
artifact before a beta; implementation does not accept their baselines
|
||||
[golden: `general-color-popover-desktop-en`,
|
||||
`device-ripple-color-popover-mobile-ru`,
|
||||
`space-room-color-popover-desktop-ru`].
|
||||
|
||||
## Contextual help (issues #68 and #86)
|
||||
|
||||
- [ ] A setting with complete EN/RU/DE/FR help body and ARIA copy shows one 32 px
|
||||
desktop / 40 px coarse-pointer button with the outlined circled-question
|
||||
icon. Mouse hover, keyboard focus and tap open the same text surface
|
||||
[auto: `smoke_help_affordance`].
|
||||
- [ ] Empty or whitespace-only help body produces no trigger. A non-empty body
|
||||
without a complete ARIA label also produces no trigger; neither case adds
|
||||
a tab stop or reserves visible space [auto: `smoke_help_affordance`].
|
||||
- [ ] Escape, outside pointer, owning-dialog scroll, toast and a competing colour
|
||||
picker close help in the documented order. The Popover and portal fallback
|
||||
paths stay inside the visual viewport [auto: `smoke_help_affordance`].
|
||||
- [ ] Opening help by hover, focus or tap changes neither `clientHeight`,
|
||||
`scrollHeight` nor `scrollTop` of the owning dialog body. The native
|
||||
Popover and forced portal fallback have the same no-layout-shift contract
|
||||
[auto: `smoke_help_affordance`].
|
||||
- [ ] Party 1 contains exactly the 11 agreed logical settings: space scale,
|
||||
general and device Glow radius, room fill, source role and controlled
|
||||
sources, general/space north, general/space background, zero-thickness
|
||||
wall style and the show-hidden catalog filter. The regular and onboarding
|
||||
space dialogs expose the same five space-help controls; opening help does
|
||||
not change a draft setting or the session-only filter
|
||||
[unit: `i18n.test`; auto: `smoke_help_affordance`;
|
||||
mutation: `settings-help-party1-placement-removed`].
|
||||
- [ ] `marker.controls_hint`, `gs.bg_daynight_hint`, `gs.north_hint` and
|
||||
`space.zero_wall_help` are absent from all four dictionaries and production
|
||||
templates; state-specific sun/Glow notes remain visible [unit: `i18n.test`].
|
||||
|
||||
## Help & private feedback (#43)
|
||||
|
||||
- [ ] Header order is Fit/zoom → General settings → Help; Help has a 44×44
|
||||
target, remains available in View and all editors, and is absent in kiosk.
|
||||
About appears once in Help, Russian routes to the Russian User Guide and
|
||||
every other locale routes to English [unit: `support-feedback.test.mjs`;
|
||||
pre-beta: support dialog smoke/golden matrix].
|
||||
- [ ] A fresh dialog has empty message/contact and attachment off. Empty or
|
||||
over-limit Unicode input cannot submit; Ctrl/Cmd+Enter uses the same
|
||||
guard [unit: `support-feedback.test.mjs`; pre-beta: phone validation
|
||||
smoke].
|
||||
- [ ] Preview builds no external request and exposes exact JSON bytes, size and
|
||||
hash. Download equals preview; refresh changes the package namespace;
|
||||
expiry/discard/replacement and successful submit invalidate only the
|
||||
intended owner/draft token [backend: `test_support_package.py`,
|
||||
`test_ha_websocket.py`; pre-beta: browser network capture].
|
||||
- [ ] Forbidden sentinels (raw and escaped/base64), unknown fields, names, HA
|
||||
ids, URLs/paths and live states never reach package bytes. Geometry and
|
||||
referential pseudonyms survive [backend: `test_support_package.py`].
|
||||
- [ ] Relay transport uses only the compiled HTTPS host, follows no redirect,
|
||||
bounds timeouts/response and never reflects provider text. Relay request,
|
||||
rate/idempotency, spool-before-delivery and purge tests run in CI
|
||||
[backend: `test_ha_support_transport.py`; relay: `python -m unittest
|
||||
discover -s scripts/support-relay/tests -q`].
|
||||
- [ ] Success keeps a copyable report id; failure keeps the draft and exact
|
||||
attachment with Retry, Copy message, Download and manual links. Old or
|
||||
mismatched backend leaves About/Guide usable but exposes no fake submit
|
||||
[pre-beta: success/429/timeout/unknown-command smokes in light/dark].
|
||||
|
||||
## v1.71 audit polish (#434)
|
||||
|
||||
- [ ] Physical decor inventory counts exact lower-hex allow-listed blob files
|
||||
and their real sizes independently of sidecars; a valid-shaped sidecar
|
||||
without its blob remains absent from catalog/list/resolve
|
||||
[backend: `test_decor_assets.py`; mutations:
|
||||
`decor-physical-inventory-follows-sidecars`,
|
||||
`decor-catalog-accepts-sidecar-without-blob`].
|
||||
- [ ] Exact orphan re-upload repairs metadata at a full physical quota with
|
||||
`reused:false`; valid catalog reuse stays `true`, and digest mismatch
|
||||
changes no file. Explicit delete removes all and only exact allow-listed
|
||||
blob names plus the sidecar [HA: `test_ha_websocket.py`; mutations:
|
||||
`decor-orphan-repair-runs-after-quota`,
|
||||
`decor-orphan-repair-claims-reuse`, `decor-delete-skips-orphan-blobs`].
|
||||
- [ ] Static cards learn exact `decor_assets_api:1` only from fresh config/get,
|
||||
revoke it on downgrade and never resolve without it. Positive and missing
|
||||
resolve caches share only connection + config revision + sorted id set;
|
||||
failed calls retry [unit: `config-store`, `space-card-audit-lows`,
|
||||
`decor-assets`; smoke: `smoke_space_card_decor_capability`].
|
||||
- [ ] Ready→warm cancels an existing dangerous-action promise and removes its
|
||||
dialog while preserving the committed body; a request in warm refuses,
|
||||
and warm→ready allows immediately before another render
|
||||
[smoke: `smoke_danger_confirm_branches`; mutations:
|
||||
`danger-confirm-warm-language-guard-removed`,
|
||||
`danger-confirm-warm-transition-cancel-removed`].
|
||||
- [ ] Area absence evidence cannot outlive its current snapshot binding
|
||||
[unit: `device-area-relocation`; mutation:
|
||||
`area-cleanup-keeps-candidate-outside-current-snapshot`].
|
||||
- [ ] The smoke job has its own 20-minute bound and each file is wrapped by GNU
|
||||
`timeout --kill-after=10s 180s`; both German route waits have one-second
|
||||
diagnostics [unit: `smoke-exception-guard`; CI: `validate.yml`].
|
||||
- [ ] Every well-formed token from an invalid, stale or locally unadoptable
|
||||
support preview response is discarded exactly once; malformed tokens are
|
||||
not echoed and cleanup never hides the original failure
|
||||
[smoke: `smoke_support_feedback`; mutation:
|
||||
`support-invalid-response-leaks-issued-token`].
|
||||
|
||||
## Надёжность сводной панели (#493)
|
||||
|
||||
- [ ] `test/summary-panel.test.mjs` строит индекс по 10 000 HA entities,
|
||||
находит exact `entity_id` через полный индекс, ограничивает выдачу 100
|
||||
строками и доказывает 0 rebuild при смене state value и ровно один при
|
||||
add/remove/friendly-name change.
|
||||
- [ ] `test/summary-panel-runtime.test.mjs` доказывает authority локальных
|
||||
200%/150% при repeated same-key `setConfig`, stable-id ownership после
|
||||
reorder/delete и lifecycle generation при route/user/permission/kiosk,
|
||||
disconnect/reconnect и поздних async completion.
|
||||
- [ ] `tests_backend/test_summary_panel.py`, `test_ha_websocket.py` и
|
||||
`test_ha_import_export.py` проверяют одну writer matrix: omission/explicit
|
||||
empty, readable и старую broken reference, Optimize, full/space/plan-only
|
||||
import, Optimize/Import Undo, copy и delete вместе с independent-setting
|
||||
sentinels.
|
||||
- [ ] Перед `S7-code-review`: `node demo/smoke_summary_panel.mjs`. Fixture
|
||||
содержит 200 rows и 10 000 states; закрытые строки не содержат entity
|
||||
options, открыт ровно один picker (до 100 entity + 3 system + current
|
||||
broken), а после трёх warmups и 20 samples действуют бюджеты open p95
|
||||
≤250 ms и input p95 ≤50 ms. Тот же smoke проверяет 320/390 CSS px,
|
||||
RU/EN, light/dark, admin/household/kiosk и 200% text без горизонтального
|
||||
overflow и с targets не меньше 44 px.
|
||||
- [ ] `node scripts/mutation-gate.mjs --changed origin/dev..HEAD` обязан поймать
|
||||
снятие DOM bound, state-value rebuild, local-scale authority, lifecycle
|
||||
generation и Optimize writer guard. Полные golden/smoke/performance и
|
||||
Linux CI HA harness остаются обязательным гейтом точного SHA беты.
|
||||
|
||||
## Идентичность сводной панели в Masonry (#561)
|
||||
|
||||
- [ ] `test/summary-panel.test.mjs` пересоздаёт весь production-shaped
|
||||
`hui-masonry-view`: canonical `cards=[A,B,C]`, wide-колонки `[A,C] [B]`
|
||||
и новый narrow DOM `[A,B,C]`. Slots обязаны остаться
|
||||
`masonry-v2:0/1/2`, без коллизии old C → new B.
|
||||
- [ ] Тот же unit проверяет live reflow, nested stack через ShadowRoot,
|
||||
remount внутренней House Plan card, два nested instances и повторную
|
||||
попытку resolution после временно отсутствующего `masonry.cards`.
|
||||
- [ ] `test/summary-panel-runtime.test.mjs` доказывает отсутствие localStorage
|
||||
read/write при unresolved identity, отсутствие импорта/удаления старого
|
||||
DOM-path key и независимое восстановление show/scale одинаковых карточек
|
||||
после полного reload.
|
||||
- [ ] Мутант `summary-masonry-identity-uses-visual-dom-path` возвращает
|
||||
структурный DOM path вместо canonical index; focused #561 unit обязан
|
||||
покраснеть. Перед бетой selected summary-panel smoke проверяет ротацию
|
||||
touch/kiosk viewport по общему release-процессу.
|
||||
|
||||
## Доступность основного View (#565)
|
||||
|
||||
- [ ] `test/device-presentation.test.mjs` доказывает whole-segment
|
||||
дедупликацию доступного имени во всех четырёх локалях, сохранение порядка
|
||||
и отсутствие ложного удаления при частичном совпадении.
|
||||
- [ ] `demo/smoke_household_journeys.mjs` проверяет именованную навигацию и
|
||||
единственный `aria-current="page"`, реальный Tab-focus tooltip, переход к
|
||||
следующему устройству, blur-cleanup и попадание подсказки в viewport.
|
||||
Комбинированный mouse+keyboard сценарий доказывает, что комната не
|
||||
заменяет focus-tooltip, другое устройство сохраняет обычный hover, а
|
||||
pointerleave восстанавливает подсказку всё ещё сфокусированного устройства.
|
||||
- [ ] `demo/smoke_space_card.mjs` считает сегменты тревоги в доступном имени
|
||||
статической карточки и не добавляет ей интерактивность.
|
||||
- [ ] Мутанты `view-current-space-aria-removed`,
|
||||
`device-accessible-label-dedup-removed`,
|
||||
`device-focus-tooltip-handler-removed` и
|
||||
`device-focus-tooltip-blur-cleanup-removed`, а также мутанты раунда r2
|
||||
`device-focus-tooltip-room-hover-overwrites` и
|
||||
`device-pointer-leave-clears-focus-fallback` обязаны покраснеть на своих
|
||||
заявленных гардах.
|
||||
@@ -0,0 +1,804 @@
|
||||
# Геометрия: стены, проёмы, комнаты, холст
|
||||
|
||||
> Приложение к [`docs/TESTING.md`](../TESTING.md): перенесено оттуда дословно (#634).
|
||||
> Индекс всех приложений — [`README.md`](README.md).
|
||||
|
||||
## Stable wall-segment identity (#282)
|
||||
|
||||
- [ ] Wall junction limits (#329): drawing refuses an apex under 15°, a seventh
|
||||
wall in one node, a wall shorter than 20 cm or than its own thickness,
|
||||
nodes closer than 5 cm and a room with under 25 cm² of interior, each
|
||||
through the surface's own channel — a toast naming the rule for drawing
|
||||
and Thickness, a stopped wall for Resize. A T-joint stays legal, a short
|
||||
filler atom compensating a thickness step stays legal (length is measured
|
||||
along the collinear same-thickness wall run), and an inherited violation
|
||||
never blocks an unrelated edit
|
||||
[unit: junction-limits; auto: smoke_junction_limits, smoke_island_rooms;
|
||||
mutants: junction-limit-angle-not-enforced,
|
||||
junction-limit-write-gate-removed, degenerate-apex-bevelled-again].
|
||||
- [ ] A degenerate sharp apex renders as ONE point on both faces — no flat
|
||||
chamfer, no bow-tie fold, no jags between the inner and outer vertex; the
|
||||
room ring of the #329 fixture triangle has exactly three distinct
|
||||
vertices [unit: junction-limits §4].
|
||||
- [ ] Shared fixture `test/fixtures/282-wall-identity-parity.json` produces the
|
||||
same exact v8 catalog, room references, opening host and draft IDs in
|
||||
TypeScript and Python.
|
||||
- [ ] Initial v7 migration is deterministic and idempotent; new post-v8 atoms
|
||||
use UUIDs. A split/promoted draft keeps one documented carrier ID, while
|
||||
reserved/colliding deterministic IDs receive stable `-2`, `-3` suffixes.
|
||||
- [ ] The three structural writer families — interactive commit, Undo/Redo
|
||||
restore and Optimize — are enumerated by the source guard and each has an
|
||||
independent bypass mutant in `scripts/mutation-gate.mjs`. A rejected
|
||||
migration changes neither config, Undo history nor revision.
|
||||
- [ ] Full/space imports cover v7→v7 (no upgrade), v7→v8 and v8→v8; copy/merge
|
||||
remaps every ID and reference together. A byte-equivalent legacy-client
|
||||
round-trip of v8 is accepted, while a structural legacy change is rejected.
|
||||
- [ ] Resize, Split/Merge, opening edit and Optimize browser smokes retain wall
|
||||
thickness and ownership across reload. Performance gate:
|
||||
`npm run benchmark:wall-model` materialises 10,000 atoms with p95 below
|
||||
500 ms on the reference Windows machine.
|
||||
- [ ] Local commands: `npm test`, `npm run typecheck`,
|
||||
`npm run benchmark:wall-model`; backend parity/schema tests run through
|
||||
`tests_backend/test_wall_segment_model.py` and
|
||||
`tests_backend/test_validation.py`. HA import/export coverage runs in the
|
||||
normal Linux/CI Home Assistant harness when unavailable natively.
|
||||
|
||||
## Legacy draft migration and atomic wall-chain writes (#314, #478)
|
||||
|
||||
- [ ] `demo/smoke_v8_draft_write.mjs` (compatibility filename) proves that a
|
||||
model-v9 draft migrates once to ordinary partitions, preserves existing
|
||||
edge IDs/thicknesses and leaves no `room_drafts` in model v10.
|
||||
- [ ] The same fake-WS smoke proves each accepted current wall reaches storage,
|
||||
a later edge preserves earlier identities, Undo removes only the terminal
|
||||
wall, and a closed chain creates a durable room without carrier debris.
|
||||
- [ ] A rejected in-flight physical write synchronously rolls back its whole
|
||||
pending batch: active path, session partition IDs, pending map and command
|
||||
history are empty, so no optimistic ghost wall survives.
|
||||
- [ ] Frontend/backend model tests consume the same
|
||||
`478-room-draft-migration-vectors.json`: missing/null IDs, colliding IDs,
|
||||
numeric strings, `null`/boolean thickness and epsilon-length edges must
|
||||
produce the same exact partitions or rejection reason in both runtimes.
|
||||
Backend coverage also rejects a stale v9 `room_drafts` write over v10.
|
||||
- [ ] `demo/smoke_unified_wall_tool.mjs` accepts a room over a partially
|
||||
coincident older partition, preserves only its outside tail and an
|
||||
unrelated partition, then proves an immediate Optimize reports zero
|
||||
reconciliation and no config change. A forced missing post-mutation wall
|
||||
model restores the whole room transaction.
|
||||
- [ ] Local commands: `npm test`, `npm run bundle:sync`,
|
||||
`node demo/smoke_v8_draft_write.mjs`, targeted backend pytest and
|
||||
`node scripts/check-docs.mjs --external`.
|
||||
- [ ] Mutation `current-rejected-physical-write-keeps-optimistic-wall` proves
|
||||
the browser rollback scenario fails when rejection recovery is bypassed.
|
||||
- [ ] Mutation `room-accept-leaves-coincident-partitions` proves the real editor
|
||||
smoke fails if accepted room carriers stop being consumed atomically.
|
||||
|
||||
## Current-writer fixed point (#477)
|
||||
|
||||
- [ ] `test/writer-fixed-point.test.mjs` proves seed-bounded repeated collinear
|
||||
merge, safe positive coincident reconciliation/opening rehost, identical
|
||||
fail-closed cases, exact room-reference rewrite/restore and the executable
|
||||
writer-owner manifest.
|
||||
- [ ] `demo/smoke_writer_fixed_point.mjs` finishes a real straight Walls chain
|
||||
through the production bundle, then proves the immediate, post-Undo and
|
||||
post-Redo Optimize previews are no-ops. The same smoke deletes a room and
|
||||
restores/reapplies direct and cross-space vacuum references without
|
||||
overwriting unrelated marker fields.
|
||||
- [ ] `demo/benchmark_wall_draw_click.mjs` keeps the #461 terminal-click budgets
|
||||
and structural counters, then separately proves finish uses one bounded
|
||||
physical/junction transaction, one write, no extra history and sublinear
|
||||
scaling when unrelated rooms are added.
|
||||
- [ ] `test/align-grid.test.mjs` proves Optimize leaves complete furniture/image
|
||||
transforms byte-equivalent while an ordinary decor rectangle remains
|
||||
grid-bound.
|
||||
- [ ] The `writer-*` mutations in `scripts/mutation-gate.mjs` remove one finish
|
||||
owner, pre-adoption safety, history normalization, direct/vacuum reference
|
||||
rewrite, free-transform exclusion, terminal-click separation, seed merge
|
||||
and seed reconciliation; each named witness must turn red.
|
||||
|
||||
## Resize: реальный pointer pipeline (#293)
|
||||
|
||||
- [ ] `demo/smoke_resize_pointer_real_plan.mjs` загружает tracked fixture
|
||||
второго этажа обычным `houseplan/config/get`, включает Resize кнопкой и
|
||||
двигает доступную общую стену только реальными `page.mouse` событиями.
|
||||
Прямые вызовы приватных resize-методов в этом smoke запрещены source
|
||||
guard-юнитом.
|
||||
- [ ] На десятом шаге сетки обе комнаты имеют видимый preview, а server config
|
||||
ещё байт-в-байт исходный. Pointerup создаёт одну history-команду и одну
|
||||
запись; wall count и набор толщин сохраняются; Ctrl+Z возвращает исходную
|
||||
геометрию.
|
||||
- [ ] Pointer capture продолжает жест минимум в двух диаметрах и 120 px от
|
||||
хэндла, чужой
|
||||
pointer id игнорируется, а Escape и `lostpointercapture` возвращают DOM и
|
||||
config без дополнительной записи.
|
||||
- [ ] Невозможная физическая preview-геометрия останавливает стену на последней
|
||||
безопасной позиции и показывает один локализованный toast за жест. Отдельно
|
||||
проверяется отказ финального preflight без commit/history.
|
||||
- [ ] Мутанты `resize-pointer-delta-zeroed`,
|
||||
`resize-shared-seam-not-coalesced`, `resize-pointer-capture-removed`,
|
||||
`resize-preview-reject-silent` и
|
||||
`safe-resize-commit-preflight-bypassed` обязаны красить соответствующие
|
||||
unit/production smoke guards.
|
||||
- [ ] Fixed-topology wall records (#298): moving-wall breakpoints translate
|
||||
rigidly, side-wall interior endpoints never scale proportionally,
|
||||
the exact first-floor 49→52 gesture ends on 17/52/57/101, unrelated
|
||||
records remain byte-equivalent, and a full-span carrier/lattice proof
|
||||
rejects gaps before preview. Key-only legacy records move only by one
|
||||
whole-edge identity; partial midpoint ambiguity produces no preview,
|
||||
history or config write [unit: `wall-thickness.test.mjs`; auto:
|
||||
`smoke_resize_pointer_real_plan`, `smoke_resize_wall_thickness`, six
|
||||
`smoke_edit_walk` runs; mutation:
|
||||
`safe-resize-wall-endpoints-affine-scaled`,
|
||||
`safe-resize-legacy-midpoint-fail-open`].
|
||||
|
||||
## Opening symbol centreline (#242, #250)
|
||||
|
||||
- [ ] Unit and browser checks prove that door/window/gate stay on the wall
|
||||
centreline for both `flip_v` values, door/window flips change only their
|
||||
direction, and gate `flip_v` reverses the first-leaf 10° turn on shared room walls,
|
||||
independent partitions and hidden Iso without translating the gate
|
||||
[unit: `opening-symbol.test.mjs`, `iso-openings.test.mjs`; auto:
|
||||
`smoke_wall_thickness.mjs`, `smoke_isometric_contract.mjs`; mutations:
|
||||
`opening-symbol-flip-restores-edge-offset`,
|
||||
`opening-gate-flip-cancels-turn`].
|
||||
- [ ] Matrix v37 adds four dedicated semantic scenes. Before PNG comparison
|
||||
they assert the saved flip value, wall centreline, visible-group offset,
|
||||
full jamb depth, window glass membership and opposite gate turn signs:
|
||||
`opening-symbol-room-wall-light`,
|
||||
`opening-symbol-diagonal-partition-dark`,
|
||||
`opening-symbol-flip-pairs-light`,
|
||||
`isometric-opening-symbol-parity-dark`.
|
||||
- [ ] #250 reuses those four scenes and requires `offset: center` for every
|
||||
door/window/gate entry, including flipped pairs. The semantic guard must
|
||||
fail before PNG comparison if any saved flip restores a wall-face offset.
|
||||
- [ ] The exact existing golden impact set below contains **67** scenes. It was
|
||||
measured by comparing `actualSha256` for HEAD and `origin/dev` under the
|
||||
same Chromium build; baseline status alone is not used because `dev`
|
||||
already has unrelated pending pre-release candidates. Every listed frame
|
||||
uses a shared fixture containing an affected opening or retains that plan
|
||||
behind an editor/dialog. No other existing frame changed:
|
||||
|
||||
`isometric-geometry-view-dark`, `isometric-geometry-view-light`,
|
||||
`isometric-live-layers-dark`, `isometric-no-borders-dark`,
|
||||
`isometric-touch-kiosk-dark`, `isometric-large-warm-remount-dark`,
|
||||
`geometry-view-dark-fit`, `geometry-view-light-fit`,
|
||||
`room-label-parity-view-dark`, `room-label-parity-plan-dark`,
|
||||
`room-label-parity-view-light`, `room-label-parity-plan-light`,
|
||||
`day-cycle-dawn-dark`, `day-cycle-day-dark`, `day-cycle-dusk-dark`,
|
||||
`day-cycle-night-dark`, `geometry-plan-editor-dark`,
|
||||
`space-tab-drop-before-light`, `space-tab-drop-after-dark`,
|
||||
`plan-snap-endpoint-light`, `plan-snap-line-gaps-dark`,
|
||||
`junction-patch-resilience-plan-dark`,
|
||||
`opening-placement-door-thick-wall-dark`,
|
||||
`opening-placement-passage-thick-wall-dark`,
|
||||
`opening-placement-passage-thick-wall-light`,
|
||||
`geometry-devices-editor-dark`, `geometry-decor-editor-dark`,
|
||||
`tray-wide-selection-en`, `tray-wide-tool-ru`,
|
||||
`tray-medium-group-en`, `tray-medium-selection-ru`,
|
||||
`tray-narrow-palette-en`, `tray-narrow-tool-ru`,
|
||||
`geometry-diagonal-45-opening-dark`, `openings-thick-wall-dark`,
|
||||
`lighting-glow-sun-dark`, `device-value-badge-positions-dark`,
|
||||
`device-icon-state-table-light`, `device-icon-state-table-dark`,
|
||||
`device-text-shell-long-light`, `device-text-shell-long-dark`,
|
||||
`lighting-sun-window-state-only-dark`,
|
||||
`lighting-fill-light-axis-split-dark`,
|
||||
`lighting-fill-temp-axis-split-dark`,
|
||||
`lighting-fill-lqi-axis-split-dark`, `lighting-temp-glow-dark`,
|
||||
`lighting-temp-glow-light`, `lighting-custom-glow-dark`,
|
||||
`lighting-opaque-glow-two-doorways-dark`,
|
||||
`lighting-custom-glow-light`, `lighting-temp-glow-no-sources-dark`,
|
||||
`lighting-temp-glow-room-override-dark`,
|
||||
`lighting-manual-auto-spill-overlap-dark`, `hover-over-glow-dark`,
|
||||
`hover-nested-room-dark`, `large-house-zoom-040-dark`,
|
||||
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`,
|
||||
`device-dialog-desktop-en`, `device-help-popover-light-ru`,
|
||||
`decor-color-popover-desktop-en`, `general-color-popover-desktop-en`,
|
||||
`space-room-color-popover-desktop-ru`,
|
||||
`backup-full-preview-desktop-en`,
|
||||
`backup-plan-only-export-desktop-en`,
|
||||
`optimize-preflight-dialog-dark-en`,
|
||||
`optimize-preflight-dialog-light-ru`.
|
||||
- [ ] Baselines for the 67 existing and four dedicated scenes are accepted
|
||||
only from the reviewed full Linux pre-beta artifact. Local
|
||||
`golden:accept` remains forbidden.
|
||||
|
||||
## Empty-space lifecycle (#113)
|
||||
|
||||
- [ ] Active selection keeps active-or-first compatibility, while an empty
|
||||
model returns `undefined`; exact lookup of a stale saved id never falls
|
||||
back to another space [unit: `space-model-selection.test.mjs`].
|
||||
- [ ] There are no unguarded `_spaceModel().…` dereferences, explicit-id calls
|
||||
use `_spaceModelById()`, and marker/position persistence validates its target
|
||||
before config/file/WS side effects
|
||||
[unit: `optional-space-model-contract.test.mjs`].
|
||||
- [ ] Delete the last space while an editor gesture and debounced write are
|
||||
active: the empty card renders, View is restored, pointer/draft/dialog
|
||||
state is cleared, the pending write is cancelled and Add space still
|
||||
opens Create. Recreate a plan, then receive an empty WS config and repeat
|
||||
under a theme/resize/read-only tick [auto: `smoke_optional_space_model`].
|
||||
- [ ] Removing the authoritative empty-state cleanup makes that smoke red
|
||||
[mutation: `empty-space-cleanup-disabled`].
|
||||
|
||||
## Fixed card space (#210)
|
||||
|
||||
- [ ] `floor` resolves exact stable IDs and zero-based finite integer indexes;
|
||||
quoted numeric strings stay IDs, and explicit empty, unknown, fractional,
|
||||
negative or out-of-range values fail closed [unit: `initial-load.test.mjs`].
|
||||
- [ ] Three coexisting instances (fixed ID, fixed index and unpinned) keep
|
||||
independent authority while sharing the legacy navigation key. Fixed
|
||||
cards ignore hash, tabs, guarded internal transitions, warm remount,
|
||||
kiosk swipe/cycle/dots and never read or write saved navigation. Invalid
|
||||
config renders an accessible error without a spatial stage, while the
|
||||
unpinned card still restores legacy navigation
|
||||
[auto: `smoke_fixed_floor.mjs`, `smoke_nav_persist.mjs`, `smoke_kiosk.mjs`].
|
||||
- [ ] The GUI offers stable IDs only, preserves an existing numeric YAML value
|
||||
during unrelated edits and deletes the `floor` property when cleared
|
||||
[unit: `fixed-floor-contract.test.mjs`; auto: `smoke_fixed_floor.mjs`].
|
||||
- [ ] Bypassing the shared transition guard makes the focused browser scenario
|
||||
red; before-fix evidence also records that `origin/dev` has no fixed
|
||||
resolver or guarded transition
|
||||
[mutation: `fixed-floor-transition-guard-bypassed`].
|
||||
|
||||
## Open passage (#157)
|
||||
|
||||
- [ ] Подменю и диалог показывают четвёртый тип в порядке Окно / Дверь /
|
||||
Открытый проём / Ворота; новый проём имеет ширину 90 см. [auto: open-passage-contract, opening-placement]
|
||||
- [ ] В Plan/View у passage отсутствуют створка, дуга, рамка и пунктир, но
|
||||
сохраняются hitbox, wall cut и room-coloured tunnel. [auto: opening-symbol, smoke_open_passage]
|
||||
- [ ] Static вырезает и заполняет тоннель только для passage, не меняя старые
|
||||
door/window/gate. [auto: space-geometry, smoke_open_passage]
|
||||
- [ ] Внутренний passage пропускает Glow, внешний и неизвестный будущий тип
|
||||
остаются fail-dark. [auto: light-visibility, smoke_open_passage]
|
||||
- [ ] Passage в скрытой изометрии имеет full-height cut и zero leaves.
|
||||
[auto: iso-openings, golden]
|
||||
- [ ] Смена типа предупреждает о датчике/замке; Save удаляет пять
|
||||
неприменимых ключей, Cancel не меняет config. [auto: open-passage-contract, smoke_open_passage]
|
||||
- [ ] Full/space import отвергает forged binding до preview, а старое битое
|
||||
значение можно прочитать и очистить. [auto: test_validation, test_ha_import_export]
|
||||
- [ ] Пять passage-мутантов из `scripts/mutation-gate.mjs` пойманы своими
|
||||
guards до передачи в review. [auto: mutation-gate]
|
||||
|
||||
## Independent-wall openings and structural axes (#132, #185)
|
||||
|
||||
- [ ] Door/window/gate/passage placement on a finished independent wall stores
|
||||
`host.kind/id/t`; a coincident room wall chooses that explicit host, while
|
||||
crossing or duplicate-host ties are rejected. [auto: opening-placement,
|
||||
partition-openings]
|
||||
- [ ] Every hosted type cuts only its host full-depth in Plan/View/Static/Iso;
|
||||
exact composite room masonry is also cut, nearby bodies remain intact,
|
||||
and malformed hosts fail dark. [auto: physical-geometry,
|
||||
smoke_partition_openings]
|
||||
- [ ] Rigid host drag preserves `t` and updates projections atomically; delete
|
||||
lists hosted openings, Cancel changes nothing, Confirm cascades in one
|
||||
Undo/Redo command. [auto: partition-openings, smoke_partition_openings]
|
||||
- [ ] Contact/lock actions keep existing security rules; passage stays inert;
|
||||
windows and exterior passages stay opaque to Glow, and partition windows
|
||||
produce no sun wedge. [auto: runtime contracts, smoke_glow]
|
||||
- [ ] Door/window/gate/passage presentation gaps do not split the structural
|
||||
axis used by the Walls face graph; real `open_spans` still do. [auto:
|
||||
plan-snap-overlay, smoke_room_autoclose, smoke_partition_openings]
|
||||
- [ ] Backend rejects missing host references, out-of-range `t`, non-fitting or
|
||||
overlapping hosted openings and stale host stripping; exports round-trip
|
||||
the host. [auto: test_validation, test_ha_import_export]
|
||||
- [ ] The exact #276 Optimize candidate is shared by frontend and backend tests:
|
||||
Python independently proves the removed partition, two-room solid wall,
|
||||
envelope, opening identity and non-overlap; config/set and every partial
|
||||
or mutated candidate remain rejected. Linux HA WS persists and reloads
|
||||
the implicit opening, then Undo restores the partition and explicit host.
|
||||
[auto: coincident-partitions, test_validation, test_ha_websocket]
|
||||
|
||||
## Independent-wall opening jamb margin (#186)
|
||||
|
||||
- [ ] Strict resolver and placement reserve half the host depth for
|
||||
door/window/gate/passage at both endpoints, including exact-boundary,
|
||||
diagonal, reversed, thickness and scale matrices; room-wall placement
|
||||
keeps its zero-jamb rule. [auto: partition-openings, opening-placement]
|
||||
- [ ] Direct drag, dialog length edits and rebind share the same formatted
|
||||
RU/EN guidance; a rejected edit writes neither config nor history.
|
||||
[auto: smoke_partition_openings]
|
||||
- [ ] Backend config/set and optimize reject a new/direct invalid geometry with
|
||||
`invalid_partition_opening_jamb_margin`, while unrelated writes, rigid
|
||||
translation and full backup restore preserve a legacy near-end record.
|
||||
[auto: test_validation, test_ha_websocket, test_ha_import_export]
|
||||
|
||||
## Room resize (docs/RESIZE.md)
|
||||
|
||||
- [ ] `ResizeController` is the sole owner of selection, gesture, preview,
|
||||
labels and eligibility cache. The card is only the DOM/render/persistence
|
||||
adapter; controller state-machine unit tests cover foreign pointers,
|
||||
repeated deltas, rejection rollback, cancel and exact commit
|
||||
- [ ] One production wall-record preservation helper serves Resize and the
|
||||
invariant CLI. Resize checks exact multiplicity for every finite value,
|
||||
including `cm: 0`; the CLI keeps positive-value presence semantics
|
||||
[unit: wall-record-preservation + resize-controller]
|
||||
- [ ] The «Размер» tool appears in the Plan editor toolbar; in EVERY other
|
||||
tool (and in Devices/Decor/View) there is not a single `.rszhandle`
|
||||
- [ ] Every edge has a finger-sized midpoint handle. Eligible handles capture
|
||||
the pointer; ineligible handles remain visible/dimmed, expose a localized
|
||||
reason through hover/focus/tap, carry `aria-disabled=true`, and create no
|
||||
drag, Undo or write [auto: smoke_room_resize + resize-production-path]
|
||||
- [ ] Only a numerically horizontal/vertical wall with perpendicular side
|
||||
edges is eligible. Diagonal, partial/unequal shared, coincident physical
|
||||
extra and third-owner cases fail closed with the stable reason matrix
|
||||
[unit: resize.test]
|
||||
- [ ] A non-shared drag changes exactly one room; an exact endpoint-to-endpoint
|
||||
shared drag changes exactly two. Both existing endpoints move by one
|
||||
vector and every room keeps its vertex count/order [unit + smoke]
|
||||
- [ ] An irregular exact pair moves only until the first corner/grid node that
|
||||
would change the moving segment or collapse a side. No third room can
|
||||
join the gesture. The anonymized private #277 topology stays predictably
|
||||
disabled [unit fixture + production pointer smoke]
|
||||
- [ ] Side-wall ownership stays atomic (#289): the anonymized 43-step repro is
|
||||
disabled before pointer capture in both directions, while an outer side
|
||||
reaches but cannot cross the next room's edge. No thickness record can
|
||||
become partly shared and partly outer
|
||||
[unit: resize.test + fixture 289-mixed-role-resize; auto:
|
||||
smoke_room_resize; mutation: safe-resize-side-ownership-bypassed]
|
||||
- [ ] Wall compaction preserves physical ownership (#299): equal thickness on
|
||||
`shared(A,B) -> outer(A)` and `shared(A,B) -> shared(A,C)` remains split
|
||||
at the exact role breakpoint, while equal neighbouring atoms inside one
|
||||
role still compact. Optimize on `real-plan-first-floor.json` is immutable,
|
||||
invariant-clean and idempotent; real-plan edit-walk seeds 1 and 3 exercise
|
||||
Optimize and Delete-room/Keep-walls without producing a mixed-role record
|
||||
[unit: wall-thickness + plan-optimizer; auto: smoke_edit_walk seeds 1/3;
|
||||
mutation: wall-compaction-owner-role-bypassed]
|
||||
- [ ] Live badges while dragging: lengths of the dragged wall + both
|
||||
adjacent walls, and the m² area at the room centre; dragging a shared
|
||||
wall shows BOTH areas; all numbers update continuously
|
||||
- [ ] Stops are contiguous from zero: 30 cm room clearance, first topology
|
||||
corner, foreign room/island, partition/column and every side-wall
|
||||
opening. The opening jamb includes half the moving wall thickness, and a
|
||||
wall cannot jump through an invalid interval to a later valid position
|
||||
- [ ] An ordinary door/window/gate ON the moving wall travels exactly once;
|
||||
length/type/angle/other fields remain byte-equivalent. A hosted opening
|
||||
never transfers to a room wall through Resize
|
||||
- [ ] The corner scale frame and its four handles are absent. Source guard
|
||||
proves `applyRoomScale`, `clampRoomScale`, partial-shared insertion and
|
||||
commit-time `simplifyPoly` are unreachable from `houseplan-card.ts`
|
||||
- [ ] Esc, pointercancel and lost capture cancel instantly with original
|
||||
persisted geometry, zero Undo and zero config writes
|
||||
- [ ] Ctrl+Z / ⌘Z after releasing a handle restores the previous geometry —
|
||||
one release = one undo step (rooms AND openings)
|
||||
- [ ] The Plan toolbar names the next Undo/Redo operation; Ctrl+Shift+Z and
|
||||
Ctrl+Y redo it, and a new geometry edit after Undo clears the redo branch.
|
||||
Fifty committed operations remain available in the shared stack
|
||||
[auto: command-stack.test]
|
||||
- [ ] History shortcuts are layout-independent without conflating physical and
|
||||
labelled keys: Cyrillic Ctrl/Cmd+Z works, QWERTZ Ctrl+Z/Ctrl+Y pick the
|
||||
labelled command, and AZERTY Ctrl+W never becomes Undo. Focused inputs
|
||||
keep native history [auto: smoke_editor_tabs]
|
||||
- [ ] Exact eligible wall thickness/open spans re-key losslessly in the same
|
||||
overlay; unrelated extras and rooms are byte-equivalent. Any production
|
||||
wall/floor failure on the exact final preview cancels before save
|
||||
- [ ] `npm run benchmark:safe-resize`: pointer clamp p95 ≤16 ms, ≤20% over the
|
||||
same-run historical baseline (with bounded noise); cached pointerup
|
||||
preflight p95 ≤75 ms; active-plan delta cache ≤4096 entries
|
||||
- [ ] `npm run benchmark:safe-resize-render`: on the 20-room/80-handle floor,
|
||||
a warm Resize layer takes one geometry snapshot per frame and stays at
|
||||
p95 ≤25 ms
|
||||
- [ ] Six Resize mutants are caught: axis eligibility, third-room cascade,
|
||||
topology signature, side ownership, physical jamb and controller commit
|
||||
preflight
|
||||
- [ ] The test-only Resize eligibility audit calls the production resolver,
|
||||
pins exact post-Optimize totals/reason counts and per-handle identities
|
||||
for both real-plan fixtures, and reports stable handle ids when the
|
||||
baseline changes. The known second-floor shared seam has two enabled
|
||||
owner handles; raw-vs-optimized classification proves near-axis repair
|
||||
removes only false angle reasons [unit: resize-availability-audit.test;
|
||||
source: resize-production-path; mutation: resize-audit-resolver-bypassed]
|
||||
- [ ] Disabled Resize handles expose the same actionable localized explanation
|
||||
through aria-label, click, Enter and Space; pointerdown starts no drag and
|
||||
creates no history/write [auto: smoke_room_resize]
|
||||
- [ ] Device markers do not move; the room settings gear re-centres itself
|
||||
- [ ] Smoke: `node demo/smoke_room_resize.mjs`
|
||||
|
||||
## Infinite canvas (docs/CANVAS.md, dev)
|
||||
|
||||
- [ ] **A plan drawn past the old square opens whole**: a space whose rooms
|
||||
live at normalised 1.5..3.0 renders complete and centred (it used to
|
||||
frame empty canvas with the house off-screen) [auto:
|
||||
smoke_infinite_canvas; units: test/canvas.test.mjs]
|
||||
- [ ] **Nothing stops at an edge any more**: in the Plan / Devices / Decor
|
||||
editors a room, a marker and a decor shape can be drawn, dragged and
|
||||
SAVED far outside `0..1`, on any floor; reload keeps them there
|
||||
[backend: tests_backend/test_validation.py::test_infinite_canvas_range]
|
||||
- [ ] **A typical small plan is visually unchanged** — same framing, same
|
||||
room and label positions as before the feature. The ONE intended
|
||||
difference is icon size (below) [auto: smoke_infinite_canvas
|
||||
(legacyFrameUnchanged); the whole smoke suite is the regression net]
|
||||
- [ ] **Icons no longer grow with zoom** (§6, owner is aware): a marker keeps
|
||||
the same pixel size at zoom 1, 4 and at the zoom-out floor; the
|
||||
per-device size multiplier, kiosk icon/font scales, badges, LQI chips
|
||||
and presence rings all still scale from `--dev-size`
|
||||
- [ ] **Start view follows the content**: opening a space frames what is
|
||||
drawn plus a small margin, on every floor, with and without a backdrop
|
||||
image (with one the IMAGE sets the extent — it must not be cropped to
|
||||
the outlined rooms)
|
||||
- [ ] **What is not drawn does not frame** (audit DEV-2C947-01): tick «hide
|
||||
from plan» on a marker standing far from the house and the view snaps
|
||||
back to the house — the hidden marker neither renders nor stretches the
|
||||
frame, on the full card and on `houseplan-space-card`. Untick it and the
|
||||
frame takes it in again; room LQI counted it the whole time
|
||||
[auto: smoke_canvas_frame]
|
||||
- [ ] **The editor frame does not follow you out** (audit DEV-2C947-02): move
|
||||
the only room five canvases away in the Plan editor (the frame grows
|
||||
there, deliberately), close the editor — View frames the room where it
|
||||
is NOW, not the union with where it was; re-entering the editor starts
|
||||
from the current geometry [auto: smoke_canvas_frame]
|
||||
- [ ] **A far stray does not inflate the icons either** (audit DEV-2C947-03):
|
||||
one ROOM dragged an order of magnitude away is rejected from the frame
|
||||
(as before) and the markers of the main plan keep the size they have
|
||||
without it; auto-placement spacing goes with them
|
||||
[auto: smoke_canvas_frame + unit canvas.test.mjs]
|
||||
- [ ] **A far stray does not break the view** (§4.1): a marker dragged an
|
||||
order of magnitude away leaves the opening view alone and raises the
|
||||
inline chip «Объектов далеко от плана: N» with «Показать». No modal.
|
||||
«Показать» fits the plan AND the stray; the chip then disappears
|
||||
- [ ] **«Вписать всё»** (middle zoom button): fits the content from any pan
|
||||
and any zoom, is never disabled, tooltip en/ru
|
||||
- [ ] **Zoom-out floor**: the wheel / the minus button stop at three times the
|
||||
content frame; zoom-in still stops at 800 %
|
||||
- [ ] **Pan has slack, not walls**: you can pan a full screen past the plan in
|
||||
every direction; when the plan is fully off screen a small arrow points
|
||||
home and one click fits it back
|
||||
- [ ] **Pan at ANY zoom** (owner's report 2026-08-04): dragging empty scene
|
||||
moves the view at 100 %, at 50 % and at the zoom-out floor — in View and
|
||||
in all three editors, with every plan tool selected. The tools keep the
|
||||
pointer they own (a resize handle resizes, a device badge in the Devices
|
||||
editor moves the device, an opening slides along its wall — none of them
|
||||
pan), two fingers still pinch, and on a kiosk screen a horizontal drag
|
||||
is still the floor swipe (a vertical one pans) [auto: smoke_pan_any_zoom]
|
||||
- [ ] **Kiosk: a pan stays a pan to the very end** (dev, audit DEV-1DA1-02):
|
||||
on a wall tablet at 100 % start a drag with a small VERTICAL lead-in
|
||||
(the plan starts following the finger — the gesture is locked as `pan`),
|
||||
then curve it far to the left or right and lift. The floor must NOT
|
||||
change: the decision taken on the first movement is final, and only a
|
||||
gesture locked as a swipe may switch storeys. Mirror check: a horizontal
|
||||
lead-in locks the swipe — the plan never slides under it, and if the
|
||||
trajectory then bends vertically and no longer qualifies as a swipe, the
|
||||
gesture simply does nothing (it does not turn into a pan). Straight
|
||||
swipes still switch, straight vertical drags still pan, a motionless
|
||||
double tap still resets the zoom [auto: smoke_kiosk_pan_lock]
|
||||
- [ ] **Adaptive grid** (§7): in the Plan editor zoomed far out the grid does
|
||||
not merge into a grey wash — fine dots thin out, every 5th/10th node
|
||||
stays bigger; zoomed in the grid is the usual one and snapping still
|
||||
lands on the same nodes as before
|
||||
- [ ] **Everything else on a far-out plan**: sun wedges, glow radii, open
|
||||
boundaries, room resize handles/rulers, opening rulers, split/merge,
|
||||
vacuum trails, the static `houseplan-space-card` and kiosk carousel all
|
||||
behave exactly as on a plan inside `0..1`
|
||||
- [ ] **Real config regression**: a production config (e.g. the dacha, 3
|
||||
floors / 106 markers) frames bit-identically to the previous release —
|
||||
no outliers reported, no frame movement
|
||||
|
||||
## «+» adds a space from anywhere (dev, unreleased)
|
||||
|
||||
- [ ] **The button is where the floors are, always**: as an admin, open the
|
||||
card in View — the «+» sits at the end of the tab row next to the floor
|
||||
names, is at least icon-sized and actually hittable (nothing overlaps
|
||||
it), and opens the NEW-space dialog. Repeat in all three editors (Plan,
|
||||
Devices, Background): the same button in the same place, not only in the
|
||||
Plan editor as before [auto: smoke_gear_tabs]
|
||||
- [ ] **A kiosk has no «+»**: a card with `kiosk: true` does not RENDER the
|
||||
button at all — checking that the header is `display:none` is not
|
||||
enough, a hidden node is still clickable from script [auto: smoke_gear_tabs]
|
||||
- [ ] **The tab row still fits a phone**: at 390 px the row wraps, nothing
|
||||
scrolls sideways out of the card, and the «+» stays inside the card and
|
||||
hittable [auto: smoke_gear_tabs measures `scrollWidth` vs `clientWidth`]
|
||||
- [ ] **A non-admin never sees it**: the button follows the same rule as the
|
||||
per-space gear (`_canEdit`) [manual, needs a non-admin HA user]
|
||||
|
||||
## Honest new-space display defaults (#204, dev, unreleased)
|
||||
|
||||
- [ ] **The dialog and Save agree**: open a new space. File begins with room
|
||||
borders/names `false/false`; before either control is touched, switching
|
||||
to Draw shows `true/true` and switching back restores `false/false`.
|
||||
Saving either source and reopening it yields the exact visible pair
|
||||
[auto: `space-dialog.test`, `smoke_space_create_display_defaults`,
|
||||
`smoke_space_settings`].
|
||||
- [ ] **One touch protects both choices**: on Draw change either display
|
||||
switch, including the mixed `true/false` and `false/true` cases. Further
|
||||
File ↔ Draw switches change only the source; Save never silently restores
|
||||
`true/true` [auto: `space-dialog.test`,
|
||||
`smoke_space_create_display_defaults`; mutation:
|
||||
`space-create-hidden-display-override`].
|
||||
- [ ] **Draft state does not leak**: Cancel and a fresh Create return to File
|
||||
`false/false`. In Floors/Areas onboarding every next floor also starts
|
||||
clean and cannot inherit the preceding floor's touched state
|
||||
[auto: `smoke_space_create_display_defaults`].
|
||||
|
||||
## Wall thickness (docs/WALL-THICKNESS.md, Unreleased)
|
||||
|
||||
- [ ] **Walls + adjacent draw thickness**: the first Plan-editor tool is named
|
||||
“Walls” / «Стены» rather than “Add”; while it is active, its thickness
|
||||
field is the immediately following toolbar element, before Merge. The
|
||||
field still defaults to 15 cm and disappears when another tool is selected
|
||||
[auto: smoke_draw_wall_thickness]
|
||||
- [ ] **Tool + hover + input**: Plan editor → Thickness. Hover highlights
|
||||
the whole wall; click opens the cm/in field; empty/0 clears; Esc closes
|
||||
without applying; «Apply to all walls of this room» fills every allowed
|
||||
edge [auto: smoke_wall_thickness]
|
||||
- [ ] **Hover width follows wall centimetres (#303)**: on a 30 cm cell a
|
||||
50 cm wall's hover fill matches the masonry within 2%; a zero-thickness
|
||||
wall keeps the same physical visual minimum across cell sizes, while the
|
||||
pointer still hits at five grid pitches from the axis
|
||||
[unit: grid-scale.test.mjs; auto: smoke_wallthick_hover_width;
|
||||
mutations: wallthick-hover-floor-back,
|
||||
wallthick-zero-strip-not-visual, wallthick-hit-narrowed]
|
||||
- [ ] **Hatched body, clean-floor area**: after setting thickness a `.wallbody`
|
||||
path appears; room-card and tooltip m² both decrease to the same inner-
|
||||
contour area
|
||||
[auto: smoke_wall_thickness]
|
||||
- [ ] **Exact thickness transition after Split**: split one zero-thickness room,
|
||||
apply 10 cm to every wall of one child and keep the other child at zero.
|
||||
Both halves of the 10 cm facade end at the divider endpoint; no hatch,
|
||||
paper or light masonry continues along the zero side. Plan, View, static
|
||||
and hidden Iso consume the same stepped geometry
|
||||
[auto: smoke_wall_thickness_transition + test/wall-thickness.test.mjs]
|
||||
- [ ] **One virtual-junction patch cannot blank the plan (#197)**: load the
|
||||
complete 8-room, 25-wall, 3-cut regression fixture. Its ULP-noisy patch
|
||||
is stabilised below geometry tolerance; a forced failure of one optional
|
||||
patch retains the previous body and later patches still run. Plan, View,
|
||||
kiosk, static and hidden Iso keep one non-empty canonical masonry path;
|
||||
paper, clean-floor and light/sun consumers stay non-empty, and theme/HA
|
||||
ticks neither rebuild topology nor write configuration
|
||||
[auto: test/wall-thickness.test.mjs +
|
||||
smoke_junction_patch_resilience + junction-patch-resilience golden
|
||||
scenarios].
|
||||
- [ ] **A bounded T-junction keeps its exterior half-wall (#261)**: in the
|
||||
anonymised #197 fixture the measured point `(895.5, 556)` is filled by
|
||||
room masonry, final masonry and paper, and excluded from clean floor.
|
||||
Plan, View, kiosk, Static, hidden Iso and light/sun barriers agree at that
|
||||
point, while the old excessive #249 spike remains absent
|
||||
[unit: test/wall-thickness.test.mjs; auto:
|
||||
smoke_junction_patch_resilience; golden:
|
||||
junction-patch-resilience-plan-dark +
|
||||
junction-patch-resilience-view-dark; mutation:
|
||||
multi-wall-paper-full-origin-cut].
|
||||
- [ ] **Degree-3 repair stops at every finite ray endpoint (#271)**: canonical
|
||||
co-directional rays retain separate short-thick and long-thin supports;
|
||||
the rebuilt masonry, paper and light barrier contain the real short arm
|
||||
but no area after its endpoint. Plan, View, kiosk, Static, hidden Iso and
|
||||
clean floor agree, independent of owner order, winding and scale
|
||||
[unit: test/wall-thickness.test.mjs; auto:
|
||||
smoke_junction_patch_resilience; golden:
|
||||
junction-patch-resilience-plan-dark +
|
||||
junction-patch-resilience-view-dark; mutation:
|
||||
multi-wall-finite-ray-disabled].
|
||||
- [ ] **A degree-3+ junction has no enclosed white triangle (#272)**: every
|
||||
excessive bevel sector remains empty but is connected to the exterior
|
||||
through a finite-width local corridor. Equal/mixed T and X nodes at
|
||||
`cell_cm: 1/5` have zero local holes in room/final masonry and paper;
|
||||
Plan, View, kiosk, Static, hidden Iso and light/sun agree
|
||||
[unit: test/wall-thickness.test.mjs; auto: smoke_multiwall_junction;
|
||||
golden: multiwall-junction-bevel-view-dark; mutation:
|
||||
multi-wall-exterior-corridor-disabled].
|
||||
- [ ] **A perpendicular T/X junction keeps every real wall strip (#275)**:
|
||||
every finite ray with a perpendicular partner remains filled through its
|
||||
node even when the removed sector is exterior-connected or neighbouring
|
||||
node masks overlap. Mixed orthogonal/diagonal nodes protect only the
|
||||
qualifying rays; the non-orthogonal #249 wedge stays empty. Raw,
|
||||
Optimize preview, applied storage and reload agree at `cell_cm: 5/1`, as
|
||||
do Plan, View, kiosk, Static, hidden Iso, paper, clean floor and light
|
||||
[unit: test/wall-thickness.test.mjs; auto:
|
||||
smoke_multiwall_strip_containment; golden:
|
||||
orthogonal-strip-cell-5-view-dark +
|
||||
orthogonal-strip-cell-1-view-dark; mutation:
|
||||
multi-wall-orthogonal-strip-protection-disabled; exact local gate:
|
||||
scripts/wall-strip-containment.mjs].
|
||||
- [ ] **A short multi-wall ray cannot erase its attached shared wall (#288)**:
|
||||
the real second-floor `349 / 120 / 5` node keeps the 20 cm wall beginning
|
||||
at the short ray's far endpoint; both tracked real plans have zero
|
||||
undeclared centreline gaps, while the #271 outer phantom remains absent
|
||||
[unit: test/wall-thickness.test.mjs; auto:
|
||||
smoke_real_plan_masonry; mutation:
|
||||
multi-wall-shared-continuation-protection-disabled].
|
||||
- [ ] **Openings cut the slab**: a door/window/gate on a thick wall leaves a gap in
|
||||
the body; the door swing is offset toward the inner face and gate leaves
|
||||
toward the exterior face; with
|
||||
`hide_openings` the symbols hide but the cut remains
|
||||
[auto: smoke_wall_thickness]
|
||||
- [ ] **Opening tunnel repeats the room fill**: check a door, window and gate on
|
||||
outer and shared thick walls. An outer opening uses its room colour for
|
||||
the complete wall depth; a shared opening with different fills has one
|
||||
hard transition exactly on the wall axis and no white/alpha seam. Window
|
||||
glass and all architectural symbols remain above it. Repeat with hidden
|
||||
opening symbols, hidden wall borders and in the Background editor; Glow
|
||||
and sun geometry must not change
|
||||
[auto: smoke_opening_tunnel_fill + test/wall-thickness.test.mjs +
|
||||
test/logic.test.mjs]
|
||||
- [ ] **Opening association and overlap edges**: a parallel room separated by
|
||||
an air gap does not colour the outer half; a perpendicular T arm does not
|
||||
capture the opening; a 45° wall keeps its local-axis split; nested rooms
|
||||
resolve deterministically; a legacy opening beyond an endpoint paints
|
||||
only the real wall interval. Exact and partial duplicate openings do not
|
||||
stack alpha, and an angle-invalid opening is consistently rejected by
|
||||
symbol offset, wall cut and tunnel fill
|
||||
[auto: test/wall-thickness.test.mjs]
|
||||
- [ ] **Door/gate light uses the clear tunnel**: place an off-centre light beside a
|
||||
door or gate in a thick wall. In the neighbouring room the glow is limited by
|
||||
sight lines through both the near and far inner-face corners; neither
|
||||
side crosses a solid jamb return. Clearing wall thickness restores the
|
||||
wider centreline-based sector [auto: test/logic.test.mjs; manual visual]
|
||||
- [ ] **Wide gate stays compact**: add a 300–400 cm Gate. It has two equal
|
||||
leaves with no swing arc; without a contact they open exactly 10°
|
||||
outwards, and a closed/open contact changes the angle between 0° and
|
||||
10°. A lock badge and Glow tunnel behave exactly like a door
|
||||
[auto: test/logic.test.mjs + test/wall-thickness.test.mjs +
|
||||
tests_backend/test_validation.py + smoke_styling_hooks]
|
||||
- [ ] **Shared once / zero → line / safe Resize**: one body for a shared wall;
|
||||
setting thickness to zero restores the centreline. Resize preserves
|
||||
thickness on an eligible uniformly thick exact wall. Partial/unequal
|
||||
shared and mixed-thickness walls remain visibly disabled; an attempted
|
||||
drag changes no rooms, openings, wall atoms or Undo history
|
||||
[auto: smoke_wall_thickness + smoke_zero_walls +
|
||||
smoke_resize_wall_thickness + smoke_room_resize +
|
||||
test/wall-thickness.test.mjs + mutation-gate]
|
||||
- [ ] **Wall key survives storage round-trip (#258)**: exact grid endpoints and
|
||||
their nine-decimal stored form produce one midpoint key, including odd
|
||||
and even lengths, negative/reversed coordinates and render-space scale.
|
||||
Both known persisted key variants resolve the same exact span immediately
|
||||
without accepting a parent, child, neighbour or parallel wall. The
|
||||
affected T-node stays filled in Plan, View, kiosk, Static and hidden Iso;
|
||||
clean-floor and light barriers use the same masonry. Explicit Optimize
|
||||
rewrites the stable key and the next in-memory/backend echo is a no-op
|
||||
[unit: test/wall-thickness.test.mjs + test/plan-optimizer.test.mjs +
|
||||
test/model-invariants.test.mjs; auto: smoke_wall_key_roundtrip; golden:
|
||||
wall-key-roundtrip-view-dark; mutation: wall-key-storage-normalization-disabled +
|
||||
wall-exact-span-fallback-disabled + invariant-wall-key-storage-normalization-disabled].
|
||||
- [ ] **Zero-wall T-junction**: when two positive thick arms from different
|
||||
room contours meet at a zero-wall endpoint, the outside corner is a clean
|
||||
mitre with no stair-step. Editors paint the complete zero axis above the
|
||||
real hatch; View paints it below the body so jambs mask its ends without
|
||||
changing stored geometry [auto: test/wall-thickness.test.mjs +
|
||||
smoke_zero_walls].
|
||||
- [ ] **Zero fragment normalisation**: adjacent or overlapping `cm:0` atoms
|
||||
with the same ownership compact without losing the exact breakpoint at a
|
||||
positive thickness or owner-role change. Resize transforms their stable
|
||||
endpoints without recreating legacy `open_spans/open_to`
|
||||
[auto: test/wall-segment-model.test.mjs + test/wall-thickness.test.mjs +
|
||||
smoke_zero_walls].
|
||||
- [ ] **Near-axis authoring and explicit repair (#290)**: the shared
|
||||
`0.25°` classifier includes `316×1`, excludes `316×2` and 30° diagonals,
|
||||
and Walls preview/click persist `316×0` without claiming the wrong saved
|
||||
endpoint. Optimize deduplicates the tracked shared wall across two room
|
||||
owners, reports one wall and an exact physical maximum, rekeys thickness,
|
||||
passes production preflight, applies one atomic write, reloads as a no-op
|
||||
and restores the original through one Undo. Independent walls use the
|
||||
same classifier; unsafe candidates are counted as skipped
|
||||
[unit: test/near-axis.test.mjs; auto: smoke_plan_drawing_repairs +
|
||||
smoke_near_axis_optimize; mutations: `near-axis-threshold-weakened`,
|
||||
`near-axis-inclusive-boundary-disabled`,
|
||||
`near-axis-authoring-snap-bypassed`].
|
||||
- [ ] **Explicit Optimize cleans only an isolated micro-interval (#198)**:
|
||||
`22 → 15 → 22` with a centre shorter than half a grid step and no
|
||||
room/opening node becomes one 22 cm run in Preview and Apply; Cancel
|
||||
writes nothing and server Undo restores the three exact entries. Exact
|
||||
half-step, end, unequal-neighbour, chained and topological cases remain
|
||||
lossless at normalized/render scales; a second Optimize is idempotent
|
||||
[unit: test/plan-optimizer.test.mjs; auto:
|
||||
smoke_optimize_micro_interval; mutation:
|
||||
`optimizer-micro-interval-cleanup-disabled`].
|
||||
- [ ] **A single T-node does not preserve an artificial thickness island
|
||||
(#273)**: the minimized beta.5 `22 → 15 → 22` profile has a 1.381904-unit
|
||||
centre beside one perpendicular room edge. Preview/Apply store one 22 cm
|
||||
run, the T coordinate and incident room stay unchanged, render probes
|
||||
see one continuous outer face, reload is idempotent and server Undo
|
||||
restores the exact entries. A second topology endpoint or any open-span
|
||||
endpoint still blocks cleanup [unit: test/plan-optimizer.test.mjs; auto:
|
||||
smoke_optimize_micro_interval; mutation:
|
||||
`optimizer-single-topology-island-blocked`].
|
||||
- [ ] **Optimize reconciles only one exact coincident wall (#276)**: the
|
||||
anonymized two-room fixture keeps its two 5 cm endpoint offsets while an
|
||||
exact independent wall is removed, its hosted door becomes an ordinary
|
||||
opening at the same centre/angle with all bindings and unknown fields,
|
||||
and the wider centred thickness survives. Direction and room order do
|
||||
not matter; three non-overlapping hosted door/window/gate records are
|
||||
rehosted atomically, while a hosted-hosted overlap and all other
|
||||
partial/extra/unknown/column/opening conflicts fail closed. Preview
|
||||
writes nothing, Apply uses one WS transaction,
|
||||
reload is idempotent, Undo restores the hosted form, and Boundary plus
|
||||
Thickness target the resulting shared wall. Four targeted golden scenes
|
||||
retain the 5 cm offset and show before, 10 cm, 30 cm and virtual results;
|
||||
the paired large-house benchmark enforces p95 overhead ≤15% and ≤25 ms,
|
||||
while source ownership plus an injected counter keep the helper out of
|
||||
render/pointer paths [unit:
|
||||
test/coincident-partitions.test.mjs + test/plan-optimizer.test.mjs;
|
||||
auto: smoke_optimize_coincident_partition; mutations:
|
||||
`optimizer-coincident-opening-rehost-disabled`,
|
||||
`optimizer-coincident-partial-accepted`, existing
|
||||
`optimize-preflight-bypassed`; performance:
|
||||
`npm run benchmark:coincident-partitions`; golden:
|
||||
`coincident-partition-{before,thin,thick,virtual}-dark`].
|
||||
- [ ] **Optimize unlocks only proved zero-range Resize handles (#281)**: three
|
||||
exact independent partitions over solid one-room outer boundaries block
|
||||
the affected shared-wall Resize before maintenance. Optimize removes all
|
||||
three, materializes both hosted windows without changing their fields,
|
||||
passes independent backend proof and is idempotent. Afterwards the target
|
||||
handle has a non-zero grid step in both directions and one production
|
||||
pointer gesture changes exactly the two adjacent rooms. Partial, unknown
|
||||
or opening-overlapped outer candidates remain untouched. Every handle
|
||||
reported enabled on the anonymized `44.json` fixture has a non-zero
|
||||
contiguous range; a zero-range handle stays visible/focusable but disabled
|
||||
and captures no pointer [unit: test/resize-optimize.test.mjs; backend:
|
||||
tests_backend/test_validation.py + tests_backend/test_ha_websocket.py;
|
||||
auto: smoke_resize_outer_reconciliation].
|
||||
- [ ] **Unit + backend**: inset/mitre/bevel, key from either end, degrade,
|
||||
rekey, cm↔inches; `walls` schema bounds
|
||||
[auto: test/wall-thickness.test.mjs + tests_backend/test_validation.py]
|
||||
- [ ] **Thin-on-screen parity**: at the same card width a 1 cm wall suppresses
|
||||
the hatch (solid fill stays) in both `houseplan-card` and
|
||||
`houseplan-space-card`; a 20 cm wall restores the hatch in both
|
||||
[auto: smoke_wall_thickness + test/wall-thickness.test.mjs]
|
||||
- [ ] **Split through an open span**: split one side of a shared wall through
|
||||
the middle of an existing open stretch. Both resulting pieces remain in
|
||||
`open_spans`, and both new rooms link to the neighbour in `open_to`
|
||||
[auto: smoke_merge_split + test/open-spans.test.mjs]
|
||||
|
||||
## Wall chains, partitions and columns
|
||||
|
||||
- [ ] **Persistence and joining**: every accepted wall-chain edge is immediately
|
||||
one ordinary partition with its selected thickness and one history/save
|
||||
boundary. Switching tools or reloading leaves those walls intact but does
|
||||
not resume their chain. A current config never stores `room_drafts`; a v9
|
||||
draft migrates edge-for-edge to partitions [auto: smoke_free_walls +
|
||||
smoke_unified_wall_tool + frontend/backend wall-segment-model tests].
|
||||
- [ ] **Creation limits and validation**: 1/100 cm partitions and 1/150 cm
|
||||
columns save exactly. Zero, NaN and 101/151 cm block the final click with
|
||||
a range toast and create no history entry. Client caps match backend:
|
||||
2000 partitions and 500 columns [auto: backend validation +
|
||||
smoke_free_walls].
|
||||
- [ ] **Select gestures**: creation tools click through existing physical
|
||||
bodies. Select gives at least a 24 px target, cycles overlaps on repeated
|
||||
clicks and moves a partition rigidly on the grid. Escape/pointercancel
|
||||
restores the pre-drag state. A square column rotates in 5° steps (Shift
|
||||
free); a circle has no rotation handle [auto: smoke_free_walls; manual].
|
||||
- [ ] **Delete contract**: every accepted open-chain edge is an ordinary
|
||||
independently selectable partition. Delete and properties therefore use
|
||||
the standard partition confirmation/dialog; there is no whole-outline or
|
||||
segment-of-draft action [auto: smoke_free_walls].
|
||||
- [ ] **Area and light**: overlapping bodies are subtracted once from clean
|
||||
area; closed partition rings keep the enclosed floor; bodies outside all
|
||||
rooms create no paper. Glow does not cross a long nearby partition and a
|
||||
source inside masonry lights nothing. Window rays are blocked by the same
|
||||
bodies. `show_borders: false` changes paint only [auto:
|
||||
physical-geometry.test; manual visual].
|
||||
- [ ] **Seamless junctions**: partition L corners (right, acute and
|
||||
obtuse), unequal thickness, endpoint-on-line T and a branch touching a
|
||||
room wall use one bounded joined body; near-miss, X crossing, malformed
|
||||
segments and flat free caps keep their documented semantics. The active
|
||||
rubber-band has the same contour before and after commit, target records
|
||||
are not split, Plan/View/static/hidden-iso paths agree, clean floor and
|
||||
light use the joined corner, and preview/render never writes config
|
||||
[auto: wall-thickness.test, physical-geometry.test,
|
||||
smoke_wall_junctions, wall-junctions golden scenarios; manual golden
|
||||
artifact review].
|
||||
- [ ] **Lifecycle/performance**: an external config revision cancels live
|
||||
move/rotate state before replacing geometry. Drag preview performs no
|
||||
polygon boolean work; clean floor and Glow clips are reused until the
|
||||
config/space/source changes [auto: editor/preloader smokes; performance
|
||||
profile for a dense plan].
|
||||
|
||||
## Вписывание выбранной комнаты (#152)
|
||||
|
||||
- [ ] В View чистый mouse click и одиночный touch tap по полу простой,
|
||||
вогнутой и вложенной комнаты центрируют именно browser-target и помещают
|
||||
её видимый пол вместе с ограничивающими стенами в центральные 80% stage.
|
||||
- [ ] В Flat и Iso ограничивающая ось даёт поля `10% ± 1 CSS px`; устройства,
|
||||
badges, room label, Glow, солнце, подложка и декор bounds не расширяют.
|
||||
- [ ] Device/capsule, opening/lock/action, vacuum и HA Area link выполняют только
|
||||
своё действие; pan выше click threshold, pinch и long press не запускают
|
||||
room fit, а движение ниже threshold остаётся tap.
|
||||
- [ ] В kiosk два tap по комнате не запускают Fit all и не меняют background
|
||||
double-tap sequence; два tap по свободному фону сохраняют прежний reset.
|
||||
- [ ] Быстрые клики разных комнат retarget-ят один camera controller; повторный
|
||||
fit уже вписанной комнаты не создаёт RAF и не пишет `LS_ZOOM`.
|
||||
- [ ] Stable resize атомарно пересчитывает активную комнату. Wheel, zoom buttons,
|
||||
Fit all/home, pan/pinch, mode/space/projection, hidden/disconnect и новая
|
||||
structural snapshot снимают intent.
|
||||
- [ ] Видимая подпись имеет один `role=button`/tab stop, локализованное имя и
|
||||
`:focus-visible`; Enter/Space вызывают тот же fit и не двигают focus.
|
||||
Скрытая/безымянная подпись не создаёт невидимый tab stop.
|
||||
- [ ] Pure proofs: `node --test test/room-fit.test.mjs`; полный cycle:
|
||||
`npm run typecheck`, `npm test`, `npm run build`. Browser matrix и
|
||||
мутации room ownership и session-only zoom persistence выполняются перед
|
||||
бетой.
|
||||
@@ -0,0 +1,49 @@
|
||||
# История прогонов и партий
|
||||
|
||||
> Приложение к [`docs/TESTING.md`](../TESTING.md): перенесено оттуда дословно (#634).
|
||||
> Индекс всех приложений — [`README.md`](README.md).
|
||||
|
||||
## Last self-run
|
||||
|
||||
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[manual]` items pass (73 frontend
|
||||
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
|
||||
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
|
||||
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
|
||||
Finding turned into a fix (shipped this release): **Split required the click to land on a grid
|
||||
node**, so it silently failed on rooms whose walls are not grid-aligned (imported/legacy
|
||||
polygons) — the click now snaps to the nearest wall instead of the grid, and `splitRoom()`
|
||||
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
|
||||
was missing. The earlier self-run record follows.
|
||||
|
||||
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[manual]` items pass
|
||||
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
|
||||
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
|
||||
1. Edit dialog: switching an existing space from image to "draw" kept the old
|
||||
background (`plan_url` not detached) — fixed.
|
||||
2. `_stateClass` crashed on state objects without `entity_id` (domain is now
|
||||
derived from `d.primary`, which the state was looked up by) — fixed; found by
|
||||
the 150-device perf item of this checklist.
|
||||
3. Perf item measured: 162 devices build in ~14 ms, re-render ~1 ms — well within budget.
|
||||
4. (earlier rounds) long-press phantom after `pointercancel`; `_saveConfigNow`
|
||||
conflict without resync — fixed in v1.13.2.
|
||||
Unchecked boxes above (real browsers/devices, multi-tab live sync, Companion apps)
|
||||
require hands on real hardware — they remain for the human pass.
|
||||
|
||||
## Batch 2026-08-04 (dev, unreleased)
|
||||
|
||||
- [ ] **Room borders have no teeth** (owner 2026-08-04): draw a room with a
|
||||
sharp corner (a wedge with a 30-60° apex, or an L) — the corner is
|
||||
ROUNDED off by the stroke's own radius, never a spike sticking out past
|
||||
the two walls and never a flat bevel. Same in the plan View, in the Plan
|
||||
editor and on the static `houseplan-space-card`; a room with OPEN
|
||||
boundaries (its trimmed outline) has round corners too
|
||||
[auto: smoke_render_parity, still: demo/shot_room_joins.mjs]
|
||||
- [ ] **The Background editor measures what you draw** (owner 2026-08-04, «в
|
||||
редакторе подложки у линий писать длину»): while a decor LINE is being
|
||||
dragged out, a badge on the MIDDLE of the segment shows «length · angle»
|
||||
in the HA unit system (`cell_cm`, metres or feet) and turns green on a
|
||||
45° multiple — exactly the badge a wall gets in the Plan editor. It
|
||||
updates on every move, is absent before the drag has any length, and is
|
||||
gone the moment the shape is committed. Rectangles show «W × H» plus
|
||||
area; circles show `R`, and non-circular ovals show `Rx × Ry`
|
||||
[auto: smoke_decor]
|
||||
@@ -0,0 +1,106 @@
|
||||
# Инфраструктура тестов и съёмки
|
||||
|
||||
> Приложение к [`docs/TESTING.md`](../TESTING.md): перенесено оттуда дословно (#634).
|
||||
> Индекс всех приложений — [`README.md`](README.md).
|
||||
|
||||
### Issue #73 baseline and implementation (2026-08-11)
|
||||
|
||||
The published v1.61.0-beta.6 exact SHA is the renderer baseline for #73: it
|
||||
contains the accepted visibility-based light model and the matrix-v7 published
|
||||
baseline; local review fixes prepare matrix-v8 with a semantic warm-pixel gate,
|
||||
including `lighting-opaque-glow-two-doorways-dark`; canonical light behaviour
|
||||
is `docs/LIGHT.md`. The owner explicitly started #73 on 2026-08-11.
|
||||
|
||||
`test/visual-continuity.test.mjs` covers tokens, quick/long return, delayed
|
||||
overlay timing, paint barriers and fingerprints. `demo/smoke_visual_continuity.mjs`
|
||||
samples presented frames and rejects hidden/empty plans, viewport rollback,
|
||||
overlay over a stale frame, missing production attributes and unbounded or
|
||||
sensitive trace data. It supplements rather than replaces warm-remount,
|
||||
websocket-resilience and golden verification.
|
||||
|
||||
`npm run continuity:screencast` is the separate compositor-level gate required
|
||||
before a stable release. It captures acknowledged PNG frames through CDP
|
||||
`Page.startScreencast`, drives an oscillating multi-frame touch pinch, crops the
|
||||
real plan stage, requires multiple compositor-presented pinch frames and rejects
|
||||
uniform, black, white or transparent-looking frame regressions. It writes the
|
||||
exact frames plus metrics to
|
||||
`artifacts/continuity-screencast`. Prereleases keep the faster mandatory rAF
|
||||
smoke; the stable release workflow installs Chromium and runs the screencast
|
||||
before attaching the public card asset.
|
||||
|
||||
The #579 live-viewport witness is split deliberately. `test/live-viewport.test.mjs`
|
||||
proves that a budget refresh keeps the identity transform, transform origin,
|
||||
`will-change` and overflow without a demotion; `demo/smoke_live_pan_coverage.mjs`
|
||||
also holds the pointer through a full Lit commit in flat, isometric and kiosk
|
||||
modes, then proves terminal cleanup. Before a beta, repeat a physical pinch in
|
||||
HA Companion on the reported Android WebView: Chromium CDP proves the contract
|
||||
and records presented frames, but cannot claim that a vendor WebView compositor
|
||||
has no separate defect.
|
||||
|
||||
## Lazy editor runtime and frontend asset tree (#337)
|
||||
|
||||
- [ ] A cold configured View reaches a complete interactive frame without any
|
||||
request for `houseplan-editor-runtime-*.js`. The first Plan/Devices/
|
||||
Background intent requests it once; later editor switches do not repeat
|
||||
the request [auto: `smoke_lazy_editor_chunk`].
|
||||
- [ ] Two failed network requests leave mode, camera and plan in View, show
|
||||
the localized retry advice, and the next explicit press starts a fresh
|
||||
load cycle that opens the editor once the network is back. A runtime with
|
||||
a different build fingerprint is terminal and shows the refresh advice
|
||||
(#353) [auto: `editor-runtime-loader.test`, `smoke_lazy_editor_chunk`].
|
||||
- [ ] A cached stale entry whose main chunk the server no longer lists shows a
|
||||
localized "reload the page" panel instead of a silently dead card; hashed
|
||||
chunks are served immutable and an orphan chunk on disk fails the bundle
|
||||
tree check (#353) [auto: `smoke_entry_stale`, `bundle-assets.test`,
|
||||
`test_frontend_assets`].
|
||||
- [ ] An empty installation requests the dedicated onboarding chunk, displays
|
||||
the first-space dialog and still has no editor request. Saving a drawn
|
||||
first space requests the editor once and continues into Plan; async
|
||||
`getConfigElement()` still returns `houseplan-card-editor`
|
||||
[auto: `smoke_lazy_editor_chunk`].
|
||||
- [ ] `bundle:budget` follows transitive static imports and keeps initial View
|
||||
at or below 300000 B gzip (#352: ~10% headroom over the calibrated fact,
|
||||
printed with the trend on every run; recalibrated in #367 from 282000 after
|
||||
the fact moved 255 993 → 273 697 B, of which +14 KB came in a single step —
|
||||
furniture plan-art in the eager graph. Recalibration records the growth, it
|
||||
does not fix it: the lever is a lazy graph. A warning fires while headroom
|
||||
is still 15000 B, two average features before the wall). Bundle sync, demo freshness, CI artifacts and
|
||||
release zip validation fail when any manifest-listed asset is missing or
|
||||
its SHA-256 differs [auto: `bundle-assets.test`, `bundle-freshness.test`,
|
||||
release-contract tests].
|
||||
|
||||
## Съёмка документации запускается с флагами детерминизма (#424)
|
||||
|
||||
- [ ] `demo/docs/browser-args.mjs` содержит `--disable-partial-raster` и
|
||||
`--run-all-compositor-stages-before-draw` — оба, а не один. По отдельности
|
||||
ни один дрейф не убирает: проверено перебором, восемь прогонов на
|
||||
конфигурацию.
|
||||
|
||||
Первый запрещает переиспользовать ранее нарисованные куски тайла (иначе кадр
|
||||
зависит от того, что композитор рисовал до него), второй заставляет пройти все
|
||||
стадии композитора до отрисовки (иначе снимок берётся на полпути). Подпись
|
||||
дефекта, если он вернётся: один-два случайных кадра из десяти расходятся между
|
||||
прогонами на единицы пикселей и два уровня, а `--stability=3` при этом зелёный —
|
||||
внутри одного процесса всё стабильно.
|
||||
|
||||
## Воспроизводимость съёмки документации (#410, #422)
|
||||
|
||||
Кадр в `docs/images/` обязан зависеть только от коммита. Если он зависит ещё и
|
||||
от прогона, приёмка скриншотов теряет смысл: «изменилось десять кадров»
|
||||
перестаёт что-либо означать, и разобрать, продукт это или среда, нечем.
|
||||
|
||||
Проверяется по двум осям, и одна не заменяет другую:
|
||||
|
||||
- [ ] `node scripts/capture-determinism.mjs` — снимает набор дважды в разных
|
||||
процессах и сравнивает хеши. Ловит дрейф **между прогонами** — тот самый,
|
||||
что был в #410 (дробная обрезка пересчитывалась от раскладки).
|
||||
- [ ] `node demo/docs/capture.mjs --stability=3` — три снимка подряд в одном
|
||||
процессе и одном состоянии страницы. Ловит дрейф **внутри состояния** —
|
||||
анимацию, не успевшую замереть, или зависимость от времени.
|
||||
|
||||
Обе команды ничего не принимают и не трогают манифест: это измерение, а не
|
||||
съёмка. Первая перезаписывает `docs/images` (дважды), поэтому запускать её
|
||||
удобнее до приёмки, а не после.
|
||||
|
||||
Целочисленность обрезки — единственная часть съёмки, которую можно проверить без
|
||||
браузера: `node --test test/capture-clip.test.mjs`.
|
||||
@@ -0,0 +1,602 @@
|
||||
# Живые слои и интеграции
|
||||
|
||||
> Приложение к [`docs/TESTING.md`](../TESTING.md): перенесено оттуда дословно (#634).
|
||||
> Индекс всех приложений — [`README.md`](README.md).
|
||||
|
||||
## PDF export polish (#482)
|
||||
|
||||
- [ ] Нормализация контура сначала схлопывает соседние и шовные дубли в
|
||||
физическом допуске 1 мм и только затем удаляет коллинеарные точки; ложная
|
||||
диагональная хорда из почти совпавших вершин не появляется
|
||||
[unit: `test/pdf-dimensions.test.mjs`].
|
||||
- [ ] Размеры выводятся только для горизонтальных/вертикальных граней с
|
||||
каноническим допуском 0,25°. Одна локальная пара противоположных граней
|
||||
комнаты или связного наружного кольца получает одну подпись; одинаковые
|
||||
длины в разных комнатах, осях и несвязных кольцах сохраняются
|
||||
[unit: `test/pdf-dimensions.test.mjs`, `test/pdf-scene.test.mjs`].
|
||||
- [ ] Подписи центрированы на своей стене и сдвигаются от кладки целыми
|
||||
размерными дорожками без касательного джиттера; вогнутые комнаты выбирают
|
||||
внутреннюю нормаль по геометрии, а не по центроиду. Bbox текста и все
|
||||
сегменты полосы проверяются точными пересечениями, включая отверстия,
|
||||
вырожденные отрезки и разные направления ring
|
||||
[unit: `test/pdf-collision.test.mjs`, `test/pdf-scene.test.mjs`, golden: PDF scene].
|
||||
- [ ] Стены, перегородки и колонны имеют точную заливку `#7f7f7f`, общую
|
||||
привязанную к странице штриховку 45° с шагом 3 мм и чистые even-odd
|
||||
вырезы всех проёмов; стены нулевой толщины не получают штриховку
|
||||
[unit: `test/pdf-writer.test.mjs`, `test/pdf-scene.test.mjs`, golden].
|
||||
- [ ] Ориентация и первый подходящий стандартный масштаб выбираются по bbox
|
||||
полной сцены вместе с размерами, выносками, декором и растром. Вся сцена
|
||||
центрирована в доступном поле листа с допуском 0,5 мм; тяжёлая геометрия
|
||||
пространства вычисляется один раз
|
||||
[unit: `test/pdf-layout.test.mjs`, `test/pdf-scene.test.mjs`].
|
||||
- [ ] Векторный компас правильно поворачивается для 0/90/180/270°, а текст PDF
|
||||
не содержит удалённой легенды `wall · door · window`
|
||||
[unit: `test/pdf-compass.test.mjs`, `test/pdf-scene.test.mjs`].
|
||||
- [ ] Реальный диалог при ширине View 320 px не имеет горизонтального scroll,
|
||||
действия остаются внутри окна, имеют высоту не менее 44 px и на узком
|
||||
экране идут вертикально; сохранение PDF работает во всех четырёх локалях
|
||||
[auto: `demo/smoke_pdf_export.mjs`].
|
||||
- [ ] Каждый поведенческий барьер выше защищён соответствующим мутантом, а
|
||||
обновлённый PDF golden принят только после визуальной проверки Linux CI
|
||||
[mutation: `scripts/mutation-gate.mjs`; golden: `demo/golden/`].
|
||||
|
||||
## Многоэтажный робот: карты и пространства (#162)
|
||||
|
||||
- [ ] Чистый резолвер разбирает все шесть исходов на общей фикстуре и не
|
||||
зависит от порядка списка маршрутов; усыновление легаси-прогона даёт
|
||||
ровно три исхода, оба отрицательных — fail-closed
|
||||
[unit: `test/vacuum-routes.test.mjs`, `tests_backend/test_vacuum_routes.py`,
|
||||
фикстуры `test/fixtures/vacuum-routes/*.json`].
|
||||
- [ ] Живой оверлей рисуется в пространстве активного маршрута, док остаётся в
|
||||
своём; прошлый прогон виден в пространстве СВОЕГО маршрута, а легаси-конфиг
|
||||
сохраняет прежнее правило [unit: `test/vacuum-routes.test.mjs`].
|
||||
- [ ] Рекордер подписывается на источники всех маршрутов и пишет прогон под
|
||||
маршрутом, который его породил; смена маршрута начинает новый прогон даже
|
||||
при том же `map_id`
|
||||
[backend: `tests_backend/test_trail_recorder.py`, `test_trails.py`].
|
||||
- [ ] Правка маршрутов проверяется семантически на `config/set`, optimize и обоих
|
||||
импортах, нетронутые легаси/будущие данные не блокируют чужое сохранение
|
||||
[backend: `tests_backend/test_vacuum_route_validation.py`].
|
||||
- [ ] Удаление пространства называет число чужих карт и уносит только их
|
||||
маршруты; экспорт одного пространства отбрасывает кросс-пространственные
|
||||
маршруты и считает их в `dropped_marker_links`
|
||||
[unit: `test/space-deletion.test.mjs`, backend: `tests_backend/test_ha_import_export.py`].
|
||||
- [ ] Восемь мутантов краснеют: `vacuum-route-ambiguity-takes-the-first`,
|
||||
`vacuum-route-missing-space-falls-back-to-dock`,
|
||||
`vacuum-route-unmapped-draws-anyway`,
|
||||
`vacuum-route-identity-duplicates-allowed`,
|
||||
`vacuum-legacy-run-adopts-the-first-candidate`,
|
||||
`vacuum-overlay-ignores-the-rendered-space`,
|
||||
`vacuum-previous-run-follows-the-robot`,
|
||||
`vacuum-route-warning-stays-silent`, плюс серверные
|
||||
`vacuum-run-forgets-its-route`, `vacuum-retargeted-route-keeps-its-old-trails`,
|
||||
`vacuum-route-validation-accepts-a-dead-space` и
|
||||
`space-delete-keeps-foreign-vacuum-routes`
|
||||
[mutation: `scripts/mutation-gate.mjs`].
|
||||
- [ ] Продакшен-бандл показывает робота на этаже активной карты, а на этаже дока
|
||||
не показывает; предупреждение у дока появляется на движущемся роботе с
|
||||
несопоставленной картой; донастройка предложения с высоким residual
|
||||
открывается на этаже маршрута, а не дока
|
||||
[auto: `demo/smoke_vacuum_multifloor.mjs`].
|
||||
- [ ] Отдельная camera на карту: источник без читаемого id карты маршрута не
|
||||
создаёт и объясняет причину [ручная проверка блока «Карты и этажи»].
|
||||
|
||||
## Vacuum trail smoothing (#209)
|
||||
|
||||
- [ ] Pure `smoothVacPath` tests prove exact endpoints, separate subpaths,
|
||||
degenerate/reversal fallbacks, the 17.5 cm sampled deviation bound and
|
||||
the 64-subpath/4000-point `≤2N` command budget
|
||||
[unit: `test/vacuum.test.mjs`].
|
||||
- [ ] The production bundle renders current and previous runs through paired
|
||||
case/core `<path>` elements with quadratic commands, unchanged source
|
||||
authority/modes, live-target trim and puck tip
|
||||
[auto: `demo/smoke_vacuum.mjs`].
|
||||
- [ ] Flat and dormant-Iso projections retain the same curved live layer on
|
||||
touch and across HA updates [auto: `demo/smoke_isometric_live_touch.mjs`].
|
||||
- [ ] `vacuum-trail-smoothing-dark` records the 17.5 cm default in fixture data,
|
||||
contains two current subpaths plus a stored previous run, and its semantic
|
||||
guard requires two `M` sections, quadratic
|
||||
commands and byte-identical case/core geometry before PNG comparison.
|
||||
The baseline is accepted only from reviewed Linux CI
|
||||
[golden: `demo/golden/matrix.mjs`].
|
||||
- [ ] Replacing the quadratic corner with a straight vertex makes the focused
|
||||
unit guard red [mutation: `vacuum-trail-smoothing-disabled`].
|
||||
|
||||
## Live vacuums (docs/VACUUM.md)
|
||||
|
||||
- Docked robot: only the base marker, at the user-placed spot (the dock).
|
||||
- Cleaning + calibrated map: a round pulsing puck — the base badge but
|
||||
circular and 20% smaller, same plate colors, glyph dead-centre — drives
|
||||
the plan; the base marker never moves. No heading arrow.
|
||||
- Puck motion: glides ~1.2 s between telemetry points; TELEPORTS (no glide)
|
||||
on zoom, pan, space switch, browser-tab return, and after a >10 s data
|
||||
gap. Stale coords (>60 s while cleaning) freeze and dim it.
|
||||
- «Показывать путь робота» has three modes: never / while cleaning (default
|
||||
— the line hides the instant the run ends) / always (the only mode that
|
||||
also shows the previous run at 40% opacity).
|
||||
- The trail is server-recorded (trails.py watches the source entity; two
|
||||
runs kept per marker, survives reloads, shared by every screen) and never
|
||||
outruns the icon: drawn segments lag one point, and the last segment is a
|
||||
rAF-driven tip glued to the puck centre every frame.
|
||||
- Same-map `cleaning → docked/paused/error → cleaning` resumes the ended
|
||||
current through exactly 30:00 and retains every point plus any older
|
||||
previous run; 30:00 + epsilon or a map change rotates as before. Repeated
|
||||
stop samples do not extend the window; malformed timestamps and clock
|
||||
rollback fail closed [backend: `test_trails.py`, `test_trail_recorder.py`].
|
||||
- `unavailable`, `unknown` and a missing vacuum state are neutral. While
|
||||
stopped, default mode hides the ended current; after backend resume the
|
||||
production card paints the complete reopened current, while `always` keeps
|
||||
its current/previous styling [auto: `smoke_vacuum`; mutation:
|
||||
`vacuum-trail-resume-disabled`].
|
||||
- Calling trail delete for a marker with no stored run is a true no-op: its
|
||||
source/vacuum pair stays subscribed. A successful delete removes only that
|
||||
marker's pairs and immediately rebuilds the subscription
|
||||
[backend: test_trail_recorder.py].
|
||||
- A successful config edit purges trails for both a missing marker and its
|
||||
`removed: true` tombstone, but keeps live/hidden markers; a semantic no-op
|
||||
performs no surprise cleanup. A startup refresh that samples a new point
|
||||
schedules one save and one throttled update event
|
||||
[backend: test_ha_websocket.py + test_trail_recorder.py].
|
||||
- Trail style: cartography casing (dark halo 2.25 + light core 0.9),
|
||||
readable over any room fill.
|
||||
- Hidden marker: neither puck nor trail. Uncalibrated active map: no puck.
|
||||
- Calibration: «Настроить автоматически» (≥3 rooms matched by name) or the
|
||||
fit panel — drag the dashed room ghost, stretch by 4 corner handles,
|
||||
rotate 90°/mirror buttons (mirror ON by default), Save/Cancel/Esc. Real
|
||||
clicks must land on the overlay (elementFromPoint smoke guards it).
|
||||
- Multi-floor: one matrix per robot map (Dreame `selected_map` on the
|
||||
vacuum entity names the active one).
|
||||
|
||||
## Sun on the plan (docs/SUN.md)
|
||||
|
||||
- [ ] Four-phase background resolves strict real `sun.sun` data atomically:
|
||||
`<= -6°` night, `>= +6°` day, the middle band dawn while rising and dusk
|
||||
while falling. Missing/garbage elevation, azimuth, or rising switches the
|
||||
whole sample to local-clock fallback at exact 05:00/08:00/18:00/21:00
|
||||
boundaries [auto: `test/sun.test.mjs`, `smoke_sun_live_bg`]
|
||||
- [ ] Background works without `north_deg` and without valid `sun.sun`; the
|
||||
latter arms one visible-only 30-second fallback timer, stops it while
|
||||
hidden/disconnected, and catches up on pageshow/visible return. Window
|
||||
rays independently remain gated by valid sun + compass
|
||||
[auto: `smoke_sun_live_bg`]
|
||||
- [ ] The ⚙ compass: dragging the «N» arrow turns it in 1° steps (15° with
|
||||
Shift); the number field mirrors the dial and accepts 0–359; «Clear»
|
||||
returns the unset state
|
||||
- [ ] «Plan background» selector: `static` keeps the existing color picker
|
||||
and behaviour byte-for-byte; `daynight` hides the picker and shows exact
|
||||
dawn/day/dusk/night environment tokens. Only environment and outer
|
||||
plan-paper outline cross-fade for 1100 ms; reduced motion is instant
|
||||
[auto: `smoke_sun_live_bg`, golden `day-cycle-*`]
|
||||
- [ ] A physically ordinary day-cycle plan stored at 1 cm/grid-point keeps the
|
||||
outline compositor surface bounded by the visible stage during pinch.
|
||||
Direct gestures and animated camera commands activate the fallback, the
|
||||
inner paper group becomes unfiltered, the plan layer is explicit rather
|
||||
than overlap-promoted, no
|
||||
content layer exceeds 4096 in either dimension, and sampled presented
|
||||
frames contain no white tile. Before input the historical inner outline
|
||||
remains byte-identical to the accepted `day-cycle-*` goldens.
|
||||
The same isolated outline still satisfies the #532 relative raster
|
||||
budget; its smoke compares the median of three paired, closed-path
|
||||
static/day-cycle pans so one shared-runner spike cannot replace the A/B
|
||||
signal. The static space card uses one filtered stage-sized outline from
|
||||
its first frame and leaves its inner paper group unfiltered
|
||||
[auto: `smoke_daycycle_layer_budget`, `smoke_daycycle_raster`,
|
||||
`smoke_live_pan_coverage`]
|
||||
- [ ] Opaque plan paper (2026-08-03, owner): the scene background —
|
||||
`bg_color` or the daynight sky — is visible ONLY around the plan and
|
||||
NEVER bleeds through it, in view/kiosk/editors and on the static
|
||||
space-card. An image plan papers the backdrop image rect
|
||||
(`rect.hp-paper`). A hand-drawn plan papers the ROOM CONTOURS: one
|
||||
`.hp-paper` shape per room in exactly the room's own geometry (fill
|
||||
only, no stroke), so an L-shaped house or detached buildings never
|
||||
grow a white bounding rectangle — the scene colour reaches the
|
||||
exterior walls, shows in the L's pocket and between buildings, and an
|
||||
empty drawn space has no paper at all. A live resize preview
|
||||
(`_rszPreview`) moves the paper together with the dragged wall.
|
||||
Colours: historical white for drawn plans, the theme card background
|
||||
under an image. Across all four phases the paper and every plan pixel
|
||||
remain unchanged: no brightness/tint/opacity/blend filter. Pixel-proofed against
|
||||
an acid `#ff00ff` background [auto: smoke_bg_color]
|
||||
- [ ] Per-space overrides (background mode, north, sun-in-windows) inherit
|
||||
when empty, exactly like show_lqi/fill_mode
|
||||
- [ ] «Sunlight through windows» (default OFF): wedges appear only from
|
||||
windows on EXTERIOR walls facing the sun; interior windows, open
|
||||
(virtual) boundaries and doors never light up
|
||||
- [ ] Wedge direction follows the compass; length grows toward
|
||||
sunrise/sunset and shrinks toward noon; every wedge is clipped by its
|
||||
room polygon; night = no wedges at all
|
||||
- [ ] With wall thickness, both crisp side edges start at the two room-side
|
||||
corners of the window opening (the full span translated inward by half
|
||||
the wall depth), including at an oblique sun angle; no edge starts on the
|
||||
wall centreline [auto: unit `sun.test.mjs` + `smoke_wall_thickness`]
|
||||
- [ ] **Window face (#577):** General settings always shows the global
|
||||
`inner`/`outer` selector, even when rays are off; save/reopen preserves it.
|
||||
Missing/invalid read-side values resolve to `inner`, backend writes reject
|
||||
invalid values, full export/import and support projection preserve only a
|
||||
valid enum [auto: `smoke_sun`, `sun.test.mjs`,
|
||||
`config-schema-parity.test.mjs`, `test_validation.py`,
|
||||
`test_ha_import_export.py`, `test_support_package.py`]
|
||||
- [ ] In `outer`, a thick-wall ray starts at both exterior window corners and
|
||||
reaches the clean floor only through the physical opening tunnel; wall
|
||||
body and exterior space never light up. Direction, nominal reach, fade,
|
||||
colour, thresholds and shadows are identical to `inner`; at `d = 0` the
|
||||
two results are byte-identical. Switching the pending dialog value
|
||||
invalidates the geometry memo without waiting for a server revision
|
||||
[auto: `sun.test.mjs`, `smoke_sun`; golden:
|
||||
`lighting-sun-window-state-only-dark` (inner) and
|
||||
`lighting-sun-window-outer-thick-dark` (outer tunnel + room seam)]
|
||||
- [ ] Brightness + the 3° threshold (2026-08-03): wedges are visibly brighter
|
||||
(peak alpha 0.30, was 0.18) yet still readable over white paper AND the
|
||||
dark glow canvas; there is NO gradual ramp near the horizon — below 3°
|
||||
no rays at all, at/above 3° full strength; crossing the threshold fades
|
||||
the whole layer in/out over exactly 2 s (CSS on `.sunlayer`, the
|
||||
geometry never moves), and `prefers-reduced-motion` makes it instant.
|
||||
Every other way of losing the wedges (editor, feature off, night)
|
||||
stays instant [auto: smoke_sun «the 3° threshold» + unit rayAlpha/
|
||||
raysVisible/rayPeakAlpha; shots: demo/shot_sun_bright.mjs]
|
||||
- [ ] Weather independence: sunny, cloudy, rain and snow all leave the same
|
||||
wedge geometry and peak opacity; a legacy `weather_entity` value is ignored
|
||||
- [ ] Sun geometry recomputes ONLY when the sun attributes or the config
|
||||
change — an unrelated `hass` tick reuses the memo
|
||||
- [ ] Editors (plan/devices/decor) render with NO wedges and NO four-phase
|
||||
environment; kiosk and static space-card share phase/palette/fallback
|
||||
(wedges remain full-card-only)
|
||||
- [ ] New install and new manual/Floors spaces materialize `daynight`; legacy
|
||||
missing global mode migrates once to `static`; full/space transfer
|
||||
materializes source semantics before preview/apply
|
||||
[auto: `test_ha_import_export.py`]
|
||||
- [ ] `prefers-reduced-motion` → no transitions, current phase renders directly
|
||||
- [ ] Smoke: `node demo/smoke_sun.mjs`; units: `test/sun.test.mjs`;
|
||||
backend: `tests_backend/test_validation.py` (sun settings)
|
||||
|
||||
## The text block on the plan (docs/LIVE-TEXT.md, dev, unreleased)
|
||||
|
||||
- [ ] **One text, many HA variables**: write `Бак {sensor.tank}, зал
|
||||
{climate.hall:current_temperature}` — both values render and update
|
||||
independently. The hand-written dotted attribute form
|
||||
`{climate.hall.current_temperature}` works too; ordinary/invalid braces
|
||||
stay literal [auto: smoke_live_text + unit logic.test]
|
||||
- [ ] **Picker inserts at the caret**: put the cursor between two words, choose
|
||||
an entity and then its state/attribute — the full token appears exactly
|
||||
at that selection and focus returns immediately after it. Continue typing
|
||||
and insert another variable until the textarea's 200-character limit
|
||||
[auto: smoke_live_text]
|
||||
- [ ] **No separate unit/preview/single-slot UI**: the dialog has none of the
|
||||
old unit field, `{}` hint, or preview block. State units come from HA;
|
||||
attributes do not inherit the state unit, and a custom suffix is ordinary
|
||||
text after the token [manual]
|
||||
- [ ] **A dead sensor says so**: make the entity unavailable (or delete it) —
|
||||
the value becomes «—» and the rest of the caption stays. The dash carries
|
||||
no unit [auto: smoke_live_text]
|
||||
- [ ] **Nothing is rounded**: a sensor reporting `23.94781` shows `23.94781`.
|
||||
Rounding is the sensor's `display_precision`, not ours [auto:
|
||||
smoke_live_text]
|
||||
- [ ] **Old links migrate without loss**: a stored beta.9 `text + entity/attr`
|
||||
label still renders unchanged. A representable link moves into the
|
||||
textarea token and drops legacy fields on save; an explicit `unit` or an
|
||||
attribute name outside the inline grammar remains legacy and survives an
|
||||
otherwise unrelated text/colour edit
|
||||
[auto: smoke_live_text]
|
||||
- [ ] **The same everywhere**: the label reads identically in View, in the
|
||||
editors and on a kiosk screen [auto: smoke_live_text]
|
||||
- [ ] **Physical text size**: the dialog has no Small/Medium/Large; instead it
|
||||
exposes a numeric centimetre/inch size. Select a label and pull a corner
|
||||
— the text scales uniformly about its anchor even with Shift; the handle
|
||||
above it turns the block in 5° steps, Shift for any angle. A new/edited
|
||||
label stores `size_cm`, while legacy `size/scale` remains pixel-identical
|
||||
until edited or explicitly optimized. Rotating back to zero leaves a straight label
|
||||
[auto: smoke_decor_text]
|
||||
- [ ] **Old labels keep their size**: a plan made before the handles renders
|
||||
its Small/Medium/Large labels at exactly the old 14/20/30 px, and the
|
||||
first corner drag converts that setting into the equivalent `size_cm`
|
||||
[auto: smoke_decor_text + unit logic.test]
|
||||
- [ ] **Enter is a new line**: type two lines in the dialog (Ctrl/⌘+Enter or
|
||||
the button saves) — the plan shows two lines, centred, growing around the
|
||||
anchor. A very long single line is NOT wrapped for you
|
||||
[auto: smoke_decor_text]
|
||||
- [ ] **The text tool edits the label under the cursor**: with the text tool
|
||||
selected, press an existing label — its form opens, prefilled, and no
|
||||
second label is created. Press empty canvas, or a line/rectangle, and a
|
||||
NEW label is created there instead (non-text shapes stay inert under
|
||||
drawing tools) [auto: smoke_decor_text, smoke_decor]
|
||||
- [ ] **A text label has one atomic hit area where supported**: in Chromium,
|
||||
clicks between glyphs and on multiline gaps still select/edit/erase the
|
||||
whole label via `pointer-events: bounding-box`. Gecko/WebKit fall back to
|
||||
`visiblePainted`: glyph clicks must still edit the existing label instead
|
||||
of creating a new one, while gap hit-testing may degrade to painted ink
|
||||
[auto: smoke_decor_text (Chromium); manual: Firefox/Safari fallback]
|
||||
- [ ] **A label is still a caption, not a device**: no tap action, no icon, no
|
||||
part in room averages; it is not offered in any of the device pickers
|
||||
[manual]
|
||||
- [ ] **Grazing sunlight** (dev, audit DEV-EB173-01): with `sun_rays` on, set
|
||||
the sun almost ALONG a wall carrying a window (e.g. a west window,
|
||||
azimuth 190°, elevation 90° at north_deg 0). The shaft is a true
|
||||
parallelogram — both sides exactly as long as the nominal reach, 70 % of
|
||||
the pre-v1.57 curve — the whole pane of glass is at FULL brightness (no
|
||||
end of the window starts out transparent), and the light fades along the
|
||||
ray, dying out before the far edge. A sun within ~3° of the wall plane
|
||||
(`RAY_MIN_COS`) casts nothing at all [auto: smoke_sun_soft + unit sun.test]
|
||||
- [ ] **Nothing stops at the old canvas border** (owner 2026-08-04, DEV-B58-01:
|
||||
«названия комнат и устройства не перетаскиваются дальше старых границ
|
||||
холста»). On an ORDINARY plan (rooms inside 0..1), in the Devices editor
|
||||
drag a marker far outside the drawing — to about 2.5 / 2.2 normalised. It
|
||||
follows the cursor the whole way, the position is stored, the plan's frame
|
||||
grows to include it, and it is still there after a reload. Repeat with a
|
||||
room NAME in the Plan editor (this one used to stop at the old unit square
|
||||
exactly), with a decor shape in the Background editor (draw it far out,
|
||||
then drag it further), and with an opening on a wall that lives past the
|
||||
old square. The only thing that still stops you is ±5000 — drag wildly and
|
||||
the marker parks there instead of at 1e12 [auto: smoke_drag_bounds]
|
||||
- [ ] **Everything lands on the grid** (owner 2026-08-04, docs/CANVAS.md §9):
|
||||
place a device, a room name, a decor rectangle, a decor text, a room
|
||||
vertex and a resize handle with the mouse — each ends exactly on a grid
|
||||
node, never between two. An opening is wall-bound rather than freely
|
||||
grid-bound: it stays ON its wall, at a whole number of steps along it.
|
||||
Holding **Shift** must not bypass the grid. For a room-outline vertex it
|
||||
additionally selects the nearest grid node on a 45° ray from the previous
|
||||
point; all other positions keep their existing modifier behaviour
|
||||
[auto: smoke_grid_snap]
|
||||
- [ ] **«Выровнять всё по сетке»** (owner 2026-08-04): gear → general settings →
|
||||
**Grid** → the button. On an already tidy plan it says everything is
|
||||
already on the grid and offers no confirm button. On a plan with elements
|
||||
between the nodes it names how many will move and the largest shift in cm,
|
||||
and warns there is no undo. Press it: rooms, decor, markers and room names
|
||||
snap to nodes in ONE write, openings stay on their walls, and pressing the
|
||||
button a second time reports nothing to do. Cancel does nothing at all
|
||||
[auto: smoke_grid_snap + unit test/align-grid.test.mjs]
|
||||
- [ ] **Optimize removes stored ULP coordinate noise (#223)**: a six-room
|
||||
fixture whose shared grid vertices differ by `5.5e-17` offers Apply with
|
||||
`moved: 0` and a positive cleaned-coordinate count. Preview distinguishes
|
||||
updated spaces from removed coordinate noise; Cancel writes nothing;
|
||||
Apply stores exact grid nodes in one transaction; the next run is a no-op
|
||||
and server Undo restores the original geometry in canonical
|
||||
representation. A rejected hosted
|
||||
partition contributes nothing to the counter
|
||||
[unit: align-grid + plan-optimizer + i18n; auto:
|
||||
smoke_optimize_coordinate_canonicalization; mutation:
|
||||
`snapn-returns-input-near-node`].
|
||||
- [ ] **Optimize rejects an unrenderable candidate before writing (#199)**:
|
||||
all spaces pass the shared production wall/opening/physical/floor input
|
||||
projection. A forced wall/floor `null` or exception produces only a
|
||||
bounded failure code; valid empty/image-only spaces remain allowed. One
|
||||
failing floor removes Apply and makes zero Optimize WS calls without
|
||||
changing config, layout, revisions or Undo. The first three safe names,
|
||||
the remaining count and recovery hint are exact in RU/EN; unchanged Apply
|
||||
reuses its fingerprint result, while a changed candidate is checked again.
|
||||
The 3-floor/60-room/100-opening/60-partition/40-column fixture must stay
|
||||
under 250 ms p95 and within direct-builder p95 × 1.2 + 15 ms
|
||||
[unit: test/plan-geometry-preflight.test.mjs; auto:
|
||||
smoke_optimize_geometry_preflight; benchmark:
|
||||
benchmark_optimize_geometry_preflight; golden: dark/light failure dialog;
|
||||
mutations: `optimize-preflight-bypassed`,
|
||||
`optimize-preflight-active-space-only`,
|
||||
`optimize-preflight-accepts-null`,
|
||||
`optimize-preflight-renders-apply-on-failure`].
|
||||
- [ ] **A local wall-union failure stays local and cannot be saved (#278)**:
|
||||
the anonymized production-derived fixture returns `degraded-extra` with
|
||||
two deterministic components instead of a global empty wall layer. Plan,
|
||||
View, Static, hidden Iso, paper and light barriers retain both components
|
||||
in light and dark themes. Every physical-geometry writer crosses the
|
||||
common exact-candidate barrier; forced degradation restores the previous
|
||||
state and creates zero Undo/WS calls, while a title-only edit still saves.
|
||||
Optimize and `model-invariants` reject the same fixture with bounded
|
||||
diagnostics. Valid large-house overhead is at most 10% and 20 ms p95;
|
||||
degraded p95 is below 100 ms
|
||||
[unit: wall-thickness + plan-geometry-preflight +
|
||||
wall-union-isolation; auto: smoke_wall_union_isolation +
|
||||
smoke_optimize_geometry_preflight + smoke_room_resize; golden:
|
||||
wall-union-isolation-view-light/dark; benchmark:
|
||||
benchmark_wall_union_isolation; mutations:
|
||||
`wall-component-failure-kills-primary`,
|
||||
`wall-isolated-extra-discarded`, `strict-wall-barrier-accepts-degraded`,
|
||||
`wall-thickness-writer-bypasses-common-barrier`,
|
||||
`model-invariants-bypasses-production-geometry`].
|
||||
- [ ] **Missing space references recover without losing a marker (#244)**:
|
||||
exact import signatures remap space, room, marker/room-label positions
|
||||
and vacuum segments; Area remap and detach preserve the marker record and
|
||||
leave an old position for the owner-aware #252 decision rather than
|
||||
guessing. Ambiguous/truncated signatures and opaque layout are never
|
||||
guessed. Preview/Apply/Undo use one exact candidate and
|
||||
show remaining debt even for a no-op. Space import repairs target refs by
|
||||
its known map. With another space present, space delete deduplicates active
|
||||
marker blockers; deleting the sole remaining space instead preserves every
|
||||
affected active/removed marker record while clearing only `space` and
|
||||
`room_id`. Both paths recheck both revisions under the backend lock and
|
||||
remove owned layout without deleting marker metadata. The sole-space path
|
||||
also keeps the #113 empty-state smoke green. A missing `default_floor`
|
||||
remains raw and
|
||||
gains a RU/EN inline warning after spaces load
|
||||
[unit: space-reference-repair, plan-optimizer, space-deletion,
|
||||
card-editor-validation; backend: test_ha_import_export,
|
||||
test_ha_websocket; smoke: orphan-space-references + optional-space-model;
|
||||
pre-release: targeted browser smoke and light/dark golden].
|
||||
- [ ] **Optimize explains and safely cleans forgotten positions (#252)**:
|
||||
the owner matrix covers room labels, marker tombstones, known HA devices,
|
||||
`lg_` entities and unknown namespaces across authoritative and limited
|
||||
registries. Only proven-absent owners enter the default candidate; live
|
||||
owners are named and preserved until the secondary opt-in, and unverified
|
||||
owners never receive a destructive action. The main RU/EN report contains
|
||||
bounded human categories rather than IDs; closed Details contains at most
|
||||
ten technical entries plus the remainder, and vacuum mappings remain a
|
||||
separate warning. Cancel and the secondary toggle write nothing; Apply
|
||||
writes the exact preview once, reload is a no-op, and Undo restores all
|
||||
removed positions [unit: space-reference-repair + plan-optimizer + i18n;
|
||||
auto: smoke_orphan_space_references; golden: dark EN + light RU;
|
||||
mutations: `orphan-cleanup-proven-owners-kept`,
|
||||
`orphan-cleanup-partial-registry-deletes`].
|
||||
- [ ] **Every write prevents new ULP coordinate noise (#224)**: config/layout
|
||||
schema, import, direct storage writers, startup recovery and maintenance
|
||||
Undo produce the same nine-decimal allow-listed geometry as the frontend.
|
||||
A first noisy write creates one canonical revision; a repeated canonical
|
||||
write creates no store write/event/revision and preserves the maintenance
|
||||
backup. `view_box`, physical/presentation values, colours and vacuum
|
||||
calibration remain exact; the six-room #218 union and Glow clip stay
|
||||
non-empty [unit: coordinate-canonicalization + physical-geometry;
|
||||
backend: test_coordinate_canonicalization + test_ha_websocket +
|
||||
test_ha_import_export; mutations: `schema-quantization-removed`,
|
||||
`frontend-writes-raw-coords`, `quantization-hits-allowlist`,
|
||||
`import-path-bypasses-schema`; pre-release: golden verify].
|
||||
- [ ] **Optimize remains idempotent across storage and reload (#248)**: its
|
||||
final config/layout pair equals the shared nine-decimal writer target;
|
||||
a second run in memory, after schema/storage round-trip, after update
|
||||
events and after a cold reload returns `changed:false`, zero change
|
||||
counters and a deep-equal pair. The shared two-scale fixture is consumed
|
||||
independently by Node and Python; the Optimize handler records the exact
|
||||
pair in pending and both final stores, and startup recovery converges on
|
||||
it [unit: plan-optimizer + coordinate-canonicalization; backend:
|
||||
test_coordinate_canonicalization + test_ha_websocket +
|
||||
test_ha_import_export; auto: smoke_optimize_coordinate_canonicalization;
|
||||
mutations: `optimize-storage-boundary-removed`,
|
||||
`optimize-config-storage-half-raw`, `optimize-layout-storage-half-raw`].
|
||||
- [ ] **An unfinished config/layout pair survives the next writer (#491)**:
|
||||
Optimize and Optimize Undo use the same intent → exact reload/retry →
|
||||
rollback protocol as import and space deletion. A runtime config writer
|
||||
resolves pending before CAS; a point layout writer applies only its
|
||||
delta to the recovered target; a continuing Store failure rejects that
|
||||
writer without changing either half or deleting intent/backup. A Store
|
||||
exception after the final durable layout is recognized as success, while
|
||||
persistent Optimize/Undo target failures restore the exact before-pair
|
||||
including unknown metadata and revisions. Setup shares the resolver and
|
||||
legacy pending remains compatible [backend/HA harness:
|
||||
`test_issue_491_*`, existing `test_setup_recovers_*` and import pair
|
||||
fault tests; mutations: `pair-recovery-config-writer-skips-fence`,
|
||||
`pair-recovery-point-writer-skips-fence`,
|
||||
`optimize-skips-pair-retry-rollback`,
|
||||
`optimize-undo-skips-pair-retry-rollback`].
|
||||
- [ ] Optimizer migration safety: legacy decor width/text size is clamped to
|
||||
the backend schema, `fill: true` receives explicit fill style, invalid
|
||||
legacy `plan_scale` is preserved for repair, an already canonical plan is
|
||||
a no-op, a future model version is never downgraded, and zero/null/negative
|
||||
`cell_cm` is repaired to the 5 cm default (positive subminimum values clamp
|
||||
to 0.1 cm)
|
||||
[unit: plan-optimizer.test.mjs; backend: test_validation.py]
|
||||
|
||||
## Sun ray rim (docs/SUN.md «The rim», dev, unreleased)
|
||||
|
||||
- [ ] **A ray reads on white paper**: with `sun_rays` on and a LIGHT scene
|
||||
(`bg_mode: daynight` at midday, or a white plan), a lit wedge is bounded
|
||||
by a thin dark hairline along its two SIDE edges — the ones running
|
||||
inward from the ends of the window. There is NO line across the glass and
|
||||
none across the far end; the hairline fades out with the light and is
|
||||
already gone before the wedge's tip [auto: smoke_sun_rim]
|
||||
- [ ] **It stays a hairline**: zoom the plan all the way in and all the way out
|
||||
— the line is one pixel wide at every zoom, never a growing black band.
|
||||
Check on a phone and on a kiosk display too [auto: smoke_sun_rim
|
||||
(`non-scaling-stroke`), still: demo/shot_sun_rim.mjs]
|
||||
- [ ] **It is not an outline on a dark scene**: switch to the glow fill or
|
||||
night — the rim is a subtle darker edge on the shaft, not a drawn contour
|
||||
around it [manual, visual]
|
||||
- [ ] **It lives and dies with the wedge**: below 3° it goes with the wedge in
|
||||
the same two-second fade (not a frame before, not a frame after); weather
|
||||
does not alter either layer; the editors show neither; the kiosk and the
|
||||
plan view agree
|
||||
[auto: smoke_sun_rim + smoke_sun]
|
||||
- [ ] **A wall still stops it**: point the sun so a shaft runs into the
|
||||
opposite wall or into the inner corner of an L — the hairline stops on
|
||||
the wall exactly where the wedge does, and never continues into the next
|
||||
room [auto: unit sun.test «a room that cuts the shaft cuts the rim»]
|
||||
|
||||
## Coming back to the tab (docs/WARM-REMOUNT.md, dev, unreleased)
|
||||
|
||||
- [ ] **A quick return does not flash**: leave the browser tab or minimise the
|
||||
window for a few seconds and return. The existing viewport, day/night
|
||||
background and room hover remain painted continuously; the continuity
|
||||
token stays unchanged and no recovery overlay is created
|
||||
[auto: smoke_sun_live_bg, smoke_visual_continuity]
|
||||
- [ ] **A long return holds the complete frame**: every sampled frame keeps a
|
||||
visible `.zoomwrap`, non-empty rooms and the same viewport while
|
||||
config/layout reconnect data is revalidated. A stale frame never gets a
|
||||
recovery overlay [auto: smoke_visual_continuity, smoke_ws_resilience]
|
||||
- [ ] **Protected backdrops survive remount and refresh**: a loaded authority-
|
||||
scoped signed URL is available synchronously to another placement; an
|
||||
aging URL remains painted until its replacement decodes
|
||||
[unit: signing.test; auto: smoke_plan_signed, smoke_space_card_bg]
|
||||
- [ ] **The view does not twitch**: pan the plan into a corner and zoom in
|
||||
(say 2.5×), leave the tab for long enough that HA reconnects, come back —
|
||||
the plan is in exactly the same place at exactly the same scale. Not
|
||||
«about the same»: the restored viewport is the same rectangle, and the
|
||||
smoke compares it frame by frame [auto: smoke_warm_dialogs]
|
||||
- [ ] **The same inside an editor**: do it while the Devices editor is open at
|
||||
a working zoom (say 350 %) — the editor and its zoom both come back
|
||||
(before the fix the mode came back and the zoom fell to 100 %). Leaving
|
||||
the editor afterwards still restores the view-mode viewport
|
||||
[auto: smoke_warm_dialogs]
|
||||
- [ ] **An open dialog stays open**: leave the tab with the space settings (or
|
||||
a device card) open and a field edited but NOT saved — on return the
|
||||
dialog is still there with the same draft [auto: smoke_warm_dialogs]
|
||||
- [ ] **A closed dialog stays closed**: close it with Esc (or Cancel, or Save)
|
||||
and only then leave the tab — nothing reopens on return, and it does not
|
||||
reappear on a second reconnect either (the snapshot is consumed once)
|
||||
[auto: smoke_warm_dialogs]
|
||||
- [ ] **Confirmations are never resurrected**: open «Align everything to the
|
||||
grid», leave the tab, come back — the confirmation is GONE and the plan
|
||||
is untouched. Same for the room-merge confirmation. This is deliberate:
|
||||
a modal whose whole content is «press OK to rewrite your plan» must not
|
||||
be waiting under a returning user's cursor [auto: smoke_warm_dialogs]
|
||||
- [ ] **Nothing is revived into the wrong place**: switch to another floor (or
|
||||
another editor) after the reconnect — a dialog that belonged to the old
|
||||
space/mode does not appear there [auto: smoke_warm_dialogs (space/mode
|
||||
guard), manual for the floor switch]
|
||||
- [ ] **A save in flight is not offered twice**: press Save in the space dialog
|
||||
and reload/reconnect during the write — the dialog does not come back
|
||||
with a live Save button; the reloaded config shows the outcome [manual]
|
||||
- [ ] **Two identical cards keep to themselves**: put the SAME card config
|
||||
twice on one view, park one in the Devices editor at 350 % and leave the
|
||||
other in View with an unsaved space dialog, then force a rebuild — each
|
||||
card comes back with its OWN floor, mode and zoom, and the draft returns
|
||||
to the card that owned it, not to its neighbour (AUD-159B1-01)
|
||||
[auto: smoke_warm_owners, section A]
|
||||
- [ ] **Two dashboard views keep to themselves**: the same card config on two
|
||||
views of one dashboard — switching between them never carries a viewport
|
||||
or a dialog across (`location.pathname` is part of the key) [manual]
|
||||
- [ ] **A rebuild storm keeps the draft**: a dashboard that rebuilds twice in a
|
||||
row (config churn, a flapping websocket) still returns the unsaved dialog
|
||||
— the draft travels down the chain of instances (AUD-159B1-02)
|
||||
[auto: smoke_warm_owners, section B]
|
||||
- [ ] **A forgotten draft frees its plan file**: open the space dialog with a
|
||||
plan chosen, leave the view and do not come back — after the 10-second
|
||||
TTL the memo no longer holds the dialog (a plan is base64 in memory), and
|
||||
nothing revives afterwards (AUD-159B1-03)
|
||||
[auto: smoke_warm_owners, section C]
|
||||
|
||||
## Реальная raw-карта Zigbee2MQTT (#450)
|
||||
|
||||
- [ ] **Обновить карту** на Zigbee2MQTT 1.x/2.x принимает camelCase-поля
|
||||
`ieeeAddr` / `networkAddress` и показывает связи вместо
|
||||
`error_invalid_payload`; сохранена совместимость со snake_case.
|
||||
- [ ] Плоские `sourceIeeeAddr` / `targetIeeeAddr` имеют приоритет над
|
||||
вложенными концами связи, а массив `failed` не превращается в ложную
|
||||
недоступность устройства [unit + production-shaped fixture + mutation].
|
||||
|
||||
## Порядок слоя Zigbee-топологии (#464)
|
||||
|
||||
- [ ] `smoke_zigbee_topology_hover.mjs` доказывает единый camera/stacking
|
||||
context: overlay — ребёнок `.devlayer` без собственной live-camera
|
||||
проекции; topology выше пересекающихся room label и постороннего marker,
|
||||
но ниже полных roots source и каждого реально нарисованного local
|
||||
endpoint. Для source это проверяется настоящим `page.mouse.move`, чтобы
|
||||
CSS `:hover` участвовал в каскаде; синтетический `pointerover` этого не
|
||||
доказывает. Host и primitives остаются pointer-transparent.
|
||||
- [ ] Exact endpoint ownership очищается при pointerleave, touch/pen, потере
|
||||
hover gate, смене mode/space/setting, invalidation mapping, замене marker
|
||||
DOM и disconnect. Remote/unplaced цели не поднимают marker.
|
||||
- [ ] Unknown-LQI local link состоит из совпадающих dashed strokes: casing
|
||||
`#2e2e2e`/4 px и gray core/2 px с одним `5 5`, linecap и
|
||||
non-scaling-stroke. Raster probe видит тёмную кромку и прозрачный gap;
|
||||
known-LQI и solid parent route не получают casing, forced-colors
|
||||
сохраняет системную палитру.
|
||||
- [ ] Мутанты слоя/endpoints/cleanup/double projection/casing обязаны краснеть;
|
||||
`npm run benchmark:zigbee-topology`, `bundle:budget`, selected smokes и
|
||||
`golden:verify` сохраняют действующие ceilings.
|
||||
|
||||
## Полнота источников радара (#545)
|
||||
|
||||
- [ ] `tests_backend/test_radar_validation.py` проверяет точный inventory всех
|
||||
Stage 1 profiles: известные общие и профильные роли включены, future-поля
|
||||
остаются inert.
|
||||
- [ ] `tests_backend/test_ha_radar.py` и
|
||||
`tests_backend/test_ha_radar_websocket.py` доказывают подписку, rebind,
|
||||
cleanup и fail-closed read ACL именно для основных `entity_id` профилей
|
||||
`range_v1`/`zones_v1`.
|
||||
- [ ] Мутант `radar-profile-source-entity-id-omitted` удаляет обе роли
|
||||
`entity_id`; точный backend guard обязан покраснеть.
|
||||
@@ -70,7 +70,6 @@ export const NOT_AN_INPUT = [
|
||||
['scripts/install-hooks.mjs', 'установка git-хуков при npm ci'],
|
||||
['scripts/golden-accept.mjs', 'приёмка эталонов человеком, после прогона (#344)'],
|
||||
['scripts/golden-container.mjs', 'локальная съёмка в пиновом образе (#334), ручной запуск'],
|
||||
['scripts/inventory.mjs', 'отчёт для аудита, не гейт'],
|
||||
['scripts/benchmark-wall-segment-model.mjs', 'ручной бенчмарк (npm run benchmark:wall-model)'],
|
||||
['scripts/wall-strip-containment.mjs', 'ручной гейт внешних бэкапов планов (docs/WALL-THICKNESS.md)'],
|
||||
['scripts/sh3d-convert/cli.mjs', 'CLI конвертера для человека'],
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
// #634: стоимость входа агента — сколько слов он читает до первого файла кода.
|
||||
//
|
||||
// Аудит 22.09 намерил ≈ 26 700 слов обязательного входа (SCOPE, AGENTS, полный
|
||||
// PROCESS, STATUS) — 43–77 k токенов до первой строки кода. Ролевые конспекты
|
||||
// `docs/process/AUTHOR.md` и `REVIEWER.md` заменяют на входе полный канон, а
|
||||
// этот скрипт держит цену входа измеримой: маршрут по роли — один список здесь,
|
||||
// `AGENTS.md` («Read this first») называет те же файлы в том же порядке, и тест
|
||||
// `test/entry-cost.test.mjs` сверяет оба и краснеет на превышении бюджета.
|
||||
//
|
||||
// node scripts/entry-cost.mjs таблица по всем маршрутам
|
||||
// node scripts/entry-cost.mjs --check exit 1, если маршрут с бюджетом его превысил
|
||||
// node scripts/entry-cost.mjs --json
|
||||
//
|
||||
// Слово — как у `wc -w`: непустой отрезок между пробельными символами. Токены
|
||||
// не считаются: их число зависит от токенизатора, а слова — нет.
|
||||
// Вне замера сознательно: `CODEX-RUNBOOK.md` и `CLAUDE.md` папки владельца —
|
||||
// они не в репозитории, и CI их не видит; тело issue — своё у каждой задачи.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
/** Маршруты входа по роли. `budget: null` — только замер, без порога. */
|
||||
export const ROUTES = Object.freeze({
|
||||
author: {
|
||||
budget: 12000, // AC1 #634
|
||||
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/AUTHOR.md', 'docs/STATUS.md'],
|
||||
},
|
||||
reviewer: {
|
||||
budget: 9000,
|
||||
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/REVIEWER.md'],
|
||||
},
|
||||
// Тот, кто правит конвейер, гейты или сам процесс, читает канон целиком.
|
||||
canon: {
|
||||
budget: null,
|
||||
files: ['docs/SCOPE.md', 'AGENTS.md', 'PROCESS.md', 'docs/STATUS.md'],
|
||||
},
|
||||
});
|
||||
|
||||
export const countWords = (text) => text.split(/\s+/).filter(Boolean).length;
|
||||
|
||||
/** Замер одного маршрута: слова по файлам, сумма и вердикт бюджета. */
|
||||
export function measureRoute(route, read = (rel) => readFileSync(join(ROOT, rel), 'utf8')) {
|
||||
const files = route.files.map((file) => ({ file, words: countWords(read(file)) }));
|
||||
const total = files.reduce((sum, row) => sum + row.words, 0);
|
||||
const over = route.budget != null && total > route.budget;
|
||||
return { files, total, budget: route.budget, over };
|
||||
}
|
||||
|
||||
export function measureAll(routes = ROUTES, read) {
|
||||
return Object.fromEntries(Object.entries(routes).map(([name, route]) => [name, measureRoute(route, read)]));
|
||||
}
|
||||
|
||||
export function renderTable(results) {
|
||||
const lines = [];
|
||||
for (const [name, result] of Object.entries(results)) {
|
||||
const limit = result.budget == null ? 'без бюджета' : `бюджет ${result.budget}`;
|
||||
const mark = result.over ? ' ПРЕВЫШЕН' : '';
|
||||
lines.push(`${name}: ${result.total} слов (${limit})${mark}`);
|
||||
for (const row of result.files) lines.push(` ${String(row.words).padStart(6)} ${row.file}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const results = measureAll();
|
||||
if (process.argv.includes('--json')) console.log(JSON.stringify(results, null, 2));
|
||||
else console.log(renderTable(results));
|
||||
if (process.argv.includes('--check')) {
|
||||
const failed = Object.entries(results).filter(([, result]) => result.over).map(([name]) => name);
|
||||
if (failed.length) {
|
||||
console.error(`entry-cost: превышен бюджет входа: ${failed.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-28
@@ -6,37 +6,50 @@
|
||||
// underestimates the coverage that exists (review R5-2). The counts live here
|
||||
// now, one command away, and STATUS.md describes the layers instead.
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { METRIC_NAMES, collectMetrics, readBaseline } from './monolith-metrics.mjs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
const count = (dir, match, re) =>
|
||||
readdirSync(dir)
|
||||
const count = (root, dir, match, re) =>
|
||||
readdirSync(join(root, dir))
|
||||
.filter((f) => match.test(f))
|
||||
.reduce((n, f) => n + (readFileSync(`${dir}/${f}`, 'utf8').match(re) || []).length, 0);
|
||||
.reduce((n, f) => n + (readFileSync(join(root, dir, f), 'utf8').match(re) || []).length, 0);
|
||||
|
||||
const files = (dir, match) => readdirSync(dir).filter((f) => match.test(f)).length;
|
||||
const files = (root, dir, match) => readdirSync(join(root, dir)).filter((f) => match.test(f)).length;
|
||||
|
||||
// Count both node:test spellings and indented pytest methods. Pure backend
|
||||
// includes validation plus trail helper/recorder tests; only `test_ha_*` needs
|
||||
// the Home Assistant harness (AUD-159B7-03).
|
||||
const rows = [
|
||||
['Node unit (frontend + tooling)', count('test', /\.test\.mjs$/, /^\s*(test|it)\(/gm)],
|
||||
['pure backend (pytest, no HA)', count('tests_backend', /^test_(?!ha_).*\.py$/, /^\s*(?:async\s+)?def test_/gm)],
|
||||
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^\s*(?:async\s+)?def test_/gm)],
|
||||
['browser smokes (headless chromium)', files('demo', /^smoke_.*\.mjs$/)],
|
||||
];
|
||||
const w = Math.max(...rows.map(([n]) => n.length));
|
||||
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
|
||||
|
||||
// #624: связность монолита — те же шесть чисел и тот же модуль, что у гейта
|
||||
// `npm run lint:unused` («одно число — один источник»); рядом — база, чтобы
|
||||
// движение было видно без git blame. bundleBytes есть только после сборки.
|
||||
const { metrics } = collectMetrics(process.cwd());
|
||||
const baseline = readBaseline(process.cwd()) || {};
|
||||
console.log('\nmonolith (scripts/monolith-metrics.mjs; база scripts/monolith-baseline.json)');
|
||||
const mw = Math.max(...METRIC_NAMES.map((n) => n.length));
|
||||
for (const name of METRIC_NAMES) {
|
||||
const now = metrics[name];
|
||||
const base = baseline[name];
|
||||
const delta = now != null && base != null && now !== base ? ` (${now > base ? '+' : ''}${now - base} к базе ${base})` : '';
|
||||
console.log(`${name.padEnd(mw)} ${now ?? 'n/a — npm run build'}${delta}`);
|
||||
/**
|
||||
* Счётчики тестов по слоям. Один источник для этого отчёта и для таблицы
|
||||
* Snapshot в docs/STATUS.md (`scripts/status-snapshot.mjs`, #634).
|
||||
*
|
||||
* Count both node:test spellings and indented pytest methods. Pure backend
|
||||
* includes validation plus trail helper/recorder tests; only `test_ha_*` needs
|
||||
* the Home Assistant harness (AUD-159B7-03).
|
||||
*/
|
||||
export function testInventory(root = process.cwd()) {
|
||||
return [
|
||||
{ id: 'node', label: 'Node unit (frontend + tooling)', count: count(root, 'test', /\.test\.mjs$/, /^\s*(test|it)\(/gm) },
|
||||
{ id: 'backend-pure', label: 'pure backend (pytest, no HA)', count: count(root, 'tests_backend', /^test_(?!ha_).*\.py$/, /^\s*(?:async\s+)?def test_/gm) },
|
||||
{ id: 'backend-ha', label: 'HA-harness backend (CI, py3.13)', count: count(root, 'tests_backend', /^test_ha_.*\.py$/, /^\s*(?:async\s+)?def test_/gm) },
|
||||
{ id: 'smokes', label: 'browser smokes (headless chromium)', count: files(root, 'demo', /^smoke_.*\.mjs$/) },
|
||||
];
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const rows = testInventory().map(({ label, count: n }) => [label, n]);
|
||||
const w = Math.max(...rows.map(([n]) => n.length));
|
||||
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
|
||||
|
||||
// #624: связность монолита — те же шесть чисел и тот же модуль, что у гейта
|
||||
// `npm run lint:unused` («одно число — один источник»); рядом — база, чтобы
|
||||
// движение было видно без git blame. bundleBytes есть только после сборки.
|
||||
const { metrics } = collectMetrics(process.cwd());
|
||||
const baseline = readBaseline(process.cwd()) || {};
|
||||
console.log('\nmonolith (scripts/monolith-metrics.mjs; база scripts/monolith-baseline.json)');
|
||||
const mw = Math.max(...METRIC_NAMES.map((n) => n.length));
|
||||
for (const name of METRIC_NAMES) {
|
||||
const now = metrics[name];
|
||||
const base = baseline[name];
|
||||
const delta = now != null && base != null && now !== base ? ` (${now > base ? '+' : ''}${now - base} к базе ${base})` : '';
|
||||
console.log(`${name.padEnd(mw)} ${now ?? 'n/a — npm run build'}${delta}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// #634: якоря заголовков Markdown так, как их строит GitHub, и ссылки из текста.
|
||||
//
|
||||
// Ролевые конспекты процесса и индекс приложений TESTING.md ссылаются на
|
||||
// разделы по якорю; тесты проверяют, что якорь существует. Правило GitHub:
|
||||
// нижний регистр, выбросить всё, кроме букв, цифр, `_`, пробела и дефиса,
|
||||
// каждый пробел — дефис (без схлопывания: «2.6 В разработке — реализация» →
|
||||
// `26-в-разработке--реализация`), повтор — суффикс `-1`, `-2`.
|
||||
// `scripts/check-docs.mjs` схлопывает дефисы для публичных документов — там
|
||||
// это исторический контракт, здесь нужен настоящий якорь GitHub.
|
||||
|
||||
export const githubSlug = (heading) => heading
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '')
|
||||
.replace(/ /g, '-');
|
||||
|
||||
/** Заголовки документа вне fenced-блоков: [{ level, text, anchor, line }]. */
|
||||
export function headings(text) {
|
||||
const seen = new Map();
|
||||
const out = [];
|
||||
let fenced = false;
|
||||
text.replace(/\r\n?/g, '\n').split('\n').forEach((line, index) => {
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; return; }
|
||||
if (fenced) return;
|
||||
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
||||
if (!match) return;
|
||||
const base = githubSlug(match[2]);
|
||||
const count = seen.get(base) || 0;
|
||||
seen.set(base, count + 1);
|
||||
out.push({ level: match[1].length, text: match[2], anchor: count ? `${base}-${count}` : base, line: index });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Текст раздела по якорю: от заголовка до следующего того же или старшего уровня. */
|
||||
export function sectionText(text, anchor) {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
||||
const all = headings(text);
|
||||
const at = all.findIndex((heading) => heading.anchor === anchor);
|
||||
if (at < 0) return null;
|
||||
const next = all.slice(at + 1).find((heading) => heading.level <= all[at].level);
|
||||
return lines.slice(all[at].line, next ? next.line : lines.length).join('\n');
|
||||
}
|
||||
|
||||
/** Относительные ссылки `[текст](путь#якорь)`; внешние URL пропускаются. */
|
||||
export function markdownLinks(text) {
|
||||
return [...text.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)]
|
||||
.map((match) => match[2])
|
||||
.filter((target) => !/^[a-z]+:/i.test(target))
|
||||
.map((target) => {
|
||||
const hash = target.indexOf('#');
|
||||
return {
|
||||
target,
|
||||
file: hash >= 0 ? target.slice(0, hash) : target,
|
||||
anchor: hash >= 0 ? decodeURIComponent(target.slice(hash + 1)) : '',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -8627,6 +8627,86 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " current = [line]; // mutant: last physical line instead of the paragraph\n }\n for (const paragraph of paragraphs) {",
|
||||
}],
|
||||
},
|
||||
// #634: ролевые конспекты PROCESS.md, цена входа агента, генерируемый
|
||||
// Snapshot и индекс приложений TESTING.md. Каждая защита — от тихого
|
||||
// расхождения выжимки с каноном или тихого роста входа.
|
||||
{
|
||||
id: 'process-digest-dead-anchor',
|
||||
guard: 'node --test --test-name-pattern="#634 конспект: каждая ссылка" test/process-digests.test.mjs',
|
||||
because: 'a digest link to a PROCESS.md heading that does not exist sends the agent nowhere; '
|
||||
+ 'the digest must fail as soon as an anchor goes stale (#634 AC2)',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: '[§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)',
|
||||
replace: '[§5.1](../../PROCESS.md#51-короткий-трек)',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-digest-bullet-without-canon-link',
|
||||
guard: 'node --test --test-name-pattern="#634 конспект: каждый пункт" test/process-digests.test.mjs',
|
||||
because: 'every digest item cites its canon section; an item without a link is a rule the '
|
||||
+ 'digest invented or a paraphrase nobody can check (#634 AC2)',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: ' нечего ([§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)).',
|
||||
replace: ' нечего.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-digest-key-rule-dropped',
|
||||
guard: 'node --test --test-name-pattern="#634 конспект: ключевые правила" test/process-digests.test.mjs',
|
||||
because: 'the empty third column is a Medium finding (PROCESS §2.7, #435); a reviewer digest '
|
||||
+ 'that softens it must fail, not pass as a shorter paraphrase (#634)',
|
||||
patches: [{
|
||||
file: 'docs/process/REVIEWER.md',
|
||||
find: ' результатом прогона. Пустой третий столбец — находка Medium, а не\n примечание.',
|
||||
replace: ' результатом прогона. Пустой третий столбец желательно заполнить.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'entry-cost-author-route-over-budget',
|
||||
guard: 'node --test --test-name-pattern="#634 entry-cost: вход автора" test/entry-cost.test.mjs',
|
||||
because: 'AC1 #634: the author entry route stays within 12 000 words; a digest that grows '
|
||||
+ 'back into the canon must redden the measurement, not the next audit',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: '## Запрещено\n',
|
||||
replace: `## Запрещено\n\n${'слово '.repeat(2000)}\n`,
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'entry-cost-budget-never-over',
|
||||
guard: 'node --test --test-name-pattern="#634 entry-cost: превышение" test/entry-cost.test.mjs',
|
||||
because: 'the budget verdict is the whole point of the measurement; a comparison that never '
|
||||
+ 'reports «over» turns --check into a word counter (#634 AC1)',
|
||||
patches: [{
|
||||
file: 'scripts/entry-cost.mjs',
|
||||
find: ' const over = route.budget != null && total > route.budget;',
|
||||
replace: ' const over = false; // mutant: budget never exceeded',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'status-snapshot-hides-version-mismatch',
|
||||
guard: 'node --test --test-name-pattern="#634 status-snapshot: рассинхрон" test/status-snapshot.test.mjs',
|
||||
because: 'a snapshot that reports the majority version and drops the odd source repeats the '
|
||||
+ 'hand-written row it replaced: it looks current while the tree disagrees (#634)',
|
||||
patches: [{
|
||||
file: 'scripts/status-snapshot.mjs',
|
||||
find: " const version = versions.mismatches.length\n",
|
||||
replace: " const version = false // mutant: mismatches hidden\n",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'testing-notes-index-drops-section',
|
||||
guard: 'node --test --test-name-pattern="#634 индекс приложений" test/testing-notes-index.test.mjs',
|
||||
because: 'AC3 #634: appendices moved out of TESTING.md stay reachable only through the index; '
|
||||
+ 'a section missing from it is text nobody will find again',
|
||||
patches: [{
|
||||
file: 'docs/testing-notes/README.md',
|
||||
find: '- [Open passage (#157)](geometry.md#open-passage-157)\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
// #624: гейт мёртвого кода и связности монолита — три защиты, три мутанта.
|
||||
{
|
||||
id: 'unused-gate-allows-everything',
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
// #634: таблица «Snapshot» в docs/STATUS.md — генерируется, а не пишется руками.
|
||||
//
|
||||
// Раньше строка версии и счётчики в STATUS.md правились вручную и отставали от
|
||||
// дерева за дни (review R5-2 уже убрал оттуда числа тестов по той же причине).
|
||||
// Теперь всё, что вычислимо из дерева и git, считает этот скрипт из тех же
|
||||
// источников, что и гейты: версии — `parseVersionSources` релизного контракта
|
||||
// (`scripts/release-contract.mjs`), счётчики — `testInventory`
|
||||
// (`scripts/inventory.mjs`), теги — `git tag`. Метки issue сюда не входят:
|
||||
// api.github.com из песочницы агента недостижим, а статус живёт в метках.
|
||||
//
|
||||
// node scripts/status-snapshot.mjs напечатать блок
|
||||
// node scripts/status-snapshot.mjs --write заменить блок в docs/STATUS.md
|
||||
// node scripts/status-snapshot.mjs --check exit 1, если блок в файле устарел
|
||||
// [--date=YYYY-MM-DD] [--root=<клон>]
|
||||
//
|
||||
// `--check` не стоит в CI сознательно: на shallow-checkout тегов нет, а
|
||||
// счётчик тестов меняется с каждым новым тестом — гейт краснел бы на каждой
|
||||
// задаче. Блок — снимок на дату в первой строке; свежий — одна команда.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseVersionSources } from './release-contract.mjs';
|
||||
import { testInventory } from './inventory.mjs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
export const BEGIN = '<!-- status-snapshot:begin — generated by `node scripts/status-snapshot.mjs --write`; do not edit by hand -->';
|
||||
export const END = '<!-- status-snapshot:end -->';
|
||||
export const STATUS_FILE = 'docs/STATUS.md';
|
||||
const STABLE_TAG = /^v\d+\.\d+\.\d+$/;
|
||||
const PRERELEASE_TAG = /^v\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/;
|
||||
|
||||
const git = (root, args) => {
|
||||
const run = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
return run.status === 0 ? run.stdout.trim() : null;
|
||||
};
|
||||
|
||||
/** Сводка версий: одно значение во всех источниках либо список расхождений. */
|
||||
export function summarizeVersions(sources) {
|
||||
const entries = Object.entries(sources);
|
||||
const values = [...new Set(entries.map(([, value]) => value))];
|
||||
if (values.length === 1 && values[0]) return { version: values[0], sources: entries.length, mismatches: [] };
|
||||
const counts = new Map();
|
||||
for (const [, value] of entries) counts.set(value, (counts.get(value) || 0) + 1);
|
||||
const majority = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
||||
return {
|
||||
version: majority,
|
||||
sources: entries.length,
|
||||
mismatches: entries.filter(([, value]) => value !== majority).map(([name, value]) => `${name}=${JSON.stringify(value)}`),
|
||||
};
|
||||
}
|
||||
|
||||
/** Последние по дате создания стабильный и пре-релизный теги (порядок `git tag --sort=-creatordate`). */
|
||||
export function latestTags(tagsNewestFirst) {
|
||||
return {
|
||||
stable: tagsNewestFirst.find((tag) => STABLE_TAG.test(tag)) || null,
|
||||
prerelease: tagsNewestFirst.find((tag) => PRERELEASE_TAG.test(tag)) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Файлы, из которых `parseVersionSources` читает версию, — те же, что у релизного контракта. */
|
||||
export const VERSION_FILES = Object.freeze({
|
||||
packageJson: 'package.json',
|
||||
packageLock: 'package-lock.json',
|
||||
manifest: 'custom_components/houseplan/manifest.json',
|
||||
constSource: 'custom_components/houseplan/const.py',
|
||||
cardSource: 'src/houseplan-card.ts',
|
||||
editorRuntimeSource: 'src/houseplan-editor-runtime.ts',
|
||||
});
|
||||
|
||||
/** Собрать входы из дерева и git. Всё, что недоступно, — null, а не догадка. */
|
||||
export function collectSnapshot(root, { date = new Date().toISOString().slice(0, 10) } = {}) {
|
||||
const read = (rel) => readFileSync(join(root, rel), 'utf8');
|
||||
const versions = summarizeVersions(parseVersionSources(Object.fromEntries(
|
||||
Object.entries(VERSION_FILES).map(([key, rel]) => [key, read(rel)]))));
|
||||
const tagList = git(root, ['tag', '--sort=-creatordate']);
|
||||
const tags = latestTags(tagList ? tagList.split('\n').filter(Boolean) : []);
|
||||
// Расстояние «N коммитов после тега» сюда не входит: закоммиченный блок
|
||||
// устаревал бы на собственном коммите, и `--check` не имел бы смысла.
|
||||
return { date, versions, tags, tests: testInventory(root) };
|
||||
}
|
||||
|
||||
const code = (value) => (value ? `\`${value}\`` : 'n/a — tags are not available in this checkout');
|
||||
|
||||
/** Отрисовать блок. Чистая функция: вход — результат collectSnapshot. */
|
||||
export function renderSnapshot({ date, versions, tags, tests }) {
|
||||
const version = versions.mismatches.length
|
||||
? `**${versions.version}** — NOT synchronized: ${versions.mismatches.join(', ')}`
|
||||
: `**${versions.version}** in all ${versions.sources} version sources (\`scripts/release-contract.mjs\`)`;
|
||||
const counts = tests.map((row) => `${row.label.replace(/\s*\(.*\)$/, '')} ${row.count}`).join(' · ');
|
||||
return [
|
||||
BEGIN,
|
||||
'| Item | State |',
|
||||
'|---|---|',
|
||||
`| Generated | ${date} — rerun \`node scripts/status-snapshot.mjs\` for the current tree |`,
|
||||
`| Version | ${version} |`,
|
||||
`| Latest stable tag | ${code(tags.stable)} |`,
|
||||
`| Latest prerelease tag | ${code(tags.prerelease)} |`,
|
||||
`| Tests | ${counts} (\`npm run inventory\`) |`,
|
||||
END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Заменить блок между маркерами; маркеров нет или их больше одной пары — ошибка. */
|
||||
export function replaceBlock(text, block) {
|
||||
const start = text.indexOf(BEGIN);
|
||||
const end = text.indexOf(END);
|
||||
if (start < 0 || end < start || text.indexOf(BEGIN, start + 1) >= 0 || text.indexOf(END, end + 1) >= 0)
|
||||
throw new Error(`${STATUS_FILE}: expected exactly one ${BEGIN} … ${END} block`);
|
||||
return text.slice(0, start) + block + text.slice(end + END.length);
|
||||
}
|
||||
|
||||
/** Дата из существующего блока — чтобы `--check` сравнивал содержание, а не день запуска. */
|
||||
export function blockDate(text) {
|
||||
return /\| Generated \| (\d{4}-\d{2}-\d{2}) /.exec(text)?.[1] || null;
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const root = resolve(arg('root') || fileURLToPath(new URL('..', import.meta.url)));
|
||||
const file = join(root, STATUS_FILE);
|
||||
const current = process.argv.includes('--write') || process.argv.includes('--check') ? readFileSync(file, 'utf8') : '';
|
||||
const date = arg('date') || (process.argv.includes('--check') ? blockDate(current) : undefined);
|
||||
const block = renderSnapshot(collectSnapshot(root, date ? { date } : {}));
|
||||
if (process.argv.includes('--write')) {
|
||||
writeFileSync(file, replaceBlock(current, block));
|
||||
console.log(`${STATUS_FILE}: snapshot block updated`);
|
||||
} else if (process.argv.includes('--check')) {
|
||||
if (replaceBlock(current, block) !== current) {
|
||||
console.error(`${STATUS_FILE}: snapshot block is stale — run node scripts/status-snapshot.mjs --write`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`${STATUS_FILE}: snapshot block is current`);
|
||||
} else {
|
||||
console.log(block);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// #634: цена входа агента измерима и ограничена (AC1: автор ≤ 12 000 слов).
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { ROUTES, countWords, measureAll, measureRoute } from '../scripts/entry-cost.mjs';
|
||||
|
||||
const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8');
|
||||
|
||||
test('#634 entry-cost: вход автора и ревьюера в бюджете (AC1)', () => {
|
||||
const results = measureAll();
|
||||
assert.equal(ROUTES.author.budget, 12000, 'бюджет автора — AC1 #634');
|
||||
for (const [name, result] of Object.entries(results)) {
|
||||
if (result.budget == null) continue;
|
||||
assert.equal(result.over, false, `${name}: ${result.total} слов > бюджета ${result.budget}`);
|
||||
assert.ok(result.total <= result.budget);
|
||||
}
|
||||
// Маршрут автора не содержит полного канона — иначе бюджет держится случайно.
|
||||
assert.ok(!ROUTES.author.files.includes('PROCESS.md'));
|
||||
assert.ok(ROUTES.author.files.includes('docs/process/AUTHOR.md'));
|
||||
assert.ok(ROUTES.reviewer.files.includes('docs/process/REVIEWER.md'));
|
||||
});
|
||||
|
||||
test('#634 entry-cost: превышение бюджета краснит замер', () => {
|
||||
const files = { 'a.md': 'one two three', 'b.md': 'четыре пять\nшесть\t семь' };
|
||||
const reader = (rel) => files[rel];
|
||||
assert.equal(countWords(files['b.md']), 4);
|
||||
const within = measureRoute({ budget: 7, files: ['a.md', 'b.md'] }, reader);
|
||||
assert.deepEqual([within.total, within.over], [7, false]);
|
||||
const over = measureRoute({ budget: 6, files: ['a.md', 'b.md'] }, reader);
|
||||
assert.deepEqual([over.total, over.over], [7, true]);
|
||||
assert.equal(measureRoute({ budget: null, files: ['a.md'] }, reader).over, false);
|
||||
});
|
||||
|
||||
test('#634 entry-cost: AGENTS.md называет те же маршруты в том же порядке', () => {
|
||||
const agents = read('AGENTS.md');
|
||||
const section = agents.slice(agents.indexOf('**Reading order by role**'), agents.indexOf('The two digests'));
|
||||
assert.ok(section.length > 0, 'раздел порядка чтения в AGENTS.md');
|
||||
const route = (prefix) => {
|
||||
const item = section.split('\n- ').find((chunk) => chunk.startsWith(prefix));
|
||||
assert.ok(item, `AGENTS.md: нет пункта «${prefix}»`);
|
||||
return [...item.matchAll(/`([^`]+\.md)`/g)].map((match) => match[1]);
|
||||
};
|
||||
assert.deepEqual(route('author'), ROUTES.author.files);
|
||||
assert.deepEqual(route('reviewer'), ROUTES.reviewer.files);
|
||||
assert.deepEqual(route('changing the pipeline'), ROUTES.canon.files);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { prepareGoldenFixture } from '../demo/golden/harness.mjs';
|
||||
import {
|
||||
@@ -847,7 +847,12 @@ test('opening symbol goldens lock room, diagonal, flip-pair and hidden Iso contr
|
||||
assert.equal(OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT.length, 67);
|
||||
assert.equal(new Set(OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT).size, 67);
|
||||
const scenarioIds = new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.id));
|
||||
const testingDoc = readFileSync(new URL('../docs/TESTING.md', import.meta.url), 'utf8');
|
||||
// #634: чек-листы по issue перенесены в docs/testing-notes/ — документация
|
||||
// тестирования это TESTING.md вместе с приложениями.
|
||||
const notesDir = new URL('../docs/testing-notes/', import.meta.url);
|
||||
const testingDoc = [new URL('../docs/TESTING.md', import.meta.url),
|
||||
...readdirSync(notesDir).filter((name) => name.endsWith('.md')).sort().map((name) => new URL(name, notesDir))]
|
||||
.map((url) => readFileSync(url, 'utf8')).join('\n');
|
||||
for (const id of OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT) {
|
||||
assert.equal(scenarioIds.has(id), true, id);
|
||||
assert.match(testingDoc, new RegExp(`\\b${id}\\b`), id);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// #634: ролевые конспекты PROCESS.md не расходятся с каноном.
|
||||
//
|
||||
// `docs/process/AUTHOR.md` и `docs/process/REVIEWER.md` — вход автора и
|
||||
// ревьюера вместо полного PROCESS.md. Конспект, который тихо разошёлся с
|
||||
// каноном, хуже его отсутствия: агент действует по устаревшей выжимке. Поэтому
|
||||
// (AC2) каждый пункт конспекта ссылается на существующий раздел канона, а
|
||||
// ключевые правила (перечень ниже) записаны в конспекте теми же словами, что в
|
||||
// каноне, и именно в том разделе, на который пункт ссылается. Меняется
|
||||
// формулировка в PROCESS.md — краснеет этот тест, и конспект правится тем же
|
||||
// коммитом.
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, normalize } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { headings, markdownLinks, sectionText } from '../scripts/md-anchors.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n?/g, '\n');
|
||||
const DIGESTS = ['docs/process/AUTHOR.md', 'docs/process/REVIEWER.md'];
|
||||
const CANON = 'PROCESS.md';
|
||||
// Сравнение формулировок: без Markdown-выделения, пробелы схлопнуты, регистр
|
||||
// не важен (пункт конспекта начинается с заглавной там, где в каноне середина фразы).
|
||||
const norm = (text) => text.replace(/\*\*/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
|
||||
/** Пункты верхнего уровня: строка `- ` и её продолжения с отступом. */
|
||||
function topLevelBullets(text) {
|
||||
const bullets = [];
|
||||
let current = null;
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.startsWith('- ')) { current = [line]; bullets.push(current); continue; }
|
||||
if (current && /^\s+\S/.test(line)) { current.push(line); continue; }
|
||||
current = null;
|
||||
}
|
||||
return bullets.map((lines) => lines.join('\n'));
|
||||
}
|
||||
|
||||
// Ключевые правила: формулировка, дословная в каноне и в конспекте, и раздел
|
||||
// канона, где она записана. Отбор — то, нарушение чего дороже всего стоило
|
||||
// процессу: вход в код, трек, вопросы владельцу, трейлеры, доказательство
|
||||
// защитных AC, гейты, дисциплина хендоффа и формат вердикта.
|
||||
const KEY_RULES = {
|
||||
'docs/process/AUTHOR.md': [
|
||||
['1-основное-правило', 'Изменение продуктового кода без issue запрещено'],
|
||||
['1-основное-правило', 'ни одного файла класса A'],
|
||||
['1-основное-правило', 'D сильнее A'],
|
||||
['3-правила', 'Ровно одна метка статуса'],
|
||||
['3-правила', 'Статус меняется до действия, а не после'],
|
||||
['5-лёгкий-трек-метка-small--путь-по-умолчанию', 'обосновывается не выбор лёгкого трека, а отказ от него'],
|
||||
['51-короткий-трек-метка-trivial', 'ожидаемое поведение уже зафиксировано'],
|
||||
['71-цепочка', 'Владельцу задаются только продуктовые вопросы'],
|
||||
['71-цепочка', 'issue остаётся в `S3-spec` и получает `blocked`'],
|
||||
['26-в-разработке--реализация', 'каждый коммит несёт трейлеры `Issue: #<NN>` и `User-Visible: yes|no`'],
|
||||
['3-правила', '`User-Visible: yes` требует правок в обоих changelog в том же коммите'],
|
||||
['26-в-разработке--реализация', 'шесть классов риска'],
|
||||
['26-в-разработке--реализация', 'Скоуп не расширяется'],
|
||||
['26-в-разработке--реализация', 'Документация — в том же коммите, что и поведение'],
|
||||
['27-код-ревью', 'Защитный AC доказывается таблицей «чем краснеет»'],
|
||||
['27-код-ревью', 'Пустой третий столбец — находка Medium'],
|
||||
['27-код-ревью', 'Контракты по монолиту — исполнением, не regex по тексту'],
|
||||
['8-гейты', 'Новый код не добавляет `any`'],
|
||||
['8-гейты', '`// any-ok: <конкретная причина>`'],
|
||||
['8-гейты', 'Одно число — один источник'],
|
||||
['8-гейты', 'Полные наборы — предрелизный гейт, а не гейт ревью'],
|
||||
['8-гейты', 'Упавший предрелизный гейт автор чинит и повторно прогоняет'],
|
||||
['3-правила', 'проверено чтением, не исполнением'],
|
||||
['104-событийный-конвейер-метка-как-триггер', 'Один хендофф — один пуш'],
|
||||
['104-событийный-конвейер-метка-как-триггер', 'Ветка приводится к `dev` до ревью, а не после'],
|
||||
['104-событийный-конвейер-метка-как-триггер', 'Автор обязан дождаться вердикта, а не заканчивать сессию'],
|
||||
['104-событийный-конвейер-метка-как-триггер', 'После прогона ревью метка меняется всегда'],
|
||||
['72-шаблоны-комментариев', 'Вперёд двигает только зелёный вердикт'],
|
||||
['12-запрещено', 'force-push в `dev`'],
|
||||
['12-запрещено', 'попутные правки «раз уж я здесь»'],
|
||||
],
|
||||
'docs/process/REVIEWER.md': [
|
||||
['27-код-ревью', 'Ревьюер ≠ исполнитель'],
|
||||
['24-тз-на-ревью', '`docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`'],
|
||||
['27-код-ревью', '`docs/reviews/CODE-REVIEW-<tag|NN>-r<N>.md`'],
|
||||
['27-код-ревью', 'Ревьюер отвечает за полноту доказательств AC, а не заменяет их исполнение'],
|
||||
['27-код-ревью', 'проверено чтением, не исполнением'],
|
||||
['27-код-ревью', 'Защитный AC доказывается таблицей «чем краснеет»'],
|
||||
['27-код-ревью', 'Пустой третий столбец — находка Medium, а не примечание'],
|
||||
['27-код-ревью', '«Тест умеет падать» без названной мутации и её вывода доказательством не является'],
|
||||
['27-код-ревью', 'Вердикт привязан к SHA (#312)'],
|
||||
['27-код-ревью', 'новое имя в нём — находка ревью, а не запись в список'],
|
||||
['8-гейты', 'ревьюер обязан перечислить, какие гейты прогнал, какие нет и почему'],
|
||||
['8-гейты', 'какое число в этом диффе видно дважды и один ли у него источник'],
|
||||
['8-гейты', 'Полные наборы — предрелизный гейт, а не гейт ревью'],
|
||||
['210-повторный-раунд-ревью--объём-по-дельте', 'Предмет повторного раунда — дельта, а не задача целиком'],
|
||||
['210-повторный-раунд-ревью--объём-по-дельте', 'Если SHA не резолвится — это не находка, а обычное дело'],
|
||||
['210-повторный-раунд-ревью--объём-по-дельте', 'SHA, мёртвый уже в момент публикации отчёта'],
|
||||
['210-повторный-раунд-ревью--объём-по-дельте', 'раздел «Унаследовано из r<N−1>»'],
|
||||
['210-повторный-раунд-ревью--объём-по-дельте', 'Разбор остаётся полным, если дельта не локальна'],
|
||||
['210-повторный-раунд-ревью--объём-по-дельте', 'Сокращается объём разбора, а не строгость'],
|
||||
['3-правила', 'High блокирует. Medium в скоупе чинится в текущем issue'],
|
||||
['72-шаблоны-комментариев', '`Вердикт: зелёный/жёлтый/красный · заход r<N> · блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… · Документ: docs/reviews/…`'],
|
||||
['72-шаблоны-комментариев', 'Вперёд двигает только зелёный вердикт'],
|
||||
['4-лимит-циклов-ревью-4', 'Зелёный вердикт цикла не образует'],
|
||||
['71-цепочка', 'Технический спор автора и ревьюера решается вердиктом, а не владельцем'],
|
||||
['12-запрещено', 'Medium-находки, оставленные как TODO в документе ревью'],
|
||||
],
|
||||
};
|
||||
|
||||
test('#634 конспект: каждая ссылка ведёт на существующий файл и заголовок', () => {
|
||||
for (const digest of DIGESTS) {
|
||||
const text = read(digest);
|
||||
const links = markdownLinks(text);
|
||||
assert.ok(links.some((link) => link.anchor && link.file.endsWith(CANON)), `${digest}: нет ни одной ссылки на раздел ${CANON}`);
|
||||
for (const link of links) {
|
||||
const target = link.file ? normalize(join(dirname(digest), link.file)).replace(/\\/g, '/') : digest;
|
||||
assert.ok(existsSync(join(ROOT, target)), `${digest}: ссылка на несуществующий файл ${link.target}`);
|
||||
if (!link.anchor) continue;
|
||||
const anchors = new Set(headings(read(target)).map((heading) => heading.anchor));
|
||||
assert.ok(anchors.has(link.anchor), `${digest}: нет заголовка ${target}#${link.anchor}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('#634 конспект: каждый пункт ссылается на раздел канона (AC2)', () => {
|
||||
for (const digest of DIGESTS) {
|
||||
const bullets = topLevelBullets(read(digest));
|
||||
assert.ok(bullets.length >= 20, `${digest}: подозрительно мало пунктов (${bullets.length})`);
|
||||
for (const bullet of bullets) {
|
||||
const cited = markdownLinks(bullet).some((link) => link.anchor && link.file.endsWith(CANON));
|
||||
assert.ok(cited, `${digest}: пункт без ссылки на раздел ${CANON}:\n${bullet}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('#634 конспект: ключевые правила дословно в каноне и в пункте со ссылкой на их раздел', () => {
|
||||
const canon = read(CANON);
|
||||
for (const [digest, rules] of Object.entries(KEY_RULES)) {
|
||||
const bullets = topLevelBullets(read(digest));
|
||||
for (const [anchor, phrase] of rules) {
|
||||
const section = sectionText(canon, anchor);
|
||||
assert.ok(section, `${CANON}: нет раздела #${anchor}`);
|
||||
assert.ok(norm(section).includes(norm(phrase)), `${CANON}#${anchor} больше не содержит «${phrase}» — поправьте ${digest}`);
|
||||
const owner = bullets.find((bullet) => norm(bullet).includes(norm(phrase)));
|
||||
assert.ok(owner, `${digest}: ключевое правило «${phrase}» (${CANON}#${anchor}) пропало из конспекта`);
|
||||
assert.ok(markdownLinks(owner).some((link) => link.anchor === anchor),
|
||||
`${digest}: пункт с «${phrase}» не ссылается на ${CANON}#${anchor}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('#634 конспект: объявляет себя выжимкой, канон — PROCESS.md', () => {
|
||||
for (const digest of DIGESTS) {
|
||||
const text = norm(read(digest));
|
||||
assert.ok(text.includes(norm('Это выжимка, а не канон.')), digest);
|
||||
assert.ok(text.includes(norm('при расхождении побеждает он')), digest);
|
||||
}
|
||||
const canon = norm(read(CANON));
|
||||
for (const digest of DIGESTS) assert.ok(canon.includes(norm(digest)), `${CANON} называет ${digest}`);
|
||||
});
|
||||
|
||||
test('#634 промпт ревьюера: конспект вместо пересказа, машинные требования на месте', () => {
|
||||
const workflow = read('.github/workflows/process.yml');
|
||||
const start = workflow.indexOf(' prompt: |\n');
|
||||
const end = workflow.indexOf(' claude_args: |', start);
|
||||
assert.ok(start > 0 && end > start, 'блок prompt найден');
|
||||
const prompt = workflow.slice(start, end);
|
||||
const flat = prompt.replace(/\s+/g, ' ');
|
||||
assert.match(flat, /docs\/process\/REVIEWER\.md/, 'ревьюер читает конспект');
|
||||
for (const required of [
|
||||
'`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`',
|
||||
'Материал ревью — ровно `${{ needs.prepare.outputs.material_sha }}`',
|
||||
'Не делай `git fetch`, `git pull` и `git checkout` на другой коммит',
|
||||
'переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория)',
|
||||
'В самом репозитории не создавай файлов вообще',
|
||||
'SPEC-REVIEW для этапа spec, CODE-REVIEW для code',
|
||||
'Ты НЕ правишь ни ТЗ, ни продуктовый код',
|
||||
'«AC · чем доказан · чем краснеет»',
|
||||
'пустой третий столбец — находка Medium',
|
||||
'«Закрытие раунда r<N-1>»',
|
||||
'«Унаследовано из r<N-1>»',
|
||||
'какие гейты прогнал, какие нет и почему',
|
||||
'тип, приоритет, S1-new',
|
||||
'${{ needs.prepare.outputs.validated_note }}',
|
||||
'${{ needs.prepare.outputs.rebase_note }}',
|
||||
'Затем верни JSON по схеме',
|
||||
]) assert.ok(flat.includes(required), `промпт потерял: ${required}`);
|
||||
// Правила живут в каноне; промпт, снова набравший пересказ, — возврат к 60–90 k
|
||||
// контекста до первого git diff (аудит 22.09). До #634 — 1 642 слова.
|
||||
const words = prompt.split(/\s+/).filter(Boolean).length;
|
||||
assert.ok(words <= 1400, `промпт ревьюера ${words} слов > 1400`);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// #634: таблица Snapshot в docs/STATUS.md генерируется из дерева и git.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
BEGIN, END, VERSION_FILES, blockDate, collectSnapshot, latestTags, renderSnapshot, replaceBlock, summarizeVersions,
|
||||
} from '../scripts/status-snapshot.mjs';
|
||||
|
||||
const put = (root, rel, text) => {
|
||||
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
||||
writeFileSync(join(root, rel), text);
|
||||
};
|
||||
const git = (root, args, date) => {
|
||||
const env = { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date,
|
||||
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
|
||||
const run = spawnSync('git', args, { cwd: root, encoding: 'utf8', env });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
};
|
||||
// Фикстура кладёт каждый источник версии по пути из VERSION_FILES — того же
|
||||
// списка, по которому читает генератор.
|
||||
const writeVersions = (root, version) => {
|
||||
const content = {
|
||||
packageJson: JSON.stringify({ name: 'x', version }),
|
||||
packageLock: JSON.stringify({ version, packages: { '': { version } } }),
|
||||
manifest: JSON.stringify({ version }),
|
||||
constSource: `DOMAIN = "houseplan"\nVERSION = "${version}"\n`,
|
||||
cardSource: `const CARD_VERSION = '${version}';\n`,
|
||||
editorRuntimeSource: `const CARD_VERSION = '${version}';\n`,
|
||||
};
|
||||
assert.deepEqual(Object.keys(content).sort(), Object.keys(VERSION_FILES).sort());
|
||||
for (const [key, rel] of Object.entries(VERSION_FILES)) put(root, rel, content[key]);
|
||||
};
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hp-status-'));
|
||||
writeVersions(root, '1.2.0');
|
||||
put(root, 'test/a.test.mjs', "test('a', () => {});\ntest('b', () => {});\n");
|
||||
put(root, 'tests_backend/test_pure.py', 'def test_one():\n pass\n');
|
||||
put(root, 'tests_backend/test_ha_x.py', 'async def test_ha():\n pass\n');
|
||||
put(root, 'demo/smoke_one.mjs', '');
|
||||
put(root, 'demo/smoke_two.mjs', '');
|
||||
git(root, ['init', '-q']);
|
||||
git(root, ['add', '-A']);
|
||||
git(root, ['commit', '-qm', 'one'], '2026-09-01T10:00:00Z');
|
||||
git(root, ['tag', '-a', 'v1.2.0', '-m', 'stable'], '2026-09-01T10:00:00Z');
|
||||
writeVersions(root, '1.3.0-beta.1');
|
||||
git(root, ['commit', '-qam', 'two'], '2026-09-02T10:00:00Z');
|
||||
git(root, ['tag', '-a', 'v1.3.0-beta.1', '-m', 'beta'], '2026-09-02T10:00:00Z');
|
||||
put(root, 'README.md', 'x\n');
|
||||
git(root, ['add', '-A']);
|
||||
git(root, ['commit', '-qm', 'three'], '2026-09-03T10:00:00Z');
|
||||
return root;
|
||||
}
|
||||
|
||||
test('#634 status-snapshot: версии, теги и счётчики берутся из дерева и git', () => {
|
||||
const root = fixture();
|
||||
try {
|
||||
const snapshot = collectSnapshot(root, { date: '2026-09-24' });
|
||||
assert.deepEqual(snapshot.tags, { stable: 'v1.2.0', prerelease: 'v1.3.0-beta.1' });
|
||||
assert.deepEqual(snapshot.versions, { version: '1.3.0-beta.1', sources: 7, mismatches: [] });
|
||||
assert.deepEqual(snapshot.tests.map((row) => row.count), [2, 1, 1, 2]);
|
||||
const block = renderSnapshot(snapshot);
|
||||
assert.ok(block.startsWith(`${BEGIN}\n`) && block.endsWith(`\n${END}`));
|
||||
assert.match(block, /\| Version \| \*\*1\.3\.0-beta\.1\*\* in all 7 version sources/);
|
||||
assert.match(block, /\| Latest stable tag \| `v1\.2\.0` \|/);
|
||||
assert.match(block, /\| Latest prerelease tag \| `v1\.3\.0-beta\.1` \|/);
|
||||
assert.match(block, /\| Tests \| Node unit 2 · pure backend 1 · HA-harness backend 1 · browser smokes 2 /);
|
||||
assert.equal(blockDate(block), '2026-09-24');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('#634 status-snapshot: рассинхрон версий виден в таблице, а не сглажен', () => {
|
||||
const sources = { 'package.json': '1.3.0', 'custom_components/houseplan/const.py': '1.2.9', 'package-lock.json': '1.3.0' };
|
||||
const summary = summarizeVersions(sources);
|
||||
assert.deepEqual(summary, { version: '1.3.0', sources: 3, mismatches: ['custom_components/houseplan/const.py="1.2.9"'] });
|
||||
const block = renderSnapshot({ date: '2026-09-24', versions: summary, tags: { stable: null, prerelease: null }, tests: [] });
|
||||
assert.match(block, /\| Version \| \*\*1\.3\.0\*\* — NOT synchronized: custom_components\/houseplan\/const\.py="1\.2\.9" \|/);
|
||||
assert.match(block, /\| Latest stable tag \| n\/a — tags are not available/);
|
||||
});
|
||||
|
||||
test('#634 status-snapshot: последние теги — по дате создания, стабильный отдельно от пре-релиза', () => {
|
||||
assert.deepEqual(latestTags(['v1.78.0-beta.1', 'v1.77.0', 'v1.77.0-beta.5', 'not-a-tag']),
|
||||
{ stable: 'v1.77.0', prerelease: 'v1.78.0-beta.1' });
|
||||
assert.deepEqual(latestTags([]), { stable: null, prerelease: null });
|
||||
});
|
||||
|
||||
test('#634 status-snapshot: --write заменяет только блок, без маркеров — отказ', () => {
|
||||
const block = `${BEGIN}\n| Item | State |\n${END}`;
|
||||
const text = `# S\n\n${BEGIN}\nold\n${END}\n\n## Rest\nkept\n`;
|
||||
const next = replaceBlock(text, block);
|
||||
assert.equal(next, `# S\n\n${block}\n\n## Rest\nkept\n`);
|
||||
assert.equal(replaceBlock(next, block), next, 'идемпотентно');
|
||||
assert.throws(() => replaceBlock('# S\n', block), /expected exactly one/);
|
||||
assert.throws(() => replaceBlock(`${text}${BEGIN}\n${END}\n`, block), /expected exactly one/);
|
||||
});
|
||||
|
||||
test('#634 status-snapshot: docs/STATUS.md несёт один сгенерированный блок той же формы', () => {
|
||||
const status = readFileSync(new URL('../docs/STATUS.md', import.meta.url), 'utf8').replace(/\r\n?/g, '\n');
|
||||
const start = status.indexOf(BEGIN);
|
||||
const end = status.indexOf(END);
|
||||
assert.ok(start > 0 && end > start, 'блок на месте');
|
||||
assert.equal(status.indexOf(BEGIN, start + 1), -1, 'блок один');
|
||||
const committed = status.slice(start, end + END.length);
|
||||
const labels = (block) => block.split('\n').filter((line) => line.startsWith('| ')).map((line) => line.split('|')[1].trim());
|
||||
const fresh = renderSnapshot({ date: blockDate(committed), versions: { version: 'x', sources: 7, mismatches: [] },
|
||||
tags: { stable: null, prerelease: null }, tests: [] });
|
||||
assert.deepEqual(labels(committed), labels(fresh), 'строки блока — ровно те, что рисует генератор');
|
||||
assert.ok(blockDate(committed), 'дата генерации в блоке');
|
||||
// Статус задач живёт только в метках: генератор меток не читает и в прозе их нет.
|
||||
assert.doesNotMatch(committed, /S[1-8]-[a-z]/);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// #634: docs/TESTING.md — действующая инструкция (AC3: ≤ 800 строк), приложения
|
||||
// по issue и ручные чек-листы — в docs/testing-notes/, доступны по индексу.
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { headings, markdownLinks } from '../scripts/md-anchors.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const NOTES = 'docs/testing-notes';
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n?/g, '\n');
|
||||
const noteFiles = () => readdirSync(join(ROOT, NOTES)).filter((name) => name.endsWith('.md') && name !== 'README.md').sort();
|
||||
|
||||
test('#634 TESTING.md: инструкция не длиннее 800 строк и ведёт к индексу приложений (AC3)', () => {
|
||||
const testing = read('docs/TESTING.md');
|
||||
const lines = testing.split('\n').length - (testing.endsWith('\n') ? 1 : 0);
|
||||
assert.ok(lines <= 800, `docs/TESTING.md: ${lines} строк > 800 — приложение по issue кладётся в ${NOTES}/`);
|
||||
assert.ok(markdownLinks(testing).some((link) => link.file === 'testing-notes/README.md'), 'ссылка на индекс');
|
||||
// Правила для новых тестов остаются в инструкции — их читают все.
|
||||
assert.match(testing, /^## Правила для новых тестов \(issue #85\) — обязательны$/m);
|
||||
});
|
||||
|
||||
test('#634 индекс приложений: каждый раздел каждого приложения — строкой, каждая ссылка жива', () => {
|
||||
const index = read(`${NOTES}/README.md`);
|
||||
const links = markdownLinks(index);
|
||||
const listed = new Set(links.map((link) => `${link.file}#${link.anchor}`));
|
||||
const files = noteFiles();
|
||||
assert.ok(files.length >= 5, 'приложения на месте');
|
||||
for (const file of files) {
|
||||
assert.ok(links.some((link) => link.file === file && !link.anchor), `${file}: нет заголовка группы в индексе`);
|
||||
for (const heading of headings(read(`${NOTES}/${file}`)).filter((h) => h.level >= 2)) {
|
||||
assert.ok(listed.has(`${file}#${heading.anchor}`), `${file}: раздел «${heading.text}» не в индексе`);
|
||||
}
|
||||
}
|
||||
for (const link of links) {
|
||||
if (link.file.startsWith('../')) { assert.ok(existsSync(join(ROOT, 'docs', link.file.slice(3)))); continue; }
|
||||
assert.ok(files.includes(link.file), `индекс ссылается на отсутствующий файл ${link.file}`);
|
||||
if (!link.anchor) continue;
|
||||
const anchors = new Set(headings(read(`${NOTES}/${link.file}`)).map((heading) => heading.anchor));
|
||||
assert.ok(anchors.has(link.anchor), `индекс: нет заголовка ${link.file}#${link.anchor}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user