Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b46a62503 | ||
|
|
cd3c7752a8 | ||
|
|
9f2c5f5ff4 | ||
|
|
20d7883699 | ||
|
|
6ebf12af1e | ||
|
|
09143e23a6 | ||
|
|
0e6cb7570b | ||
|
|
321d153c22 | ||
|
|
7f70b64f48 | ||
|
|
6c37cd5f05 | ||
|
|
7642c484d2 | ||
|
|
ec7408f3d5 | ||
|
|
0f8d35f516 | ||
|
|
295257240d | ||
|
|
f2fcf0d594 | ||
|
|
3e4e549b56 | ||
|
|
875b09cd8d | ||
|
|
84cd5f9331 | ||
|
|
debb13baa2 | ||
|
|
ab2a014568 | ||
|
|
558dae95cd | ||
|
|
1937c32572 | ||
|
|
5ed2821161 | ||
|
|
f66cf8ad4d | ||
|
|
d66cd2ebab | ||
|
|
f0e7700805 | ||
|
|
aad625a84d | ||
|
|
b57ea94cb8 | ||
|
|
737e7b62aa | ||
|
|
f11a4e1085 | ||
|
|
548677a99c | ||
|
|
087f7cf381 | ||
|
|
b860ef4c43 | ||
|
|
3b0b9eea50 | ||
|
|
0cf10613f2 | ||
|
|
e894ce2986 | ||
|
|
ac30f8913d | ||
|
|
de46db3343 | ||
|
|
782ff54e0f | ||
|
|
b989c84b71 | ||
|
|
400ca7043e | ||
|
|
9f5d729538 | ||
|
|
8eb4bab7c6 | ||
|
|
6c48c6d5c6 | ||
|
|
5bebc26aeb | ||
|
|
f5c36da648 | ||
|
|
e9a148315a | ||
|
|
fb4096f67b | ||
|
|
e83da25085 | ||
|
|
ae10b2861b | ||
|
|
eef3634f23 | ||
|
|
6ecbedfb85 | ||
|
|
e6366b6548 | ||
|
|
159094cfec | ||
|
|
188a386cd8 | ||
|
|
5df8b723e7 | ||
|
|
de0171dd02 | ||
|
|
f1e6cca3db | ||
|
|
1079cdfab2 | ||
|
|
b7b28ee579 | ||
|
|
af851cda85 | ||
|
|
0af095a6c4 | ||
|
|
9ad2b3b4ef | ||
|
|
fea0d55c67 | ||
|
|
bc98116a31 | ||
|
|
328ed7afc0 | ||
|
|
0e69c4a183 | ||
|
|
ef3cc98d1c | ||
|
|
e13215c02f | ||
|
|
42b3f44c4a | ||
|
|
4c73e2ccdb | ||
|
|
76ce755742 | ||
|
|
50099acc75 | ||
|
|
a282f850af | ||
|
|
d7f3bb8119 | ||
|
|
5c6ab8ea9b | ||
|
|
fda4893f0c | ||
|
|
888e90450a | ||
|
|
b2263a6551 | ||
|
|
565f518dcd | ||
|
|
02e0c9801d | ||
|
|
e93c405b13 | ||
|
|
955de3e69c | ||
|
|
7af4146614 | ||
|
|
a43602934c | ||
|
|
516257e322 | ||
|
|
9177c9a944 | ||
|
|
8a3f6efa0a | ||
|
|
be7d6b9706 | ||
|
|
7c1edbfa9b | ||
|
|
024cdc0d94 | ||
|
|
2fd042a7de | ||
|
|
4e539b02df | ||
|
|
d7e2c4d4f0 | ||
|
|
948f2848dd | ||
|
|
3270e039d8 | ||
|
|
8e6b6c7ee3 | ||
|
|
dbe12f1a54 | ||
|
|
1e9952db35 | ||
|
|
d1be6891b2 | ||
|
|
39f5312f97 | ||
|
|
cc3b0f12f2 | ||
|
|
316ee76a29 | ||
|
|
9be81c1413 | ||
|
|
7a2577dba0 | ||
|
|
869fe169d8 | ||
|
|
fafeca4540 | ||
|
|
e0ddbcd79e | ||
|
|
6ea3ebff17 | ||
|
|
22e98c5555 | ||
|
|
d38a5be68b | ||
|
|
42335bc16d | ||
|
|
a36b3129f6 | ||
|
|
0ef900a3ae | ||
|
|
8cecaf2c5e | ||
|
|
5aa8771dc3 | ||
|
|
02502c990c | ||
|
|
a841d85e40 | ||
|
|
6d61529168 | ||
|
|
f87d71ac18 | ||
|
|
7ba2de7c89 | ||
|
|
74b08df88c | ||
|
|
9f02d88b42 | ||
|
|
c18224cdd2 | ||
|
|
3ade633538 | ||
|
|
ee2357b914 | ||
|
|
9e176aa1d7 | ||
|
|
7a76fb78fc | ||
|
|
c585f0268d | ||
|
|
df5be154ee | ||
|
|
68596a75a0 | ||
|
|
65a86db122 | ||
|
|
39dd5de857 | ||
|
|
a29df12e0b | ||
|
|
0509e1a008 | ||
|
|
e043974c44 | ||
|
|
05b38e67c4 | ||
|
|
b9062c1740 | ||
|
|
9146b4c357 | ||
|
|
97d932a384 | ||
|
|
30f71af200 | ||
|
|
9ad01be813 | ||
|
|
495a99872b | ||
|
|
53da8a1773 | ||
|
|
f339398f56 | ||
|
|
ab609ab165 | ||
|
|
e2b0fbfc06 | ||
|
|
ce40c57a3b | ||
|
|
31d81ad4ef | ||
|
|
37032203dd | ||
|
|
cf77d7d2e1 | ||
|
|
8e2973fa7a | ||
|
|
9bb5f7c5a8 | ||
|
|
121c9f10b9 | ||
|
|
661eb784fb | ||
|
|
9e74051652 | ||
|
|
01980ac3e6 | ||
|
|
36e81e9fb1 | ||
|
|
6d0f97ef82 | ||
|
|
bd9b6a6d75 | ||
|
|
4b03b888ff | ||
|
|
58d952e94e | ||
|
|
5d3f580df0 | ||
|
|
4dc3fdef36 | ||
|
|
8a417539e0 | ||
|
|
c41231a7ba |
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
message_file=$1
|
||||
|
||||
# Git-generated merge commits do not represent an independently authored
|
||||
# product change and inherit provenance from their parents.
|
||||
case "${message_file##*/}" in
|
||||
MERGE_MSG) exit 0 ;;
|
||||
esac
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
node "$repo_root/scripts/validate-commit-provenance.mjs" \
|
||||
--message-file "$message_file" --staged --check-hook-mode
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# PROCESS.md 10.1: the blocking process gate lives here, because commits go
|
||||
# straight to dev without pull requests and GitHub blocks nothing on its side.
|
||||
# CI still runs the same script (10.3), but by then the code is already in dev —
|
||||
# that catch-up pass reports, it does not prevent.
|
||||
#
|
||||
# Git feeds one line per ref on stdin:
|
||||
# <local ref> <local sha> <remote ref> <remote sha>
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
gate="$repo_root/scripts/process-gate.mjs"
|
||||
zero=$(printf '%040d' 0)
|
||||
|
||||
# The gate reasons about commits. A repository without it — an old checkout, a
|
||||
# bisect, a worktree from before the script existed — must still be pushable.
|
||||
if [ ! -f "$gate" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Reading issue status needs gh, and a hook that cannot work on a train is a
|
||||
# hook people disable. Offline the checks that need no network still run, and the
|
||||
# strict pass happens in CI, where gh is always present.
|
||||
issues_flag=""
|
||||
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||
issues_flag="--issues"
|
||||
else
|
||||
echo "process-gate: gh недоступен, проверка статуса issue пропущена — её выполнит CI" >&2
|
||||
fi
|
||||
|
||||
status=0
|
||||
|
||||
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
# Deleting a remote branch pushes nothing to examine.
|
||||
if [ "$local_sha" = "$zero" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Tags carry no process state of their own: the commit they point at was
|
||||
# already checked when it was pushed.
|
||||
case "$local_ref" in
|
||||
refs/tags/*) continue ;;
|
||||
esac
|
||||
|
||||
if [ "$remote_sha" = "$zero" ]; then
|
||||
# A branch that does not exist on the remote yet. Everything it adds on top
|
||||
# of dev is new, so that is the range — not the whole history, which would
|
||||
# drag in every violation committed before the gate existed.
|
||||
base=$(git merge-base "$local_sha" refs/remotes/origin/dev 2>/dev/null || true)
|
||||
if [ -z "$base" ]; then
|
||||
echo "process-gate: не нашёл общего предка с origin/dev, проверяю последние 20 коммитов" >&2
|
||||
base="$local_sha~20"
|
||||
fi
|
||||
else
|
||||
base="$remote_sha"
|
||||
fi
|
||||
|
||||
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
|
||||
# shellcheck disable=SC2086
|
||||
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$status" -ne 0 ]; then
|
||||
cat >&2 <<'EOF'
|
||||
|
||||
Push остановлен: нарушен процесс (PROCESS.md §10.2).
|
||||
|
||||
Починить надо причину, а не симптом. Если нарушение уже опубликовано, его
|
||||
исправляет следующий коммит плюс issue с меткой `process` — не force-push
|
||||
(§12, правило 17).
|
||||
|
||||
Обойти проверку можно через `git push --no-verify`, и тогда то же самое найдёт
|
||||
job `process-gate` в Validate — уже после того, как код окажется в dev.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Mutation gate
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
|
||||
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
|
||||
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
|
||||
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
|
||||
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
|
||||
- cron: '20 5 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: mutation-gate
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mutants:
|
||||
runs-on: ubuntu-latest
|
||||
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
|
||||
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Реестр применим к текущему коду
|
||||
run: node scripts/mutation-gate.mjs --check
|
||||
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs
|
||||
@@ -148,6 +148,10 @@ jobs:
|
||||
run: |
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
@@ -161,6 +165,8 @@ jobs:
|
||||
working-directory: candidate
|
||||
run: |
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
name: Process
|
||||
|
||||
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
|
||||
# сообщение: она порождает событие, событие запускает следующий шаг.
|
||||
#
|
||||
# S4-spec-review -> ревью ТЗ -> S5-ready | S3-spec
|
||||
# S7-code-review -> код-ревью -> слияние в dev -> S8-merged | S6-in-progress
|
||||
#
|
||||
# Три вещи, без которых конвейер молча не работает:
|
||||
#
|
||||
# 1. Метки переставляются токеном HP_PROCESS_TOKEN, а не GITHUB_TOKEN. GitHub
|
||||
# намеренно не запускает workflow от событий, вызванных GITHUB_TOKEN, чтобы
|
||||
# не было циклов — цепочка оборвалась бы после первого шага.
|
||||
# 2. Этот файл обязан лежать в ветке по умолчанию (main). Для события `issues`
|
||||
# GitHub берёт workflow только оттуда, независимо от того, что в dev.
|
||||
# 3. Многострочный текст внутри `run:` — только через heredoc. Строка с нулевым
|
||||
# отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||||
# Проверять не только YAML, но и каждый `run` через `bash -n`.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
concurrency:
|
||||
# Два события по одному issue не должны запускать два прогона.
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
# Обязательно: claude-code-action получает OIDC-токен для авторизации
|
||||
# GitHub App. Без этого прогон падает с «Could not fetch an OIDC token».
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||||
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
|
||||
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
stage=""; marker=""
|
||||
case "$LABEL" in
|
||||
S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;;
|
||||
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
|
||||
*) echo "метка $LABEL конвейер не запускает" ;;
|
||||
esac
|
||||
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
|
||||
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
|
||||
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
|
||||
# код-ревью упиралось бы в review-4 после одной правки.
|
||||
#
|
||||
# Этап опознаётся по имени документа в теле комментария. Если документа
|
||||
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
|
||||
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
|
||||
done_cycles=0
|
||||
if [ -n "$stage" ]; then
|
||||
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
|
||||
--json comments \
|
||||
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
# Ревьюшная метка обещает работу; если конвейер её не начал и промолчал,
|
||||
# задача стоит в этом статусе бесконечно и никто об этом не узнаёт.
|
||||
# Так и вышло на #123: чужой issue довели до S4-spec-review, guard
|
||||
# отказался за 9 секунд, и в issue не было ни слова.
|
||||
#
|
||||
# Пишем только когда пытались запустить ревью, то есть stage опознан.
|
||||
# Иначе комментарий уходил бы на каждую смену любой метки.
|
||||
refuse() {
|
||||
echo "$1"
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||||
"Конвейер ревью не запущен: $2
|
||||
|
||||
Метка \`$LABEL\` обещает работу, которая не начнётся, поэтому статус лучше вернуть в предыдущий — иначе задача простоит здесь бесконечно. [Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
|
||||
stage=""
|
||||
}
|
||||
|
||||
# Автор issue здесь не проверяется (решение владельца 2026-08-13).
|
||||
# Проверка стоит на входе в процесс, а не на каждом шаге: как только
|
||||
# задача получила статусную метку, она в работе, и кто её завёл — не
|
||||
# имеет значения. Само присвоение метки и есть явное подтверждение
|
||||
# владельца, причём проверенное платформой: метки может ставить только
|
||||
# тот, у кого есть право записи в репозиторий. Прежняя проверка здесь
|
||||
# дублировала эту гарантию и заставляла переоформлять чужие отчёты
|
||||
# своими issue — чистая работа впустую, как на #123.
|
||||
if [ -z "$stage" ]; then
|
||||
:
|
||||
elif [ "$BLOCKED" = "true" ]; then
|
||||
refuse "стоит blocked — конвейер не запускается" \
|
||||
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
|
||||
elif [ "$EXHAUSTED" = "true" ]; then
|
||||
refuse "стоит review-4 — решение за владельцем" \
|
||||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||||
elif [ "$done_cycles" -ge "$limit" ]; then
|
||||
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||||
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||||
stage=""
|
||||
else
|
||||
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
|
||||
fi
|
||||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
needs: guard
|
||||
if: needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
|
||||
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
|
||||
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
|
||||
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
|
||||
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
|
||||
# тяжёлых job, здесь его не было.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||||
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
|
||||
- name: Перейти на ветку задачи
|
||||
id: branch
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
|
||||
| head -1 | sed 's|.*refs/heads/||')
|
||||
if [ -n "$branch" ]; then
|
||||
git checkout -q "origin/$branch"
|
||||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||||
echo "name=$branch" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Ты ревьюер проекта House Plan. Язык ответа — русский.
|
||||
|
||||
Issue: #${{ github.event.issue.number }}
|
||||
Репозиторий: ${{ github.repository }}
|
||||
Этап: ${{ needs.guard.outputs.stage }}
|
||||
spec — ревью ТЗ (PROCESS.md §2.4)
|
||||
code — код-ревью (PROCESS.md §2.7)
|
||||
|
||||
Прочитай в этом порядке, прежде чем судить:
|
||||
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:
|
||||
терминология интерфейса берётся оттуда, а не изобретается.
|
||||
5. Канонический документ затронутой подсистемы: docs/SUN.md,
|
||||
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
|
||||
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
|
||||
|
||||
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
|
||||
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
|
||||
Проверь обязательные разделы §7.1, однозначность каждого AC и
|
||||
указание способа доказательства. Отдельно проверь, что автор не
|
||||
выдал догадку за решение: утверждение о поведении, которого нет ни
|
||||
в одном документе и которое не помечено как предположение, —
|
||||
замечание. Не бывает сложной задачи без единого открытого вопроса.
|
||||
|
||||
Владельцу задаются только продуктовые вопросы: что человек видит или
|
||||
делает и каков объём видимых изменений в этом issue. Технический
|
||||
вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и
|
||||
решаешь по существу в своём вердикте.
|
||||
|
||||
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
|
||||
и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет,
|
||||
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
|
||||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||||
умеет падать, либо разобран по коду с явной записью «проверено
|
||||
чтением, не исполнением». «Verified» без названной команды и её
|
||||
результата доказательством не является. Зависимости уже установлены
|
||||
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
|
||||
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
|
||||
changelog в том же коммите.
|
||||
|
||||
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
Всегда, они дешёвые:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла.
|
||||
|
||||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
|
||||
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
|
||||
только когда задача действительно задевает всё;
|
||||
- `npm run golden:verify` — если diff может изменить видимый
|
||||
результат: рендер, геометрия, стили, слои;
|
||||
- `python -m pytest tests_backend -q` — если тронут
|
||||
`custom_components/**/*.py`;
|
||||
- performance-профили — если названы в AC либо тронуты
|
||||
чувствительные к перфу пути.
|
||||
|
||||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||||
тем тестам, которые ты прогонял.
|
||||
|
||||
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
|
||||
почему.** Это условие честности такого сужения: непрогнанный гейт
|
||||
становится видимым решением, а не молчаливым пропуском. Раздел «чего
|
||||
не проверял» в документе ревью — не формальность, а главный его
|
||||
раздел на коротких задачах.
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
|
||||
Low либо правится, либо снимается с записью. Жёлтый вердикт
|
||||
допустим при полностью выполненных AC, если изменение не решает
|
||||
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
|
||||
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
|
||||
|
||||
Каждую Medium-находку заведи отдельным issue со ссылкой на
|
||||
#${{ github.event.issue.number }} и метками: тип, приоритет,
|
||||
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
|
||||
запрещено §12.
|
||||
|
||||
Напиши полный документ ревью в файл
|
||||
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
|
||||
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
|
||||
находки с воспроизведением, что проверено и корректно, чего не
|
||||
проверял. Каталог docs/reviews/ создай, если его нет. Больше не
|
||||
пиши ничего: любой файл вне docs/reviews/ опубликован не будет.
|
||||
|
||||
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
|
||||
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
|
||||
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
|
||||
|
||||
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
|
||||
без него метка не переставится и конвейер встанет.
|
||||
claude_args: |
|
||||
--max-turns 150
|
||||
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
|
||||
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
|
||||
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
|
||||
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
|
||||
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
|
||||
target="${BRANCH:-dev}"
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
|
||||
fi
|
||||
git checkout -- . 2>/dev/null || true
|
||||
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
|
||||
git add docs/reviews 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
echo "::warning::документ ревью не создан"
|
||||
exit 0
|
||||
fi
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F - <<EOF
|
||||
docs: review document for #$NUM
|
||||
|
||||
Issue: #$NUM
|
||||
User-Visible: no
|
||||
EOF
|
||||
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
|
||||
# либо dev, поэтому push нужен с явным перебазированием при гонке:
|
||||
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"; then
|
||||
git fetch -q origin "$target"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase "origin/$target"; then
|
||||
git rebase --abort || true
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
|
||||
exit 0
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"
|
||||
fi
|
||||
echo "документ опубликован в $target"
|
||||
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
run: |
|
||||
verdict=$(echo "$OUT" | jq -r '.verdict')
|
||||
high=$(echo "$OUT" | jq -r '.high')
|
||||
echo "вердикт: $verdict, High: $high"
|
||||
|
||||
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
|
||||
# автору: на прогоне #111 жёлтый означал, что AC описывает неверное
|
||||
# изменение контракта — реализовать такое ТЗ значит сделать ошибку
|
||||
# по инструкции. Оба считаются циклом.
|
||||
if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then
|
||||
green=true
|
||||
case "$STAGE" in
|
||||
spec) from=S4-spec-review; to=S5-ready ;;
|
||||
code) from=S7-code-review; to=S8-merged ;;
|
||||
esac
|
||||
else
|
||||
green=false
|
||||
case "$STAGE" in
|
||||
spec) from=S4-spec-review; to=S3-spec ;;
|
||||
code) from=S7-code-review; to=S6-in-progress ;;
|
||||
esac
|
||||
fi
|
||||
echo "green=$green" >> "$GITHUB_OUTPUT"
|
||||
echo "from=$from" >> "$GITHUB_OUTPUT"
|
||||
echo "to=$to" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
|
||||
# ДО метки, иначе она врёт в промежутке.
|
||||
#
|
||||
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
|
||||
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
|
||||
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
|
||||
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
|
||||
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
|
||||
- name: Слить ветку в dev
|
||||
id: merge
|
||||
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git fetch -q origin dev
|
||||
git checkout -q -B merge-into-dev "origin/$BRANCH"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase origin/dev; then
|
||||
git rebase --abort || true
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
|
||||
cat > /tmp/conflict.md <<EOF
|
||||
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
|
||||
|
||||
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
|
||||
2. запушить ветку;
|
||||
3. вернуть метку \`S7-code-review\`.
|
||||
|
||||
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
|
||||
exit 0
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
echo "слито в dev: $(git rev-parse --short HEAD)"
|
||||
|
||||
- name: Переставить метку
|
||||
env:
|
||||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
FROM: ${{ steps.decide.outputs.from }}
|
||||
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
|
||||
# автору: метка утверждала бы, что код в dev, а его там нет.
|
||||
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }}
|
||||
run: |
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label "$TO" --remove-label "$FROM"
|
||||
echo "$FROM -> $TO"
|
||||
|
||||
- name: Позвать владельца, если ревью упало
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
# Тело через heredoc, а не многострочный --body: строка с нулевым
|
||||
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
|
||||
cat > /tmp/failure.md <<EOF
|
||||
Автоматическое ревью не отработало: [прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
|
||||
|
||||
Если вердикт выше всё же опубликован — сбой произошёл после него. Перестановку метки в этом случае выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
|
||||
EOF
|
||||
gh issue comment "${{ github.event.issue.number }}" \
|
||||
--repo "${{ github.repository }}" --body-file /tmp/failure.md
|
||||
@@ -191,6 +191,60 @@ jobs:
|
||||
);
|
||||
}
|
||||
|
||||
# PROCESS.md 10.2 item 10: closing issues and stripping status labels happens
|
||||
# because a beta was published, not because someone remembered to do it. The
|
||||
# manual step was skipped twice, and both times it broke the invariant that a
|
||||
# closed issue carries no status label — the one thing `verify` relies on.
|
||||
#
|
||||
# A manual step after a successful release is the worst kind: by the time it is
|
||||
# due, the work already looks finished, which is exactly why it gets forgotten.
|
||||
close-merged:
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do
|
||||
# not start workflows, so removing the label cannot wake the review
|
||||
# pipeline. A PAT here would build a cascade out of a bookkeeping step.
|
||||
issues: write
|
||||
steps:
|
||||
- name: Close the S8-merged queue and strip status labels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
URL: ${{ needs.publish.outputs.url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Only the owner's issues take part in the process; issues filed by
|
||||
# anyone else never carry status labels and are not ours to close.
|
||||
numbers=$(gh issue list --repo "$REPO" --state open --label S8-merged \
|
||||
--author Matysh --limit 100 --json number --jq '.[].number')
|
||||
if [ -z "$numbers" ]; then
|
||||
echo "the S8-merged queue is empty, nothing to close"
|
||||
else
|
||||
for n in $numbers; do
|
||||
gh issue comment "$n" --repo "$REPO" \
|
||||
--body "Выпущено в \`$TAG\` · [релиз]($URL)"
|
||||
# Label first, then close. If the run dies between the two steps an
|
||||
# open issue without a status is visible and fixable in the flow;
|
||||
# the reverse order would recreate the exact breakage this job is
|
||||
# here to prevent.
|
||||
gh issue edit "$n" --repo "$REPO" --remove-label S8-merged
|
||||
gh issue close "$n" --repo "$REPO" --reason completed
|
||||
echo "closed #$n"
|
||||
done
|
||||
fi
|
||||
# Targeted at the defect that actually recurs, not at the invariant in
|
||||
# general: no closed issue may still carry S8-merged.
|
||||
leftover=$(gh issue list --repo "$REPO" --state closed --label S8-merged \
|
||||
--limit 100 --json number --jq 'length')
|
||||
test "$leftover" = "0" || {
|
||||
echo "::error::$leftover closed issues still carry S8-merged"
|
||||
exit 1
|
||||
}
|
||||
|
||||
announce:
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
|
||||
@@ -15,7 +15,99 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Validate commit trailers and hook mode
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
|
||||
|
||||
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
|
||||
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в dev
|
||||
# без PR — GitHub на своей стороне не блокирует ничего. Это последнее место,
|
||||
# где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и
|
||||
# не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим.
|
||||
process-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Process gate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TARGET_REF: ${{ github.ref }}
|
||||
# Публичный репозиторий: штатного токена хватает на чтение issue.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/process-gate.mjs --github-range --issues
|
||||
|
||||
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
|
||||
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
|
||||
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
|
||||
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
|
||||
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
|
||||
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
|
||||
# там делает код-ревью, которое гоняет гейты само (#127).
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
echo "dev: без фильтров, всё true"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
zero=$(printf '%040d' 0)
|
||||
base="$BEFORE_SHA"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
|
||||
# иначе классифицировалась бы вся история.
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||||
git fetch -q origin dev
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
|
||||
{
|
||||
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
|
||||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
hacs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -25,6 +117,8 @@ jobs:
|
||||
category: integration
|
||||
|
||||
hassfest:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -32,6 +126,8 @@ jobs:
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
frontend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -152,6 +248,8 @@ jobs:
|
||||
path: artifacts/performance-smoke
|
||||
|
||||
backend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -6,60 +6,398 @@ House Plan is one HACS package with two parts plus a demo harness:
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
|
||||
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
|
||||
## Read this first
|
||||
|
||||
**`docs/SCOPE.md` before anything else.** It was fixed with the owner and states
|
||||
its own authority: features are built, improved and accepted **only** if they
|
||||
serve a job listed there. It carries the mission, the three personas, the core
|
||||
user jobs and the out-of-scope list.
|
||||
|
||||
Its central consequence: **View mode is the product for two of the three
|
||||
personas.** Editors are admin-only tools and must never leak interactions into
|
||||
View.
|
||||
|
||||
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`.
|
||||
|
||||
Standard commands live in `package.json` scripts, `CONTRIBUTING.md` and
|
||||
`docs/DEVELOPMENT.md`.
|
||||
|
||||
## Canonical backlog and status
|
||||
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical
|
||||
task records: problem, scope, acceptance criteria and discussion.
|
||||
|
||||
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
|
||||
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
|
||||
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
|
||||
issue. Labels are the whole of it: GitHub Projects is no longer used.
|
||||
|
||||
Two shortcuts exist for small work. `small` — the light track: the spec lives in
|
||||
the issue body and its review is a comment. `trivial` — the short track: no spec
|
||||
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
|
||||
issue body first. `trivial` requires a bug confined to one surface with no new UX
|
||||
contract, no migration, no i18n, no perf or touch impact, at most three checkable
|
||||
AC, **and expected behaviour already on record** — nothing left to decide. Code
|
||||
review is never skipped on either track; it is what stands in for testing.
|
||||
`PROCESS.md` §5 and §5.1 hold the criteria.
|
||||
|
||||
An issue filed by an outsider is worked exactly like one of the owner's own, once
|
||||
the owner has decided to take it. The check sits **at the entrance**, not on every
|
||||
step: while an issue carries no status label it is outside the process and the
|
||||
invariants do not apply to it; once a label is on, the task is in flight and **who
|
||||
filed it stops mattering**.
|
||||
|
||||
Applying that first label *is* the owner's explicit decision, and the platform
|
||||
already guarantees it — only someone with write access can label. The earlier rule
|
||||
made outside reports be refiled as the owner's own issues, which turned out to be
|
||||
work for nothing: on #123 the spec was already written by the time the guard
|
||||
refused.
|
||||
|
||||
Specs, audits and ADRs may live under `docs/`, but must link to their issue and
|
||||
must not become a parallel task list. When repository documentation disagrees with
|
||||
Issues, the issue wins.
|
||||
|
||||
## Rule #1
|
||||
|
||||
> Changing product code without an issue is forbidden. Code changes only when the
|
||||
> issue exists and sits in "Ready for development" or later.
|
||||
|
||||
Check before touching product code:
|
||||
|
||||
```
|
||||
gh issue view <NN> --repo Matysh/houseplan-card --json number,state,labels
|
||||
```
|
||||
|
||||
The label must be one of `S5-ready`, `S6-in-progress`, `S7-code-review`. Anything
|
||||
else — refuse and say why. "Issue #83 is in `S2-analysis`, code is off limits.
|
||||
Start with the spec?" is the correct answer, not a smaller patch.
|
||||
|
||||
## Change classes
|
||||
|
||||
| Class | Paths | Issue required |
|
||||
|---|---|---|
|
||||
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
|
||||
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
|
||||
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
|
||||
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | never changes on its own |
|
||||
|
||||
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
|
||||
configuration files this one omits — `package.json`, `package-lock.json`,
|
||||
`pytest.ini`, `.gitignore`, `.gitattributes`, `.githooks/**` and the rest of
|
||||
`.github/**` are class B. Where paths overlap, **D beats A**: the built bundle
|
||||
lives inside `custom_components/houseplan/frontend/` and would otherwise read as
|
||||
product source.
|
||||
|
||||
## Commits
|
||||
|
||||
Hooks install themselves: `package.json` runs `"prepare": "node
|
||||
scripts/install-hooks.mjs"`, so `npm ci` sets `core.hooksPath` in every fresh
|
||||
clone. Verify with `git config core.hooksPath` — expect `.githooks`.
|
||||
|
||||
Every non-merge commit carries **terminal** trailers:
|
||||
|
||||
```text
|
||||
Issue: #123
|
||||
User-Visible: yes
|
||||
```
|
||||
|
||||
One `Issue:` line per issue if a commit closes several. `User-Visible: no` for
|
||||
tests, refactors, tooling and documentation that does not change the product.
|
||||
`User-Visible: yes` requires edits to **both** changelogs — `docs/CHANGELOG.md`
|
||||
and `docs/CHANGELOG.ru.md` — in the same commit.
|
||||
|
||||
A commit touching `demo/golden/baselines/**` additionally requires:
|
||||
|
||||
```text
|
||||
Release: v1.62.0-beta.9
|
||||
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/<run-id>
|
||||
```
|
||||
|
||||
Never invent a review link and never rewrite published history to satisfy
|
||||
trailers. `.githooks/commit-msg` and the `provenance` CI job both run
|
||||
`scripts/validate-commit-provenance.mjs`.
|
||||
|
||||
Branch: `issue/<NN>-slug`. Direct commits to `dev`, no PR — the owner's decision;
|
||||
CI checks after the fact, and a violation is fixed with a follow-up commit, never
|
||||
a force-push.
|
||||
|
||||
**Push after every task, not before a beta.** While work sits unpushed there is
|
||||
nothing to review, and reviewing twenty tasks at once is not review. `dev` may hold
|
||||
unreviewed code while a task is in flight; what matters is its state when the
|
||||
reviewer says it is accepted.
|
||||
|
||||
**Standing permission: push `issue/<NN>-slug` without asking.** The reviewer runs
|
||||
in CI and can only read what is on the remote — an unpushed spec or commit means
|
||||
the review either stalls or judges the wrong tree. Pushing a task branch publishes
|
||||
nothing to users and does not touch the integration branch, so it needs no command.
|
||||
|
||||
**Do not merge into `dev` by hand.** On a green code review the pipeline rebases
|
||||
the task branch onto `dev`, pushes it, and only then sets `S8-merged` — the label
|
||||
asserts the code is in `dev`, so the merge has to happen first or the label lies
|
||||
in between.
|
||||
|
||||
If the rebase conflicts the pipeline says so in the issue and sends the task back
|
||||
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
|
||||
remaining work is the rebase. Resolve it, push the branch, re-apply
|
||||
`S7-code-review`. The second review run is not a formality — after a rebase onto a
|
||||
moved `dev` this is different code, and accepting it unchecked is how regressions
|
||||
arrive. Cycles are counted per stage, so a code review spends its own budget.
|
||||
|
||||
Everything else still requires the owner's explicit command: pushing `main`,
|
||||
creating tags, publishing betas and releases, closing issues.
|
||||
|
||||
## Working trees (#115)
|
||||
|
||||
One checkout, one `HEAD`: two agents sharing a directory inherit each other's
|
||||
branch, and twice in one hour a commit landed on someone else's task branch that
|
||||
way. The layout is therefore fixed:
|
||||
|
||||
- **`houseplan-card-src/houseplan-card`** — the author's tree. Task branches live
|
||||
here; nobody else commits in it. Unfamiliar local changes belong to the author
|
||||
or the owner — never reset or clean them away.
|
||||
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
|
||||
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
|
||||
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
|
||||
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
|
||||
and publishes through the GitHub API; it makes no local commits at all, so it
|
||||
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
|
||||
pruned after use.
|
||||
|
||||
A worktree is only usable on the machine that created it: the `.git` file records
|
||||
an absolute path in that machine's format. One created from a Linux sandbox is
|
||||
dead on Windows and vice versa — create worktrees on the machine that will use
|
||||
them, which for `hp-dev` means the owner's.
|
||||
|
||||
## Two-agent workflow
|
||||
|
||||
House Plan is developed by two agents: **Codex is the author** (analysis,
|
||||
estimate, spec, implementation) and **Claude is the reviewer** (estimate, spec,
|
||||
code). They exchange remarks through a local, git-ignored message bus —
|
||||
**read `.agents/PROTOCOL.md` before acting on any task**. It defines the
|
||||
message format, the estimate scales every agent must use, the three stages
|
||||
(`estimate` → `spec` → `code`), the three-round convergence limit and what gets
|
||||
published to GitHub.
|
||||
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
|
||||
code and owns infrastructure and distribution. The owner rules on disputes, closes
|
||||
issues and commands releases.
|
||||
|
||||
Two rules that matter even if you read nothing else:
|
||||
Author and reviewer are different models, which is what "a fresh session without
|
||||
implementation context" means in practice. The reviewer never edits product code;
|
||||
the author never grades their own work.
|
||||
|
||||
- write only into the *other* agent's inbox, never edit a file you did not
|
||||
create, and move a processed message to `.agents/archive/`;
|
||||
- "verified" without a command and its output is not evidence — from either side.
|
||||
**Infrastructure-only work runs outside this flow.** CI, scripts, labels, demo
|
||||
stands, the landing page and distribution are Claude's alone, and running them
|
||||
through spec-writing and review buys nothing: the spec would restate what is
|
||||
already unambiguous, and author and reviewer would be the same role. So no spec
|
||||
file, no spec review, no code review, no walk through `S1`…`S8`.
|
||||
|
||||
## Canonical backlog
|
||||
The test for "infrastructure only" is mechanical: **not a single class A file** —
|
||||
nothing under `src/**`, no `custom_components/**/*.py`, no manifests, no i18n. A
|
||||
task that touches class A even once is not infrastructure and takes the full flow;
|
||||
there is no such thing as "mostly infrastructure". The strictness is deliberate:
|
||||
a loose reading would turn this into the route by which product changes skip
|
||||
review.
|
||||
|
||||
GitHub is the only active backlog for House Plan:
|
||||
What stays mandatory either way: an issue exists, both trailers are on every
|
||||
commit, `typecheck`, `test` and `build` are green, and any non-obvious decision is
|
||||
written down in the code or the issue rather than kept in someone's head.
|
||||
|
||||
- [GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the
|
||||
canonical task records: problem, scope, acceptance criteria and discussion.
|
||||
- [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is
|
||||
the canonical prioritization and workflow-status view. Every open in-scope
|
||||
issue must be present there.
|
||||
**Review starts by itself.** Applying `S4-spec-review` or `S7-code-review` fires the
|
||||
pipeline, which reviews without anyone asking and takes ten to forty-five minutes.
|
||||
|
||||
Before starting planned work, find or create its issue and keep its description,
|
||||
labels and Project status current as decisions and implementation state change.
|
||||
Close an issue only after the result is verified. Specs, audits and ADRs may
|
||||
remain under `docs/`, but must link to their issue and must not become a parallel
|
||||
task list. When repository documentation disagrees with Issues or Project v2,
|
||||
the GitHub backlog wins.
|
||||
**Having applied one of those labels, wait for the result instead of ending the
|
||||
session.** Reporting "handed over for review" stops a conveyor that could have kept
|
||||
moving on its own. An agent has no clock — it exists only during its own turn — so
|
||||
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
|
||||
the command timeout. Watch the **label**, not the comment: the label is the state,
|
||||
the comment only explains it. Do not wait at all while `blocked` is set — the task
|
||||
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
|
||||
tell the owner: a failed run leaves the label where it was, forever.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
What the new label means:
|
||||
|
||||
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
|
||||
| Now reads | What happened | What you do |
|
||||
|---|---|---|
|
||||
| `S5-ready` | the spec is accepted | write the code |
|
||||
| `S3-spec` | the spec came back | read the verdict, revise, re-apply `S4-spec-review` |
|
||||
| `S6-in-progress` | the code came back | revise, re-apply `S7-code-review` — **or**, if the verdict was green and only the merge conflicted, just rebase and re-apply. The comment says which |
|
||||
| `S8-merged` | accepted and already in `dev` | nothing |
|
||||
| `review-4` | the cycle limit is spent | stop, the owner decides |
|
||||
|
||||
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, 424 tests at v1.60.0), `npm run build`. After building, keep both committed snapshots in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/` and `cp dist/houseplan-card.js demo/srv/assets/`. CI enforces both comparisons byte-for-byte.
|
||||
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (150 tests at v1.60.0: 100 pure + 50 HA harness). Running `python3 -m pytest tests_backend` without Home Assistant silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the pure set.
|
||||
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed demo snapshot must remain byte-identical to `dist` (CI checks it); rebuilding first also guarantees the browser suite tests the current source in an uncommitted worktree. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
- **Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a stale demo bundle. Build and copy the current bundle first, then review `artifacts/golden/actual/` and `diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the complete Linux CI artifact; never accept a partial scenario or images merely to make CI green. See `demo/golden/README.md`.
|
||||
- **Freshness contract**: the embedded fingerprint covers `src/` plus Rollup, TypeScript and package-lock build inputs. Benchmark and golden tooling must call `assertFreshDemoBundle` before recording any result; a missing or mismatched fingerprint is a hard failure, not a warning.
|
||||
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
|
||||
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
|
||||
- **Owner's local Windows checkout is invisible here.** Path
|
||||
`C:\Users\Sergey\Downloads\dev\houseplan-dev` (workflow notes + often
|
||||
unpushed edits) is **not mounted** into managed Cloud Agent VMs. Do not
|
||||
expect to `ls` or diff that folder. To bring local work into the cloud
|
||||
agent: push a branch to GitHub and say its name, or run a **local** Cursor
|
||||
Agent / My Machines worker inside that checkout. Day-to-day source of truth
|
||||
for cloud sessions remains **`origin/dev`** (minors) and **`origin/main`**
|
||||
(releases) — see `docs/STATUS.md`.
|
||||
**After a review run the label always changes.** If it did not, the run itself
|
||||
failed rather than the work — say so to the owner instead of polling on.
|
||||
|
||||
**A failed pre-release gate does not send the issue back to review.** The
|
||||
implementation loop runs only typecheck, unit and build; golden, browser smokes,
|
||||
performance and the full HA harness run before a beta, which is after the code
|
||||
review has passed and the issue sits in `S8-merged`. Some defects cannot surface
|
||||
any earlier.
|
||||
|
||||
Fix it, re-run what failed, and a green run is enough for the release to continue.
|
||||
The issue stays in `S8-merged`. Record the **exact command and its result** in the
|
||||
issue — "verified" without a command proves nothing. Trailers as usual, and
|
||||
`User-Visible: yes` still means both changelogs in the same commit.
|
||||
|
||||
The exception covers repairing the defect the gate named, not carrying on
|
||||
development under the name of a repair. It goes through the normal flow — a new
|
||||
issue, or back to `S6-in-progress` — if the fix changes a behaviour contract, gives
|
||||
the user something new, reaches a subsystem the task never touched, or is
|
||||
comparable in size to the task itself. And editing the gate so it stops failing is
|
||||
concealment, not repair; the exception is a defect proven to be **in the fixture**,
|
||||
as on #89, where the sun sat at azimuth 180° and the only window faced north, so no
|
||||
ray was ever built.
|
||||
|
||||
Baselines are still accepted only via `npm run golden:accept -- --reviewed` on a
|
||||
complete Linux CI artefact. "So the gate goes green" is not a reason.
|
||||
|
||||
The exchange happens in **issue comments** — there is no local message bus. Verdict
|
||||
format:
|
||||
|
||||
```text
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
|
||||
```
|
||||
|
||||
High blocks. Medium must become its own issue. Low is fixed or waived with a note
|
||||
in the review document. A yellow verdict is legitimate even when every acceptance
|
||||
criterion passes, if the change does not solve the stated scenario or degrades a
|
||||
neighbouring one.
|
||||
|
||||
**Four review cycles** (two on the light track). The counter lives in the document
|
||||
name, `-r1`…`-r4`; the fourth adds the `review-4` label. There is no fifth attempt:
|
||||
the owner splits the task, rejects it, or arbitrates.
|
||||
|
||||
On the light track (`small`: complexity ≤3, one surface, no config migration, no
|
||||
new UX contract, no perf or touch impact — all at once) the spec lives in the issue
|
||||
body and the spec review is a comment. Code review is never skipped.
|
||||
|
||||
## Specs
|
||||
|
||||
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
|
||||
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
|
||||
on which surface, at what moment; and what the person sees before and after, in one
|
||||
sentence without implementation terms.
|
||||
|
||||
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
|
||||
fact is the worst kind of defect: it passes review because it looks like a decision.
|
||||
|
||||
The owner answers exactly two kinds of question: **what a person sees or does**, and
|
||||
**how much user-visible change belongs in this issue**. Behaviour in a boundary case,
|
||||
which persona wins when two conflict, what counts as acceptable degradation, whether
|
||||
a neighbouring behaviour is in scope here or becomes its own issue.
|
||||
|
||||
Everything a user cannot observe is yours to settle: where state is stored, which
|
||||
module carries the guard, naming, file layout, test strategy, migration mechanics,
|
||||
development policy. Decide it, record it in an explicit "assumed, change freely"
|
||||
block, and let the reviewer challenge it. A technical disagreement between author and
|
||||
reviewer is settled by the verdict, not by the owner; it reaches him only when the
|
||||
cycle limit is exhausted.
|
||||
|
||||
Split a mixed question instead of escalating all of it. "Where does this state live"
|
||||
is technical. "Does it survive a page reload and follow the plan across screens" is
|
||||
product. Ask the second, decide the first.
|
||||
|
||||
Ask in one batched issue comment, each question carrying a proposed default, and put
|
||||
`blocked` on top of `S3-spec` while waiting. A question with a default costs the
|
||||
owner seconds; one without costs him minutes.
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run build
|
||||
npm run inventory # the only correct way to get test counts
|
||||
```
|
||||
|
||||
Never copy test counts into documents by hand; they go stale in days.
|
||||
|
||||
After building, keep all three bundle snapshots in sync — CI compares them
|
||||
byte-for-byte:
|
||||
|
||||
```
|
||||
cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
```
|
||||
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
|
||||
environment, which changes one thing (#151): **before moving an issue to
|
||||
`S7-code-review`, run the smokes named in its AC locally** — `node
|
||||
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
|
||||
locally it costs a minute. Precedent: on #89 a fixture error lived through a
|
||||
whole review round that a local run would have caught immediately.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
|
||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
Locally only the pure subset runs; `python -m pytest tests_backend/ -q` without
|
||||
Home Assistant **silently skips** `test_ha_*.py` (`conftest.py` ignores them when
|
||||
`homeassistant` is not importable), so a green result proves nothing. Say so in the
|
||||
report instead of claiming the backend was verified. Cloud agents have the harness
|
||||
at `.venv-backend/bin/python`.
|
||||
|
||||
**Running the app / smoke suite**: build a fresh bundle and copy it into the demo
|
||||
assets first, then run `node demo/smoke_*.mjs`. No real Home Assistant server is
|
||||
required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
|
||||
**Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a
|
||||
stale demo bundle. Build and copy first, then review `artifacts/golden/actual/` and
|
||||
`diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the
|
||||
complete Linux CI artifact; never accept a partial scenario or images merely to make
|
||||
CI green. See `demo/golden/README.md`.
|
||||
|
||||
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
|
||||
TypeScript and package-lock build inputs. Benchmark and golden tooling must call
|
||||
`assertFreshDemoBundle` before recording any result; a missing or mismatched
|
||||
fingerprint is a hard failure, not a warning.
|
||||
|
||||
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
|
||||
success` run for the candidate's SHA, not "the last green one"; a new push cancels
|
||||
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
## Environments
|
||||
|
||||
**Local Windows checkout** is the day-to-day environment: Node 22 as in CI, Python
|
||||
3.13 in a venv, `gh` authenticated. `.venv-backend` does **not** exist there — it is
|
||||
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
|
||||
Playwright Chromium.
|
||||
|
||||
Known environment-sensitive smoke: `demo/smoke_opening_measure.mjs` fails two
|
||||
sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned
|
||||
Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
|
||||
reproduces against the pristine committed bundle, so treat it as
|
||||
pre-existing/pixel-precision, not a regression you introduced.
|
||||
|
||||
## Labs flags
|
||||
|
||||
`src/labs.ts` is the single registry and resolver for hidden presentation
|
||||
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
|
||||
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
|
||||
YAML/config switch for a Labs-only experiment. A new entry needs a unique
|
||||
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
|
||||
unit/browser coverage. Invalid or duplicate registry entries fail closed.
|
||||
|
||||
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
|
||||
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
|
||||
experiment or graduate it through its own reviewed issue; never extend expiry as
|
||||
an incidental change. Labs may alter presentation only and must not gate data,
|
||||
migrations, stores, HA actions or network calls. Current renderer details are in
|
||||
`docs/ISOMETRIC.md`.
|
||||
|
||||
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
|
||||
page directly in a browser renders the floor plan but **device icons only appear
|
||||
after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The
|
||||
smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does
|
||||
not. This is a harness limitation, not a card bug.
|
||||
|
||||
## Promotion rule
|
||||
|
||||
@@ -69,3 +407,10 @@ stable release commit is promotion-only: version fields, generated bundle
|
||||
snapshots and changelog/release metadata. Do not add feature source code in
|
||||
that commit. An explicit owner-requested emergency hotfix is the only exception
|
||||
and must be called out in the release handoff.
|
||||
|
||||
A `Release vX.Y.Z-beta.N candidate` commit is **not** promotion-only: it carries
|
||||
the work itself and follows the ordinary rules, trailers included.
|
||||
|
||||
Issues are closed in a batch when a beta ships, not when implementation ends: that
|
||||
way a bug found in the beta returns to the same task, and the beta announcement can
|
||||
list what went in. Status labels are stripped as the issues close.
|
||||
|
||||
@@ -18,13 +18,12 @@ requests still belong in [issues](https://github.com/Matysh/houseplan-card/issue
|
||||
|
||||
## Backlog and work status
|
||||
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
|
||||
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
|
||||
only active project backlog. Issues own scope and acceptance criteria; Project
|
||||
v2 owns prioritization and workflow status. Before starting planned work, link
|
||||
it to an existing issue or create one, add it to the Project, and keep both
|
||||
surfaces current until the verified result is closed. Design specs and ADRs may
|
||||
support an issue, but they do not replace it or maintain a separate checklist.
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the only
|
||||
active backlog. An issue owns scope and acceptance criteria; its **labels** own
|
||||
priority and workflow status — `PROCESS.md` §9 holds the vocabulary. Before
|
||||
starting planned work, link it to an existing issue or create one, and keep it
|
||||
current until the verified result is closed. Design specs and ADRs may support an
|
||||
issue, but they do not replace it or maintain a separate checklist.
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
@@ -35,6 +34,7 @@ npm run typecheck # tsc --noEmit (strict)
|
||||
npm test # node:test — pure logic, i18n parity, tap-action security
|
||||
npm run build # tsc + rollup → dist/houseplan-card.js
|
||||
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
|
||||
npm install # also installs .githooks through the prepare script
|
||||
```
|
||||
|
||||
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
|
||||
@@ -50,7 +50,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
|
||||
- The built card must be committed in sync: `cp dist/houseplan-card.js
|
||||
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
|
||||
- Tap actions have a security model (locks/alarms never toggle from the plan) —
|
||||
see `resolveTapAction` in `src/logic.ts`; don't weaken it.
|
||||
see `resolveToggleIntent` in `src/device-toggle.ts`; don't weaken it.
|
||||
- Every commit follows the issue and trailer contract in `PROCESS.md`.
|
||||
- Follow the Integration Quality Scale where applicable —
|
||||
`custom_components/houseplan/quality_scale.yaml` tracks the self-assessment.
|
||||
|
||||
|
||||
@@ -0,0 +1,884 @@
|
||||
# Процесс работы над House Plan
|
||||
|
||||
> **Статус документа: канон** (редакция 2026-08-13). Решения владельца, на
|
||||
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
|
||||
> имена английские · лёгкий трек **включён** · автор и ревьюер — разные модели ·
|
||||
> инфраструктурные задачи идут **вне** флоу.
|
||||
>
|
||||
> **Область действия:** обязателен для владельца и для любого агента. Читается
|
||||
> сразу после `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`. Живёт в
|
||||
> репозитории: до августа 2026 канон лежал только в папке владельца, и свежий клон
|
||||
> его не содержал вовсе.
|
||||
>
|
||||
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> документации с GitHub побеждает GitHub. При расхождении этого документа с
|
||||
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
|
||||
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
|
||||
> заводится issue с меткой `process`.
|
||||
>
|
||||
> При расхождении процесса и привычки побеждает процесс.
|
||||
|
||||
---
|
||||
|
||||
## 1. Основное правило
|
||||
|
||||
**Изменение продуктового кода без issue запрещено.** Код меняется только тогда,
|
||||
когда issue существует и находится в статусе «Готово к разработке» или дальше.
|
||||
Исключения — только §11, и каждое оставляет след.
|
||||
|
||||
Правило работает лишь при точной границе «продуктового кода», иначе спор
|
||||
переносится на границу:
|
||||
|
||||
| Класс | Что входит | Нужен ли issue |
|
||||
|---|---|---|
|
||||
| **A. Продукт** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, `src/i18n/*.json`, `custom_components/**/translations/*` | **Да, обязательно.** Только из «Готово к разработке» или дальше |
|
||||
| **B. Гейты и инструменты** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, весь `.github/**`, `.githooks/**`, `rollup.config.mjs`, `tsconfig*.json`, `package.json`, `package-lock.json`, `pytest.ini`, `.gitignore`, `.gitattributes` | **Да.** Может использовать issue того изменения, которое покрывает; самостоятельная работа над гейтом получает свой issue (тип `tech-debt`) |
|
||||
| **C. Документация** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md`, `CONTRIBUTING.md`, `PROCESS*.md`, `LICENSE`, `(CODE\|SPEC)-REVIEW-*.md` | Документирование A/B в том же коммите — часть DoD своего issue. Самостоятельная работа над документацией — свой issue |
|
||||
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
|
||||
|
||||
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
|
||||
бандл» перестают быть лазейками.
|
||||
|
||||
Классы неупорядочены, но при пересечении путей **D сильнее A**: собранный бандл
|
||||
лежит внутри `custom_components/houseplan/frontend/`, и без этого правила он
|
||||
считался бы продуктовым исходником.
|
||||
|
||||
**Инфраструктурная задача идёт вне флоу** (решение владельца 2026-08-13,
|
||||
issue #118). Признак механический: **ни одного файла класса A**. Такая задача
|
||||
делается без ТЗ, ревью ТЗ, код-ревью и без прохода по статусам — флоу построен
|
||||
для изменений, у которых есть персона и видимое поведение, а в инфраструктуре ТЗ
|
||||
пересказывало бы очевидное, и автор с ревьюером оказались бы одной ролью.
|
||||
Проверкой служат гейты и CI. Обязательным остаётся issue, трейлеры и зелёные
|
||||
`typecheck`, `test`, `build`.
|
||||
|
||||
Задача, задевающая класс A хотя бы одним файлом, инфраструктурной **не
|
||||
является** и идёт полным флоу. «В основном инфраструктурная» не бывает: иначе
|
||||
это дорога, по которой продуктовые правки минуют ревью. Признак задан через
|
||||
класс файлов, а не через самоощущение исполнителя, именно поэтому.
|
||||
|
||||
---
|
||||
|
||||
## 2. Жизненный цикл
|
||||
|
||||
Восемь рабочих статусов и два служебных. Фазы тестирования в цикле сознательно
|
||||
**нет**: найденные позже дефекты заводятся отдельными issue и проходят цикл
|
||||
заново. Issue закрывается после выпуска беты.
|
||||
|
||||
```
|
||||
S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
|
||||
|
||||
служебные: blocked (поверх статуса) rejected (закрыт)
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
|
||||
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
```
|
||||
|
||||
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
|
||||
порождает событие, событие запускает ревью (§10.4). Остальные ставит исполнитель.
|
||||
|
||||
### 2.1 Новое — заведение задачи
|
||||
|
||||
- **Кто:** любой — владелец, агент, пользователь (Telegram, GitHub).
|
||||
- **Вход:** проблема в пользовательских терминах; как проявляется или зачем нужно.
|
||||
Решение **не требуется** и не приветствуется.
|
||||
- **Запрещено:** ставить приоритет, оценивать, писать ТЗ, начинать код.
|
||||
|
||||
### 2.2 Аналитика и оценка
|
||||
|
||||
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
|
||||
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
|
||||
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
|
||||
однозначно.
|
||||
|
||||
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
|
||||
- **Чек-лист**, результат — комментарием в issue:
|
||||
1. дубликаты проверены (ссылки на похожие issue);
|
||||
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
|
||||
3. **пользовательская ценность 1–10** и **ценность для разработки** — что
|
||||
упрощает или разблокирует;
|
||||
4. **сложность и риск 1–10** — трудоёмкость плюс вероятность задеть смежное;
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
|
||||
владельцем на любой стадии.
|
||||
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
|
||||
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
|
||||
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
|
||||
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
|
||||
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
|
||||
него в ТЗ пишется блок принятых предположений.
|
||||
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
|
||||
это единственный случай, когда аналитика останавливается и ждёт владельца.
|
||||
|
||||
### 2.3 ТЗ в работе — написание ТЗ
|
||||
|
||||
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
|
||||
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
|
||||
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
|
||||
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
|
||||
- **Выход:** полная первая редакция по §7.
|
||||
|
||||
### 2.4 ТЗ на ревью
|
||||
|
||||
- **Ревьюер ≠ автор.** Ревьюер получает issue и ТЗ, без устных пояснений автора.
|
||||
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
|
||||
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
|
||||
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
|
||||
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
|
||||
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
|
||||
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
|
||||
4 циклов (§4).
|
||||
|
||||
### 2.5 Готово к разработке (DoR)
|
||||
|
||||
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
|
||||
Все пункты обязательны:
|
||||
|
||||
- ТЗ существует, ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте;
|
||||
- **AC1…ACn** — пронумерованные проверяемые критерии приёмки; у каждого указано,
|
||||
чем он доказывается: `unit` / `backend` / `smoke` / `golden` / «ревью кода»;
|
||||
- перечислены затронутые файлы и модули;
|
||||
- i18n: ключи en + ru перечислены;
|
||||
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
|
||||
- влияние на производительность и бюджеты названо (или явно «нет»);
|
||||
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
|
||||
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
|
||||
документация, golden/скриншоты, performance/security);
|
||||
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
|
||||
- открытых продуктовых вопросов нет; риски перечислены.
|
||||
|
||||
Если хоть один пункт не выполнен — статус не «Готово к разработке», как бы ни
|
||||
хотелось начать.
|
||||
|
||||
### 2.6 В разработке — реализация
|
||||
|
||||
- **Занятие (claim):** назначить себя, поставить метку, комментарий
|
||||
«Взял: <роль> · сессия <id> · ветка `issue/<NN>-<slug>`».
|
||||
- **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более
|
||||
**3** одновременно на цикл релиза, не более **2** в «Код-ревью».
|
||||
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры
|
||||
`Issue: #<NN>` и `User-Visible: yes|no`.
|
||||
- **Автотесты — часть реализации, а не отдельная фаза.** Каждый AC, помеченный
|
||||
`unit`/`backend`/`smoke`/`golden`, получает свою проверку здесь же.
|
||||
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
|
||||
тестирования, а не отсутствие тестов.
|
||||
- **Скоуп не расширяется.** Найденное по пути становится новым issue в «Новое».
|
||||
Если находка блокирует — текущий issue уходит в «Заблокировано» со ссылкой.
|
||||
Попутных правок «раз уж я здесь» не бывает.
|
||||
- **Документация — в том же коммите,** что и поведение (действующая политика
|
||||
`docs/STATUS.md`): changelog RU+EN для пользовательского, `STATUS.md` для
|
||||
состояния, `DEVELOPMENT.md` для новых грабель, `ARCHITECTURE.md` для дизайна.
|
||||
- **Выход:** локальный гейт зелёный (§8), хендофф-комментарий (§7.2).
|
||||
|
||||
### 2.7 Код-ревью
|
||||
|
||||
- **Ревьюер ≠ исполнитель**, свежая сессия без контекста реализации.
|
||||
- **Артефакт:** `docs/reviews/CODE-REVIEW-<tag|NN>-r<N>.md` в действующем
|
||||
формате: скоуп, как проверялось (таблица гейтов с результатами), находки
|
||||
High/Medium/Low с воспроизведением, что проверено и корректно, чего не проверял.
|
||||
- **Ревьюер отвечает за AC.** Раз ручного тестирования в цикле нет, именно ревью
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **High блокируют.** Medium **обязаны** превратиться в issue.
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4).
|
||||
|
||||
### 2.8 Закрытие после выпуска беты
|
||||
|
||||
- **Вход:** изменение вошло в опубликованную бету/RC, CI Validate зелёный на
|
||||
**точном SHA** тега (промоушен-правило: ни одна фича не попадает в стабильный
|
||||
релиз, не побывав в бете).
|
||||
- **Закрывает** релиз-менеджер, не исполнитель. Комментарий закрытия: тег беты,
|
||||
ссылка на прогон CI, ссылка на бюллетень changelog.
|
||||
- **Стабильный релиз статусов не двигает** — issue уже закрыты; релизный коммит
|
||||
promotion-only, changelog ссылается на закрытые issue.
|
||||
- **Что приходит потом:** дефект, найденный на стенде, дома или пользователем, —
|
||||
**новый issue** типа «баг» со ссылкой на исходный. Исходный не переоткрывается.
|
||||
|
||||
### 2.9 Заблокировано / Отклонено
|
||||
|
||||
- **Заблокировано:** обязательна ссылка на блокирующий issue или внешнюю причину
|
||||
и дата пересмотра. Без причины статус не ставится.
|
||||
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
|
||||
оправдана). Тихое закрытие без причины запрещено.
|
||||
|
||||
---
|
||||
|
||||
## 3. Правила
|
||||
|
||||
Продолжение черновика владельца. Каждое правило проверяемо — глазами или машиной.
|
||||
|
||||
1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к
|
||||
разработке» или дальше.
|
||||
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ с зелёным ревью,
|
||||
пронумерованных AC с указанием доказательства и назначенного исполнителя.
|
||||
3. **Issue не может быть взят дважды.** Занятие фиксируется назначением, меткой и
|
||||
комментарием с именем ветки. У одного исполнителя одновременно не более одного
|
||||
issue в разработке.
|
||||
4. **Статус меняется до действия, а не после.** Взял — поставил метку; отдал на
|
||||
ревью — поставил метку. Метка, поставленная задним числом, — дефект процесса.
|
||||
5. **Ровно одна метка статуса** на issue в любой момент. Ноль или две — дефект,
|
||||
еженедельная гигиена его показывает.
|
||||
6. **Автор не ревьюит своё** — ни ТЗ, ни код. Никто не переводит свою работу через
|
||||
ревью-гейт.
|
||||
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
|
||||
отклонить или арбитраж (§4).
|
||||
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
|
||||
решением ревьюера с записью в документе.
|
||||
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
|
||||
том же коммите.
|
||||
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
|
||||
коммитом документация не бывает.
|
||||
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
|
||||
принятие эталонов со ссылкой на прогон CI.
|
||||
13. **Golden-эталоны принимаются только** `npm run golden:accept -- --reviewed` по
|
||||
полному Linux-артефакту. Принятие ради зелёного CI — нарушение процесса.
|
||||
14. **Issue закрывается после выпуска беты** с зелёным CI на точном SHA. Не
|
||||
раньше, не «по факту наличия кода», не исполнителем.
|
||||
15. **Закрытый issue не переоткрывается.** Новый дефект — новый issue со ссылкой.
|
||||
16. **Стабильный релиз — promotion-only:** версии, сгенерированные бандлы,
|
||||
changelog и release-метаданные. Продуктового кода там нет.
|
||||
17. **История `dev` не перезаписывается.** На неё ссылаются теги. Нарушение
|
||||
исправляется следующим коммитом плюс issue с меткой `process` — не
|
||||
force-push'ем.
|
||||
18. **AC доказывает автотест или запись ревьюера.** Фразы «проверил локально, всё
|
||||
работает» в процессе не существует: либо тест, который умеет падать, либо
|
||||
честное «проверено чтением, не исполнением».
|
||||
19. **Параллельных бэклогов нет.** Планы, разборы и приоритеты живут в issue;
|
||||
файловые отчёты — разовые и датированные.
|
||||
20. **Аварийный хотфикс — только решением владельца** и только по §11.2.
|
||||
|
||||
---
|
||||
|
||||
## 4. Лимит циклов ревью: 4
|
||||
|
||||
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
|
||||
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
|
||||
|
||||
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
|
||||
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
|
||||
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
|
||||
решение одно из трёх:
|
||||
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
|
||||
ясным скоупом (частый настоящий диагноз: ТЗ было слишком большим);
|
||||
2. **отклонить** — цена решения оказалась выше ценности;
|
||||
3. **арбитраж владельца** — владелец фиксирует решение в issue, оно принимается
|
||||
как есть; несогласие ревьюера записывается, но не блокирует.
|
||||
- **Граница между «циклом» и «новым багом»:** до закрытия беты находка ревьюера —
|
||||
возврат на правки; после закрытия — новый issue. Иначе лимит 4 обходится
|
||||
заведением issue вместо возврата.
|
||||
- Для лёгкого трека лимит ревью ТЗ — **2** цикла: задача на три часа, которую
|
||||
переписывают трижды, лёгкой не была.
|
||||
|
||||
---
|
||||
|
||||
## 5. Лёгкий трек (метка `small`)
|
||||
|
||||
**Критерии — все одновременно:**
|
||||
|
||||
- сложность и риск ≤ 3;
|
||||
- одна поверхность (один диалог, один модуль, один эндпоинт);
|
||||
- нет миграции конфига и новых compatibility-полей;
|
||||
- нет нового UX-контракта — меняется поведение в рамках уже описанного;
|
||||
- нет влияния на производительность и на touch-контракт.
|
||||
|
||||
**Что упрощается:**
|
||||
|
||||
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
|
||||
доказательством · откат. Файл в `docs/specs/` не создаётся;
|
||||
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
|
||||
- лимит ревью ТЗ — 2 цикла.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры коммитов, changelog,
|
||||
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается
|
||||
никогда — именно оно в этом процессе заменяет тестирование. Единственное
|
||||
исключение — починка упавшего предрелизного гейта, §11.4.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен (появилась миграция, задело второй
|
||||
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
|
||||
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
|
||||
|
||||
### 5.1 Короткий трек (метка `trivial`)
|
||||
|
||||
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
|
||||
обходится без него совсем.
|
||||
|
||||
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
|
||||
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
|
||||
|
||||
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
|
||||
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
|
||||
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
|
||||
|
||||
**Критерии, все обязательны:**
|
||||
|
||||
- тип `bug`;
|
||||
- правка ограничена одной поверхностью, нового UX-контракта нет;
|
||||
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
|
||||
- AC выражаются тремя проверяемыми утверждениями или меньше;
|
||||
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
|
||||
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
|
||||
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
|
||||
|
||||
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
|
||||
где владелец утверждает и приоритет.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
|
||||
Лимит циклов код-ревью — 2, как на лёгком треке.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
|
||||
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
|
||||
диагностика.
|
||||
|
||||
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
|
||||
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
|
||||
|
||||
---
|
||||
|
||||
## 6. Роли
|
||||
|
||||
Один агент может исполнять несколько ролей в разных issue, но **не две роли в
|
||||
одном артефакте**.
|
||||
|
||||
| Роль | Делает | Не имеет права |
|
||||
|---|---|---|
|
||||
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
|
||||
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
|
||||
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
|
||||
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
|
||||
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
|
||||
| Релиз-менеджер | пре-релиз, стабильный релиз, закрытие issue | добавлять код в релизный коммит |
|
||||
| Владелец | приоритет, ценность, скоуп, отклонение, арбитраж, хотфикс | — |
|
||||
|
||||
**Правило разделения:** ревьюер работает состязательно. Ему передаётся тег или
|
||||
диапазон коммитов и ТЗ — не рассказ автора о том, как всё хорошо.
|
||||
|
||||
**Роли закреплены за исполнителями** (решение владельца 2026-08-12):
|
||||
|
||||
| Исполнитель | Роли |
|
||||
|---|---|
|
||||
| **Codex** | аналитик, автор ТЗ, разработчик, релиз-инженер по команде владельца |
|
||||
| **Claude** | ревьюер ТЗ, ревьюер кода, вся инфраструктура и дистрибуция |
|
||||
| **Владелец** | приоритет, скоуп, арбитраж, закрытие issue, команда на выпуск |
|
||||
|
||||
Автор и ревьюер — **разные модели**, и это сильнее требования «другая сессия»:
|
||||
одна модель, читая свой же артефакт заново, повторяет свои же слепые пятна.
|
||||
|
||||
Ревью ТЗ и код-ревью держатся в **разных сессиях** Claude: ревьюер кода не должен
|
||||
приходить с контекстом того, как обсуждали ТЗ.
|
||||
|
||||
---
|
||||
|
||||
## 7. Артефакты и трассируемость
|
||||
|
||||
### 7.1 Цепочка
|
||||
|
||||
```
|
||||
issue #NN
|
||||
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
|
||||
↔ ветка issue/NN-slug
|
||||
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
|
||||
↔ ревью кода docs/reviews/CODE-REVIEW-<tag|NN>-rN.md
|
||||
↔ changelog бюллетень RU+EN со ссылкой на #NN
|
||||
↔ бета тег, зелёный CI на точном SHA → закрытие
|
||||
```
|
||||
|
||||
Обязательные разделы ТЗ: **сценарий** · **что человек увидит до и после** ·
|
||||
проблема · скоуп и **не-скоуп** · контракт поведения · UX · модель данных и
|
||||
миграция · i18n · критерии приёмки AC1…ACn с указанием доказательства · план
|
||||
автотестов · риски · откат · release-артефакты.
|
||||
|
||||
Два первых раздела — продуктовые, и они идут первыми не случайно. **Сценарий:**
|
||||
какая персона (`docs/SCOPE.md`), на какой поверхности, в какой момент это
|
||||
встретит. **Что человек увидит:** одной фразой, без терминов реализации. ТЗ,
|
||||
которое не может ответить на эти два вопроса, описывает работу, а не изменение
|
||||
продукта.
|
||||
|
||||
**Размытое место не додумывается, а выносится владельцу.** Догадка, записанная
|
||||
как факт, — худший вид дефекта: она проходит ревью, потому что выглядит решением.
|
||||
|
||||
Но спрашивать обо всём нельзя: владелец один, и анкета из двадцати пунктов хуже
|
||||
угадывания. Порог такой (решение владельца 2026-08-13).
|
||||
|
||||
**Владельцу задаются только продуктовые вопросы** — что человек видит или делает
|
||||
и какой объём видимых изменений входит в этот issue. Поведение в пограничном
|
||||
случае; какая из персон важнее в конфликте; что считать приемлемой деградацией;
|
||||
относится ли смежное поведение сюда или становится отдельной задачей.
|
||||
|
||||
**Всё, чего пользователь не наблюдает, агенты решают сами** либо согласовывают
|
||||
между собой: где хранится состояние, в каком модуле стоит гвард, именование,
|
||||
раскладка файлов, стратегия тестов, механика миграции. Решение записывается явным
|
||||
блоком в конце ТЗ — «принято предположительно, поменять свободно», и ревьюер
|
||||
вправе его оспорить. Технический спор автора и ревьюера решается вердиктом, а не
|
||||
владельцем; до него он доходит только при исчерпании лимита циклов (§4).
|
||||
|
||||
**Смешанный вопрос делится, а не эскалируется целиком.** «Где живёт это
|
||||
состояние» — техническое. «Переживает ли оно перезагрузку страницы и общее ли оно
|
||||
для всех экранов» — продуктовое.
|
||||
|
||||
Вопросы задаются **одним комментарием, пачкой**, каждый в форме: что неясно ·
|
||||
что изменится от ответа · **предлагаемый вариант по умолчанию**. Вопрос с готовым
|
||||
вариантом стоит владельцу пяти секунд, вопрос без него — пяти минут. Пока ждём
|
||||
ответа, issue остаётся в `S3-spec` и получает `blocked`: статус не подменяется,
|
||||
`blocked` его дополняет, иначе конвейер считает задачу в работе, а она стоит.
|
||||
|
||||
### 7.2 Шаблоны комментариев
|
||||
|
||||
Короткие и однообразные, чтобы читались и человеком, и машиной.
|
||||
|
||||
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
|
||||
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
|
||||
High: N · Medium: N → #… · Документ: docs/reviews/…`
|
||||
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
|
||||
|
||||
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
|
||||
разница между ними содержательна для человека, но не для маршрута. Первая
|
||||
редакция конвейера (§10.4) пропускала жёлтый при `High: 0`, и первый же живой
|
||||
прогон показал, почему это неверно: жёлтый там означал, что AC описывает неверное
|
||||
изменение контракта — реализовать такое ТЗ значило бы сделать ошибку по инструкции.
|
||||
|
||||
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
|
||||
|
||||
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
|
||||
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
|
||||
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
|
||||
таблицу «issue ↔ ТЗ».
|
||||
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
|
||||
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
|
||||
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
|
||||
ревью кладёт в `docs/reviews/` сам конвейер, в ветку задачи.
|
||||
|
||||
---
|
||||
|
||||
## 8. Гейты
|
||||
|
||||
**Локальный гейт перед выходом из «В разработке»** — минимальный набор,
|
||||
покрывающий изменённые поверхности (действующее правило владельца):
|
||||
|
||||
```
|
||||
npx tsc --noEmit
|
||||
npm test
|
||||
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
|
||||
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
node demo/smoke_<целевые>.mjs
|
||||
npm run golden:verify # если менялся визуал
|
||||
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
|
||||
```
|
||||
|
||||
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
|
||||
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
|
||||
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
|
||||
результата, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
|
||||
|
||||
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
|
||||
Упавший предрелизный гейт автор чинит и повторно прогоняет; зелёный прогон
|
||||
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
|
||||
|
||||
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
|
||||
Performance зелёные на точном SHA; статусов issue не касается.
|
||||
|
||||
---
|
||||
|
||||
## 9. Метки — канонический статус
|
||||
|
||||
Статус читается из меток: их видно в списке issue, их читает любой токен с
|
||||
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
|
||||
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
|
||||
представление статуса рядом с метками требовало отдельного скоупа токена,
|
||||
синхронизации и внимания, а давало вид доски. Два источника одного факта
|
||||
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
|
||||
|
||||
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
|
||||
документе были только на бумаге; репозиторий с самого начала жил на английских.
|
||||
|
||||
| Метка | Статус |
|
||||
|---|---|
|
||||
| `S1-new` | Новое, не разобрано |
|
||||
| `S2-analysis` | Аналитика и оценка |
|
||||
| `S3-spec` | ТЗ в работе |
|
||||
| `S4-spec-review` | ТЗ на ревью |
|
||||
| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код |
|
||||
| `S6-in-progress` | В разработке, занято исполнителем |
|
||||
| `S7-code-review` | Код-ревью |
|
||||
| `S8-merged` | Ревью пройдено, код в `dev`, ждёт беты. Issue закрывается пачкой при выпуске |
|
||||
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
|
||||
| `rejected` | Отклонено, issue закрыт |
|
||||
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
|
||||
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
|
||||
ортогональны процессу.
|
||||
|
||||
Инварианты: **ровно одна `S*`-метка** на открытом issue; закрытый issue статусных
|
||||
меток не несёт; `blocked` не заменяет статус, а дополняет его.
|
||||
|
||||
**Чужой issue берётся в работу так же, как свой — после явного решения
|
||||
владельца** (решение владельца 2026-08-13, уточнено в тот же день). Репозиторий
|
||||
публичный, отчёты заводят и посторонние; проверка стоит **на входе**, а не на
|
||||
каждом шаге.
|
||||
|
||||
Входом служит присвоение первой статусной метки: пока меток нет, issue вне
|
||||
процесса и инварианты на него не распространяются. Как только метка стоит, задача
|
||||
в работе, и **кто её завёл, дальше не имеет значения** — статусы, ревью и лимиты
|
||||
работают одинаково.
|
||||
|
||||
Присвоение метки и есть то самое явное решение, причём проверенное платформой:
|
||||
метки может ставить только тот, у кого есть право записи в репозиторий. Прежняя
|
||||
редакция требовала переоформлять чужой отчёт своим issue со ссылкой на исходный;
|
||||
это оказалось работой впустую — на #123 к моменту отказа ТЗ уже было написано.
|
||||
|
||||
`S8-merged` появился позже остальных и закрывает разрыв, который раньше
|
||||
закрывался памятью человека: код принят, но бета ещё не вышла, и issue закрывать
|
||||
рано. Без него принятая задача либо висела в `S7-code-review`, либо закрывалась
|
||||
досрочно.
|
||||
|
||||
---
|
||||
|
||||
## 10. Механизация при прямых коммитах в `dev`
|
||||
|
||||
Решение владельца — работать без PR. Значит, GitHub не может ничего заблокировать
|
||||
на своей стороне: **основной гейт переезжает на клиента, CI остаётся страховкой.**
|
||||
|
||||
### 10.1 Хуки, которые невозможно забыть поставить
|
||||
|
||||
`.githooks/` в репозитории, `core.hooksPath` выставляется автоматически при
|
||||
установке зависимостей:
|
||||
|
||||
```json
|
||||
"scripts": { "prepare": "node scripts/install-hooks.mjs" }
|
||||
```
|
||||
|
||||
`npm ci` вызывает `prepare` сам — значит, хуки появляются в каждом окружении,
|
||||
включая свежий контейнер облачного агента, без отдельного шага в инструкции.
|
||||
|
||||
- **`commit-msg`** — есть, работает. Отклоняет коммит без терминального
|
||||
`Issue: #NN`, требует ровно один `User-Visible: yes|no`, а для коммитов,
|
||||
трогающих `demo/golden/baselines/**`, — `Release:` плюс `Baseline-Reviewed:`.
|
||||
Реализация — `scripts/validate-commit-provenance.mjs`, тот же скрипт вызывается
|
||||
job `provenance` в `validate.yml`.
|
||||
- **`pre-push`** — есть, работает. Прогоняет `scripts/process-gate.mjs` по каждому
|
||||
пушимому ref и останавливает push при нарушении. Это и есть блокирующий гейт
|
||||
вместо PR. Удаление ветки и теги пропускаются: в первом случае проверять нечего,
|
||||
во втором коммит уже проверен, когда его пушили. Для новой ветки диапазон
|
||||
считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него
|
||||
попали бы все нарушения, совершённые до появления гейта.
|
||||
|
||||
При возврате `main` в `dev` диапазон merge-коммита содержит второй родитель —
|
||||
уже опубликованные в `main` коммиты с закрытыми issue. Для destination `dev`
|
||||
общий скрипт pre-push/CI исключает только SHA, доказанно достижимые из
|
||||
`origin/main`; сам merge и новые post-merge коммиты остаются под всеми
|
||||
проверками. На `main`, beta/issue-ветки и обычный push в `dev` это исключение
|
||||
не распространяется (issue #155).
|
||||
|
||||
Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает
|
||||
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
|
||||
который не работает в самолёте, отключают целиком, а строгий проход всё равно
|
||||
делает CI.
|
||||
|
||||
**Хук обязан быть исполняемым, и это тише всего ломается.** Git **молча** не
|
||||
запускает файл без бита `+x`: гейт сообщает об успехе тем, что его нет. Проверено
|
||||
на настоящем push — при `644` от гейта ноль строк и push проходит, при `755` он
|
||||
останавливается.
|
||||
|
||||
Через GitHub API режим не выставляется: файл, отправленный так, приезжает
|
||||
`100644`. Поэтому `scripts/install-hooks.mjs` восстанавливает бит при каждой
|
||||
установке зависимостей, а `assertHookMode` дополнительно проверяет бит
|
||||
`.githooks/commit-msg` в индексе. Правится вручную:
|
||||
`git update-index --chmod=+x .githooks/<хук>`.
|
||||
|
||||
### 10.2 Что проверяет `process-gate.mjs`
|
||||
|
||||
Реализовано, `scripts/process-gate.mjs`, issue #105. Офлайн, без GitHub API:
|
||||
|
||||
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
|
||||
2. имя ветки `issue/NN-slug` соответствует трейлерам;
|
||||
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
|
||||
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
|
||||
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
|
||||
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
|
||||
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
|
||||
`Baseline-Reviewed: <ссылка на прогон CI>`;
|
||||
6. релизный коммит не содержит изменений в `src/` и `custom_components/**/*.py`;
|
||||
7. документов ревью на один issue не больше четырёх (`-r1`…`-r4`).
|
||||
|
||||
С токеном GitHub:
|
||||
|
||||
8. `--issues` тянет каждый упомянутый issue и требует метку из
|
||||
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
|
||||
недоступный или помеченный `blocked` — отказ (**fail closed**).
|
||||
|
||||
Три оговорки к проверке 8 выяснились при реализации.
|
||||
|
||||
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
|
||||
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
|
||||
Validate стартует от этого push и успевает прочитать issue уже в `S8-merged`.
|
||||
Строгое множество красило бы каждую принятую задачу. Локальная строгость
|
||||
возвращается флагом `--no-merged`.
|
||||
|
||||
**Статус спрашивается только у коммитов класса A/B.** Правило №1 говорит о
|
||||
продуктовом коде и инструментах, а не о документации. Иначе краснел бы каждый
|
||||
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
|
||||
`S7-code-review`, то есть заведомо вне рабочего множества.
|
||||
|
||||
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
|
||||
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
|
||||
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
|
||||
Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через
|
||||
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
|
||||
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
|
||||
|
||||
Не реализовано и остаётся долгом:
|
||||
|
||||
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
|
||||
зелёный вердикт код-ревью;
|
||||
10. закрытие issue и снятие статусных меток при публикации беты делаются руками —
|
||||
`node process-labels/apply.mjs cleanup --apply`, а не `publish-prerelease.yml`.
|
||||
Пропуск этого шага уже ломал инвариант «закрытый issue без статусной метки».
|
||||
|
||||
### 10.3 Страховка и разбор
|
||||
|
||||
- **`process-gate.mjs` — job `process-gate` в `validate.yml`**, без `needs`:
|
||||
краснеет сам и не роняет остальные. При прямом push проверка догоняющая: код уже
|
||||
в `dev`, CI краснеет после. Это принятая цена отказа от PR: `pre-push` ловит
|
||||
нарушение до отправки, а этот job — то, что прошло мимо хука, включая
|
||||
`--no-verify` и окружение без установленных зависимостей.
|
||||
- **Нарушение не откатывается force-push'ем** (правило 17): исправляющий коммит
|
||||
плюс issue с меткой `process`. Починить надо проверку, а не только симптом.
|
||||
- **Еженедельная гигиена** (workflow): issue в `S1-new` дольше 14 дней и в
|
||||
`S6-in-progress` дольше 7; issue класса A в `S5-ready` без ТЗ; issue с нулём или
|
||||
двумя `S*`-метками; коммиты без трейлера за неделю — **цель 0**; rework rate и
|
||||
число issue, дошедших до `review-4`; **баги, заведённые после закрытия беты** —
|
||||
прямая цена отказа от фазы тестирования.
|
||||
|
||||
### 10.4 Событийный конвейер: метка как триггер
|
||||
|
||||
`.github/workflows/process.yml`, issue #114. Смена статусной метки — не запись в
|
||||
журнал, а **сообщение**: она порождает событие, событие запускает следующий шаг.
|
||||
|
||||
```
|
||||
S4-spec-review → ревью ТЗ → S5-ready либо возврат в S3-spec
|
||||
S7-code-review → код-ревью → слияние в dev → S8-merged либо возврат в S6-in-progress
|
||||
```
|
||||
|
||||
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
|
||||
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
|
||||
вердикту, а не модель.**
|
||||
|
||||
Четыре вещи, без которых конвейер молча не работает:
|
||||
|
||||
1. метки переставляет **PAT**, а не `GITHUB_TOKEN`: GitHub намеренно не порождает
|
||||
события от `GITHUB_TOKEN`, чтобы не было циклов, и цепочка обрывалась бы после
|
||||
первого шага без ошибок в логах;
|
||||
2. `process.yml` обязан лежать в **ветке по умолчанию**: для события `issues`
|
||||
GitHub берёт workflow только оттуда, независимо от содержимого `dev`;
|
||||
3. слияние в `dev` происходит **до** простановки `S8-merged`, иначе метка врёт в
|
||||
промежутке — она утверждает, что код в `dev`;
|
||||
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
|
||||
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||||
|
||||
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
|
||||
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
|
||||
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
|
||||
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
|
||||
секунд, не более 30 попыток. Смотреть на метку, а не на комментарий: метка и есть
|
||||
состояние. При `blocked` не ждать — задача ждёт владельца.
|
||||
|
||||
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
|
||||
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
|
||||
автор ждёт смену метки, метка не менялась, и он тридцать раз опрашивал впустую,
|
||||
чтобы отчитаться «лимит исчерпан» при зелёном вердикте. Состояние, из которого
|
||||
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
|
||||
ошибки.
|
||||
|
||||
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
|
||||
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
|
||||
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
|
||||
метка `S7-code-review` возвращается и ревью идёт заново — не формальность:
|
||||
после ребейза на ушедший вперёд `dev` это другой код.
|
||||
|
||||
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
|
||||
сообщать владельцу, а не продолжать опрос.
|
||||
|
||||
Цикл считается **по этапу**: вердикт по ТЗ не расходует бюджет код-ревью. Раньше
|
||||
считались все вердикты подряд, и первое код-ревью #89 получило `r2/4`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Исключения
|
||||
|
||||
### 11.1 Лёгкий трек
|
||||
|
||||
См. §5 — это не исключение из правила №1, а более дешёвый путь по тем же статусам.
|
||||
|
||||
### 11.2 Аварийный хотфикс (метка `hotfix`, решение владельца)
|
||||
|
||||
Разрешено писать код до появления issue. Обязательно:
|
||||
|
||||
- issue создан в **той же сессии до коммита**, метка `hotfix`;
|
||||
- ТЗ «как сделано» + раздел «почему нельзя было ждать»;
|
||||
- в течение 24 часов задача ретроспективно проходит код-ревью;
|
||||
- аварийность названа явно в релизном хендоффе (действующее правило `AGENTS.md`).
|
||||
|
||||
### 11.3 Гигиена репозитория
|
||||
|
||||
Механические изменения без изменения поведения (форматирование, мёртвые файлы)
|
||||
идут под квартальный umbrella-issue «Гигиена репозитория»; каждый коммит
|
||||
ссылается на него. Трассируемость 1:1 сохраняется.
|
||||
|
||||
### 11.4 Починка предрелизных гейтов без повторного код-ревью
|
||||
|
||||
Решение владельца 2026-08-13.
|
||||
|
||||
В цикле реализации гоняется только лёгкий набор — typecheck, unit, build (§8).
|
||||
Golden, браузерные смоки, performance и полный HA-харнесс запускаются перед бетой,
|
||||
то есть **после** того, как код-ревью пройдено и issue в `S8-merged`. Часть
|
||||
проблем физически не может быть найдена раньше.
|
||||
|
||||
**Если предрелизный гейт упал, автор правит, повторно прогоняет упавшее, и
|
||||
зелёного прогона достаточно, чтобы релиз продолжился.** Issue остаётся в
|
||||
`S8-merged` и на повторное код-ревью не отправляется.
|
||||
|
||||
Причина: полный цикл ревью в момент выпуска стоит дороже, чем риск, который он
|
||||
здесь снимает. Гейт уже назвал дефект точно, а исправление проверяется тем же
|
||||
гейтом — то есть проверка объективна и не зависит от чьего-либо суждения.
|
||||
|
||||
**Что при этом обязательно:**
|
||||
|
||||
- прогон упавшего гейта записан в issue: **точная команда и её результат**.
|
||||
«Verified» без команды доказательством не является (§8);
|
||||
- трейлеры на коммите как обычно, `Issue: #NN` того же issue;
|
||||
- при `User-Visible: yes` — правки в оба changelog в том же коммите;
|
||||
- эталоны golden принимаются только через `npm run golden:accept -- --reviewed`
|
||||
на полном артефакте Linux CI. «Чтобы гейт позеленел» основанием не является.
|
||||
|
||||
**Границы, за которыми исключение не действует.** Оно про починку названного
|
||||
гейтом дефекта, а не про продолжение разработки под видом починки. Правка идёт
|
||||
обычным путём — новым issue либо возвратом в `S6-in-progress` — если она:
|
||||
|
||||
- меняет контракт поведения или добавляет пользователю что-то новое;
|
||||
- задевает подсистему, которой в исходной задаче не было;
|
||||
- по объёму сопоставима с самой задачей;
|
||||
- меняет сам гейт вместо кода — правка теста, чтобы он перестал падать, это не
|
||||
починка, а сокрытие. Исключение — когда дефект **в фикстуре** и это доказано
|
||||
разбором, как на #89: солнце на азимуте 180° и единственное окно на северной
|
||||
стене, поэтому луч честно не строился.
|
||||
|
||||
Границу определяет автор, и здесь процесс сознательно отдаёт ему то, что в
|
||||
остальных местах не доверяет — оценку собственной работы. Плата за скорость в
|
||||
единственной точке, где цикл ревью стоит дороже всего. Компенсируется тем, что
|
||||
запись в issue публична и релиз-менеджер видит, что именно было сделано перед
|
||||
выпуском.
|
||||
|
||||
Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) —
|
||||
единственное, и относится только к окну между `S8-merged` и выпуском.
|
||||
|
||||
---
|
||||
|
||||
## 12. Запрещено
|
||||
|
||||
- код без issue или из статуса раньше «Готово к разработке»;
|
||||
- ТЗ, написанное после кода (кроме §11.2, и тогда с пометкой «как сделано»);
|
||||
- ревью своей работы; перевод своей работы через ревью-гейт;
|
||||
- пятый цикл ревью вместо разбора по §4;
|
||||
- заведение issue вместо возврата на правки, чтобы обойти лимит циклов;
|
||||
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
|
||||
- закрытие issue до выпуска беты с зелёным CI;
|
||||
- переоткрытие закрытого issue вместо нового бага;
|
||||
- Medium-находки, оставленные как TODO в документе ревью;
|
||||
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
|
||||
- ревью-документы вне репозитория;
|
||||
- попутные правки «раз уж я здесь»;
|
||||
- фича или материальное изменение поведения в стабильном релиз-коммите;
|
||||
- force-push в `dev`;
|
||||
- ручное копирование на домашний инстанс.
|
||||
|
||||
**Нарушение процесса — тоже issue** (метка `process`): если правило удалось
|
||||
нарушить незаметно, виновата проверка.
|
||||
|
||||
---
|
||||
|
||||
## 13. Внедрение
|
||||
|
||||
Состояние на 2026-08-13.
|
||||
|
||||
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
|
||||
одна `S*`-метка, инварианты чистые.
|
||||
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
|
||||
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
|
||||
код, которого уже нет.
|
||||
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
|
||||
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
|
||||
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
|
||||
4. ✅ **Долг ревью списан решением владельца.** Беты `beta.2`…`beta.10` сделаны по
|
||||
прежнему процессу и не пересматриваются. Точка отсчёта — релиз 1.62.0; отсчёт
|
||||
начинается с первой беты следующей линии.
|
||||
5. ⏳ Завести issue на находку «смок `visual_continuity` не умеет падать» — это
|
||||
ровно тот класс дефектов, который в процессе без ручного тестирования стоит
|
||||
дороже всего.
|
||||
6. ✅ `BACKLOG-2026-08-11.md` — разовый отчёт, решения живут в issue.
|
||||
7. ✅ `AGENTS.md` переписан целиком, шире блока §14.
|
||||
8. ✅ **Канон перенесён в репозиторий** (issue #112). До этого полный процесс жил
|
||||
только в папке владельца, а в репозитории лежал файл на 51 строку про трейлеры
|
||||
коммитов — из свежего клона канон не был виден вообще.
|
||||
9. ✅ **`pre-push` написан** (§10.1, issue #121). Блокирующая проверка на клиенте
|
||||
есть; обойти её можно только `--no-verify`, и тогда то же найдёт CI.
|
||||
|
||||
---
|
||||
|
||||
## 14. Блок для AGENTS.md
|
||||
|
||||
```markdown
|
||||
## Процесс: код только через issue
|
||||
|
||||
Изменение продуктового кода без issue запрещено. Код меняется только из статуса
|
||||
«Готово к разработке» или дальше. Полные правила, критерии статусов и гейты —
|
||||
`docs/PROCESS.md`, читать до начала работы.
|
||||
|
||||
Жизненный цикл (статус = метка issue): `S1-new` → `S2-analysis` → `S3-spec` →
|
||||
`S4-spec-review` → `S5-ready` → `S6-in-progress` → `S7-code-review` → `S8-merged`
|
||||
→ закрытие пачкой при выпуске беты. Оба ревью возвращают на правки не более 4
|
||||
циклов; пятый заход — разбор у владельца (разделить / отклонить / арбитраж).
|
||||
|
||||
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review` и идёт до
|
||||
45 минут. Поставив такую метку, автор не заканчивает работу, а ждёт смены метки
|
||||
опросом и продолжает по тому, чем она стала.
|
||||
|
||||
- ветка `issue/<NN>-<slug>`, коммиты с трейлерами `Issue: #NN` и `User-Visible: yes|no`;
|
||||
- работаем прямыми коммитами в `dev`, без PR: блокирующий гейт — локальный
|
||||
`pre-push` (ставится автоматически через `npm ci`), CI — страховка. Force-push
|
||||
в `dev` запрещён;
|
||||
- автор ≠ ревьюер, ни для ТЗ, ни для кода;
|
||||
- фазы ручного тестирования нет: автотесты пишутся в реализации, AC проверяет
|
||||
код-ревью, найденные позже дефекты — новые issue типа «баг»;
|
||||
- мелкие задачи (метка `small`, сложность ≤3): ТЗ в теле issue, ревью ТЗ
|
||||
комментарием, код-ревью — как обычно;
|
||||
- найденное вне скоупа — новый issue, а не попутная правка;
|
||||
- issue закрывает релиз-менеджер после выпуска беты, не исполнитель.
|
||||
```
|
||||
@@ -278,12 +278,15 @@ Switch to the **Devices** tab to arrange icons: drag them with the mouse, click
|
||||
|
||||
### Tap actions: control devices from the plan
|
||||
|
||||
By default a tap on an icon opens its info card. In the card settings you can switch
|
||||
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
|
||||
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
|
||||
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
|
||||
specific device (except locks and alarms — those never toggle from the plan) in its
|
||||
edit dialog. A **long press** always opens the info card.
|
||||
By default a tap on an icon opens its info card. A device can instead use the
|
||||
universal **Toggle state** action. Its editor shows the exact entity or configured
|
||||
group, the current state and what the next tap will do; when nothing can be toggled,
|
||||
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
|
||||
Lights keep their convenient toggle default. Covers and valves use open/close/stop
|
||||
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
|
||||
remain blocked. An exact entity binding never falls through to a sibling switch, and
|
||||
temporarily unavailable group members are skipped without erasing the configuration.
|
||||
A **long press** still opens the info card and right-click still opens HA more-info.
|
||||
|
||||
### Icon rules
|
||||
|
||||
@@ -309,6 +312,17 @@ Always source is deliberately constant-on. With several own `light.*`/`switch.*`
|
||||
entities, Always also offers a leading-entity selector; a missing saved choice
|
||||
is warned about and retained while a deterministic fallback is used.
|
||||
|
||||
To make that virtual lamp manually switchable without creating a Home
|
||||
Assistant helper, also choose **Tap action → Toggle state** on the lamp itself.
|
||||
This exact combination — virtual binding, **Light source → Always**, and
|
||||
**Toggle state** — stores a shared on/off state in the House Plan integration.
|
||||
It survives page reloads and Home Assistant restarts and updates Glow, room
|
||||
fill/statistics, full cards and `houseplan-space-card` together. Any signed-in
|
||||
dashboard viewer may toggle it. While this manual mode is active, saved
|
||||
**Controls other light sources** remain intact but are not called; changing the
|
||||
role, binding or tap action restores their normal behaviour. This operational
|
||||
state is deliberately not part of plan exports or Home Assistant entities.
|
||||
|
||||
The same dialog controls how the device looks on the plan. **Display** switches between the
|
||||
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
|
||||
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
|
||||
|
||||
@@ -24,7 +24,12 @@ from .const import (
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
|
||||
from .store import (
|
||||
HouseplanConfigEntry,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
create_data,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,6 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
await data.config_store.async_load()
|
||||
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
|
||||
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
||||
try:
|
||||
await data.virtual_light_store.async_load()
|
||||
except Exception: # noqa: BLE001 — operational state fails safe to default on
|
||||
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
|
||||
entry.runtime_data = data
|
||||
|
||||
# server-side vacuum trails: the integration records the path itself
|
||||
@@ -152,7 +161,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
rev = int(stored.get("rev", 0))
|
||||
if cfg and migrate_config(cfg): # 2. the config half
|
||||
rev += 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
await async_save_config_state(data, cfg, rev, previous_rev=rev - 1)
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await async_save_layout_state(
|
||||
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
|
||||
@@ -186,11 +195,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
|
||||
))
|
||||
if stored.get("config") != target_config or config_rev < target_config_rev:
|
||||
previous_config_rev = config_rev
|
||||
config_rev = max(config_rev, target_config_rev)
|
||||
await data.config_store.async_save({
|
||||
"config": target_config,
|
||||
"rev": config_rev,
|
||||
})
|
||||
await async_save_config_state(
|
||||
data,
|
||||
target_config,
|
||||
config_rev,
|
||||
previous_rev=previous_config_rev,
|
||||
)
|
||||
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
|
||||
layout_rev = max(layout_rev, target_layout_rev)
|
||||
exact_metadata = pending.get("final_metadata")
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
DOMAIN = "houseplan"
|
||||
STORAGE_KEY = f"{DOMAIN}.layout"
|
||||
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_MINOR_VERSION = 1
|
||||
STORAGE_MINOR_VERSION = 2
|
||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
@@ -45,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.62.0-beta.2"
|
||||
VERSION = "1.64.0"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
@@ -64,5 +65,5 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
"markers": [],
|
||||
"settings": {},
|
||||
"settings": {"bg_mode": "daynight"},
|
||||
}
|
||||
|
||||
@@ -86,27 +86,30 @@ class HouseplanImportPreviewView(HomeAssistantView):
|
||||
blocks.append(block)
|
||||
owner_id = str(getattr(user, "id", ""))
|
||||
try:
|
||||
# Hold the global writer only while taking one coherent store
|
||||
# snapshot. Parsing up to 8 MiB, schema validation and space remap
|
||||
# are CPU work and apply will revalidate both revisions anyway.
|
||||
async with runtime.write_lock:
|
||||
config_data = await runtime.config_store.async_load() or {}
|
||||
layout_data = await runtime.store.async_load() or {}
|
||||
try:
|
||||
registry_snapshot = import_registry_snapshot(hass)
|
||||
except Exception: # noqa: BLE001 - summary must not block a valid backup
|
||||
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
|
||||
registry_snapshot = None
|
||||
result = await hass.async_add_executor_job(
|
||||
partial(
|
||||
create_preview,
|
||||
runtime,
|
||||
b"".join(blocks),
|
||||
owner_id=owner_id,
|
||||
duplicate_policy=policy,
|
||||
current_config_data=config_data,
|
||||
current_layout_data=layout_data,
|
||||
config_root=Path(hass.config.path("")),
|
||||
registry_snapshot=registry_snapshot,
|
||||
)
|
||||
try:
|
||||
registry_snapshot = import_registry_snapshot(hass)
|
||||
except Exception: # noqa: BLE001 - summary must not block a valid backup
|
||||
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
|
||||
registry_snapshot = None
|
||||
result = await hass.async_add_executor_job(
|
||||
partial(
|
||||
create_preview,
|
||||
runtime,
|
||||
b"".join(blocks),
|
||||
owner_id=owner_id,
|
||||
duplicate_policy=policy,
|
||||
current_config_data=config_data,
|
||||
current_layout_data=layout_data,
|
||||
config_root=Path(hass.config.path("")),
|
||||
registry_snapshot=registry_snapshot,
|
||||
)
|
||||
)
|
||||
except ImportFailure as err:
|
||||
status = 413 if err.code == "too_large" else 400
|
||||
return web.json_response({"error": err.code, "message": err.message}, status=status)
|
||||
|
||||
@@ -45,6 +45,7 @@ from .validation import (
|
||||
sanitize_filename,
|
||||
sanitize_marker_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_value_badges,
|
||||
MarkerControlError,
|
||||
)
|
||||
@@ -72,6 +73,35 @@ def _json_copy(value: Any) -> Any:
|
||||
return json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
|
||||
|
||||
|
||||
def _background_mode(settings: Any) -> str | None:
|
||||
if not isinstance(settings, dict):
|
||||
return None
|
||||
mode = settings.get("bg_mode")
|
||||
return mode if mode in ("static", "daynight") else None
|
||||
|
||||
|
||||
def _materialize_global_background(config: dict[str, Any], fallback: str = "static") -> str:
|
||||
"""Make the global mode portable instead of relying on a code default."""
|
||||
settings = config.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
config["settings"] = settings
|
||||
mode = _background_mode(settings) or fallback
|
||||
settings["bg_mode"] = mode
|
||||
return mode
|
||||
|
||||
|
||||
def _materialize_space_background(space: dict[str, Any], fallback: str) -> str:
|
||||
"""Make one space independent from the target installation's default."""
|
||||
settings = space.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
space["settings"] = settings
|
||||
mode = _background_mode(settings) or fallback
|
||||
settings["bg_mode"] = mode
|
||||
return mode
|
||||
|
||||
|
||||
def _stored_model_version(config: dict[str, Any]) -> int:
|
||||
"""Return the model actually stored without silently upgrading it."""
|
||||
value = config.get("model_version", 0)
|
||||
@@ -238,6 +268,7 @@ def create_export(
|
||||
# payload would either duplicate it or tempt an exporter to silently stamp
|
||||
# the current version over an older/future stored model.
|
||||
config.pop("model_version", None)
|
||||
global_background = _materialize_global_background(config)
|
||||
layout = live_layout(config, LAYOUT_SCHEMA(_json_copy(layout_data.get("layout") or {})))
|
||||
stamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
title = ""
|
||||
@@ -246,6 +277,7 @@ def create_export(
|
||||
space = next((sp for sp in config.get("spaces") or [] if str(sp.get("id")) == space_id), None)
|
||||
if not space:
|
||||
raise ImportFailure("space_not_found", "Space was not found")
|
||||
_materialize_space_background(space, global_background)
|
||||
title = str(space.get("title") or space.get("id") or "space")
|
||||
selected_layout = {
|
||||
key: pos for key, pos in layout.items()
|
||||
@@ -357,6 +389,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
if document["kind"] == "full":
|
||||
_materialize_global_background(config)
|
||||
else:
|
||||
for space in config.get("spaces") or []:
|
||||
if isinstance(space, dict):
|
||||
_materialize_space_background(space, "static")
|
||||
try:
|
||||
layout = LAYOUT_SCHEMA(_json_copy(payload.get("layout") or {}))
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
@@ -559,6 +597,7 @@ def build_space_merge(
|
||||
if len(spaces) != 1:
|
||||
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
|
||||
space = spaces[0]
|
||||
_materialize_space_background(space, "static")
|
||||
used = {
|
||||
str(value)
|
||||
for sp in current_config.get("spaces") or []
|
||||
@@ -719,6 +758,7 @@ def build_space_merge(
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
try:
|
||||
validate_marker_controls(merged_config, current_config)
|
||||
validate_marker_light_entities(merged_config, current_config)
|
||||
validate_marker_value_badges(merged_config, current_config)
|
||||
except MarkerControlError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
@@ -891,6 +931,7 @@ def create_preview(
|
||||
details["dropped_marker_links"] = dropped_marker_links
|
||||
try:
|
||||
validate_marker_controls(incoming_config, validate_all=True)
|
||||
validate_marker_light_entities(incoming_config, validate_all=True)
|
||||
validate_marker_value_badges(incoming_config, validate_all=True)
|
||||
except MarkerControlError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
@@ -1036,6 +1077,7 @@ def prepare_apply(
|
||||
_detach_missing(imported_config, candidate.get("content") or [])
|
||||
if document["kind"] == "full":
|
||||
config = imported_config
|
||||
_materialize_global_background(config)
|
||||
# Full exports keep the data-model version in the portable envelope so
|
||||
# the payload can be validated independently. Restore it before the
|
||||
# configuration is persisted; otherwise every full round-trip silently
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.62.0-beta.2"
|
||||
"version": "1.64.0"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -10,7 +12,43 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN, STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_MINOR_VERSION, STORAGE_VERSION
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
STORAGE_CONFIG_KEY,
|
||||
STORAGE_KEY,
|
||||
STORAGE_MINOR_VERSION,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_BG_MODES = frozenset({"static", "daynight"})
|
||||
|
||||
|
||||
def migrate_config_background_mode(old_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Materialize the legacy implicit background mode without changing its view.
|
||||
|
||||
Only the config-store document has a top-level ``config`` object. Layout
|
||||
and virtual-light stores pass through this helper unchanged even though
|
||||
they share the same Store subclass and minor version.
|
||||
"""
|
||||
config = old_data.get("config")
|
||||
if not isinstance(config, dict):
|
||||
return old_data
|
||||
settings = config.get("settings")
|
||||
mode = settings.get("bg_mode") if isinstance(settings, dict) else None
|
||||
if mode in _BG_MODES:
|
||||
return old_data
|
||||
|
||||
data = copy.deepcopy(old_data)
|
||||
migrated_config = data["config"]
|
||||
migrated_settings = migrated_config.get("settings")
|
||||
if not isinstance(migrated_settings, dict):
|
||||
migrated_settings = {}
|
||||
migrated_config["settings"] = migrated_settings
|
||||
migrated_settings["bg_mode"] = "static"
|
||||
return data
|
||||
|
||||
|
||||
class HouseplanStore(Store):
|
||||
@@ -28,10 +66,9 @@ class HouseplanStore(Store):
|
||||
old_minor_version: int,
|
||||
old_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
data = old_data
|
||||
# if old_major_version == 1 and old_minor_version < 2:
|
||||
# ...migrate...
|
||||
return data
|
||||
if old_major_version == 1 and old_minor_version < 2:
|
||||
return migrate_config_background_mode(old_data)
|
||||
return old_data
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,6 +77,7 @@ class HouseplanData:
|
||||
|
||||
store: HouseplanStore
|
||||
config_store: HouseplanStore
|
||||
virtual_light_store: HouseplanStore
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
@@ -71,6 +109,12 @@ def create_data(hass: HomeAssistant) -> HouseplanData:
|
||||
config_store=HouseplanStore(
|
||||
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
|
||||
),
|
||||
virtual_light_store=HouseplanStore(
|
||||
hass,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||
minor_version=STORAGE_MINOR_VERSION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -88,6 +132,7 @@ def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
||||
|
||||
OPTIMIZE_BACKUP = "optimize_backup"
|
||||
OPTIMIZE_PENDING = "optimize_pending"
|
||||
LAYOUT_STORE_CORE_KEYS = frozenset({"layout", "rev"})
|
||||
|
||||
|
||||
def layout_store_payload(
|
||||
@@ -106,7 +151,7 @@ def layout_store_payload(
|
||||
the next drag. All writers now express only the metadata they intentionally
|
||||
add/remove and this helper preserves the rest.
|
||||
"""
|
||||
excluded = {"layout", "rev", *remove}
|
||||
excluded = {*LAYOUT_STORE_CORE_KEYS, *remove}
|
||||
out = {} if replace_metadata else {
|
||||
key: value for key, value in stored.items() if key not in excluded
|
||||
}
|
||||
@@ -138,3 +183,43 @@ async def async_save_layout_state(
|
||||
)
|
||||
await runtime.store.async_save(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def async_save_config_state(
|
||||
runtime: HouseplanData,
|
||||
config: dict[str, Any],
|
||||
rev: int,
|
||||
*,
|
||||
previous_rev: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist configuration and reconcile dependent operational state.
|
||||
|
||||
Callers already hold ``runtime.write_lock``. Reading the previous
|
||||
revision here keeps less common writers (import recovery and undo) on the
|
||||
same path as ordinary editor saves without duplicating lifecycle rules.
|
||||
"""
|
||||
if previous_rev is None:
|
||||
previous = await runtime.config_store.async_load() or {}
|
||||
try:
|
||||
previous_rev = int(previous.get("rev", 0))
|
||||
except (TypeError, ValueError):
|
||||
previous_rev = 0
|
||||
|
||||
payload = {"config": config, "rev": rev}
|
||||
await runtime.config_store.async_save(payload)
|
||||
|
||||
# The config is already durable at this point. Reconciliation remains a
|
||||
# separate Store write; an interrupted pair is detected from config_rev on
|
||||
# the next read and fails safe to the compatibility default (all on).
|
||||
from .virtual_lights import async_reconcile_virtual_lights
|
||||
|
||||
try:
|
||||
await async_reconcile_virtual_lights(
|
||||
runtime.virtual_light_store,
|
||||
config,
|
||||
rev,
|
||||
previous_config_rev=previous_rev,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - config commit already stands
|
||||
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
|
||||
return payload
|
||||
|
||||
@@ -41,6 +41,7 @@ VALUE_BADGE_POSITIONS = {"right", "bottom", "left", "top"}
|
||||
VALUE_BADGE_SOURCE_KINDS = {
|
||||
"entity_state", "entity_attribute", "derived_lqi", "derived_marker_state",
|
||||
}
|
||||
_LIGHT_ENTITY_RE = re.compile(r"^(?:light|switch)\.[a-z0-9_]+\Z")
|
||||
|
||||
|
||||
def _matching_previous_marker(
|
||||
@@ -131,6 +132,38 @@ def validate_marker_value_badges(
|
||||
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
|
||||
|
||||
|
||||
def validate_marker_light_entities(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate new/changed leading-light choices without rejecting dormant data.
|
||||
|
||||
The top-level schema must stay lossless: an old or future literal that the
|
||||
current frontend cannot edit may round-trip unchanged. Imports validate the
|
||||
whole incoming document because every imported value is new to this plan.
|
||||
"""
|
||||
markers = config.get("markers") or []
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
|
||||
new_ids = {str(marker.get("id")) for marker in markers}
|
||||
consumed_old_ids: set[str] = set()
|
||||
for marker in markers:
|
||||
marker_id = str(marker.get("id"))
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
value = marker.get("light_entity")
|
||||
old_value = None if validate_all else (old_marker or {}).get("light_entity")
|
||||
if not validate_all and value == old_value:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
|
||||
raise MarkerControlError(
|
||||
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
|
||||
)
|
||||
|
||||
|
||||
def validate_marker_controls(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
@@ -849,10 +882,9 @@ MARKER_SCHEMA = vol.Schema(
|
||||
# Explicit leading entity for composite Always sources. It is kept
|
||||
# literally when temporarily absent; runtime falls back without
|
||||
# deleting the user's choice.
|
||||
vol.Optional("light_entity"): vol.Any(
|
||||
None,
|
||||
vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(?:light|switch)\.[a-z0-9_]+\Z")),
|
||||
),
|
||||
# Semantic delta validation below the schema preserves unknown/future
|
||||
# literals until that exact field is edited (lossless config doctrine).
|
||||
vol.Optional("light_entity"): object,
|
||||
vol.Optional("value_badge"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Persistent operational state for manual virtual lights."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .store import HouseplanStore
|
||||
|
||||
|
||||
EVENT_VIRTUAL_LIGHT_UPDATED = "houseplan_virtual_light_updated"
|
||||
|
||||
|
||||
def is_manual_virtual_light(marker: Any) -> bool:
|
||||
"""Return whether a marker uses the exact persistent manual-light mode."""
|
||||
return (
|
||||
isinstance(marker, dict)
|
||||
and isinstance(marker.get("id"), str)
|
||||
and bool(marker["id"])
|
||||
and marker.get("binding") == "virtual"
|
||||
and marker.get("is_light") is True
|
||||
and marker.get("tap_action") == "toggle"
|
||||
and marker.get("removed") is not True
|
||||
)
|
||||
|
||||
|
||||
def eligible_virtual_light_ids(config: Any) -> set[str]:
|
||||
"""Collect live marker ids eligible for persistent manual state."""
|
||||
if not isinstance(config, dict):
|
||||
return set()
|
||||
markers = config.get("markers")
|
||||
if not isinstance(markers, list):
|
||||
return set()
|
||||
return {marker["id"] for marker in markers if is_manual_virtual_light(marker)}
|
||||
|
||||
|
||||
def _integer(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0, parsed)
|
||||
|
||||
|
||||
def _read_state(stored: Any) -> tuple[int, int, set[str]]:
|
||||
if not isinstance(stored, dict):
|
||||
return 0, 0, set()
|
||||
raw_off = stored.get("off")
|
||||
off = (
|
||||
{item for item in raw_off if isinstance(item, str) and item}
|
||||
if isinstance(raw_off, list)
|
||||
else set()
|
||||
)
|
||||
return _integer(stored.get("rev")), _integer(stored.get("config_rev")), off
|
||||
|
||||
|
||||
def _wire(rev: int, config_rev: int, off: set[str]) -> dict[str, Any]:
|
||||
return {"rev": rev, "config_rev": config_rev, "off": sorted(off)}
|
||||
|
||||
|
||||
async def async_virtual_light_snapshot(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a coherent snapshot, repairing stale or interrupted state.
|
||||
|
||||
A revision gap means an older writer may have changed eligibility without
|
||||
knowing about this Store. Clearing every manual-off bit is conservative:
|
||||
it restores the pre-feature/default-on behaviour and cannot resurrect an
|
||||
old off state for a marker whose role changed in the meantime.
|
||||
"""
|
||||
stored = await store.async_load() or {}
|
||||
rev, state_config_rev, stored_off = _read_state(stored)
|
||||
eligible = eligible_virtual_light_ids(config)
|
||||
off = stored_off & eligible if state_config_rev == config_rev else set()
|
||||
if off != stored_off:
|
||||
rev += 1
|
||||
payload = _wire(rev, config_rev, off)
|
||||
if payload != stored:
|
||||
await store.async_save(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def async_reconcile_virtual_lights(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
*,
|
||||
previous_config_rev: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Carry eligible state across one known configuration transition."""
|
||||
stored = await store.async_load() or {}
|
||||
rev, state_config_rev, stored_off = _read_state(stored)
|
||||
eligible = eligible_virtual_light_ids(config)
|
||||
off = stored_off & eligible if state_config_rev == previous_config_rev else set()
|
||||
if off != stored_off:
|
||||
rev += 1
|
||||
payload = _wire(rev, config_rev, off)
|
||||
if payload != stored:
|
||||
await store.async_save(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def async_toggle_virtual_light(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
marker_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Atomically invert one eligible marker and persist before returning."""
|
||||
if marker_id not in eligible_virtual_light_ids(config):
|
||||
return None
|
||||
snapshot = await async_virtual_light_snapshot(store, config, config_rev)
|
||||
off = set(snapshot["off"])
|
||||
if marker_id in off:
|
||||
off.remove(marker_id)
|
||||
else:
|
||||
off.add(marker_id)
|
||||
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
|
||||
await store.async_save(payload)
|
||||
return {
|
||||
"marker_id": marker_id,
|
||||
"on": marker_id not in off,
|
||||
"rev": payload["rev"],
|
||||
}
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
from .auth import may_write
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
content_manifest,
|
||||
create_export,
|
||||
get_candidate,
|
||||
live_layout,
|
||||
@@ -39,18 +40,26 @@ from .plans import (
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
)
|
||||
from .store import (
|
||||
LAYOUT_STORE_CORE_KEYS,
|
||||
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
HouseplanData,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
get_data,
|
||||
get_entry,
|
||||
)
|
||||
from .virtual_lights import (
|
||||
EVENT_VIRTUAL_LIGHT_UPDATED,
|
||||
async_toggle_virtual_light,
|
||||
async_virtual_light_snapshot,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
|
||||
validate_marker_controls, validate_marker_value_badges, valid_space_id,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -123,6 +132,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_virtual_light_toggle)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
|
||||
@@ -167,7 +177,7 @@ def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the exact non-layout portion of a layout-store document."""
|
||||
return {
|
||||
key: value for key, value in stored.items()
|
||||
if key not in {"layout", "rev"}
|
||||
if key not in LAYOUT_STORE_CORE_KEYS
|
||||
}
|
||||
|
||||
|
||||
@@ -191,10 +201,11 @@ async def _persist_pair_intent(
|
||||
|
||||
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
|
||||
"""Write both target halves and remove the durable intent last."""
|
||||
await rt.config_store.async_save({
|
||||
"config": pending["config"],
|
||||
"rev": int(pending["config_rev"]),
|
||||
})
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
pending["config"],
|
||||
int(pending["config_rev"]),
|
||||
)
|
||||
stored = await rt.store.async_load() or {}
|
||||
await async_save_layout_state(
|
||||
rt,
|
||||
@@ -397,6 +408,14 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
missing_attachments = _missing_internal_attachments(
|
||||
Path(hass.config.path("")), target_config, config_data.get("config")
|
||||
)
|
||||
if missing_attachments:
|
||||
raise ImportFailure(
|
||||
"missing_content",
|
||||
"Attachment no longer exists: " + ", ".join(sorted(missing_attachments)),
|
||||
)
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = None
|
||||
@@ -410,9 +429,12 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
original_metadata = _layout_metadata(layout_data)
|
||||
replaced_metadata = {_OPTIMIZE_PENDING, _OPTIMIZE_BACKUP}
|
||||
if kind == "full":
|
||||
replaced_metadata.update({"repair_backup", "geom_pending"})
|
||||
final_metadata = {
|
||||
key: value for key, value in original_metadata.items()
|
||||
if key not in {_OPTIMIZE_PENDING, _OPTIMIZE_BACKUP}
|
||||
if key not in replaced_metadata
|
||||
}
|
||||
if backup is not None:
|
||||
final_metadata[_OPTIMIZE_BACKUP] = backup
|
||||
@@ -621,6 +643,7 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Optional("dry_run"): bool,
|
||||
vol.Optional("undo"): bool,
|
||||
vol.Optional("expected_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -653,6 +676,12 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout") or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Layout was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
if msg.get("undo"):
|
||||
backup = data.get("repair_backup")
|
||||
if not isinstance(backup, dict) or backup.get("space") != space_id:
|
||||
@@ -1037,14 +1066,28 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
config_rev = int(data.get("rev", 0))
|
||||
try:
|
||||
virtual_lights = await async_virtual_light_snapshot(
|
||||
rt.virtual_light_store,
|
||||
config,
|
||||
config_rev,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - config remains independently readable
|
||||
_LOGGER.exception("House Plan: reading virtual-light state failed")
|
||||
# Never expose a stale off bit after an unreadable/revision-gap
|
||||
# operational store. Compatibility/default on is the safe frame.
|
||||
virtual_lights = {"rev": 0, "config_rev": config_rev, "off": []}
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"config": config,
|
||||
"rev": data.get("rev", 0),
|
||||
"rev": config_rev,
|
||||
"virtual_lights": virtual_lights,
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
|
||||
"undo_kind": _undo_kind(data, layout_data),
|
||||
@@ -1052,6 +1095,42 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/virtual_light/toggle",
|
||||
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=500)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_virtual_light_toggle(
|
||||
hass: HomeAssistant, connection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Atomically toggle one eligible virtual light for any signed-in user."""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
result = await async_toggle_virtual_light(
|
||||
rt.virtual_light_store,
|
||||
config,
|
||||
int(data.get("rev", 0)),
|
||||
msg["marker_id"],
|
||||
)
|
||||
if result is None:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"not_toggleable",
|
||||
"Marker is not an active virtual light with tap_action=toggle",
|
||||
)
|
||||
return
|
||||
# Both the reply and event follow the durable Store write. There is no
|
||||
# optimistic client state, so all cards converge on this revision.
|
||||
connection.send_result(msg["id"], result)
|
||||
hass.bus.async_fire(EVENT_VIRTUAL_LIGHT_UPDATED, result)
|
||||
|
||||
|
||||
|
||||
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
|
||||
"""Plan file names a configuration names through OUR urls.
|
||||
@@ -1093,6 +1172,30 @@ def _missing_internal_plans(
|
||||
}
|
||||
|
||||
|
||||
def _missing_internal_attachments(
|
||||
config_root: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
|
||||
) -> set[str]:
|
||||
"""New local attachments that vanished between import preview and apply.
|
||||
|
||||
Existing broken references stay writable for the same reason as historical
|
||||
plan URLs: refusing every unrelated write would prevent the user from
|
||||
detaching or repairing them.
|
||||
"""
|
||||
known = {
|
||||
str(item["url"])
|
||||
for item in content_manifest(previous or {}, config_root)
|
||||
if item.get("kind") == "attachment" and item.get("storage") == "internal"
|
||||
}
|
||||
return {
|
||||
str(item["url"])
|
||||
for item in content_manifest(config, config_root)
|
||||
if item.get("kind") == "attachment"
|
||||
and item.get("storage") == "internal"
|
||||
and item.get("exists_at_export") is False
|
||||
and str(item["url"]) not in known
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -1147,6 +1250,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# write so an unrelated edit can still round-trip a legacy broken ref.
|
||||
try:
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
except MarkerControlError as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
@@ -1169,7 +1273,12 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
msg["config"],
|
||||
new_rev,
|
||||
previous_rev=int(current_rev),
|
||||
)
|
||||
try:
|
||||
await _discard_optimizer_snapshot(rt)
|
||||
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
|
||||
@@ -1251,6 +1360,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# as config/set; otherwise a crafted client can persist a new cycle.
|
||||
try:
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
except MarkerControlError as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
@@ -1293,11 +1403,16 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
msg["config"],
|
||||
new_config_rev,
|
||||
previous_rev=config_rev,
|
||||
)
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, msg["layout"], new_layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"),
|
||||
)
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
@@ -1363,7 +1478,12 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
restored_config,
|
||||
new_config_rev,
|
||||
previous_rev=config_rev,
|
||||
)
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, restored_layout, new_layout_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
@@ -13,7 +13,27 @@ const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
|
||||
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
space.room_drafts = [0, 1].map((draft) => {
|
||||
const y = 0.985 + draft * 0.025;
|
||||
return {
|
||||
id: `perf-draft-${floor}-${draft}`,
|
||||
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
|
||||
segments: [{ cm: 15 }, { cm: 20 }],
|
||||
};
|
||||
});
|
||||
}
|
||||
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
|
||||
}
|
||||
const viewport = { width: 1440, height: 1000 };
|
||||
|
||||
const { page, browser } = await launch(
|
||||
@@ -43,7 +63,9 @@ const rows = [];
|
||||
try {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({ fixture, sample, cardContract }) => {
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
@@ -98,15 +120,25 @@ try {
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
});
|
||||
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
if (isometric) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
|
||||
fixture.config.spaces.map((space) => [space.id, 'iso']),
|
||||
)));
|
||||
history.replaceState(null, '', '?hp-labs=iso');
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({
|
||||
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
|
||||
});
|
||||
let wsCalls = 0;
|
||||
const connection = {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
@@ -121,6 +153,7 @@ try {
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
},
|
||||
callWS: async (message) => {
|
||||
wsCalls++;
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
@@ -144,6 +177,10 @@ try {
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor(fixture.states);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
|| !(card._isoGeometryCache instanceof Map))) {
|
||||
throw new Error('large-house-isometric-v1 candidate has no renderer contract');
|
||||
}
|
||||
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
@@ -152,6 +189,18 @@ try {
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const loadLongTaskResult = await loadLongTasks.stop();
|
||||
const viewToggle = isometric ? await duration(async () => {
|
||||
if (typeof card._setProjection === 'function') {
|
||||
card._setProjection('flat');
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
} else {
|
||||
// Comparison SHAs before #89 intentionally ignore the Labs operation.
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
}) : null;
|
||||
const spaceSwitch = await duration(async () => {
|
||||
card._pickSpace('perf-floor-2');
|
||||
await card.updateComplete;
|
||||
@@ -167,6 +216,85 @@ try {
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
let planSnapDiagnostics = null;
|
||||
const planSnapPointer = planSnap ? await duration(async () => {
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
card._path = [];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
|
||||
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
|
||||
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
|
||||
const cacheValue = card._planSnapGeometryCache?.value ?? null;
|
||||
const configBefore = JSON.stringify(card._serverCfg);
|
||||
const callsBefore = wsCalls;
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const fromPlan = (x, y) => ({
|
||||
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
|
||||
});
|
||||
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
|
||||
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
|
||||
.map((line) => ({
|
||||
line,
|
||||
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
|
||||
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
|
||||
}))
|
||||
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
|
||||
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
|
||||
const points = [
|
||||
firstEndpoint
|
||||
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
|
||||
: [40, 40],
|
||||
longLine
|
||||
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
|
||||
: [120, 40],
|
||||
[10, 10],
|
||||
];
|
||||
const seenKinds = new Set();
|
||||
for (let index = 0; index < 120; index++) {
|
||||
const point = points[index % points.length];
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
...fromPlan(point[0], point[1]),
|
||||
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const active = card.renderRoot.querySelector(
|
||||
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
|
||||
);
|
||||
if (active) seenKinds.add(active.getAttribute('data-kind'));
|
||||
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
|
||||
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
|
||||
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
|
||||
}
|
||||
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
planSnapDiagnostics = {
|
||||
supported: requiresPlanSnap,
|
||||
staticLines,
|
||||
staticNodes,
|
||||
activeKinds: [...seenKinds].sort(),
|
||||
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
|
||||
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
|
||||
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
|
||||
=== staticNodes,
|
||||
configStable: JSON.stringify(card._serverCfg) === configBefore,
|
||||
wsWrites: wsCalls - callsBefore,
|
||||
};
|
||||
if (requiresPlanSnap && (
|
||||
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|
||||
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|
||||
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|
||||
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
|
||||
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
|
||||
const resizePreview = await duration(async () => {
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
@@ -249,6 +377,11 @@ try {
|
||||
sample,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
...(planSnapPointer ? {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -257,6 +390,8 @@ try {
|
||||
switchCycleMs: switchCycle.ms,
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -273,7 +408,10 @@ try {
|
||||
card.remove();
|
||||
await frame();
|
||||
return result;
|
||||
}, { fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT });
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
} finally {
|
||||
@@ -284,9 +422,11 @@ const metricNames = [
|
||||
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile: 'large-house-v1',
|
||||
profile,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildFingerprint,
|
||||
runtime: {
|
||||
@@ -304,7 +444,7 @@ const report = {
|
||||
summary: summarizeTimings(rows, metricNames),
|
||||
longTasks: summarizeLongTasks(rows),
|
||||
rows,
|
||||
note: 'Compare with a base-SHA report captured by the same runner and evaluate demo/performance/budgets.json.',
|
||||
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
|
||||
};
|
||||
|
||||
const text = `${JSON.stringify(report, null, 2)}\n`;
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
|
||||
|
||||
const fingerprintForTree = async (root) => {
|
||||
const modulePath = resolve(root, 'scripts/source-fingerprint.mjs');
|
||||
if (!existsSync(modulePath)) return sourceFingerprint(root);
|
||||
const module = await import(pathToFileURL(modulePath).href);
|
||||
if (typeof module.sourceFingerprint !== 'function') {
|
||||
throw new Error(`${modulePath} does not export sourceFingerprint`);
|
||||
}
|
||||
return module.sourceFingerprint(root);
|
||||
};
|
||||
|
||||
/** Refuse measurements/screenshots made by a committed bundle from old source. */
|
||||
export async function assertFreshDemoBundle(page, root = process.cwd()) {
|
||||
const expected = sourceFingerprint(root);
|
||||
// A comparative performance run may load an older tree whose fingerprint
|
||||
// contract is intentionally different from the candidate's. Validate that
|
||||
// tree with the implementation that built it, not with today's algorithm.
|
||||
const expected = await fingerprintForTree(root);
|
||||
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
|
||||
if (loaded !== expected) {
|
||||
throw new Error(
|
||||
|
||||
@@ -131,9 +131,9 @@ const runtime = () => {
|
||||
attributes: { azimuth: 180, elevation: 24 },
|
||||
},
|
||||
};
|
||||
entities['sun.sun'] = {
|
||||
entity_id: 'sun.sun', device_id: null, platform: 'sun', disabled_by: null,
|
||||
};
|
||||
// Keep sun.sun state-only on purpose. Core/runtime entities and YAML
|
||||
// entities without unique_id may have a live state without a registry row.
|
||||
// The production projection must preserve them.
|
||||
const layout = {};
|
||||
const areas = Object.fromEntries(
|
||||
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,26 +1,48 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 12,
|
||||
"acceptedAt": "2026-08-11T19:20:51.270Z",
|
||||
"sourceFingerprint": "2fa0c824eaf356e9d909f54eba8ebddb92d6957c1163e2576986a29cdf497d46",
|
||||
"matrixVersion": 22,
|
||||
"acceptedAt": "2026-08-14T16:03:22.017Z",
|
||||
"sourceFingerprint": "24117bcfb4c94d5421e0304612089743615233eab3911f065e8cfa003f48fa7d",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
|
||||
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
|
||||
"geometry-plan-editor-dark": "14e63fcdaef876ba68c601227b5e4de9a9818bd92228f6d91c680582570daf27",
|
||||
"geometry-devices-editor-dark": "959321fc043f9754ea311ef92f00791f08b5a83c58b929166fb5ef71380337b6",
|
||||
"geometry-decor-editor-dark": "cee7ffe6670ac91b4af814b222fda5251c77fa20ad4c2ddd4920b71aeb4d7a39",
|
||||
"tray-wide-selection-en": "1c601a16298f24c6d6803125d8e9441958a0b34440ae02c9b727c3249314a232",
|
||||
"tray-wide-tool-ru": "5591428022d32af54709f853da15a5d111304031ba4c94ca52c6cb5cd5c8068e",
|
||||
"tray-medium-group-en": "f99a52346bdc3a12471325d1d3749d816f340c79b2817b4e6cd1ec2a250f4b97",
|
||||
"tray-medium-selection-ru": "6f54ab6a4139de3f8310dad0669c0092fbff3df478a461a273d2e04c2660884d",
|
||||
"tray-narrow-palette-en": "d1fc7a4bf0c973d71ee49e24c421f48ea776f7c1cab5e35f9561e36f0efeca8d",
|
||||
"tray-narrow-tool-ru": "9d00dcb692350b05602d31938f5f8fd1eadfbe584972a2f40c904457635a64ff",
|
||||
"geometry-diagonal-45-opening-dark": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
|
||||
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
|
||||
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
|
||||
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
|
||||
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
|
||||
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
|
||||
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
|
||||
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
|
||||
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
|
||||
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
|
||||
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
|
||||
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
|
||||
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
|
||||
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
|
||||
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
|
||||
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
|
||||
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
|
||||
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
|
||||
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
|
||||
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
|
||||
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
|
||||
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
|
||||
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
|
||||
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
|
||||
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
|
||||
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
|
||||
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "074fb859d2ae1dc2ccd2045e2d6fb6d8389e430cbef20287419b8840cbbd5094",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
|
||||
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
|
||||
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
|
||||
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
|
||||
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
|
||||
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
|
||||
@@ -33,15 +55,15 @@
|
||||
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
|
||||
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
|
||||
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
|
||||
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
|
||||
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
|
||||
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
|
||||
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
|
||||
"device-dialog-desktop-en": "250a7d586676baa861bb3dc9e3d6e02d324213b191eaa86791fc491c7c93c5be",
|
||||
"device-dialog-mobile-ru": "31a13cd6c42f8a1be48d2bc5b7b1a5361c3df2e91145680c9133753434c758e6",
|
||||
"device-help-popover-light-ru": "e463c5361ebc546d217debafb317761a0e25804a04ab41023a10025c48478e37",
|
||||
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
|
||||
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
|
||||
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
|
||||
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
|
||||
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
|
||||
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
|
||||
"backup-space-preview-mobile-ru": "16d06859ea6aac6cbed586f0d6918da972e9c95206fffd771fe43b6084f79877"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -1,5 +1,5 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
|
||||
|
||||
@@ -51,6 +51,64 @@ async function stableEnvironment(page, scenario) {
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
export function prepareGoldenFixture(scenario) {
|
||||
const fixture = fixtureFor(scenario.fixture);
|
||||
if (scenario.cornerSplitWall) {
|
||||
const stage = scenario.cornerSplitWall;
|
||||
if (!['before', 'thin', 'thick'].includes(stage))
|
||||
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.50];
|
||||
const br = [0.90, 0.90], bl = [0.10, 0.90];
|
||||
const entry = (from, to, cm) => ({
|
||||
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
|
||||
});
|
||||
const before = stage === 'before';
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
name: 'Corner Split',
|
||||
rooms: before
|
||||
? [{ id: 'corner-source', name: 'Before Split', area: null, poly: [a, tr, br, bl] }]
|
||||
: [
|
||||
{ id: 'corner-source', name: 'Main room', area: null, poly: [a, tr, split] },
|
||||
{ id: 'corner-fresh', name: 'New room', area: null, poly: [split, br, bl, a] },
|
||||
],
|
||||
walls: before
|
||||
? [entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15)]
|
||||
: [
|
||||
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
|
||||
entry(br, bl, 15), entry(bl, a, 15), entry(a, split, stage === 'thin' ? 15 : 100),
|
||||
],
|
||||
settings: { show_borders: true, fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 } },
|
||||
});
|
||||
}
|
||||
if (scenario.wallJunctions) {
|
||||
const a = [0.06, 0.06], tr = [0.94, 0.06], br = [0.94, 0.94], bl = [0.06, 0.94];
|
||||
const entry = (from, to, cm) => ({
|
||||
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
|
||||
});
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
title: 'Wall junctions',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
rooms: [{ id: 'junction-room', name: 'Room', area: null, poly: [a, tr, br, bl] }],
|
||||
walls: [entry(a, tr, 10), entry(tr, br, 10), entry(br, bl, 10), entry(bl, a, 10)],
|
||||
partitions: [
|
||||
{ id: 'junction-l-a', a: [0.16, 0.25], b: [0.38, 0.25], cm: 18 },
|
||||
{ id: 'junction-l-b', a: [0.38, 0.25], b: [0.38, 0.46], cm: 30 },
|
||||
{ id: 'junction-oblique-a', a: [0.58, 0.22], b: [0.78, 0.38], cm: 22 },
|
||||
{ id: 'junction-oblique-b', a: [0.78, 0.38], b: [0.62, 0.52], cm: 14 },
|
||||
{ id: 'junction-t-through', a: [0.18, 0.70], b: [0.78, 0.70], cm: 24 },
|
||||
{ id: 'junction-t-branch', a: [0.50, 0.54], b: [0.50, 0.70], cm: 16 },
|
||||
{ id: 'junction-room-branch', a: [0.30, 0.82], b: [0.30, 0.94], cm: 18 },
|
||||
],
|
||||
room_drafts: [{
|
||||
id: 'junction-draft', points: [[0.16, 0.54], [0.30, 0.54], [0.30, 0.64]],
|
||||
segments: [{ cm: 12 }, { cm: 20 }],
|
||||
}],
|
||||
wall_columns: [],
|
||||
});
|
||||
}
|
||||
const requireSpace = () => {
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
@@ -61,14 +119,16 @@ export function prepareGoldenFixture(scenario) {
|
||||
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
}
|
||||
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean') {
|
||||
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
|
||||
const space = requireSpace();
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
|
||||
...(scenario.bgMode ? { bg_mode: scenario.bgMode } : {}),
|
||||
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
|
||||
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
|
||||
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
|
||||
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
|
||||
};
|
||||
}
|
||||
@@ -84,6 +144,22 @@ export function prepareGoldenFixture(scenario) {
|
||||
}
|
||||
space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
|
||||
}
|
||||
if (scenario.openingGeometry) {
|
||||
const space = requireSpace();
|
||||
const opening = (space.openings || []).find(
|
||||
(item) => item.id === scenario.openingGeometry.id,
|
||||
);
|
||||
if (!opening || opening.type !== scenario.openingGeometry.type
|
||||
|| Math.abs(Number(opening.angle) - scenario.openingGeometry.angle) > 0.001) {
|
||||
throw new Error(
|
||||
`golden openingGeometry references a missing/mismatched opening: `
|
||||
+ `${scenario.openingGeometry.id}`,
|
||||
);
|
||||
}
|
||||
// This scenario must not remain byte-identical to the generic geometry
|
||||
// capture: isolate the intended diagonal symbol in the rendered fixture.
|
||||
space.openings = [opening];
|
||||
}
|
||||
if (scenario.wallReplacements?.length) {
|
||||
const space = requireSpace();
|
||||
const samePoint = (a, b) => Array.isArray(a) && Array.isArray(b)
|
||||
@@ -143,7 +219,13 @@ export function prepareGoldenFixture(scenario) {
|
||||
}
|
||||
if (scenario.markerOverrides) {
|
||||
const ids = new Set(scenario.markerOverrides.map((marker) => marker.id));
|
||||
const known = new Set((fixture.config.markers || []).map((marker) => marker.id));
|
||||
// Runtime devices without explicit marker settings are still valid saved
|
||||
// marker targets. A visual scenario may materialize their first setting,
|
||||
// just like the real device dialog does on save.
|
||||
const known = new Set([
|
||||
...(fixture.config.markers || []).map((marker) => marker.id),
|
||||
...Object.keys(fixture.devices || {}),
|
||||
]);
|
||||
const missing = [...ids].filter((id) => !known.has(id));
|
||||
if (missing.length) throw new Error(`golden markerOverrides reference missing marker(s): ${missing.join(', ')}`);
|
||||
fixture.config.markers = [
|
||||
@@ -174,13 +256,29 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await wait(15);
|
||||
}
|
||||
};
|
||||
const settleMode = async (card) => {
|
||||
await until(() => !card._modeTransitionBusy);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
window.__goldenCard?.remove?.();
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
history.replaceState(null, '', scenario.labs?.length
|
||||
? `?hp-labs=${encodeURIComponent(scenario.labs.join(','))}` : location.pathname);
|
||||
if (scenario.labs?.length) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(scenario.labs));
|
||||
}
|
||||
if (scenario.projection && scenario.space) {
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
|
||||
[scenario.space]: scenario.projection,
|
||||
}));
|
||||
}
|
||||
const host = document.getElementById('host');
|
||||
const cardConfig = {
|
||||
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
|
||||
language: scenario.language || 'en',
|
||||
...(scenario.kiosk ? { kiosk: true } : {}),
|
||||
};
|
||||
const hassFor = () => ({
|
||||
language: scenario.language || 'en', locale: { language: scenario.language || 'en' },
|
||||
@@ -239,12 +337,115 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
if (scenario.mode) {
|
||||
card._setMode(scenario.mode);
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
}
|
||||
if (scenario.projection === 'iso' && typeof card._setProjection === 'function') {
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
card._applyView(scenario.zoom, 500, 500);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.wallJunctionPreview) {
|
||||
const { path, pointer, cms, cm } = scenario.wallJunctionPreview;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!Array.isArray(path) || !path.length || !path.every(validPoint)
|
||||
|| !validPoint(pointer) || !Array.isArray(cms) || !cms.every(Number.isFinite)
|
||||
|| !(Number(cm) > 0)) {
|
||||
throw new Error(`invalid golden wallJunctionPreview contract: ${scenario.id}`);
|
||||
}
|
||||
card._tool = 'draw';
|
||||
card._activeDraftId = null;
|
||||
card._path = path.map((point) => [point[0] * 1000, point[1] * card._spaceH]);
|
||||
card._draftSegmentCms = [...cms];
|
||||
card._drawWallField = String(cm);
|
||||
card._cursorPt = [pointer[0] * 1000, pointer[1] * card._spaceH];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (!card.renderRoot.querySelector('.drawwall-preview'))
|
||||
throw new Error(`golden wall junction preview did not render: ${scenario.id}`);
|
||||
}
|
||||
if (scenario.planSnap) {
|
||||
const { tool, anchor, pointer, expectedKind } = scenario.planSnap;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!['draw', 'partition'].includes(tool) || !validPoint(pointer)
|
||||
|| (anchor != null && !validPoint(anchor))
|
||||
|| !['endpoint', 'line'].includes(expectedKind)) {
|
||||
throw new Error(`invalid golden planSnap contract: ${scenario.id}`);
|
||||
}
|
||||
card._tool = tool;
|
||||
card._activeDraftId = null;
|
||||
card._path = anchor ? [[anchor[0] * 1000, anchor[1] * card._spaceH]] : [];
|
||||
card._clearPlanSnapHover();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const svgRoot = card.renderRoot.querySelector('.stage svg');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const screen = new DOMPoint(pointer[0] * 1000, pointer[1] * card._spaceH)
|
||||
.matrixTransform(svgRoot.getScreenCTM());
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerId: 992, pointerType: 'mouse',
|
||||
clientX: screen.x, clientY: screen.y,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
const active = overlay?.querySelector('.plan-snap-node[data-active="true"]');
|
||||
if (!overlay || active?.getAttribute('data-kind') !== expectedKind
|
||||
|| overlay.querySelectorAll('.plan-snap-node[data-active="true"]').length !== 1) {
|
||||
throw new Error(`golden plan snap candidate did not render: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.openingPreview) {
|
||||
const { type, pointer } = scenario.openingPreview;
|
||||
if (!['window', 'door', 'gate'].includes(type)
|
||||
|| !Array.isArray(pointer) || pointer.length !== 2
|
||||
|| !pointer.every(Number.isFinite)) {
|
||||
throw new Error(`invalid golden openingPreview: ${scenario.id}`);
|
||||
}
|
||||
card._activateOpeningPlacement(type);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
// Exercise the production pointer path after the toolbar update has
|
||||
// settled. Writing `_cursorPt` before that update is racy: replacing the
|
||||
// stage under Chromium's real pointer legitimately emits pointerleave
|
||||
// and clears the preview before capture.
|
||||
const svgRoot = card.renderRoot.querySelector('.stage svg');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const screen = new DOMPoint(pointer[0] * 1000, pointer[1] * card._spaceH)
|
||||
.matrixTransform(svgRoot.getScreenCTM());
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse',
|
||||
clientX: screen.x, clientY: screen.y,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const preview = card.renderRoot.querySelector(`.opening-preview[data-kind="${type}"]`);
|
||||
if (!preview || !preview.querySelector('.op-leaf')) {
|
||||
const intervals = card._openingPlacementIntervalsCache?.value || [];
|
||||
const nearest = intervals.map((interval) => {
|
||||
const [px, py] = card._cursorPt || [0, 0];
|
||||
const [ax, ay] = interval.a, [bx, by] = interval.b;
|
||||
const dx = bx - ax, dy = by - ay, length2 = dx * dx + dy * dy || 1;
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2));
|
||||
return {
|
||||
a: interval.a, b: interval.b, cm: interval.cm, open: interval.open,
|
||||
kind: interval.kind,
|
||||
distance: Math.hypot(px - (ax + dx * t), py - (ay + dy * t)),
|
||||
};
|
||||
}).sort((a, b) => a.distance - b.distance).slice(0, 3);
|
||||
throw new Error(`golden opening preview did not render: ${scenario.id}; `
|
||||
+ `cursor=${JSON.stringify(card._cursorPt)} nearest=${JSON.stringify(nearest)}`);
|
||||
}
|
||||
}
|
||||
if (scenario.editorTray) {
|
||||
let expectedKind = '';
|
||||
if (scenario.editorTray === 'plan-selection') {
|
||||
@@ -304,6 +505,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
if (scenario.dialog === 'device') {
|
||||
card._setMode('devices');
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
const device = card._devices.find((item) => item.id === scenario.deviceId);
|
||||
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
|
||||
card._openMarkerDialog(device);
|
||||
@@ -380,6 +582,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
card._setMode('decor');
|
||||
card._decorTool = 'select';
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
const shape = card._decorList.find((item) => item.kind === 'line');
|
||||
if (!shape) throw new Error('golden decor line missing');
|
||||
card._decorShapeDbl(new MouseEvent('dblclick'), shape);
|
||||
@@ -392,6 +595,17 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
}
|
||||
if (scenario.dayCycle) {
|
||||
const environment = card.renderRoot.querySelector('.hp-day-cycle-env');
|
||||
const active = card.renderRoot.querySelector('.hp-day-cycle-bg.active');
|
||||
const paper = card.renderRoot.querySelector('.hp-paperg');
|
||||
if (environment?.dataset.dayCyclePhase !== scenario.dayCycle.phase
|
||||
|| active?.dataset.dayCycleLayer !== scenario.dayCycle.phase
|
||||
|| !active?.getAttribute('style')?.includes(scenario.dayCycle.top)
|
||||
|| !paper) {
|
||||
throw new Error(`golden day-cycle contract did not render: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
await document.fonts?.ready;
|
||||
await frame();
|
||||
return {
|
||||
@@ -403,22 +617,42 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
.some((help) => help.renderRoot?.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true'),
|
||||
editorTray: card.renderRoot.querySelector('.editor-secondary-host.open .editor-secondary')
|
||||
?.className || '',
|
||||
...(scenario.sunRayPixels ? { sun: {
|
||||
raw: card.hass?.states?.['sun.sun']?.attributes || null,
|
||||
plan: card._planHass?.states?.['sun.sun']?.attributes || null,
|
||||
render: card._renderPlanHass?.states?.['sun.sun']?.attributes || null,
|
||||
north: card._effNorth(),
|
||||
enabled: card._effSunRays(),
|
||||
editing: card._editing,
|
||||
cachedRays: card._sunRaysCache?.rays?.length || 0,
|
||||
} } : {}),
|
||||
};
|
||||
}, { fixture, scenario });
|
||||
}
|
||||
|
||||
export async function goldenClip(page, capture) {
|
||||
if (capture === 'page') return null;
|
||||
return page.evaluate(() => {
|
||||
return page.evaluate((captureKind) => {
|
||||
const card = window.__goldenCard;
|
||||
const target = card?.renderRoot?.querySelector('.stage');
|
||||
if (!target) throw new Error('golden stage capture target missing');
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (captureKind === 'sun-window') {
|
||||
// Stable crop around the exterior window and the first part of its ray:
|
||||
// deliberately excludes room labels and device markers, whose font/icon
|
||||
// rasterisation would add noise unrelated to the visual contract.
|
||||
return {
|
||||
x: Math.max(0, Math.floor(rect.left + rect.width * 0.10)),
|
||||
y: Math.max(0, Math.floor(rect.top + rect.height * 0.02)),
|
||||
width: Math.max(1, Math.ceil(rect.width * 0.35)),
|
||||
height: Math.max(1, Math.ceil(rect.height * 0.25)),
|
||||
};
|
||||
}
|
||||
const pad = 2;
|
||||
const x = Math.max(0, Math.floor(rect.left - pad));
|
||||
const y = Math.max(0, Math.floor(rect.top - pad));
|
||||
const right = Math.min(window.innerWidth, Math.ceil(rect.right + pad));
|
||||
const bottom = Math.min(window.innerHeight, Math.ceil(rect.bottom + pad));
|
||||
return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };
|
||||
});
|
||||
}, capture);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,91 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 12;
|
||||
export const GOLDEN_MATRIX_VERSION = 22;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-corner-wall-thin-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'thin', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', showBorders: false, fillMode: 'custom',
|
||||
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', kiosk: true,
|
||||
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
|
||||
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', warmRemount: true,
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-dawn-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'dawn', top: '#aabdd1' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 95, elevation: -2, rising: true } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-day-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'day', top: '#dce9ef' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 180, elevation: 42, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-dusk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'dusk', top: '#48536c' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-night-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'night', top: '#111a27' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'plan-snap-endpoint-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' },
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'plan-snap-line-gaps-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
planSnap: {
|
||||
tool: 'draw', anchor: [0.70, 0.455], pointer: [0.482, 0.455], expectedKind: 'line',
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-plan-preview-light', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.16, 0.15], [0.38, 0.15]], pointer: [0.38, 0.25], cms: [12], cm: 24,
|
||||
},
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-plan-t-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.50, 0.54]], pointer: [0.50, 0.70], cms: [], cm: 24,
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-view-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
// complete 90 cm door preset while still proving rotation, inner-face
|
||||
// offset and ruler placement on a thick wall.
|
||||
mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] },
|
||||
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
|
||||
@@ -38,6 +111,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
editorTray: 'decor-tool', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'geometry-diagonal-45-opening-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
openingGeometry: { id: 'geo-diagonal-window', type: 'window', angle: 45 },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'openings-thick-wall-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'none', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -51,12 +125,46 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ key: fixtureWallKey([0.5, 0.5], [0.5, 0.88]), a: [0.5, 0.5], b: [0.5, 0.88], cm: 32 },
|
||||
],
|
||||
}],
|
||||
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3 },
|
||||
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3, dpr2: true },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'none', glowEnabled: false, hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'device-value-badge-positions-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
glowEnabled: false, sunRays: false,
|
||||
markerOverrides: [
|
||||
{ id: 'golden-light-one', binding: 'device:golden-light-one', value_badge: {
|
||||
enabled: true, source: { kind: 'entity_state', entity_id: 'light.golden_light_one' }, position: 'right',
|
||||
} },
|
||||
{ id: 'golden-light-two', binding: 'device:golden-light-two', value_badge: {
|
||||
enabled: true, source: { kind: 'entity_state', entity_id: 'light.golden_light_two' }, position: 'left',
|
||||
} },
|
||||
{ id: 'golden-presence', binding: 'device:golden-presence', value_badge: {
|
||||
enabled: true, source: { kind: 'entity_state', entity_id: 'binary_sensor.golden_presence' }, position: 'top',
|
||||
} },
|
||||
{ id: 'golden-climate', binding: 'device:golden-climate', value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_attribute', entity_id: 'climate.golden_climate', attribute: 'current_temperature' },
|
||||
position: 'bottom',
|
||||
} },
|
||||
],
|
||||
stateOverrides: { 'climate.golden_climate': { attributes: { lqi: 190 } } },
|
||||
layoutOverrides: {
|
||||
'golden-light-one': { s: 'golden-lighting', x: 0.20, y: 0.32 },
|
||||
'golden-light-two': { s: 'golden-lighting', x: 0.20, y: 0.72 },
|
||||
'golden-presence': { s: 'golden-lighting', x: 0.80, y: 0.68 },
|
||||
'golden-climate': { s: 'golden-lighting', x: 0.80, y: 0.28 },
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
// The golden screenshot is backed by a second, sun-layer-hidden capture.
|
||||
// A real painted ray must account for enough changed pixels; DOM-only
|
||||
// presence or an accidentally accepted empty baseline is not sufficient.
|
||||
glowEnabled: false, allLightsOff: true,
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: 24 } } },
|
||||
sunRayPixels: { minPixels: 500, minChannelDelta: 4 },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...sunWindow },
|
||||
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'light', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-fill-temp-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
|
||||
@@ -110,6 +110,174 @@ async function comparePng(page, actual, baseline, threshold) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture the identical visual frame with only the rendered sun-ray SVG
|
||||
* hidden. Comparing this control frame with the reviewed golden proves that
|
||||
* the layer changes real browser pixels, not merely that its DOM exists. */
|
||||
async function captureWithoutSunRays(page, screenshotOptions) {
|
||||
const layerState = await page.evaluate(async () => {
|
||||
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
|
||||
if (!layer) throw new Error('semantic golden sun layer is missing');
|
||||
const shapes = layer.querySelectorAll('path, polygon').length;
|
||||
if (!shapes) throw new Error('semantic golden sun layer has no painted shapes');
|
||||
const previous = layer.style.visibility;
|
||||
layer.style.visibility = 'hidden';
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
return { previous, shapes };
|
||||
});
|
||||
try {
|
||||
return { png: await page.screenshot(screenshotOptions), shapes: layerState.shapes };
|
||||
} finally {
|
||||
await page.evaluate(async (previous) => {
|
||||
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
|
||||
if (layer) layer.style.visibility = previous;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
}, layerState.previous);
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture a control frame with only the transient opening symbol hidden.
|
||||
* Comparing it with the actual frame proves the preview paints browser pixels;
|
||||
* the smoke test separately locks its DOM order above the wall body. */
|
||||
async function captureWithoutOpeningPreview(page, screenshotOptions) {
|
||||
const layerState = await page.evaluate(async () => {
|
||||
const card = window.__goldenCard;
|
||||
const parts = [...(card?.renderRoot?.querySelectorAll(
|
||||
'.opening-preview, .opening-preview-dot',
|
||||
) || [])];
|
||||
if (!parts.length) throw new Error('semantic golden opening preview is missing');
|
||||
const previous = parts.map((part) => part.style.visibility);
|
||||
for (const part of parts) part.style.visibility = 'hidden';
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
return { previous, count: parts.length };
|
||||
});
|
||||
try {
|
||||
return { png: await page.screenshot(screenshotOptions), parts: layerState.count };
|
||||
} finally {
|
||||
await page.evaluate(async (previous) => {
|
||||
const card = window.__goldenCard;
|
||||
const parts = [...(card?.renderRoot?.querySelectorAll(
|
||||
'.opening-preview, .opening-preview-dot',
|
||||
) || [])];
|
||||
parts.forEach((part, index) => { part.style.visibility = previous[index] || ''; });
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
}, layerState.previous);
|
||||
}
|
||||
}
|
||||
|
||||
async function countChangedPixels(page, actual, control, spec) {
|
||||
return page.evaluate(async ({ actual64, control64, minChannelDelta }) => {
|
||||
const decode = async (base64) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
};
|
||||
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
|
||||
if (actualImage.width !== controlImage.width || actualImage.height !== controlImage.height)
|
||||
throw new Error('semantic golden control frame has different dimensions');
|
||||
const canvas = document.createElement('canvas');
|
||||
const controlCanvas = document.createElement('canvas');
|
||||
canvas.width = controlCanvas.width = actualImage.width;
|
||||
canvas.height = controlCanvas.height = actualImage.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(actualImage, 0, 0);
|
||||
controlContext.drawImage(controlImage, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const controlPixels = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let changed = 0;
|
||||
let maxDelta = 0;
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
const delta = Math.max(
|
||||
Math.abs(pixels[offset] - controlPixels[offset]),
|
||||
Math.abs(pixels[offset + 1] - controlPixels[offset + 1]),
|
||||
Math.abs(pixels[offset + 2] - controlPixels[offset + 2]),
|
||||
);
|
||||
maxDelta = Math.max(maxDelta, delta);
|
||||
if (delta >= minChannelDelta) changed++;
|
||||
}
|
||||
return { changed, maxDelta, size: [canvas.width, canvas.height] };
|
||||
}, {
|
||||
actual64: actual.toString('base64'),
|
||||
control64: control.toString('base64'),
|
||||
minChannelDelta: spec.minChannelDelta,
|
||||
});
|
||||
}
|
||||
|
||||
/** Count preview pixels which are actually painted over the physical wall
|
||||
* fill. A global changed-pixel threshold can pass even when the complete
|
||||
* symbol is accidentally hidden below masonry because its swing arc remains
|
||||
* outside the body. */
|
||||
async function countOpeningPreviewPixelsInsideWall(
|
||||
page, actual, control, clip, minChannelDelta,
|
||||
) {
|
||||
return page.evaluate(async ({ actual64, control64, clipRect, minDelta }) => {
|
||||
const decode = async (base64) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
};
|
||||
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
|
||||
const canvas = document.createElement('canvas');
|
||||
const controlCanvas = document.createElement('canvas');
|
||||
canvas.width = controlCanvas.width = actualImage.width;
|
||||
canvas.height = controlCanvas.height = actualImage.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(actualImage, 0, 0);
|
||||
controlContext.drawImage(controlImage, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const controls = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const card = window.__goldenCard;
|
||||
const preview = card?.renderRoot?.querySelector('.opening-preview');
|
||||
const walls = [...(card?.renderRoot?.querySelectorAll('.wallbody-fill') || [])]
|
||||
.map((wall) => {
|
||||
const matrix = wall.getScreenCTM?.();
|
||||
return matrix ? { wall, rect: wall.getBoundingClientRect(), inverse: matrix.inverse() } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (!preview || !walls.length) return { changed: 0, sampled: 0 };
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
const candidates = walls.filter(({ rect }) => rect.right >= previewRect.left
|
||||
&& rect.left <= previewRect.right && rect.bottom >= previewRect.top
|
||||
&& rect.top <= previewRect.bottom);
|
||||
if (!candidates.length) return { changed: 0, sampled: 0 };
|
||||
const left = Math.ceil(previewRect.left);
|
||||
const top = Math.ceil(previewRect.top);
|
||||
const right = Math.floor(previewRect.right);
|
||||
const bottom = Math.floor(previewRect.bottom);
|
||||
const originX = clipRect?.x || 0;
|
||||
const originY = clipRect?.y || 0;
|
||||
let changed = 0;
|
||||
let sampled = 0;
|
||||
for (let y = top; y <= bottom; y++) {
|
||||
for (let x = left; x <= right; x++) {
|
||||
const screenPoint = new DOMPoint(x + 0.5, y + 0.5);
|
||||
const insideWall = candidates.some(({ wall, rect, inverse }) => {
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return false;
|
||||
return wall.isPointInFill(screenPoint.matrixTransform(inverse));
|
||||
});
|
||||
if (!insideWall) continue;
|
||||
const px = Math.round(x - originX);
|
||||
const py = Math.round(y - originY);
|
||||
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) continue;
|
||||
sampled += 1;
|
||||
const offset = (py * canvas.width + px) * 4;
|
||||
const delta = Math.max(
|
||||
Math.abs(pixels[offset] - controls[offset]),
|
||||
Math.abs(pixels[offset + 1] - controls[offset + 1]),
|
||||
Math.abs(pixels[offset + 2] - controls[offset + 2]),
|
||||
Math.abs(pixels[offset + 3] - controls[offset + 3]),
|
||||
);
|
||||
if (delta >= minDelta) changed += 1;
|
||||
}
|
||||
}
|
||||
return { changed, sampled };
|
||||
}, {
|
||||
actual64: actual.toString('base64'),
|
||||
control64: control.toString('base64'),
|
||||
clipRect: clip || null,
|
||||
minDelta: minChannelDelta,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert scenario semantics against the actual capture, not only its data.
|
||||
* This protects a reviewed-but-empty baseline from becoming the reference. */
|
||||
async function countWarmPixels(page, png, region) {
|
||||
@@ -174,10 +342,10 @@ async function countHelpTextPixels(page, png, clip, spec) {
|
||||
}, { png64: png.toString('base64'), clip, spec });
|
||||
}
|
||||
|
||||
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. The
|
||||
* opening symbol is hidden by the scenario, so both centre lines should be a
|
||||
* locally constant translucent surface. Sampling both axes catches the wall
|
||||
* centre split as well as boundaries between atomic wall-profile pieces. */
|
||||
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. Sample a
|
||||
* narrow strip around local y=0: this crosses the join between both tunnel
|
||||
* half-faces at any opening angle while excluding legitimate outer-profile
|
||||
* steps where adjacent wall intervals have different physical thicknesses. */
|
||||
async function inspectTunnelContinuity(page, png, clip, spec) {
|
||||
return page.evaluate(async ({ png64, clip, spec }) => {
|
||||
const card = window.__goldenCard;
|
||||
@@ -186,6 +354,10 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
|
||||
);
|
||||
if (!tunnel) throw new Error(`semantic golden tunnel missing: ${spec.openingId}`);
|
||||
const rect = tunnel.getBoundingClientRect();
|
||||
const matrix = tunnel.getScreenCTM();
|
||||
if (!matrix) throw new Error(`semantic golden tunnel has no screen transform: ${spec.openingId}`);
|
||||
const inverse = matrix.inverse();
|
||||
const localBounds = tunnel.getBBox();
|
||||
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
||||
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -194,10 +366,19 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
const originX = clip?.x || 0, originY = clip?.y || 0;
|
||||
const left = Math.max(0, Math.ceil(rect.left - originX) + spec.insetPx);
|
||||
const top = Math.max(0, Math.ceil(rect.top - originY) + spec.insetPx);
|
||||
const right = Math.min(image.width - 1, Math.floor(rect.right - originX) - spec.insetPx);
|
||||
const bottom = Math.min(image.height - 1, Math.floor(rect.bottom - originY) - spec.insetPx);
|
||||
// Screenshots may be captured at DPR > 1. DOMRect/clip are CSS pixels,
|
||||
// image coordinates are device pixels, so derive the scale instead of
|
||||
// silently sampling the wrong strip at high DPI.
|
||||
const cssWidth = clip?.width || document.documentElement.clientWidth;
|
||||
const cssHeight = clip?.height || document.documentElement.clientHeight;
|
||||
const scaleX = image.width / Math.max(1, cssWidth);
|
||||
const scaleY = image.height / Math.max(1, cssHeight);
|
||||
const insetX = Math.max(1, Math.round(spec.insetPx * scaleX));
|
||||
const insetY = Math.max(1, Math.round(spec.insetPx * scaleY));
|
||||
const left = Math.max(0, Math.ceil((rect.left - originX) * scaleX) + insetX);
|
||||
const top = Math.max(0, Math.ceil((rect.top - originY) * scaleY) + insetY);
|
||||
const right = Math.min(image.width - 1, Math.floor((rect.right - originX) * scaleX) - insetX);
|
||||
const bottom = Math.min(image.height - 1, Math.floor((rect.bottom - originY) * scaleY) - insetY);
|
||||
if (right - left < 3 || bottom - top < 3)
|
||||
throw new Error(`semantic golden tunnel is too small: ${left},${top},${right},${bottom}`);
|
||||
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
||||
@@ -205,17 +386,45 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
|
||||
const offset = (y * image.width + x) * 4;
|
||||
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
|
||||
};
|
||||
let maxJump = 0, samplePairs = 0;
|
||||
const compare = (a, b) => {
|
||||
maxJump = Math.max(maxJump,
|
||||
let maxJump = 0, maxPair = null, samplePairs = 0;
|
||||
const compare = (a, b, x, y, direction) => {
|
||||
const jump = Math.max(
|
||||
Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
|
||||
if (jump > maxJump) {
|
||||
maxJump = jump;
|
||||
maxPair = { x, y, direction, a, b };
|
||||
}
|
||||
samplePairs++;
|
||||
};
|
||||
const centreX = Math.round((left + right) / 2);
|
||||
const centreY = Math.round((top + bottom) / 2);
|
||||
for (let x = left + 1; x <= right; x++) compare(rgb(x - 1, centreY), rgb(x, centreY));
|
||||
for (let y = top + 1; y <= bottom; y++) compare(rgb(centreX, y - 1), rgb(centreX, y));
|
||||
return { maxJump, samplePairs, bounds: [left, top, right, bottom] };
|
||||
const localXScale = Math.max(1e-6, Math.hypot(matrix.a, matrix.b));
|
||||
const localYScale = Math.max(1e-6, Math.hypot(matrix.c, matrix.d));
|
||||
const endInset = Math.max(1, spec.insetPx) / localXScale;
|
||||
const axisBand = Math.max(1.5, spec.axisBandPx || 2.5) / localYScale;
|
||||
const insideAxisBand = (x, y) => {
|
||||
const cssX = originX + (x + 0.5) / scaleX;
|
||||
const cssY = originY + (y + 0.5) / scaleY;
|
||||
const local = new DOMPoint(cssX, cssY).matrixTransform(inverse);
|
||||
return local.x >= localBounds.x + endInset
|
||||
&& local.x <= localBounds.x + localBounds.width - endInset
|
||||
&& Math.abs(local.y) <= axisBand;
|
||||
};
|
||||
for (let y = top; y <= bottom; y++) {
|
||||
for (let x = left + 1; x <= right; x++) {
|
||||
if (insideAxisBand(x - 1, y) && insideAxisBand(x, y))
|
||||
compare(rgb(x - 1, y), rgb(x, y), x, y, 'horizontal');
|
||||
}
|
||||
}
|
||||
for (let x = left; x <= right; x++) {
|
||||
for (let y = top + 1; y <= bottom; y++) {
|
||||
if (insideAxisBand(x, y - 1) && insideAxisBand(x, y))
|
||||
compare(rgb(x, y - 1), rgb(x, y), x, y, 'vertical');
|
||||
}
|
||||
}
|
||||
if (!samplePairs) throw new Error(`semantic golden tunnel axis strip is empty: ${spec.openingId}`);
|
||||
return {
|
||||
maxJump, maxPair, samplePairs, bounds: [left, top, right, bottom],
|
||||
scale: [scaleX, scaleY],
|
||||
};
|
||||
}, { png64: png.toString('base64'), clip, spec });
|
||||
}
|
||||
|
||||
@@ -226,11 +435,13 @@ if (existsSync(baselineManifestPath)) {
|
||||
catch { baselineManifest = { invalid: true }; }
|
||||
}
|
||||
|
||||
const browserArgs = ['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'];
|
||||
const browserContext = { locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' };
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1000, height: 900 },
|
||||
1,
|
||||
['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'],
|
||||
{ locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' },
|
||||
browserArgs,
|
||||
browserContext,
|
||||
);
|
||||
const chromium = await browser.version();
|
||||
const results = [];
|
||||
@@ -250,17 +461,99 @@ try {
|
||||
pageErrors.length = 0;
|
||||
result.runtime = await prepareGoldenScenario(page, scenario);
|
||||
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
|
||||
if (scenario.openingGeometry) {
|
||||
result.openingGeometry = await page.evaluate((expected) => {
|
||||
const card = window.__goldenCard;
|
||||
const opening = card?.renderRoot?.querySelector(
|
||||
`.opening[data-id="${CSS.escape(expected.id)}"]`,
|
||||
);
|
||||
if (!opening) return null;
|
||||
const transform = opening.getAttribute('transform') || '';
|
||||
const angle = Number(transform.match(/rotate\(([-+0-9.eE]+)\)/)?.[1]);
|
||||
const painted = [...opening.querySelectorAll('.op-leaf, .op-arc, .op-glass')]
|
||||
.map((part) => part.getBoundingClientRect())
|
||||
.filter((rect) => rect.width > 0 || rect.height > 0);
|
||||
const bounds = painted.length ? {
|
||||
width: Math.max(...painted.map((rect) => rect.right))
|
||||
- Math.min(...painted.map((rect) => rect.left)),
|
||||
height: Math.max(...painted.map((rect) => rect.bottom))
|
||||
- Math.min(...painted.map((rect) => rect.top)),
|
||||
} : { width: 0, height: 0 };
|
||||
return {
|
||||
type: opening.getAttribute('data-kind'), angle,
|
||||
width: bounds.width, height: bounds.height,
|
||||
visibleParts: opening.querySelectorAll('.op-leaf, .op-arc, .op-glass').length,
|
||||
};
|
||||
}, scenario.openingGeometry);
|
||||
const expected = scenario.openingGeometry;
|
||||
const actualOpening = result.openingGeometry;
|
||||
if (!actualOpening || actualOpening.type !== expected.type
|
||||
|| Math.abs(actualOpening.angle - expected.angle) > 0.001
|
||||
|| actualOpening.width <= 0 || actualOpening.height <= 0
|
||||
|| actualOpening.visibleParts <= 0) {
|
||||
throw new Error(
|
||||
`semantic golden opening geometry failed for ${expected.id}: `
|
||||
+ JSON.stringify(actualOpening),
|
||||
);
|
||||
}
|
||||
}
|
||||
const clip = await goldenClip(page, scenario.capture);
|
||||
const actual = await page.screenshot({
|
||||
const screenshotOptions = {
|
||||
...(clip ? { clip } : {}),
|
||||
animations: 'disabled',
|
||||
caret: 'hide',
|
||||
scale: 'css',
|
||||
});
|
||||
};
|
||||
const actual = await page.screenshot(screenshotOptions);
|
||||
const actualPath = resolve(actualRoot, `${scenario.id}.png`);
|
||||
writeFileSync(actualPath, actual);
|
||||
result.actualSha256 = sha256(actual);
|
||||
result.actual = actualPath;
|
||||
if (scenario.sunRayPixels) {
|
||||
const control = await captureWithoutSunRays(page, screenshotOptions);
|
||||
const sample = await countChangedPixels(page, actual, control.png, scenario.sunRayPixels);
|
||||
result.sunRayShapes = control.shapes;
|
||||
result.sunRayChangedPixels = sample.changed;
|
||||
result.sunRayMaxChannelDelta = sample.maxDelta;
|
||||
if (sample.changed < scenario.sunRayPixels.minPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: sun rays paint ${sample.changed} pixels, expected at least `
|
||||
+ `${scenario.sunRayPixels.minPixels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.openingPreviewPixels) {
|
||||
const control = await captureWithoutOpeningPreview(page, screenshotOptions);
|
||||
const sample = await countChangedPixels(
|
||||
page, actual, control.png, scenario.openingPreviewPixels,
|
||||
);
|
||||
result.openingPreviewParts = control.parts;
|
||||
result.openingPreviewChangedPixels = sample.changed;
|
||||
result.openingPreviewMaxChannelDelta = sample.maxDelta;
|
||||
const insideWall = await countOpeningPreviewPixelsInsideWall(
|
||||
page, actual, control.png, clip, scenario.openingPreviewPixels.minChannelDelta,
|
||||
);
|
||||
result.openingPreviewPixelsInsideWall = insideWall.changed;
|
||||
result.openingPreviewWallSamples = insideWall.sampled;
|
||||
if (control.parts < 2) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: opening preview is incomplete (${control.parts} parts)`,
|
||||
);
|
||||
}
|
||||
if (sample.changed < scenario.openingPreviewPixels.minPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: opening preview paints ${sample.changed} pixels, `
|
||||
+ `expected at least ${scenario.openingPreviewPixels.minPixels}`,
|
||||
);
|
||||
}
|
||||
if (insideWall.changed < scenario.openingPreviewPixels.minInsideWallPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: opening preview paints ${insideWall.changed} pixels `
|
||||
+ `inside the wall body, expected at least `
|
||||
+ `${scenario.openingPreviewPixels.minInsideWallPixels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.warmPixelRegion) {
|
||||
const sample = await countWarmPixels(page, actual, scenario.warmPixelRegion);
|
||||
result.warmPixels = sample.warm;
|
||||
@@ -288,8 +581,10 @@ try {
|
||||
page, actual, clip, scenario.tunnelContinuity,
|
||||
);
|
||||
result.tunnelMaxChannelJump = sample.maxJump;
|
||||
result.tunnelMaxJumpPair = sample.maxPair;
|
||||
result.tunnelSamplePairs = sample.samplePairs;
|
||||
result.tunnelPixelBounds = sample.bounds;
|
||||
result.tunnelImageScale = sample.scale;
|
||||
if (sample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: opening ${scenario.tunnelContinuity.openingId} `
|
||||
@@ -297,6 +592,38 @@ try {
|
||||
+ `${scenario.tunnelContinuity.maxChannelJump}`,
|
||||
);
|
||||
}
|
||||
if (scenario.tunnelContinuity.dpr2) {
|
||||
// A CSS-pixel capture cannot prove that half-device-pixel joins are
|
||||
// clean. Run the same semantic assertion once at DPR 2 without
|
||||
// adding a second reviewed baseline to the matrix.
|
||||
const highDpi = await launch(
|
||||
scenario.viewport, 2, browserArgs, browserContext,
|
||||
);
|
||||
try {
|
||||
await assertFreshDemoBundle(highDpi.page, ROOT);
|
||||
await prepareGoldenScenario(highDpi.page, scenario);
|
||||
const highDpiClip = await goldenClip(highDpi.page, scenario.capture);
|
||||
const highDpiPng = await highDpi.page.screenshot({
|
||||
...(highDpiClip ? { clip: highDpiClip } : {}),
|
||||
animations: 'disabled', caret: 'hide', scale: 'device',
|
||||
});
|
||||
const highDpiSample = await inspectTunnelContinuity(
|
||||
highDpi.page, highDpiPng, highDpiClip, scenario.tunnelContinuity,
|
||||
);
|
||||
result.tunnelDpr2MaxChannelJump = highDpiSample.maxJump;
|
||||
result.tunnelDpr2SamplePairs = highDpiSample.samplePairs;
|
||||
result.tunnelDpr2PixelBounds = highDpiSample.bounds;
|
||||
if (highDpiSample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed at DPR 2: opening ${scenario.tunnelContinuity.openingId} `
|
||||
+ `has a ${highDpiSample.maxJump}-channel local jump, expected at most `
|
||||
+ `${scenario.tunnelContinuity.maxChannelJump}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await highDpi.browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
const baselinePath = resolve(baselineRoot, `${scenario.id}.png`);
|
||||
result.baseline = baselinePath;
|
||||
|
||||
@@ -4,6 +4,24 @@
|
||||
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
|
||||
40 columns and 500 decor objects on three floors.
|
||||
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. It enables
|
||||
`hp-labs=iso`, selects iso per fixture space, measures the View toggle and
|
||||
records the capped `isoGeometry` cache. A comparison SHA predating #89 remains
|
||||
flat while reporting the same profile. The dedicated
|
||||
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
|
||||
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
|
||||
evidence; a local report is diagnostic.
|
||||
|
||||
Issue #137 adds `large-house-plan-snap-v1` without changing the meaning or
|
||||
budgets of the original profile. The same 60-room/60-partition fixture gains
|
||||
six saved open outlines, renders the Plan snap overlay, and sends 120 real
|
||||
pointer moves across endpoint, line and miss targets. Candidate bundles fail
|
||||
inside the runner if the static DOM or geometry cache grows, more than one
|
||||
active node appears, endpoint/line paths are not both exercised, or config and
|
||||
websocket traffic change. Its dedicated budget retains every original timing,
|
||||
heap and cache ceiling and adds the measured pointer series plus a one-entry
|
||||
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
|
||||
|
||||
The runner records seven measured samples after one discarded warm-up. With
|
||||
this intentionally small CI sample, the nearest-rank `p95` is the observed
|
||||
maximum; reports keep the conventional field name but should be read as a
|
||||
@@ -102,6 +120,7 @@ Build and copy a fresh demo bundle first, then run:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
|
||||
```
|
||||
|
||||
A local report is diagnostic only; it cannot replace the CI comparison.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-isometric-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 200, "hardMaxMs": 3000 },
|
||||
"firstStableRenderMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 3500 },
|
||||
"viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
|
||||
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 },
|
||||
"stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
|
||||
"panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 },
|
||||
"settingsDialogMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1200 },
|
||||
"switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 8000 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxSingleRegressionRatio": 0.2,
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.2,
|
||||
"countNoiseAllowance": 3,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.2,
|
||||
"noiseAllowanceMs": 150
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.2,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 160,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"isoGeometry": 8
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"isoGeometry": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-plan-snap-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 200,
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"planSnapPointerMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 5000
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.4,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 2000
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.35,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 7000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxSingleRegressionRatio": 0.3,
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.35,
|
||||
"countNoiseAllowance": 3,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 150
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.75,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 160,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -33,11 +33,15 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_gridPitch',
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_path',
|
||||
'_rszDrag',
|
||||
'_serverCfg',
|
||||
'_settingsDialog',
|
||||
'_tool',
|
||||
]),
|
||||
optionalFields: Object.freeze([]),
|
||||
// A comparison SHA before #89 is intentionally flat; the isometric runner
|
||||
// checks these two members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze(['_isoGeometryCache', '_planSnapGeometryCache', '_setProjection']),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_cleanFloorCache: 'map',
|
||||
@@ -46,6 +50,11 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
_gridPitch: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_model: 'array',
|
||||
_path: 'array',
|
||||
_isoGeometryCache: 'map',
|
||||
_planSnapGeometryCache: 'object',
|
||||
_setProjection: 'function',
|
||||
_serverCfg: 'object',
|
||||
_tool: 'string',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ await page.evaluate(async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
st.textContent = '.device-pulse.continuous.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
@@ -38,7 +38,7 @@ await page.evaluate(async (STATES) => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
st.textContent = '.device-pulse.continuous.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
}, STATES);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
|
||||
// Four stills of the #146 day-cycle environment.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
|
||||
@@ -16,21 +16,22 @@ await page.evaluate(async () => {
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
});
|
||||
const shot = async (az, el, name) => {
|
||||
await page.evaluate(async ([az, el]) => {
|
||||
const shot = async (az, el, rising, name) => {
|
||||
await page.evaluate(async ([az, el, rising]) => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
attributes: { azimuth: az, elevation: el, rising },
|
||||
} } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
}, [az, el]);
|
||||
}, [az, el, rising]);
|
||||
await page.waitForTimeout(300);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
|
||||
};
|
||||
await shot(180, 60, 'dn_noon');
|
||||
await shot(265, 3, 'dn_sunset');
|
||||
await shot(0, -20, 'dn_night');
|
||||
await shot(95, -2, true, 'dn_dawn');
|
||||
await shot(180, 60, false, 'dn_noon');
|
||||
await shot(265, -2, false, 'dn_sunset');
|
||||
await shot(0, -20, false, 'dn_night');
|
||||
await browser.close();
|
||||
console.log('shots done');
|
||||
|
||||
@@ -17,7 +17,7 @@ await page.evaluate(async () => {
|
||||
await set('on');
|
||||
// freeze the pulse at a visible frame for a deterministic capture
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.activity-ring.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
|
||||
st.textContent = '.device-pulse.short.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
@@ -6,7 +6,7 @@ await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._setMode('plan'); c._activateOpeningPlacement('door');
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
|
||||
@@ -257,7 +257,12 @@ const layers = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const m = c._spaceModel();
|
||||
const svgEl = c.renderRoot.querySelector('.stage svg');
|
||||
const kids = [...svgEl.children];
|
||||
// Stage 2 keeps the flat floor DOM byte-for-byte equivalent but places it
|
||||
// in one transformable scene group shared with Iso. Ordering is therefore
|
||||
// measured inside the group that actually owns paper/image/rooms.
|
||||
const layerRoot = [...svgEl.children].find((node) =>
|
||||
node.querySelector?.('.hp-paperg') && node.querySelector?.('.room')) || svgEl;
|
||||
const kids = [...layerRoot.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
const papers = [...svgEl.querySelectorAll('.hp-paper')];
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,12 @@ const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const stageBg = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
@@ -90,11 +96,32 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
// 8) editors keep their own canvas (inline bg only in view/kiosk)
|
||||
c._setMode('decor');
|
||||
await settleMode();
|
||||
const editorBg = await waitStageBg((value) => value !== rgb('#0a2a4a'));
|
||||
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
|
||||
c._setMode('view');
|
||||
await settleMode();
|
||||
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
|
||||
|
||||
// The environment, plan and zoom badge own explicit stacking levels. DOM
|
||||
// order alone let an opaque cross-fade layer cover the plan, while raising
|
||||
// only the plan hid the later percentage badge behind it.
|
||||
const previousBgMode = c._serverCfg.settings.bg_mode;
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._zoom = 2.5;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const zoomWrap = sr().querySelector('.zoomwrap');
|
||||
const zoomBadge = sr().querySelector('.zoombadge');
|
||||
const environment = sr().querySelector('.hp-day-cycle-env');
|
||||
out.dayCycleStaysBehindPlan = getComputedStyle(environment).zIndex === '0'
|
||||
&& getComputedStyle(zoomWrap).zIndex === '1';
|
||||
out.zoomBadgeStaysAbovePlan = !!zoomBadge
|
||||
&& getComputedStyle(zoomBadge).zIndex === '12';
|
||||
if (previousBgMode === undefined) delete c._serverCfg.settings.bg_mode;
|
||||
else c._serverCfg.settings.bg_mode = previousBgMode;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// 9) kiosk instance respects the setting
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
@@ -104,19 +131,27 @@ const res = await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
// the kiosk instance re-reads the pristine demo config — inject the color
|
||||
// into ITS copy; what matters here is the kiosk rendering path
|
||||
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
|
||||
k.hass = { ...c.hass };
|
||||
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a', bg_mode: 'daynight' };
|
||||
k.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 42, rising: false },
|
||||
} } };
|
||||
k.requestUpdate();
|
||||
await k.updateComplete;
|
||||
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
|
||||
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
|
||||
out.kioskSharesDay = kStage?.classList.contains('phase-day')
|
||||
&& kStage.querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'day';
|
||||
k.remove();
|
||||
|
||||
// 10) the static space-card paints the same background
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
|
||||
const hass2 = { ...c.hass, callWS: async (m) => {
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456', bg_mode: 'daynight' };
|
||||
const hass2 = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 95, elevation: -2, rising: true },
|
||||
} }, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return origWS.call(c.hass, m);
|
||||
@@ -132,6 +167,11 @@ const res = await page.evaluate(async () => {
|
||||
await sc.updateComplete;
|
||||
const st = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
|
||||
out.staticCardSharesDawn = st?.classList.contains('phase-dawn')
|
||||
&& st.querySelector('.hp-day-cycle-env')?.dataset.dayCycleSource === 'sun'
|
||||
&& st.querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'dawn';
|
||||
out.staticCardLayersStayOrdered = getComputedStyle(st.querySelector('svg')).zIndex === '1'
|
||||
&& getComputedStyle(st.querySelector('.devlayer')).zIndex === '2';
|
||||
// ...and the space override beats the global there too
|
||||
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
|
||||
sc.hass = { ...hass2 };
|
||||
@@ -158,13 +198,19 @@ checkAll(res);
|
||||
// bounding box (section 12), and never the backdrop image rect either
|
||||
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
|
||||
// asserts the new rule on exactly the case that used to be the exception.
|
||||
// The scene colour is visible ONLY around the paper; the 'daynight' night
|
||||
// dims the paper via brightness only.
|
||||
// The scene colour is visible ONLY around the paper. The four-phase
|
||||
// environment changes only outside it and adds an alpha-aware outer outline.
|
||||
const paper = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
await upd();
|
||||
@@ -183,28 +229,31 @@ const paper = await page.evaluate(async () => {
|
||||
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
|
||||
}
|
||||
// …and the picture is drawn ON that paper: image after paper, before walls
|
||||
const kids = [...sr().querySelector('.stage svg').children];
|
||||
const svgEl = sr().querySelector('.stage svg');
|
||||
const layerRoot = [...svgEl.children].find((node) =>
|
||||
node.querySelector?.('.hp-paperg') && node.querySelector?.('.room')) || svgEl;
|
||||
const kids = [...layerRoot.children];
|
||||
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
|
||||
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
|
||||
// editors keep the paper too (their canvas ignores bg_color anyway)
|
||||
c._setMode('decor');
|
||||
await upd();
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
await upd(); await settleMode();
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
await upd(); await settleMode();
|
||||
// daynight night: the paper stays pixel-identical and opaque; only its
|
||||
// grouped outer outline and the environment may change.
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._serverCfg.settings.north_deg = 0;
|
||||
c._cfgRev++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 180, elevation: -20 },
|
||||
attributes: { azimuth: 180, elevation: -20, rising: false },
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
out.nightDoesNotDimPlan = !(sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.');
|
||||
out.nightEnvironment = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'night';
|
||||
const pn = sr().querySelector('.stage svg .hp-paper');
|
||||
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
|
||||
delete c._serverCfg.settings.bg_mode;
|
||||
@@ -253,8 +302,10 @@ const drawn = await page.evaluate(async () => {
|
||||
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
|
||||
});
|
||||
// paper first, everything else on top of it (one .hp-paperg group wraps
|
||||
// all paper shapes so the daynight drop shadow composites without seams)
|
||||
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
|
||||
// all paper shapes so the day-cycle outline composites without seams)
|
||||
const noplanSvg = sr().querySelector('.stage.noplan svg');
|
||||
const noplanLayers = [...noplanSvg.children].find((node) => node.querySelector?.('.hp-paperg')) || noplanSvg;
|
||||
const first = [...noplanLayers.children].find((node) => node.tagName.toLowerCase() !== 'defs');
|
||||
out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
|
||||
&& !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
|
||||
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
|
||||
|
||||
@@ -19,7 +19,7 @@ const res = await page.evaluate(async () => {
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights,
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', tapActionTouched: true, controls: lights,
|
||||
icon: 'mdi:light-switch', display: 'icon_ripple' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.name === 'Выключатель');
|
||||
@@ -59,8 +59,28 @@ const res = await page.evaluate(async () => {
|
||||
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
out.stateOff = c._stateClass(dev2()) === '';
|
||||
// 4) без tap_action=toggle клик НЕ переключает (инфо)
|
||||
// #94/#73 boundary: a complete visual frame may temporarily retain the old
|
||||
// DevItem while live config has already changed. The click must resolve the
|
||||
// current marker by id and never call the controls captured by that frame.
|
||||
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
|
||||
const staleSource = dev2();
|
||||
const visuallyStale = {
|
||||
...staleSource,
|
||||
controls: [...(staleSource.controls || [])],
|
||||
marker: staleSource.marker ? {
|
||||
...staleSource.marker,
|
||||
controls: [...(staleSource.marker.controls || [])],
|
||||
} : null,
|
||||
};
|
||||
cfg.controls = [lights[1]];
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), visuallyStale);
|
||||
out.staleFrameUsesCurrentControls = JSON.stringify(calls.at(-1))
|
||||
=== JSON.stringify(['homeassistant', 'turn_on', [lights[1]]]);
|
||||
cfg.controls = [...lights];
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
// 4) без tap_action=toggle клик НЕ переключает (инфо)
|
||||
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
const n = calls.length;
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
@@ -96,17 +116,23 @@ const res = await page.evaluate(async () => {
|
||||
// operates the entity, and an ON hood/fan still has working-state yellow.
|
||||
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
|
||||
out.hasStandaloneSwitch = !!own;
|
||||
const self = own && {
|
||||
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
|
||||
space: c._space, area: room.area, entities: [own], primary: own,
|
||||
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
|
||||
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
|
||||
};
|
||||
const selfId = 'self-control-regression';
|
||||
if (own) {
|
||||
c._serverCfg.markers.push({
|
||||
id: selfId, name: 'Hood', icon: 'mdi:fan', binding: `entity:${own}`,
|
||||
space: c._space, area: room.area, tap_action: 'toggle', controls: [own],
|
||||
});
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
await c.updateComplete;
|
||||
}
|
||||
const self = c._devices.find((device) => device.id === selfId);
|
||||
if (self) {
|
||||
await setSt({ [own]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), self);
|
||||
out.selfControlUsesDirectToggle = JSON.stringify(calls.at(-1))
|
||||
=== JSON.stringify(['homeassistant', 'toggle', own]);
|
||||
const selfCall = calls.at(-1);
|
||||
out.selfControlUsesDirectToggle = ['switch', 'homeassistant'].includes(selfCall?.[0])
|
||||
&& selfCall?.[1] === 'turn_on' && selfCall?.[2] === own;
|
||||
c._openMarkerDialog(self); await c.updateComplete;
|
||||
out.selfControlAbsentFromDialog = c._markerDialog?.controls.length === 0;
|
||||
c._markerDialog = null;
|
||||
|
||||
@@ -103,9 +103,9 @@ const out = await page.evaluate(async () => {
|
||||
plateIsNeutral('opening');
|
||||
o.openingRings = cls().includes('activity-transition');
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
|
||||
const ring = devEl()?.querySelector('.device-pulse.continuous.transition i:first-child');
|
||||
const cs = ring ? getComputedStyle(ring) : null;
|
||||
return !!cs && cs.animationName === 'hp-activity-breathe' && cs.animationDuration === '2.2s';
|
||||
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '2.4s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
|
||||
@@ -34,7 +34,7 @@ const out = await page.evaluate(async () => {
|
||||
};
|
||||
const coverState = (state, attrs = {}) => ({
|
||||
entity_id: 'cover.office_curtain', state,
|
||||
attributes: { friendly_name: 'Office curtain', ...attrs },
|
||||
attributes: { friendly_name: 'Office curtain', supported_features: 11, ...attrs },
|
||||
});
|
||||
const push = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
@@ -80,17 +80,21 @@ const out = await page.evaluate(async () => {
|
||||
o.primaryIsTheCover = curtain()?.primary === 'cover.office_curtain';
|
||||
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
|
||||
|
||||
// ---- the dialog offers the action and SAVES it -------------------------
|
||||
// ---- the dialog offers the unified action and SAVES an intentional edit
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
|
||||
o.dialogOffersCover = optionValues().includes('cover');
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover', display: 'icon_ripple' };
|
||||
o.dialogOffersUnifiedToggle = optionValues().includes('toggle')
|
||||
&& !optionValues().includes('cover');
|
||||
c._markerDialog = {
|
||||
...c._markerDialog,
|
||||
tapAction: 'toggle', tapActionTouched: true, display: 'icon_ripple',
|
||||
};
|
||||
await c.updateComplete;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
o.markerSaved = saved?.tap_action === 'cover';
|
||||
o.markerSavedAsUnifiedToggle = saved?.tap_action === 'toggle';
|
||||
|
||||
// ---- and the tap on THAT marker opens the curtain -----------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
@@ -100,8 +104,13 @@ const out = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
};
|
||||
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
|
||||
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'toggle';
|
||||
o.coverServicesDeclared = ['open_cover', 'close_cover', 'stop_cover']
|
||||
.every((service) => Object.hasOwn(c.hass.services?.cover || {}, service));
|
||||
calls.length = 0;
|
||||
const closedIntent = c._toggleIntent(curtain());
|
||||
o.closedIntentResolvesExactTarget = closedIntent?.command?.service === 'open_cover'
|
||||
&& closedIntent.command.data.entity_id === 'cover.office_curtain';
|
||||
await tap();
|
||||
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
@@ -122,12 +131,12 @@ const out = await page.evaluate(async () => {
|
||||
// the service switch, which used to be the whole story, is never touched
|
||||
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
|
||||
|
||||
// ---- the guarded classes still degrade, read off the COVER --------------
|
||||
// ---- guarded classes are an explicit secure no-op -----------------------
|
||||
await push('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.garageCallsNothing = calls.length === 0;
|
||||
o.garageShowsInfo = !!c._infoCard;
|
||||
o.garageDoesNotFallbackToInfo = !c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
@@ -192,6 +201,19 @@ const out = await page.evaluate(async () => {
|
||||
await setSwitch('on');
|
||||
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
|
||||
&& !clsOf(curtain()).includes('on');
|
||||
calls.length = 0;
|
||||
c._infoCard = null;
|
||||
// A local card must not depend on a second registry read at click time. The
|
||||
// already-rendered curtain remains a valid informational snapshot while HA's
|
||||
// registry is transiently unavailable; service-backed actions stay gated.
|
||||
const liveBindingStatus = c._bindingStatus;
|
||||
c._bindingStatus = () => ({
|
||||
kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [],
|
||||
});
|
||||
try { await tap(); } finally { c._bindingStatus = liveBindingStatus; }
|
||||
o.infoActionOpensTheHousePlanCard = c._infoCard?.id === curtain()?.id;
|
||||
o.infoActionCallsNoHaService = calls.length === 0;
|
||||
c._infoCard = null;
|
||||
savedMarker.tap_action = 'cover';
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Covers: the «Open/close» tap action + the travelling indication
|
||||
// (owner's contract 2026-08-03).
|
||||
// Covers after issue #94: the unified «Toggle state» action + the travelling
|
||||
// indication. Persisted `cover` remains a lossless legacy runtime token.
|
||||
//
|
||||
// closed -> cover.open_cover
|
||||
// open / ajar -> cover.close_cover
|
||||
@@ -7,9 +7,8 @@
|
||||
// the next tap simply travels the other way)
|
||||
// unknown -> cover.toggle, no morphing, no pulse
|
||||
//
|
||||
// The option is offered ONLY for a binding that HAS a cover entity, and never
|
||||
// for the guarded classes (garage/door/gate) — for those a value smuggled into
|
||||
// the config degrades to 'info', exactly like a card-wide toggle does.
|
||||
// The editor never offers a separate cover option. Secure cover classes
|
||||
// (garage/door/gate) resolve to a quiet no-op rather than an info-card fallback.
|
||||
// With «Icon + activity», travelling breathes a soft activity ring (the
|
||||
// vacuum puck's 2.2s period) and the plate stays NEUTRAL — yellow means work.
|
||||
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
|
||||
@@ -26,7 +25,10 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
const gateEntity = (state, attrs) => ({
|
||||
entity_id: 'cover.gate', state,
|
||||
attributes: { friendly_name: 'Gate', ...attrs },
|
||||
// Declare the capabilities exercised below. Issue #94 intentionally
|
||||
// resolves services fail-closed, so an entity without feature metadata is
|
||||
// not a valid fixture for open / close / stop dispatch.
|
||||
attributes: { friendly_name: 'Gate', supported_features: 11, ...attrs },
|
||||
});
|
||||
/** Push a cover state into hass keeping the service recorder in place. */
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
@@ -71,26 +73,35 @@ const out = await page.evaluate(async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- the dialog offers the option only where it makes sense ------------
|
||||
// ---- the dialog offers one universal action -----------------------------
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
await setMarker('cover');
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
|
||||
// …and the confirmation checkbox follows the choice, like it does for run/toggle
|
||||
const rowsBefore = sr().querySelectorAll('hp-dialog .srcrow').length;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
|
||||
o.confirmCheckboxShownForCover = sr().querySelectorAll('hp-dialog .srcrow').length > rowsBefore;
|
||||
o.dialogOffersOnlyUnifiedToggle = optionValues().includes('toggle')
|
||||
&& !optionValues().includes('cover');
|
||||
// Opening a legacy marker projects it to the unified selector without
|
||||
// rewriting the persisted token until the user intentionally changes it.
|
||||
o.legacyCoverProjectsToToggle = c._markerDialog?.tapAction === 'toggle'
|
||||
&& c._markerDialog?.tapActionTouched === false;
|
||||
o.untouchedLegacyCoverRemainsLossless =
|
||||
c._markerTapActionFields(c._markerDialog)?.tap_action === 'cover';
|
||||
o.intentionalEditWritesUnifiedToggle = c._markerTapActionFields({
|
||||
...c._markerDialog, tapAction: 'toggle', tapActionTouched: true,
|
||||
})?.tap_action === 'toggle';
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
|
||||
// The same universal option is visible for a garage; runtime safety decides.
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
o.garageAlsoShowsUnifiedToggle = optionValues().includes('toggle')
|
||||
&& !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// …and neither does a device without a cover entity at all
|
||||
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
|
||||
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
|
||||
o.virtualAlsoShowsUnifiedToggle = optionValues().includes('toggle')
|
||||
&& !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
@@ -138,19 +149,19 @@ const out = await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
|
||||
|
||||
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
|
||||
// ---- a saved 'cover' on a GARAGE is a secure, quiet no-op ---------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.garageSavedCoverCallsNothing = calls.length === 0;
|
||||
o.garageSavedCoverShowsInfo = !!c._infoCard;
|
||||
o.garageSavedCoverDoesNotFallbackToInfo = !c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
|
||||
// ---- the indication ----------------------------------------------------
|
||||
const afterAnim = () => {
|
||||
const el = devEl()?.querySelector('.activity-ring.transition i:first-child');
|
||||
const el = devEl()?.querySelector('.device-pulse.continuous.transition i:first-child');
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
return { name: cs.animationName, dur: cs.animationDuration };
|
||||
@@ -163,7 +174,7 @@ const out = await page.evaluate(async () => {
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
|
||||
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
const a1 = afterAnim();
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-activity-breathe' && a1?.dur === '2.2s';
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-pulse-continuous' && a1?.dur === '2.4s';
|
||||
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
|
||||
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
|
||||
@@ -4,11 +4,17 @@ const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
out.threeTabs = tabs.length === 3;
|
||||
tabs[2].click(); await c.updateComplete;
|
||||
tabs[2].click(); await settleMode();
|
||||
out.decorMode = c._mode === 'decor';
|
||||
out.decorBar = !!sr().querySelector('.editbar.decorbar');
|
||||
// The compact colour/opacity control must escape the clipped editor/dialog
|
||||
@@ -253,7 +259,10 @@ const res = await page.evaluate(async () => {
|
||||
c._redoGeometry(); await c.updateComplete;
|
||||
out.lineStyleUndoRedo = undoSolid
|
||||
&& c._decorList.find((x) => x.id === 'dcprobe')?.line_style === 'dashed';
|
||||
const selectHit = sr().querySelector('.decorlayer line.dselecthit');
|
||||
// Selection handles intentionally sit above the hit proxy. Clear the frame
|
||||
// so this assertion measures the dashed line's gap, not an endpoint handle.
|
||||
c._decorSel = null; c.requestUpdate(); await c.updateComplete;
|
||||
const selectHit = sr().querySelector('.decorlayer line.dselecthit[data-id="dcprobe"]');
|
||||
const dashedPaint = probe();
|
||||
let gapPicked = null;
|
||||
if (selectHit && dashedPaint) {
|
||||
|
||||
@@ -21,6 +21,12 @@ const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
|
||||
// смок должен показать список провалов, а не упасть с исключением
|
||||
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
@@ -41,7 +47,7 @@ const res = await page.evaluate(async () => {
|
||||
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
|
||||
};
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
sr().querySelectorAll('.modetab')[2].click(); await settleMode();
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ const res = await page.evaluate(async () => {
|
||||
scale: node.style.getPropertyValue('--dev-scale'),
|
||||
rippleScale: node.style.getPropertyValue('--ripple-scale'),
|
||||
rippleColor: node.style.getPropertyValue('--ripple-color'),
|
||||
pulse: [...(node.querySelector('.device-pulse')?.classList || [])]
|
||||
.filter((name) => ['alarm', 'short', 'continuous', 'event', 'presence', 'transition', 'running'].includes(name)),
|
||||
reducedMotionDot: !!node.querySelector('.activity-dot'),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +40,45 @@ const res = await page.evaluate(async () => {
|
||||
const previewFace = face(preview?.renderRoot?.querySelector('.dev'));
|
||||
const providerShown = /demo/i.test(preview?.renderRoot?.querySelector('.previewfacts')?.textContent || '');
|
||||
|
||||
// Dynamic option creation must not leave a freshly mounted native select on
|
||||
// its first row when a different display/room/badge choice is persisted.
|
||||
// Unmount the dialog first: replacing one open marker can leave old option
|
||||
// nodes around and would miss the real first-open regression.
|
||||
c._markerDialog = null;
|
||||
await c.updateComplete;
|
||||
const badgeDevice = c._devices.find((item) => item.id === 'd_temp');
|
||||
c._openMarkerDialog({
|
||||
...badgeDevice,
|
||||
marker: {
|
||||
...(badgeDevice.marker || {}),
|
||||
display: 'value',
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'derived_lqi' },
|
||||
position: 'left',
|
||||
},
|
||||
},
|
||||
});
|
||||
await c.updateComplete;
|
||||
const displaySelect = sr().querySelector('#marker-display');
|
||||
const roomSelect = sr().querySelector('#marker-room');
|
||||
const badgeSourceSelect = sr().querySelector('#marker-value-badge-source');
|
||||
const badgePositionSelect = sr().querySelector('#marker-value-badge-position');
|
||||
const savedBadgePreview = sr().querySelector('hp-device-preview');
|
||||
await savedBadgePreview?.updateComplete;
|
||||
const savedDisplaySelected = displaySelect?.value === 'value'
|
||||
&& displaySelect?.selectedOptions?.[0]?.value === 'value';
|
||||
const expectedRoom = c._markerDialog?.room || '';
|
||||
const savedRoomSelected = expectedRoom !== '' && roomSelect?.value === expectedRoom
|
||||
&& roomSelect?.selectedOptions?.[0]?.value === expectedRoom;
|
||||
const savedBadgeSourceSelected = badgeSourceSelect?.value === 'derived:lqi'
|
||||
&& badgeSourceSelect?.selectedOptions?.[0]?.value === 'derived:lqi';
|
||||
const savedBadgePositionSelected = badgePositionSelect?.value === 'left'
|
||||
&& badgePositionSelect?.selectedOptions?.[0]?.value === 'left';
|
||||
const savedBadgePreviewMatches = savedBadgePreview?.renderRoot
|
||||
?.querySelector('.value-badge')?.textContent?.trim() === '154';
|
||||
const savedBadgeUntouched = c._markerDialog?.valueBadgeTouched === false;
|
||||
|
||||
// Issue #90: every explicit anchor, including a long value and the largest
|
||||
// activity ring, stays inside the preview safe area with a real measured gap.
|
||||
c.hass.states['sensor.hp_preview_long'] = {
|
||||
@@ -63,9 +105,53 @@ const res = await page.evaluate(async () => {
|
||||
&& badge.top >= stage.top + gap && badge.bottom <= stage.bottom - gap;
|
||||
}
|
||||
|
||||
// One persisted bottom badge is then projected through all three renderers.
|
||||
// It intentionally coexists with LQI to guard the vertical stack contract.
|
||||
const persistedBadge = {
|
||||
id: 'd_temp', binding: 'device:d_temp',
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
|
||||
position: 'bottom',
|
||||
},
|
||||
};
|
||||
c._serverCfg.markers = [
|
||||
...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'd_temp'),
|
||||
persistedBadge,
|
||||
];
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const planBadgeNode = sr().querySelector('.dev[data-id="d_temp"]');
|
||||
const planBadgeFace = face(planBadgeNode);
|
||||
const planBadgeRect = planBadgeNode?.querySelector('.value-badge')?.getBoundingClientRect();
|
||||
const planLqiRect = planBadgeNode?.querySelector('.lqi')?.getBoundingClientRect();
|
||||
const bottomBadgeLqiStacked = !!planBadgeRect && !!planLqiRect
|
||||
&& planLqiRect.top >= planBadgeRect.bottom - 1;
|
||||
|
||||
c._openMarkerDialog(c._devices.find((item) => item.id === 'd_temp'));
|
||||
await c.updateComplete;
|
||||
const persistedPreview = sr().querySelector('hp-device-preview');
|
||||
await persistedPreview?.updateComplete;
|
||||
const persistedPreviewFace = face(persistedPreview?.renderRoot?.querySelector('.dev'));
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
// A real static card reads the latest complete server/cache snapshot. Inject
|
||||
// that same revision explicitly here so this smoke compares renderers, not
|
||||
// the demo server's intentionally non-persistent websocket fixture.
|
||||
card._snap = {
|
||||
config: structuredClone(c._serverCfg),
|
||||
rev: c._cfgRev,
|
||||
configFingerprint: `fixture-${c._cfgRev}`,
|
||||
layout: structuredClone(c._layout),
|
||||
layoutRev: c._layoutRev,
|
||||
layoutFingerprint: `fixture-${c._layoutRev}`,
|
||||
};
|
||||
card._loadedOnce = true;
|
||||
card.hass = c.hass;
|
||||
document.body.appendChild(card);
|
||||
const started = Date.now();
|
||||
@@ -73,13 +159,23 @@ const res = await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
const staticNode = card.renderRoot.querySelector('.dev[data-id="d_light1"]');
|
||||
const staticFace = face(staticNode);
|
||||
const staticBadgeFace = face(card.renderRoot.querySelector('.dev[data-id="d_temp"]'));
|
||||
|
||||
return {
|
||||
allFacesPresent: !!planFace && !!previewFace && !!staticFace,
|
||||
planPreviewEqual: JSON.stringify(planFace) === JSON.stringify(previewFace),
|
||||
planStaticEqual: JSON.stringify(planFace) === JSON.stringify(staticFace),
|
||||
providerShown,
|
||||
savedDisplaySelected,
|
||||
savedRoomSelected,
|
||||
savedBadgeSourceSelected,
|
||||
savedBadgePositionSelected,
|
||||
savedBadgePreviewMatches,
|
||||
savedBadgeUntouched,
|
||||
badgeBounds,
|
||||
valueBadgePlanPreviewParity: JSON.stringify(planBadgeFace) === JSON.stringify(persistedPreviewFace),
|
||||
valueBadgePlanStaticParity: JSON.stringify(planBadgeFace) === JSON.stringify(staticBadgeFace),
|
||||
bottomBadgeLqiStacked,
|
||||
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// #140: opening and physical-object properties share the existing medium
|
||||
// dialog shell. Measure real localized buttons rather than merely checking
|
||||
// the `wide` attribute, because the contract is usable spare width plus safe
|
||||
// wrapping when the viewport becomes narrow.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const DESKTOP_WIDTH = 1000;
|
||||
const NARROW_WIDTH = 320;
|
||||
const { page, browser } = await launch({ width: DESKTOP_WIDTH, height: 820 }, 1);
|
||||
|
||||
const desktop = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
card._serverStorage = true;
|
||||
card._serverCfg = {
|
||||
spaces: [{
|
||||
id: 'dialog-layout', title: 'Dialog layout', cell_cm: 5,
|
||||
view_box: [0, 0, 1, 1], rooms: [], settings: {},
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._space = 'dialog-layout';
|
||||
card._mode = 'plan';
|
||||
card._modelCache = null;
|
||||
card._saveConfig = () => {};
|
||||
await settle();
|
||||
|
||||
const layout = (kind, language) => {
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
const dialog = root.querySelector('hp-dialog');
|
||||
const surface = dialog?.shadowRoot?.querySelector('.surface');
|
||||
const footer = dialog?.querySelector('.dialog-action-footer');
|
||||
const danger = footer?.querySelector('.dialog-action-danger');
|
||||
const commit = footer?.querySelector('.dialog-action-commit');
|
||||
const surfaceRect = surface?.getBoundingClientRect();
|
||||
const footerRect = footer?.getBoundingClientRect();
|
||||
const footerStyle = footer ? getComputedStyle(footer) : null;
|
||||
const dangerRect = danger?.getBoundingClientRect();
|
||||
const commitRect = commit?.getBoundingClientRect();
|
||||
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
||||
const innerWidth = footerRect && footerStyle
|
||||
? footerRect.width - parseFloat(footerStyle.paddingLeft) - parseFloat(footerStyle.paddingRight)
|
||||
: 0;
|
||||
const contentWidth = (dangerRect?.width || 0) + (commitRect?.width || 0);
|
||||
return {
|
||||
kind,
|
||||
language,
|
||||
widePreset: dialog?.wide === true && dialog.hasAttribute('wide'),
|
||||
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
|
||||
innerWidth: Number(innerWidth.toFixed(2)),
|
||||
contentWidth: Number(contentWidth.toFixed(2)),
|
||||
spareWidth: Number((innerWidth - contentWidth).toFixed(2)),
|
||||
buttons: buttons.length,
|
||||
oneRow: !!dangerRect && !!commitRect && Math.abs(dangerRect.top - commitRect.top) <= 1,
|
||||
noHorizontalOverflow: !!surface && !!footer
|
||||
&& surface.scrollWidth <= surface.clientWidth + 1
|
||||
&& footer.scrollWidth <= footer.clientWidth + 1,
|
||||
};
|
||||
};
|
||||
|
||||
const open = async (kind, language) => {
|
||||
card._config = { ...(card._config || {}), language };
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
if (kind === 'opening') {
|
||||
card._openingDialog = {
|
||||
id: 'opening-1', type: 'door', lengthCm: 90,
|
||||
contact: '', lock: '', invert: false, flipH: false, flipV: false,
|
||||
x: 200, y: 200, angle: 0,
|
||||
};
|
||||
} else if (kind === 'physical') {
|
||||
card._physicalDialog = {
|
||||
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
|
||||
};
|
||||
} else {
|
||||
card._openSpaceDialog('edit', 'dialog-layout');
|
||||
}
|
||||
await settle();
|
||||
const result = layout(kind, language);
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
await settle();
|
||||
return result;
|
||||
};
|
||||
|
||||
const metrics = [];
|
||||
for (const language of ['en', 'ru']) {
|
||||
metrics.push(await open('opening', language));
|
||||
metrics.push(await open('physical', language));
|
||||
}
|
||||
const space = await open('space', 'ru');
|
||||
return { metrics, space };
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: NARROW_WIDTH, height: 820 });
|
||||
|
||||
const narrow = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const measure = async (kind) => {
|
||||
card._config = { ...(card._config || {}), language: 'ru' };
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
if (kind === 'opening') {
|
||||
card._openingDialog = {
|
||||
id: 'opening-1', type: 'door', lengthCm: 90,
|
||||
contact: '', lock: '', invert: false, flipH: false, flipV: false,
|
||||
x: 200, y: 200, angle: 0,
|
||||
};
|
||||
} else if (kind === 'physical') {
|
||||
card._physicalDialog = {
|
||||
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
|
||||
};
|
||||
} else if (kind === 'draft') {
|
||||
card._physicalDialog = {
|
||||
kind: 'draft', id: 'draft-1', segment: 0, cm: '12', length: '1 m',
|
||||
};
|
||||
} else {
|
||||
card._openSpaceDialog('edit', 'dialog-layout');
|
||||
}
|
||||
await settle();
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
const dialog = root.querySelector('hp-dialog');
|
||||
const surface = dialog?.shadowRoot?.querySelector('.surface');
|
||||
const footer = dialog?.querySelector('.dialog-action-footer');
|
||||
const danger = footer?.querySelector('.dialog-action-danger');
|
||||
const commit = footer?.querySelector('.dialog-action-commit');
|
||||
const surfaceRect = surface?.getBoundingClientRect();
|
||||
const footerRect = footer?.getBoundingClientRect();
|
||||
const footerStyle = footer ? getComputedStyle(footer) : null;
|
||||
const dangerRect = danger?.getBoundingClientRect();
|
||||
const commitRect = commit?.getBoundingClientRect();
|
||||
const innerLeft = footerRect && footerStyle
|
||||
? footerRect.left + parseFloat(footerStyle.paddingLeft) : 0;
|
||||
const innerRight = footerRect && footerStyle
|
||||
? footerRect.right - parseFloat(footerStyle.paddingRight) : 0;
|
||||
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
||||
const result = {
|
||||
kind,
|
||||
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
|
||||
insideViewport: !!surfaceRect && surfaceRect.left >= -1
|
||||
&& surfaceRect.right <= innerWidth + 1,
|
||||
noHorizontalOverflow: !!surface && !!footer
|
||||
&& surface.scrollWidth <= surface.clientWidth + 1
|
||||
&& footer.scrollWidth <= footer.clientWidth + 1,
|
||||
buttonsContained: !!footerRect && buttons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.left >= innerLeft - 1 && rect.right <= innerRight + 1
|
||||
&& rect.top >= footerRect.top - 1 && rect.bottom <= footerRect.bottom + 1;
|
||||
}),
|
||||
wrapped: !!dangerRect && !!commitRect && commitRect.top >= dangerRect.bottom - 1,
|
||||
buttons: buttons.length,
|
||||
};
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
await settle();
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
opening: await measure('opening'),
|
||||
physical: await measure('physical'),
|
||||
draft: await measure('draft'),
|
||||
space: await measure('space'),
|
||||
};
|
||||
});
|
||||
|
||||
const checks = {};
|
||||
for (const metric of desktop.metrics) {
|
||||
const key = `${metric.kind}_${metric.language}`;
|
||||
checks[`${key}_medium_shell`] = metric.widePreset
|
||||
&& metric.surfaceWidth >= 499 && metric.surfaceWidth <= 501;
|
||||
checks[`${key}_three_actions_one_row`] = metric.buttons === 3 && metric.oneRow;
|
||||
checks[`${key}_positive_localization_headroom`] = metric.spareWidth > 0;
|
||||
checks[`${key}_no_horizontal_overflow`] = metric.noHorizontalOverflow;
|
||||
}
|
||||
checks.space_dialog_still_uses_medium_shell = desktop.space.widePreset
|
||||
&& desktop.space.surfaceWidth >= 499 && desktop.space.surfaceWidth <= 501;
|
||||
checks.space_dialog_footer_not_regressed = desktop.space.buttons === 3
|
||||
&& desktop.space.oneRow && desktop.space.noHorizontalOverflow;
|
||||
|
||||
for (const kind of ['opening', 'physical']) {
|
||||
const metric = narrow[kind];
|
||||
checks[`${kind}_narrow_fits_viewport`] = metric.surfaceWidth <= NARROW_WIDTH * 0.94 + 1
|
||||
&& metric.insideViewport && metric.noHorizontalOverflow && metric.buttonsContained;
|
||||
checks[`${kind}_narrow_keeps_responsive_wrap`] = metric.buttons === 3 && metric.wrapped;
|
||||
}
|
||||
checks.draft_four_actions_remain_contained = narrow.draft.buttons === 4
|
||||
&& narrow.draft.insideViewport && narrow.draft.noHorizontalOverflow
|
||||
&& narrow.draft.buttonsContained;
|
||||
checks.space_narrow_not_regressed = narrow.space.buttons === 3
|
||||
&& narrow.space.insideViewport && narrow.space.noHorizontalOverflow
|
||||
&& narrow.space.buttonsContained;
|
||||
|
||||
checkAll(checks);
|
||||
await finish(browser, {
|
||||
checks,
|
||||
desktop: {
|
||||
dialogs: desktop.metrics,
|
||||
longest: desktop.metrics.reduce((longest, metric) => (
|
||||
metric.contentWidth > longest.contentWidth ? metric : longest
|
||||
)),
|
||||
space: desktop.space,
|
||||
},
|
||||
narrow,
|
||||
});
|
||||
@@ -5,6 +5,12 @@ const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const tabs = () => [...sr().querySelectorAll('.modetab')];
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
|
||||
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
|
||||
out.labels = tabs().map((t) => t.textContent.trim());
|
||||
@@ -19,6 +25,22 @@ const res = await page.evaluate(async () => {
|
||||
const barCloseIcon = barClose?.querySelector('ha-icon');
|
||||
const barCloseRect = barClose?.getBoundingClientRect();
|
||||
const barCloseIconRect = barCloseIcon?.getBoundingClientRect();
|
||||
const enteringChrome = sr().querySelector('.editorchrome');
|
||||
// Regression #95 follow-up: the bar is already painted during the 220 ms
|
||||
// mode transition, so its explicit Close command must not be inert together
|
||||
// with the editing tools.
|
||||
out.closeRemainsInteractiveDuringEnter = c._modeTransitionBusy
|
||||
&& enteringChrome?.inert === false
|
||||
&& sr().querySelector('.editbar-tools')?.inert === true
|
||||
&& getComputedStyle(barClose).pointerEvents === 'auto';
|
||||
const enteringStage = sr().querySelector('.stage');
|
||||
const editorSceneLayer = enteringStage?.querySelector('.hp-editor-only-layer');
|
||||
const enteringVisual = c._modeTransitionVisual;
|
||||
out.sceneLayersUseTransitionWeights = c._modeTransitionBusy && !!enteringVisual
|
||||
&& !!editorSceneLayer
|
||||
&& Math.abs(Number(editorSceneLayer.getAttribute('opacity')) - enteringVisual.editorWeight) < 0.001
|
||||
&& Math.abs(Number(getComputedStyle(enteringStage).getPropertyValue('--hp-mode-view-weight'))
|
||||
- enteringVisual.viewWeight) < 0.001;
|
||||
out.planBarCloseIconIsCentered = !!barCloseRect && !!barCloseIconRect
|
||||
&& Math.abs((barCloseIconRect.left + barCloseIconRect.width / 2)
|
||||
- (barCloseRect.left + barCloseRect.width / 2)) <= 1
|
||||
@@ -28,10 +50,7 @@ const res = await page.evaluate(async () => {
|
||||
// 3) повторный клик по активной вкладке — ничего
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
out.reclickNoop = c._mode === 'plan';
|
||||
// Let the View -> editor grid transition finish first. A real user cannot
|
||||
// switch editors in the same zero-layout frame, and competing WAAPI/CSS
|
||||
// height animations in that frame are intentionally suppressed.
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
await settleMode();
|
||||
// HP-UX-11: transient tool controls live in a stage overlay. Opening the
|
||||
// thickness controls must not resize/refit the stage or move pinned Close.
|
||||
const stageBeforeContext = sr().querySelector('.stage').getBoundingClientRect();
|
||||
@@ -51,8 +70,9 @@ const res = await page.evaluate(async () => {
|
||||
out.contextDoesNotMoveClose = Math.abs(closeWithContext.left - closeBeforeContext.left) <= 1
|
||||
&& Math.abs(closeWithContext.top - closeBeforeContext.top) <= 1;
|
||||
out.closeHasPinnedEndCap = !!sr().querySelector('.editbar > .editbar-end > .barclose');
|
||||
// No current tools are grouped automatically. Inject one declarative group
|
||||
// to pin the generic launcher/keyboard/restore contract for future editors.
|
||||
// Replace the real Opening group with a side-effect-free command group to
|
||||
// pin the generic launcher/keyboard/restore contract independently of any
|
||||
// one editor tool's business logic.
|
||||
let groupInvokes = 0;
|
||||
Object.defineProperty(c, '_editorToolbarGroups', { configurable: true, get: () => [{
|
||||
id: 'smoke-group', label: 'Smoke group', icon: 'mdi:dots-grid', items: [{
|
||||
@@ -104,6 +124,23 @@ const res = await page.evaluate(async () => {
|
||||
out.dismissTailDoesNotEatUnrelatedClick = unrelatedInvokes === 1;
|
||||
outsideTool.remove();
|
||||
unrelatedTool.remove();
|
||||
// An open grouped submenu still consumes ordinary outside clicks, but an
|
||||
// explicit editor navigation command has higher priority and must close both
|
||||
// surfaces in the same pointer sequence.
|
||||
groupLauncher?.click(); await c.updateComplete;
|
||||
const closeWithOpenGroup = sr().querySelector('.editbar .barclose');
|
||||
closeWithOpenGroup?.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
pointerId: 302, pointerType: 'mouse', button: 0,
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
closeWithOpenGroup?.dispatchEvent(new MouseEvent('click', {
|
||||
button: 0, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.editorNavigationOutranksGroupDismiss = c._mode === 'view'
|
||||
&& c._editorSecondary.hasOpenGroup === false;
|
||||
await settleMode();
|
||||
tabs()[0].click(); await settleMode();
|
||||
delete c._editorToolbarGroups;
|
||||
c._tool = 'select'; c.requestUpdate(); await c.updateComplete;
|
||||
// 4) прямое переключение План → Устройства
|
||||
@@ -117,30 +154,31 @@ const res = await page.evaluate(async () => {
|
||||
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
|
||||
const swapChrome = sr().querySelector('.editorchrome');
|
||||
const swapInner = swapChrome.querySelector('.editorchrome-inner');
|
||||
out.editorSwapAnimatesHeight = swapChrome.getAnimations()
|
||||
.some((animation) => animation.id === 'hp-editor-height-swap');
|
||||
out.editorSwapAnimatesContent = swapInner.getAnimations()
|
||||
.some((animation) => animation.id === 'hp-editor-content-swap');
|
||||
out.editorSwapAnimatesHeight = c._modeTransitionBusy
|
||||
&& sr().querySelector('.stage').classList.contains('mode-transition');
|
||||
out.editorSwapAnimatesContent = c._modeTransitionBusy && !!swapInner
|
||||
&& (c._modeTransitionVisual?.toolbarContentOpacity ?? 1) < 1;
|
||||
await settleMode();
|
||||
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
|
||||
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
|
||||
// 6) крестик на панели → Просмотр
|
||||
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
|
||||
const chrome = sr().querySelector('.editorchrome');
|
||||
out.barCloseStarts = c._mode === 'view' && !chrome.classList.contains('open')
|
||||
&& chrome.getAttribute('aria-hidden') === 'true';
|
||||
// The last bar deliberately remains mounted while its grid row collapses.
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
out.barCloseStarts = c._mode === 'view' && c._modeTransitionBusy
|
||||
&& sr().querySelector('.stage').inert
|
||||
&& sr().querySelector('.stage').classList.contains('mode-transition');
|
||||
await settleMode();
|
||||
out.barCloseWorks = c._mode === 'view'
|
||||
&& getComputedStyle(chrome).visibility === 'hidden'
|
||||
&& chrome.getBoundingClientRect().height < 1;
|
||||
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
|
||||
tabs()[1].click(); await settleMode();
|
||||
tabs()[1].querySelector('.closex').click(); await settleMode();
|
||||
out.tabCrossWorks = c._mode === 'view';
|
||||
|
||||
// Layout-independent editor history: physical KeyZ works when `key` is the
|
||||
// Russian «я», while an input keeps its native browser history.
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
tabs()[0].click(); await settleMode();
|
||||
c._tool = 'draw'; c.requestUpdate(); await c.updateComplete;
|
||||
const realUndo = c._undoGeometry;
|
||||
const realRedo = c._redoGeometry;
|
||||
|
||||
@@ -5,6 +5,13 @@ const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._openSpaceDialog('create');
|
||||
out.newSpaceUsesDaynight = c._spaceDialog?.bgMode === 'daynight';
|
||||
c._spaceDialog = null;
|
||||
c._importQueue = ['Imported floor'];
|
||||
c._openNextImport();
|
||||
out.floorImportUsesDaynight = c._spaceDialog?.bgMode === 'daynight';
|
||||
c._spaceDialog = null;
|
||||
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.rows = sr().querySelectorAll('.gsrow').length;
|
||||
@@ -38,6 +45,13 @@ const res = await page.evaluate(async () => {
|
||||
settings: { ...(s.settings||{}), show_lqi: false } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||
// The space value is an explicit override, not another condition ANDed with
|
||||
// the card default. This is the projection shared by preview/static cards.
|
||||
c._config = { ...c._config, show_signal: false };
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
|
||||
settings: { ...(s.settings||{}), show_lqi: true } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.spaceLqiOverridesCardDefault = sr().querySelectorAll('.dev .lqi').length > 0;
|
||||
return out;
|
||||
});
|
||||
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
|
||||
@@ -60,6 +74,8 @@ checkAll(res, {
|
||||
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
|
||||
],
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"newSpaceUsesDaynight": true,
|
||||
"floorImportUsesDaynight": true,
|
||||
"lqiBefore": 7,
|
||||
"lqiAfter": 0,
|
||||
});
|
||||
|
||||
@@ -282,6 +282,20 @@ const rasterFixture = await page.evaluate(async () => {
|
||||
if (!source || !sourceEid || !current) return { ready: false };
|
||||
|
||||
const edgeKey = (a, b) => `${a.join(',')}/${b.join(',')}`;
|
||||
const wallKey = (a, b) => {
|
||||
const pitch = 1 / 240;
|
||||
const q = (value) => Math.round(value / pitch) * pitch;
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
dx /= length; dy /= length;
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) {
|
||||
dx = -dx; dy = -dy;
|
||||
}
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const angleBucket = Math.round(angle * 1800) / 1800;
|
||||
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angleBucket.toFixed(4)}`;
|
||||
};
|
||||
const edges = new Map();
|
||||
for (const room of current.rooms || []) {
|
||||
for (let index = 0; index < room.poly.length; index++) {
|
||||
@@ -291,13 +305,21 @@ const rasterFixture = await page.evaluate(async () => {
|
||||
if (!edges.has(reverse) && !edges.has(edgeKey(a, b))) edges.set(edgeKey(a, b), { a, b });
|
||||
}
|
||||
}
|
||||
const walls = [...edges.values()].map(({ a, b }, index) => ({
|
||||
key: `glow-raster-wall-${index}`, a, b, cm: 15,
|
||||
const walls = [...edges.values()].map(({ a, b }) => ({
|
||||
key: wallKey(a, b), a, b, cm: 15,
|
||||
}));
|
||||
const openings = [
|
||||
{ id: 'glow-raster-east', type: 'door', x: 0.55, y: 0.32, angle: 90, length: 0.09 },
|
||||
{ id: 'glow-raster-south', type: 'door', x: 0.32, y: 0.58, angle: 0, length: 0.09 },
|
||||
];
|
||||
const wallSource = [...edges.values()]
|
||||
.map(({ a, b }) => ({
|
||||
point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
|
||||
clearance: Math.min(...openings.map((opening) => Math.hypot(
|
||||
(a[0] + b[0]) / 2 - opening.x, (a[1] + b[1]) / 2 - opening.y,
|
||||
))),
|
||||
}))
|
||||
.sort((left, right) => right.clearance - left.clearance)[0]?.point;
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 },
|
||||
@@ -425,7 +447,7 @@ const rasterFixture = await page.evaluate(async () => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ready: true, litParts, sourceEid, doors, shadow };
|
||||
return { ready: true, litParts, sourceEid, sourceId: source.id, wallSource, doors, shadow };
|
||||
});
|
||||
|
||||
if (rasterFixture.ready) {
|
||||
@@ -531,6 +553,39 @@ if (rasterFixture.ready) {
|
||||
res.shadowEdgeIsCrisp = !!metrics.shadow
|
||||
&& metrics.shadow.high - metrics.shadow.low >= 8
|
||||
&& metrics.shadow.edgeWidth <= 4;
|
||||
const sourceInsideWall = await page.evaluate(async ({ sourceEid, sourceId, wallSource }) => {
|
||||
if (!wallSource) return false;
|
||||
const c = window.__card;
|
||||
const state = c.hass.states[sourceEid];
|
||||
c._layout = {
|
||||
...c._layout,
|
||||
[sourceId]: { s: c._space, x: wallSource[0], y: wallSource[1] },
|
||||
};
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: { ...c.hass.states, [sourceEid]: { ...state, state: 'on' } },
|
||||
};
|
||||
c._glowClipCache.clear();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// The continuity contract may keep the last complete device frame briefly
|
||||
// while the moved source and its HA snapshot are staged atomically. Wait
|
||||
// for that bounded hand-off instead of mistaking the allowed stale frame
|
||||
// for a geometry leak.
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const selector = `[data-glow-source="${CSS.escape(sourceEid)}"] .glow-pool`;
|
||||
const deadline = performance.now() + 1800;
|
||||
while (root.querySelector(selector) && performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await c.updateComplete;
|
||||
}
|
||||
return !root.querySelector(selector);
|
||||
}, {
|
||||
sourceEid: rasterFixture.sourceEid,
|
||||
sourceId: rasterFixture.sourceId,
|
||||
wallSource: rasterFixture.wallSource,
|
||||
});
|
||||
res.sourceInsideWallSuppressed = sourceInsideWall;
|
||||
} else {
|
||||
res.litPartsMatchClip = false;
|
||||
res.doorwayCarriesLight = false;
|
||||
@@ -538,6 +593,7 @@ if (rasterFixture.ready) {
|
||||
res.apertureItselfLit = false;
|
||||
res.occluderCastsShadow = false;
|
||||
res.shadowEdgeIsCrisp = false;
|
||||
res.sourceInsideWallSuppressed = false;
|
||||
}
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
|
||||
@@ -106,7 +106,7 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
// ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it ---
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._tool = 'opening';
|
||||
c._activateOpeningPlacement('door');
|
||||
const spm = c._spaceModel(c._space);
|
||||
const wall = (() => {
|
||||
for (const r of spm.rooms) {
|
||||
|
||||
@@ -93,18 +93,23 @@ const res = await page.evaluate(async () => {
|
||||
light_entity: draft.lightEntity || null,
|
||||
},
|
||||
} : realPreview(draft);
|
||||
c._openMarkerDialog(multi); await c.updateComplete;
|
||||
c._setMarkerLightRole('always'); await c.updateComplete;
|
||||
c._openMarkerDialog({
|
||||
...multi,
|
||||
marker: { ...(multi.marker || {}), is_light: true, light_entity: extra },
|
||||
});
|
||||
await c.updateComplete;
|
||||
out.leadingDraftIsAlways = c._markerDialog.lightRole === 'always';
|
||||
out.leadingPreviewHasExtra = c._markerPreviewDevice(c._markerDialog)?.entities?.includes(extra) === true;
|
||||
const leading = sr().querySelector('hp-dialog #marker-light-entity');
|
||||
out.leadingSelectorForMultiple = !!leading && leading.options.length === 3;
|
||||
out.savedLeadingEntitySelected = leading?.value === extra
|
||||
&& leading?.selectedOptions?.[0]?.value === extra;
|
||||
if (leading) {
|
||||
leading.value = extra;
|
||||
leading.value = multi.primary;
|
||||
leading.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
out.leadingUpdatesPreview = c._markerDialog.lightEntity === extra
|
||||
&& c._markerSpatialSource(c._markerDialog)?.eid === extra;
|
||||
out.leadingUpdatesPreview = c._markerDialog.lightEntity === multi.primary
|
||||
&& c._markerSpatialSource(c._markerDialog)?.eid === multi.primary;
|
||||
} else {
|
||||
out.leadingUpdatesPreview = false;
|
||||
}
|
||||
|
||||
@@ -48,12 +48,53 @@ const res = await page.evaluate(async () => {
|
||||
|| root().querySelector('hp-dialog')?.shadowRoot?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
|
||||
};
|
||||
|
||||
// Empty or incomplete content must not leave a dead focus target behind.
|
||||
const incompleteHelp = document.createElement('hp-help');
|
||||
incompleteHelp.text = ' ';
|
||||
incompleteHelp.ariaLabel = 'Help: unavailable description';
|
||||
document.body.append(incompleteHelp);
|
||||
await incompleteHelp.updateComplete;
|
||||
out.emptyTextHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
|
||||
&& getComputedStyle(incompleteHelp).display === 'none';
|
||||
incompleteHelp.text = 'Available description';
|
||||
incompleteHelp.ariaLabel = ' ';
|
||||
await incompleteHelp.updateComplete;
|
||||
out.emptyAriaHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
|
||||
&& getComputedStyle(incompleteHelp).display === 'none';
|
||||
incompleteHelp.ariaLabel = 'Help: available description';
|
||||
await incompleteHelp.updateComplete;
|
||||
const restoredTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
|
||||
out.circledQuestionIcon = restoredTrigger?.querySelector('svg[viewBox="0 0 24 24"] path')
|
||||
?.getAttribute('d')?.length > 0;
|
||||
incompleteHelp.text = Array.from({ length: 320 }, (_, index) => `Line ${index + 1}`).join(' · ');
|
||||
await incompleteHelp.updateComplete;
|
||||
const overflowTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
|
||||
overflowTrigger?.click();
|
||||
await incompleteHelp.updateComplete;
|
||||
await frame();
|
||||
const longSurface = incompleteHelp.shadowRoot?.querySelector('.tooltip');
|
||||
overflowTrigger?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'PageDown', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await frame();
|
||||
out.overflowHelpScrollsFromTrigger = !!longSurface
|
||||
&& longSurface.scrollHeight > longSurface.clientHeight && longSurface.scrollTop > 0;
|
||||
overflowTrigger?.click();
|
||||
incompleteHelp.remove();
|
||||
|
||||
card._setMode('devices');
|
||||
const marker = card._devices[0];
|
||||
card._openMarkerDialog(marker);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
const dialogBody = root().querySelector('hp-dialog .body');
|
||||
const scrollGeometry = () => dialogBody ? {
|
||||
clientHeight: dialogBody.clientHeight,
|
||||
scrollHeight: dialogBody.scrollHeight,
|
||||
scrollTop: dialogBody.scrollTop,
|
||||
} : null;
|
||||
|
||||
const roleHelp = root().querySelector('hp-help[data-help-key="marker.light_role.help"]');
|
||||
const roleButton = helpButton('marker.light_role.help');
|
||||
const roleDescription = roleHelp?.shadowRoot?.querySelector('[role="tooltip"]');
|
||||
@@ -61,11 +102,14 @@ const res = await page.evaluate(async () => {
|
||||
&& !!roleDescription?.id
|
||||
&& !roleButton.hasAttribute('aria-describedby');
|
||||
|
||||
const beforeHoverGeometry = scrollGeometry();
|
||||
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
|
||||
await wait(330);
|
||||
out.mouseHover = roleButton?.getAttribute('aria-expanded') === 'true'
|
||||
&& roleButton.getAttribute('aria-describedby') === roleDescription?.id
|
||||
&& !!helpSurface('marker.light_role.help');
|
||||
out.hoverKeepsDialogScrollGeometry = JSON.stringify(scrollGeometry())
|
||||
=== JSON.stringify(beforeHoverGeometry);
|
||||
roleButton?.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse', bubbles: true, composed: true }));
|
||||
await wait(180);
|
||||
out.mouseLeave = roleButton?.getAttribute('aria-expanded') === 'false';
|
||||
@@ -155,12 +199,15 @@ const res = await page.evaluate(async () => {
|
||||
const hide = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'hidePopover');
|
||||
Object.defineProperty(HTMLElement.prototype, 'showPopover', { configurable: true, value: undefined });
|
||||
Object.defineProperty(HTMLElement.prototype, 'hidePopover', { configurable: true, value: undefined });
|
||||
const beforeFallbackGeometry = scrollGeometry();
|
||||
glowHelpButton?.click();
|
||||
await frame();
|
||||
const dialog = root().querySelector('hp-dialog');
|
||||
const fallback = dialog?.shadowRoot?.querySelector('[data-hp-overlay="help"]');
|
||||
const fallbackBox = fallback?.shadowRoot?.querySelector('.tooltip')?.getBoundingClientRect();
|
||||
out.realFallback = !!fallback && !!fallbackBox?.width && !!fallbackBox?.height;
|
||||
out.fallbackKeepsDialogScrollGeometry = JSON.stringify(scrollGeometry())
|
||||
=== JSON.stringify(beforeFallbackGeometry);
|
||||
glowHelpButton?.click();
|
||||
pickerButton?.click();
|
||||
await frame();
|
||||
|
||||
@@ -154,7 +154,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
|
||||
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.activity-ring'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.device-pulse'));
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
@@ -197,7 +197,13 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
},
|
||||
// модульный кэш конфига инвалидируется только через это событие — стаб
|
||||
// сохраняет колбэк, чтобы следующий тест мог сбросить кэш
|
||||
connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
|
||||
connection: { subscribeEvents: async (cb, type) => {
|
||||
// #107 added a third, virtual-light subscription. Keep the config event
|
||||
// callback specifically; otherwise the next fixture calls the unrelated
|
||||
// virtual-light listener and reuses this stale shared config snapshot.
|
||||
if (type === 'houseplan_config_updated') window.__hpInvalidate = cb;
|
||||
return () => {};
|
||||
} } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// #122 Stage 2: Labs lifecycle, structural composition and flat editor boundary.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.renderRoot;
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const center = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
|
||||
};
|
||||
const enable = async () => {
|
||||
history.replaceState(null, '', '#space=f1&hp-labs=iso');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
const result = {};
|
||||
const configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1');
|
||||
configSpace.settings = {
|
||||
...(configSpace.settings || {}), show_borders: true, show_names: true,
|
||||
};
|
||||
configSpace.partitions = [{
|
||||
id: 'iso-smoke-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
|
||||
}];
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await enable();
|
||||
const toggle = root().querySelector('[data-hp="projection-toggle"]');
|
||||
result.labsSnapshotFrozen = Object.isFrozen(window.__hpLabs)
|
||||
&& JSON.stringify(window.__hpLabs) === '["iso"]';
|
||||
result.toggleShown = !!toggle;
|
||||
result.toggleMinHitTarget = toggle && toggle.getBoundingClientRect().width >= 44
|
||||
&& toggle.getBoundingClientRect().height >= 44;
|
||||
result.flatDefault = toggle?.getAttribute('aria-pressed') === 'false'
|
||||
&& !root().querySelector('[data-hp="iso-walls"]');
|
||||
|
||||
toggle?.click();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const isoToggle = root().querySelector('[data-hp="projection-toggle"]');
|
||||
const device = root().querySelector('[data-hp="device"]');
|
||||
const roomLabel = root().querySelector('[data-hp="room-label"]');
|
||||
result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true'
|
||||
&& !!root().querySelector('[data-hp="iso-underlay"] .iso-floor-side')
|
||||
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
|
||||
result.sharedProjectionSnapshot = ['.iso-underlay-svg', '.plan-svg', '.iso-shadows-svg', '.iso-walls-svg']
|
||||
.map((selector) => root().querySelector(selector)?.getAttribute('viewBox'))
|
||||
.every((value, _index, values) => !!value && value === values[0]);
|
||||
result.stage2DefinitionsBounded = root().querySelectorAll('[id^="hp-iso-"]').length <= 5;
|
||||
result.preferenceStored = JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
|
||||
result.anchorsFinite = [device, roomLabel].every((node) => node
|
||||
&& center(node).every((value) => Number.isFinite(value)));
|
||||
const deviceBefore = device ? center(device) : null;
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states } };
|
||||
await card.updateComplete;
|
||||
const deviceAfter = root().querySelector('[data-hp="device"]');
|
||||
result.haUpdateKeepsGeometry = root().querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint
|
||||
&& card._isoGeometryCache.size === 1;
|
||||
result.haUpdateKeepsAnchor = !!deviceBefore && !!deviceAfter
|
||||
&& Math.hypot(...center(deviceAfter).map((value, index) => value - deviceBefore[index])) <= 1;
|
||||
|
||||
card._setMode('plan');
|
||||
await card.updateComplete;
|
||||
while (card._modeTransitionBusy) await frame();
|
||||
result.editorIsFlat = card._mode === 'plan'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]')
|
||||
&& !root().querySelector('[data-hp="iso-walls"]');
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
while (card._modeTransitionBusy) await frame();
|
||||
result.viewRestoresIso = root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true'
|
||||
&& !!root().querySelector('[data-hp="iso-walls"]');
|
||||
|
||||
const source = card._isoSource;
|
||||
let attempts = 0;
|
||||
let shouldFail = true;
|
||||
const fallbackKey = 'f1|injected-render-failure';
|
||||
card._isoGeometryCache.clear();
|
||||
card._isoSource = () => ({
|
||||
key: fallbackKey,
|
||||
build: () => {
|
||||
attempts++;
|
||||
if (shouldFail) throw new Error('injected isometric failure');
|
||||
return { walls: [], floor: [], openings: [] };
|
||||
},
|
||||
});
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states } };
|
||||
await card.updateComplete;
|
||||
result.fallbackLatched = attempts === 1
|
||||
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'false'
|
||||
&& !root().querySelector('[data-hp="iso-walls"]')
|
||||
&& JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
|
||||
shouldFail = false;
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
result.explicitRetryRestoresIso = attempts === 2
|
||||
&& !!root().querySelector('[data-hp="iso-walls"]')
|
||||
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
|
||||
card._isoSource = source;
|
||||
card._isoGeometryCache.delete(fallbackKey);
|
||||
card._isoFallback.delete(fallbackKey);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
history.replaceState(null, '', '#space=f1&hp-labs=-iso');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]')
|
||||
&& !root().querySelector('[data-hp="iso-walls"]')
|
||||
&& !root().querySelector('[id^="hp-iso-"]');
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,336 @@
|
||||
// #122 Stage 2: live-floor preservation, vertical openings, touch and remount paths.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 430, height: 820 }, 1, [], { hasTouch: true, isMobile: true },
|
||||
);
|
||||
const out = await page.evaluate(async () => {
|
||||
const original = window.__card;
|
||||
const root = (card) => card.renderRoot;
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
history.replaceState(null, '', '?hp-labs=iso#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await original.updateComplete;
|
||||
const configSpace = original._serverCfg.spaces.find((space) => space.id === 'f1');
|
||||
configSpace.settings = {
|
||||
...(configSpace.settings || {}), fill_mode: 'custom',
|
||||
custom_fill: { c: '#31465f', a: 0.42 }, glow_enabled: true,
|
||||
show_borders: true, show_names: true, north_deg: 0, sun_rays: true,
|
||||
};
|
||||
configSpace.rooms[0].settings = {
|
||||
...(configSpace.rooms[0].settings || {}), fill_mode: 'none', glow: true,
|
||||
};
|
||||
configSpace.openings = [
|
||||
{ id: 'iso-window', type: 'window', x: 0.28, y: 0.14, angle: 0, length: 0.12,
|
||||
contact: 'binary_sensor.window' },
|
||||
{ id: 'iso-door', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.12,
|
||||
contact: 'binary_sensor.window', lock: 'lock.front_door' },
|
||||
{ id: 'iso-gate', type: 'gate', x: 0.72, y: 0.14, angle: 0, length: 0.16,
|
||||
contact: 'binary_sensor.gate', lock: 'lock.front_door' },
|
||||
];
|
||||
configSpace.partitions = [{
|
||||
id: 'iso-live-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
|
||||
}];
|
||||
configSpace.decor = [
|
||||
{ id: 'iso-line', kind: 'line', x1: 0.12, y1: 0.7, x2: 0.45, y2: 0.7,
|
||||
color: '#667788', opacity: 0.7, width_cm: 1 },
|
||||
{ id: 'iso-sofa', kind: 'furniture', symbol: 'sofa', x: 0.62, y: 0.55,
|
||||
w: 0.2, h: 0.12, color: '#667788', opacity: 0.7, width_cm: 1 },
|
||||
];
|
||||
original._serverCfg.markers = original._serverCfg.markers || [];
|
||||
original._serverCfg.markers.push({
|
||||
id: 'iso-vacuum', binding: 'device:d_mower', space: 'f1', area: 'living_room',
|
||||
vacuum: {
|
||||
source: 'camera.static_robot_map', trail_mode: 'always',
|
||||
calibration: { m1: [0.5, 0, 0, 0, 0.5, 0] },
|
||||
},
|
||||
});
|
||||
original._layout['iso-vacuum'] = { s: 'f1', x: 0.18, y: 0.78 };
|
||||
original._hoverRoom = { space: 'f1', room: original._spaceModel('f1').rooms[0] };
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
states: {
|
||||
...original.hass.states,
|
||||
'light.floor_lamp': {
|
||||
...original.hass.states['light.floor_lamp'], state: 'on',
|
||||
},
|
||||
'vacuum.mower': {
|
||||
...original.hass.states['vacuum.mower'], state: 'cleaning',
|
||||
},
|
||||
'camera.static_robot_map': {
|
||||
entity_id: 'camera.static_robot_map', state: 'idle',
|
||||
attributes: {
|
||||
map_name: 'm1', vacuum_position: { x: 650, y: 700, a: 0 },
|
||||
path: [[200, 200], [420, 440], [650, 700]],
|
||||
},
|
||||
},
|
||||
'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 0, elevation: 24 },
|
||||
},
|
||||
},
|
||||
};
|
||||
original._cfgEpoch++;
|
||||
original._regSignature = '';
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
await frame();
|
||||
|
||||
const count = (selector) => root(original).querySelectorAll(selector).length;
|
||||
const layers = () => ({
|
||||
rooms: count('[data-hp="room"]'),
|
||||
roomFills: count('[data-hp="room"].filled'),
|
||||
decor: count('.decorlayer [data-hp="decor"]'),
|
||||
furniture: count('.decorlayer .dfurn'),
|
||||
backdrop: count('.hp-backdrop'),
|
||||
glowBase: count('.glow-base-layer .glow-base'),
|
||||
glowSources: count('.glowlayer [data-glow-source]'),
|
||||
sun: count('.sunlayer'),
|
||||
openings: count('[data-hp="opening"]'),
|
||||
verticalOpenings: count('[data-hp="iso-openings"] .iso-opening-panel'),
|
||||
devices: count('[data-hp="device"]'),
|
||||
hover: count('.room-hover-fill'),
|
||||
vacuumPucks: count('.vacpuck'),
|
||||
vacuumTrails: count('.vactrail .case'),
|
||||
});
|
||||
const flat = layers();
|
||||
const flatSpillParts = [...root(original).querySelectorAll('.glow-pool')]
|
||||
.map((node) => node.getAttribute('data-lit-parts'));
|
||||
root(original).querySelector('[data-hp="projection-toggle"]')?.click();
|
||||
await original.updateComplete;
|
||||
await frame();
|
||||
|
||||
const before = layers();
|
||||
const beforeSpillParts = [...root(original).querySelectorAll('.glow-pool')]
|
||||
.map((node) => node.getAttribute('data-lit-parts'));
|
||||
const wallFingerprint = root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint;
|
||||
const cachedGeometry = original._isoGeometryCache.get(wallFingerprint)?.geometry;
|
||||
const light = original.hass.states['light.ceiling'];
|
||||
const openingPanelBefore = root(original)
|
||||
.querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d');
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
states: {
|
||||
...original.hass.states,
|
||||
'light.ceiling': {
|
||||
...light, attributes: { ...light.attributes, rgb_color: [255, 64, 32] },
|
||||
},
|
||||
'binary_sensor.window': {
|
||||
...original.hass.states['binary_sensor.window'], state: 'off',
|
||||
},
|
||||
},
|
||||
};
|
||||
await original.updateComplete;
|
||||
const after = layers();
|
||||
const afterSpillParts = [...root(original).querySelectorAll('.glow-pool')]
|
||||
.map((node) => node.getAttribute('data-lit-parts'));
|
||||
const ordered = [
|
||||
root(original).querySelector('.iso-underlay-svg'),
|
||||
root(original).querySelector('.hp-backdrop'),
|
||||
root(original).querySelector('.decorlayer'),
|
||||
root(original).querySelector('[data-hp="room"]'),
|
||||
root(original).querySelector('.glow-base-layer'),
|
||||
root(original).querySelector('.glowlayer'),
|
||||
root(original).querySelector('.sunlayer'),
|
||||
root(original).querySelector('.iso-shadows-svg'),
|
||||
root(original).querySelector('.iso-walls-svg'),
|
||||
root(original).querySelector('.vacpuck'),
|
||||
];
|
||||
const result = {
|
||||
isoOnTouch: !!root(original).querySelector('[data-hp="iso-walls"]'),
|
||||
flatIsoLayerParity: JSON.stringify({ ...flat, openings: 0, verticalOpenings: before.verticalOpenings })
|
||||
=== JSON.stringify(before)
|
||||
&& JSON.stringify(flatSpillParts) === JSON.stringify(beforeSpillParts),
|
||||
liveLayersPresent: before.rooms > 0 && before.roomFills > 0 && before.decor >= 2
|
||||
&& before.furniture > 0 && before.backdrop > 0 && before.glowBase > 0
|
||||
&& before.glowSources >= 2 && before.sun > 0 && before.openings === 0
|
||||
&& before.verticalOpenings === 5
|
||||
&& before.devices > 0 && before.hover > 0 && before.vacuumPucks > 0
|
||||
&& before.vacuumTrails >= 2,
|
||||
liveLayersStable: JSON.stringify(after) === JSON.stringify(before),
|
||||
spillBarrierStable: JSON.stringify(afterSpillParts) === JSON.stringify(beforeSpillParts)
|
||||
&& beforeSpillParts.length >= 2 && beforeSpillParts.every((parts) => Number(parts) > 0),
|
||||
floorToOverlayOrderPreserved: ordered.every(Boolean)
|
||||
&& ordered.slice(1).every((node, index) => Boolean(
|
||||
ordered[index].compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
)),
|
||||
haColorUpdatePainted: [...root(original).querySelectorAll('radialGradient stop')]
|
||||
.some((stop) => stop.getAttribute('stop-color') === '#ff4020'),
|
||||
sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint,
|
||||
haUpdateReusesGeometry: original._isoGeometryCache.get(wallFingerprint)?.geometry === cachedGeometry,
|
||||
contactUpdateMovesOnlyLivePanel: !!openingPanelBefore
|
||||
&& root(original).querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d')
|
||||
!== openingPanelBefore
|
||||
&& original._isoGeometryCache.size === 1,
|
||||
oneLightModel: !root(original).querySelector('.window-light,.iso-window-light,.iso-glow,.iso-sun'),
|
||||
oldFloorSymbolsNotDuplicated: before.openings === 0,
|
||||
};
|
||||
|
||||
configSpace.settings.hide_openings = true;
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.hideOpeningsKeepsStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& !root(original).querySelector('[data-hp="opening"]')
|
||||
&& root(original).querySelectorAll('.glowlayer [data-glow-source]').length >= 2;
|
||||
|
||||
configSpace.settings.hide_openings = false;
|
||||
configSpace.settings.show_borders = false;
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.noBordersUsesFloorSymbols = !root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !root(original).querySelector('[data-hp="iso-underlay"]')
|
||||
&& root(original).querySelectorAll('[data-hp="opening"]').length === 3;
|
||||
|
||||
configSpace.settings.show_borders = true;
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.visibleBordersRestoreStage2 = !!root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& original._isoGeometryCache.size === 1;
|
||||
|
||||
const supports = CSS.supports;
|
||||
CSS.supports = () => false;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.unsupportedDecorationKeepsIsoStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !!root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& !root(original).querySelector('[data-hp="iso-shadows"]');
|
||||
CSS.supports = supports;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
|
||||
const calls = [];
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
callService: async (domain, service, data) => calls.push({ domain, service, data }),
|
||||
};
|
||||
await original.updateComplete;
|
||||
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
|
||||
await frame();
|
||||
original._setProjection('flat');
|
||||
await original.updateComplete;
|
||||
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
|
||||
await frame();
|
||||
result.flatIsoActionParity = calls.length === 2
|
||||
&& JSON.stringify(calls[0]) === JSON.stringify(calls[1]);
|
||||
original._setProjection('iso');
|
||||
await original.updateComplete;
|
||||
|
||||
const stage = root(original).querySelector('.stage');
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const zoomBefore = original._zoom;
|
||||
const pointer = (type, id, x, y, buttons) => new PointerEvent(type, {
|
||||
bubbles: true, composed: true, pointerId: id, pointerType: 'touch',
|
||||
clientX: x, clientY: y, button: type === 'pointerdown' ? 0 : -1, buttons,
|
||||
});
|
||||
const cy = stageRect.top + stageRect.height / 2;
|
||||
stage.dispatchEvent(pointer('pointerdown', 8902, stageRect.left + 150, cy, 1));
|
||||
stage.dispatchEvent(pointer('pointerdown', 8903, stageRect.left + 270, cy, 1));
|
||||
stage.dispatchEvent(pointer('pointermove', 8902, stageRect.left + 115, cy, 1));
|
||||
stage.dispatchEvent(pointer('pointermove', 8903, stageRect.left + 305, cy, 1));
|
||||
stage.dispatchEvent(pointer('pointerup', 8902, stageRect.left + 115, cy, 0));
|
||||
stage.dispatchEvent(pointer('pointerup', 8903, stageRect.left + 305, cy, 0));
|
||||
await original.updateComplete;
|
||||
result.touchPinchKeepsIso = original._zoom > zoomBefore
|
||||
&& !!root(original).querySelector('[data-hp="iso-walls"]');
|
||||
|
||||
const opening = root(original).querySelector('[data-hp="opening"] .op-hit');
|
||||
opening?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
result.touchOpeningIsSafe = !original._openingDialog && !original._openingInfo
|
||||
&& original._mode === 'view' && calls.length === 2;
|
||||
|
||||
const device = root(original).querySelector('[data-hp="device"]');
|
||||
if (device) {
|
||||
const rect = device.getBoundingClientRect();
|
||||
device.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, pointerId: 8901, pointerType: 'touch',
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
button: 0, buttons: 1,
|
||||
}));
|
||||
await wait(700);
|
||||
result.touchLongPressHitsDevice = !!original._infoCard;
|
||||
device.dispatchEvent(new PointerEvent('pointerup', {
|
||||
bubbles: true, composed: true, pointerId: 8901, pointerType: 'touch',
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
button: 0, buttons: 0,
|
||||
}));
|
||||
original._closeInfoCard();
|
||||
} else result.touchLongPressHitsDevice = false;
|
||||
|
||||
original._pickSpace('garden');
|
||||
await wait(230);
|
||||
original._setProjection('iso');
|
||||
await original.updateComplete;
|
||||
const gardenIso = original._space === 'garden'
|
||||
&& root(original).querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
|
||||
original._pickSpace('f1');
|
||||
await wait(230);
|
||||
result.touchSpaceSwitchKeepsPerSpaceIso = gardenIso && original._space === 'f1'
|
||||
&& !!root(original).querySelector('[data-hp="iso-walls"]');
|
||||
|
||||
const ownVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
|
||||
await original.updateComplete;
|
||||
await frame();
|
||||
const recoveryStarted = performance.now();
|
||||
while (root(original).querySelector('.recoveryoverlay')
|
||||
&& performance.now() - recoveryStarted < 2000) await wait(20);
|
||||
result.backgroundForegroundKeepsIso = !!root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !root(original).querySelector('.recoveryoverlay');
|
||||
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
|
||||
else delete document.visibilityState;
|
||||
|
||||
const host = document.getElementById('host');
|
||||
const mount = async (kiosk = false) => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', kiosk });
|
||||
host.replaceChildren(card);
|
||||
card.hass = window.__mkHass();
|
||||
const started = performance.now();
|
||||
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
return card;
|
||||
};
|
||||
original.remove();
|
||||
const warm = await mount(false);
|
||||
result.warmRemountIso = !warm._booting
|
||||
&& !!root(warm).querySelector('[data-hp="iso-walls"]')
|
||||
&& root(warm).querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
|
||||
warm.remove();
|
||||
const kiosk = await mount(true);
|
||||
result.kioskReadsPreference = !!root(kiosk).querySelector('[data-hp="iso-walls"]');
|
||||
result.kioskHasNoToggle = !root(kiosk).querySelector('[data-hp="projection-toggle"]');
|
||||
return result;
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 820, height: 430 });
|
||||
out.orientationResizeKeepsIso = await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const anchor = card.renderRoot.querySelector('[data-hp="device"]')?.getBoundingClientRect();
|
||||
return !!card.renderRoot.querySelector('[data-hp="iso-walls"]')
|
||||
&& !!anchor && [anchor.left, anchor.top, anchor.width, anchor.height].every(Number.isFinite);
|
||||
});
|
||||
out.kioskEmergencyOffIsFlat = await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
history.replaceState(null, '', '?hp-labs=-iso#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
return !card.renderRoot.querySelector('[data-hp="iso-walls"]')
|
||||
&& !card.renderRoot.querySelector('[data-hp="projection-toggle"]');
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -7,6 +7,12 @@ const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const setFill = async (mode) => {
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
@@ -47,18 +53,18 @@ const out = await page.evaluate(async () => {
|
||||
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- редакторы используют ту же жёлтую подложку независимо от слоя glow --
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
c._setMode('devices'); await settleMode();
|
||||
le = devEl('d_lamp');
|
||||
o.devicesEditorLampYellow = !!le && le.classList.contains('on');
|
||||
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
|
||||
c._setMode('plan'); await settleMode();
|
||||
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
|
||||
// удовлетворяла бы поиску «любой .dev.on», пряча регресс самой лампы)
|
||||
le = devEl('d_lamp');
|
||||
o.planEditorLampYellow = !!le && le.classList.contains('on'); // слой скрыт — жёлтый вернулся
|
||||
o.planEditorKettleStillOn = !!devEl('d_kettle')?.classList.contains('on');
|
||||
o.planEditorNoGlow = !sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
c._setMode('view'); await settleMode();
|
||||
|
||||
// --- выключенная лампа тёмная в обоих режимах ---------------------------
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
|
||||
@@ -10,9 +10,37 @@ const res = await page.evaluate(async () => {
|
||||
// лампа (primary light) без явного действия → клик = toggle
|
||||
const lamp = c._devices.find((d) => d.primary?.startsWith('light.') && !d.tapAction);
|
||||
out.hasLamp = !!lamp;
|
||||
const lampState = c.hass.states[lamp.primary]?.state;
|
||||
const expectedService = lampState === 'off' ? 'turn_on'
|
||||
: (lampState === 'unknown' || lampState === '' ? 'toggle' : 'turn_off');
|
||||
c._infoCard = null;
|
||||
c._clickDevice(new MouseEvent('click'), lamp);
|
||||
out.lampToggles = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'toggle', lamp.primary]);
|
||||
const lampCall = calls.at(-1);
|
||||
out.lampToggles = ['light', 'homeassistant'].includes(lampCall?.[0])
|
||||
&& lampCall?.[1] === expectedService && lampCall?.[2] === lamp.primary;
|
||||
// The untouched select is an effective projection, not a frozen draft.
|
||||
// Reproduce the field race where a dialog was opened before HA identified
|
||||
// the leading entity as light: runtime already sees light-default toggle,
|
||||
// and the UI must catch up without materializing a stored tap_action.
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(lamp); await c.updateComplete;
|
||||
c._markerDialog = {
|
||||
...c._markerDialog,
|
||||
tapAction: 'info', tapActionTouched: false,
|
||||
originalHasTapAction: false, originalTapAction: undefined,
|
||||
};
|
||||
await c.updateComplete;
|
||||
const tapSelect = c.renderRoot.querySelector('#marker-tap-action');
|
||||
out.defaultLampDialogShowsEffectiveToggle = tapSelect?.value === 'toggle';
|
||||
out.defaultLampActionRemainsUnmaterialized = !c._markerDialog.tapActionTouched
|
||||
&& !c._markerDialog.originalHasTapAction;
|
||||
tapSelect.value = 'info';
|
||||
tapSelect.dispatchEvent(new Event('change'));
|
||||
await c.updateComplete;
|
||||
out.explicitDeviceCardStaysSelected = c.renderRoot.querySelector('#marker-tap-action')?.value === 'info'
|
||||
&& c._markerDialog.tapActionTouched;
|
||||
c._markerDialog = null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// устройство с не-light primary (сенсор) → клик = инфо
|
||||
const sensorDev = c._devices.find((d) => d.primary?.startsWith('sensor.') && !d.tapAction);
|
||||
const n = calls.length;
|
||||
|
||||