Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc17109249 | ||
|
|
e79f8f5aa1 | ||
|
|
024a1accd8 | ||
|
|
47c6f10a9d | ||
|
|
52ec0fb54f | ||
|
|
bcd280afb9 | ||
|
|
7c1edbfa9b | ||
|
|
024cdc0d94 | ||
|
|
4e539b02df | ||
|
|
ba56d4f768 | ||
|
|
948f2848dd | ||
|
|
3270e039d8 | ||
|
|
8e6b6c7ee3 | ||
|
|
dbe12f1a54 | ||
|
|
1e9952db35 | ||
|
|
39f5312f97 | ||
|
|
cc3b0f12f2 | ||
|
|
316ee76a29 | ||
|
|
7a2577dba0 | ||
|
|
869fe169d8 | ||
|
|
e0ddbcd79e | ||
|
|
6ea3ebff17 | ||
|
|
22e98c5555 | ||
|
|
d38a5be68b | ||
|
|
42335bc16d | ||
|
|
a36b3129f6 | ||
|
|
0ef900a3ae | ||
|
|
8cecaf2c5e | ||
|
|
5aa8771dc3 | ||
|
|
02502c990c | ||
|
|
a841d85e40 | ||
|
|
6d61529168 | ||
|
|
f87d71ac18 | ||
|
|
7ba2de7c89 | ||
|
|
74b08df88c | ||
|
|
9f02d88b42 | ||
|
|
c18224cdd2 | ||
|
|
ee2357b914 | ||
|
|
9e176aa1d7 | ||
|
|
7a76fb78fc | ||
|
|
c585f0268d | ||
|
|
df5be154ee | ||
|
|
39dd5de857 | ||
|
|
0509e1a008 | ||
|
|
e043974c44 | ||
|
|
05b38e67c4 | ||
|
|
b9062c1740 | ||
|
|
97d932a384 | ||
|
|
30f71af200 | ||
|
|
495a99872b | ||
|
|
53da8a1773 | ||
|
|
f339398f56 | ||
|
|
ab609ab165 | ||
|
|
e2b0fbfc06 | ||
|
|
ce40c57a3b | ||
|
|
31d81ad4ef | ||
|
|
37032203dd | ||
|
|
cf77d7d2e1 | ||
|
|
8e2973fa7a | ||
|
|
9bb5f7c5a8 | ||
|
|
121c9f10b9 | ||
|
|
661eb784fb | ||
|
|
9e74051652 | ||
|
|
01980ac3e6 | ||
|
|
36e81e9fb1 | ||
|
|
6d0f97ef82 | ||
|
|
bd9b6a6d75 | ||
|
|
4b03b888ff | ||
|
|
58d952e94e | ||
|
|
5d3f580df0 | ||
|
|
4dc3fdef36 | ||
|
|
8a417539e0 | ||
|
|
c41231a7ba | ||
|
|
2158e5d3f6 | ||
|
|
554d2e6544 | ||
|
|
2cf5c2748e | ||
|
|
59d028caf7 | ||
|
|
4381f65fde | ||
|
|
446f33ed31 | ||
|
|
9419842333 | ||
|
|
02373bb31f | ||
|
|
9a4ecbf961 | ||
|
|
48eebfd8a5 | ||
|
|
5c5833d69a | ||
|
|
a41a75eba5 | ||
|
|
6f34c06d74 | ||
|
|
c237baaffd | ||
|
|
d2bc908280 | ||
|
|
b0c29fb57f | ||
|
|
37d71d8cbb | ||
|
|
f8f1718ad2 | ||
|
|
c8b06996b1 | ||
|
|
1ed281b0dd | ||
|
|
d55816acaf | ||
|
|
cd55a8e897 | ||
|
|
7d152e04f9 | ||
|
|
764129a45c | ||
|
|
fb382bfa11 | ||
|
|
112c260314 | ||
|
|
5814cfe0c2 | ||
|
|
d839eb88eb | ||
|
|
bf5a040508 | ||
|
|
faa1f4ea9a | ||
|
|
caf3c44ad9 | ||
|
|
08b19363fd | ||
|
|
d5e6c5cff0 | ||
|
|
c85dbaf4cb | ||
|
|
b1deb0beab | ||
|
|
9590011ddb | ||
|
|
ddbd3288fe | ||
|
|
e177c14603 | ||
|
|
164f7cf76a | ||
|
|
2219700d63 | ||
|
|
854a6944a0 | ||
|
|
2a8302f4d6 | ||
|
|
f1537b2108 | ||
|
|
5e1315f61d | ||
|
|
4e29db1afb | ||
|
|
953063b984 | ||
|
|
d48d220a8c | ||
|
|
1c949ae49d | ||
|
|
9956a6cfe6 | ||
|
|
fe5f5b6a24 | ||
|
|
6db9eb0a66 | ||
|
|
3028122016 | ||
|
|
29fb9deb43 |
@@ -0,0 +1,14 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.mp4 binary
|
||||
*.webm binary
|
||||
*.zip binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -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}" $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,95 @@
|
||||
name: Announce release
|
||||
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
|
||||
# Stable releases are announced; prereleases are deliberately silent.
|
||||
# workflow_dispatch exists purely as a connectivity test button and therefore
|
||||
# remains allowed to send a test message.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
reusable:
|
||||
required: true
|
||||
type: boolean
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
release_name:
|
||||
required: true
|
||||
type: string
|
||||
url:
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
required: true
|
||||
type: boolean
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
TELEGRAM_BOT_TOKEN:
|
||||
required: true
|
||||
TELEGRAM_CHAT_ID:
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
telegram:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
if: ${{ inputs.reusable == true }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Send to Telegram
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
CALLED: ${{ inputs.reusable }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_NAME: ${{ inputs.release_name }}
|
||||
INPUT_URL: ${{ inputs.url }}
|
||||
INPUT_PRE: ${{ inputs.prerelease }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ github.event.release.name }}
|
||||
RELEASE_URL: ${{ github.event.release.html_url }}
|
||||
RELEASE_PRE: ${{ github.event.release.prerelease }}
|
||||
# The body goes through env, never through shell interpolation —
|
||||
# release notes are arbitrary text.
|
||||
RELEASE_BODY: ${{ github.event.release.body }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
|
||||
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
|
||||
else
|
||||
if [ "$CALLED" = "true" ]; then
|
||||
TAG=$INPUT_TAG
|
||||
NAME=$INPUT_NAME
|
||||
URL=$INPUT_URL
|
||||
PRE=$INPUT_PRE
|
||||
BODY=$(cat docs/RELEASE-NOTES.md)
|
||||
else
|
||||
TAG=$RELEASE_TAG
|
||||
NAME=$RELEASE_NAME
|
||||
URL=$RELEASE_URL
|
||||
PRE=$RELEASE_PRE
|
||||
BODY=$RELEASE_BODY
|
||||
fi
|
||||
if [ "$PRE" = "true" ]; then
|
||||
echo "Prerelease Telegram announcement is disabled"
|
||||
exit 0
|
||||
fi
|
||||
KIND="🏠 Релиз"
|
||||
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
|
||||
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
|
||||
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
|
||||
fi
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.telegram.org/bot$TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=$TEXT" \
|
||||
-d disable_web_page_preview=true
|
||||
@@ -0,0 +1,175 @@
|
||||
name: Full Performance
|
||||
|
||||
on:
|
||||
# Every main promotion is a stable-release candidate and must have an
|
||||
# exact-SHA full comparison before stable assets are published.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
comparison_ref:
|
||||
description: "Optional baseline tag, branch or SHA; empty uses the candidate parent"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: full-performance-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
performance:
|
||||
# Base and candidate stay sequential on one hosted runner. Splitting them
|
||||
# across runners would turn machine variance into a false regression.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: candidate
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Resolve comparison SHA
|
||||
id: base
|
||||
working-directory: candidate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
MANUAL_BASE: ${{ inputs.comparison_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
|
||||
git fetch --force --tags --prune --unshallow origin
|
||||
else
|
||||
git fetch --force --tags --prune origin
|
||||
fi
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$MANUAL_BASE" ]; then
|
||||
sha="$(git rev-parse "${MANUAL_BASE}^{commit}" 2>/dev/null || true)"
|
||||
source="manual comparison ref $MANUAL_BASE"
|
||||
elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && ! printf '%s' "$PUSH_BEFORE_SHA" | grep -Eq '^0+$'; then
|
||||
sha="$PUSH_BEFORE_SHA"
|
||||
source="push before"
|
||||
else
|
||||
sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
|
||||
source="candidate parent"
|
||||
fi
|
||||
requested_sha="$sha"
|
||||
|
||||
usable=true
|
||||
reason=""
|
||||
if [ -z "$sha" ] || ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
|
||||
usable=false
|
||||
reason="commit is not present after fetching all remote refs"
|
||||
elif [ "$source" = "push before" ] && ! git merge-base --is-ancestor "$sha" HEAD; then
|
||||
usable=false
|
||||
reason="commit is no longer an ancestor of the pushed revision"
|
||||
fi
|
||||
|
||||
if [ "$usable" != true ]; then
|
||||
parent_sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
|
||||
if [ -n "$parent_sha" ] && [ "$parent_sha" != "$(git rev-parse HEAD)" ]; then
|
||||
sha="$parent_sha"
|
||||
source="candidate parent (unusable requested-base fallback)"
|
||||
echo "::warning::Comparison SHA ${requested_sha:-none} is unusable ($reason); using candidate parent $sha."
|
||||
usable=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$usable" != true ]; then
|
||||
fallback_tag=""
|
||||
fallback_sha=""
|
||||
head_sha="$(git rev-parse HEAD)"
|
||||
while IFS= read -r tag; do
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
tag_sha="$(git rev-list -n 1 "$tag")"
|
||||
if [ "$tag_sha" != "$head_sha" ]; then
|
||||
fallback_tag="$tag"
|
||||
fallback_sha="$tag_sha"
|
||||
break
|
||||
fi
|
||||
done < <(git tag --merged HEAD --sort=-version:refname)
|
||||
if [ -z "$fallback_sha" ]; then
|
||||
echo "::error::No usable comparison commit or previous release tag is reachable from HEAD."
|
||||
exit 1
|
||||
fi
|
||||
sha="$fallback_sha"
|
||||
source="release tag $fallback_tag"
|
||||
echo "::warning::Using $fallback_tag ($sha) as the comparison base."
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
|
||||
echo "::warning::Comparison $sha predates HP-PERF-01; using candidate parent HEAD^."
|
||||
sha="$(git rev-parse HEAD^)"
|
||||
source="candidate parent (HP-PERF-01 compatibility)"
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out base SHA
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.base.outputs.sha }}
|
||||
path: baseline
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
candidate/package-lock.json
|
||||
baseline/package-lock.json
|
||||
|
||||
- name: Install candidate and baseline dependencies
|
||||
run: npm ci --prefix candidate && npm ci --prefix baseline
|
||||
|
||||
- name: Install pinned Chromium
|
||||
working-directory: candidate
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build both exact source trees
|
||||
run: |
|
||||
npm --prefix candidate run build
|
||||
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
|
||||
npm --prefix baseline run build
|
||||
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
|
||||
|
||||
- name: Capture base and candidate profiles
|
||||
working-directory: candidate
|
||||
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: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
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
|
||||
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
|
||||
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
fi
|
||||
|
||||
- name: Enforce relative and absolute performance budgets
|
||||
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-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
|
||||
|
||||
- name: Upload full performance reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: full-performance
|
||||
path: artifacts/performance
|
||||
@@ -0,0 +1,380 @@
|
||||
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') }}
|
||||
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; [ "$SMALL" = "true" ] && limit=2
|
||||
|
||||
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
|
||||
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #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
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
|
||||
# Материал ревью живёт в ветке задачи: ТЗ в 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
|
||||
|
||||
- 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» без названной команды и её
|
||||
результата доказательством не является. Зависимостей в рабочей
|
||||
копии нет: перед гейтами выполни `npm ci`. Проверь трейлеры Issue и
|
||||
User-Visible, при User-Visible: yes — правки в оба changelog в том же
|
||||
коммите.
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: 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: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "ветки задачи нет — документ некуда класть"; exit 0
|
||||
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 "документ ревью не создан"; 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
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$BRANCH"
|
||||
echo "документ опубликован в $BRANCH"
|
||||
|
||||
- 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
|
||||
@@ -0,0 +1,259 @@
|
||||
name: Publish prerelease
|
||||
run-name: Publish ${{ inputs.tag }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Exact prerelease tag, for example v1.61.0-beta.4"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: publish-prerelease-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
tag: ${{ steps.candidate.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Pin the current dev candidate
|
||||
id: candidate
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$REF_NAME" = "dev" || {
|
||||
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
|
||||
exit 1
|
||||
}
|
||||
SHA=$(git rev-parse HEAD)
|
||||
git fetch origin dev
|
||||
test "$(git rev-parse origin/dev)" = "$SHA" || {
|
||||
echo "::error::The dispatched SHA is no longer the origin/dev tip"
|
||||
exit 1
|
||||
}
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify version, changelogs and bilingual release notes
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
|
||||
- name: Require green Validate for this exact SHA
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
|
||||
publish:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
newly_published: ${{ steps.release.outputs.newly_published }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Build and verify both release assets before publication
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
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
|
||||
VERSION=${TAG#v}
|
||||
grep -Fq "$VERSION" dist/houseplan-card.js
|
||||
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
|
||||
unzip -l houseplan.zip | grep -q "manifest.json"
|
||||
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
|
||||
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
|
||||
test "$ZIP_VERSION" = "$VERSION" || {
|
||||
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
|
||||
exit 1
|
||||
}
|
||||
test -s dist/houseplan-card.js
|
||||
test -s houseplan.zip
|
||||
- name: Create or verify the annotated tag
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
|
||||
if [ -n "$REMOTE" ]; then
|
||||
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
|
||||
test -n "$PEELED" || {
|
||||
echo "::error::Existing remote tag $TAG is not annotated"
|
||||
exit 1
|
||||
}
|
||||
test "$PEELED" = "$SHA" || {
|
||||
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
|
||||
exit 1
|
||||
}
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" "$SHA" -m "$TAG"
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
- name: Stage, verify and publish the prerelease
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
|
||||
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
fi
|
||||
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
|
||||
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
|
||||
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)
|
||||
export RELEASE_JSON TAG
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
|
||||
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
- name: Verify the public release and assets
|
||||
id: verify
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)
|
||||
export RELEASE_JSON TAG
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
|
||||
throw new Error('release is not a public prerelease for the requested tag');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
|
||||
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
|
||||
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Verify HACS prerelease discovery order
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const first = releases.find((release) => release.prerelease && !release.draft);
|
||||
if (first?.tag_name !== process.env.EXPECTED_TAG) {
|
||||
core.setFailed(
|
||||
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
|
||||
process.env.EXPECTED_TAG,
|
||||
);
|
||||
}
|
||||
|
||||
# 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' }}
|
||||
uses: ./.github/workflows/announce.yml
|
||||
with:
|
||||
reusable: true
|
||||
tag: ${{ needs.gate.outputs.tag }}
|
||||
release_name: ${{ needs.gate.outputs.tag }}
|
||||
url: ${{ needs.publish.outputs.url }}
|
||||
prerelease: true
|
||||
ref: ${{ needs.gate.outputs.tag }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Attach HACS zip to release
|
||||
# hacs.json declares zip_release + filename=houseplan.zip, so every release
|
||||
# (prereleases included) must carry the asset — HACS installs from it and
|
||||
# GitHub's public download counter becomes a free per-version install metric
|
||||
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
|
||||
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
|
||||
# dev, stable tags once it reaches main.
|
||||
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
|
||||
# once for the latest stable after the hacs.json change reaches main).
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing release tag to attach the zip to"
|
||||
required: true
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
zip:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
env:
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
|
||||
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
|
||||
- name: Sanity check
|
||||
run: unzip -l houseplan.zip | grep -q "manifest.json"
|
||||
- name: Upload asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
|
||||
@@ -34,6 +34,15 @@ jobs:
|
||||
SHA=$(git rev-parse HEAD)
|
||||
echo "release tag: $TAG; exact commit: $SHA"
|
||||
node scripts/release-gate.mjs "$SHA"
|
||||
- name: Require full performance for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Full Performance"
|
||||
build:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
@@ -44,6 +53,18 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
- name: Verify compositor frame continuity for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
npm run continuity:screencast
|
||||
- name: Upload failed continuity frames
|
||||
if: ${{ failure() && !github.event.release.prerelease }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: continuity-screencast
|
||||
path: artifacts/continuity-screencast
|
||||
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
- name: Attach card to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -1,10 +1,61 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
# The branch commit is the release-gate authority. An annotated tag points
|
||||
# to the same SHA and must not duplicate the browser validation jobs.
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
# Exact-SHA release gates never depend on an obsolete commit.
|
||||
concurrency:
|
||||
group: validate-${{ github.event.pull_request.number || github.ref }}
|
||||
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 }}
|
||||
# Публичный репозиторий: штатного токена хватает на чтение issue.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/process-gate.mjs --github-range --issues
|
||||
|
||||
hacs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -13,18 +64,22 @@ jobs:
|
||||
uses: hacs/action@main
|
||||
with:
|
||||
category: integration
|
||||
|
||||
hassfest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
@@ -32,23 +87,25 @@ jobs:
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Card bundle in sync with integration
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
- name: Card bundle snapshots in sync
|
||||
run: |
|
||||
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
|
||||
smoke:
|
||||
# audit T2: the end-to-end layer used to run only when a human remembered.
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build a FRESH bundle for the smokes
|
||||
# the committed demo/srv/assets copy is a snapshot; testing it would
|
||||
# report green about code that no longer exists (audit T2)
|
||||
- name: Build a fresh bundle for the smokes
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Smoke suite
|
||||
run: |
|
||||
@@ -72,10 +129,77 @@ jobs:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
|
||||
golden:
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build the exact source under review
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture or verify golden matrix
|
||||
id: golden
|
||||
run: |
|
||||
if find demo/golden/baselines -maxdepth 1 -name '*.png' -print -quit | grep -q .; then
|
||||
echo "has_baselines=true" >> "$GITHUB_OUTPUT"
|
||||
npm run golden:verify
|
||||
else
|
||||
echo "has_baselines=false" >> "$GITHUB_OUTPUT"
|
||||
npm run golden:capture
|
||||
fi
|
||||
- name: Upload golden candidates/diffs
|
||||
if: failure() || steps.golden.outputs.has_baselines == 'false'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
|
||||
performance_smoke:
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build the exact candidate source
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture the heaviest Glow state
|
||||
run: |
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||||
- name: Enforce absolute smoke ceilings
|
||||
run: |
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Browser fixtures are generated by their real ESM factories and then
|
||||
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.13" }
|
||||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
|
||||
@@ -5,3 +5,5 @@ test-build/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
artifacts/
|
||||
.agents/
|
||||
|
||||
@@ -6,22 +6,371 @@ 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
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
**`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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` 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` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
|
||||
- **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/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
- **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`.
|
||||
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. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
|
||||
human-facing view synchronised from the labels, not the source of truth.
|
||||
|
||||
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.
|
||||
|
||||
## Two-agent workflow
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
**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`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
What the new label means:
|
||||
|
||||
| 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 |
|
||||
|
||||
**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 only the fast gates run. `smoke`, `golden` and
|
||||
`performance_smoke` spin up Chromium and belong to the pre-beta run — which is then
|
||||
mandatory and complete.
|
||||
|
||||
**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
|
||||
|
||||
Every new feature or material behaviour change must be published as a beta/RC
|
||||
before it can enter a stable release, even when its local audit is clean. The
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Code review — issue #68 (contextual help)
|
||||
|
||||
Date: 2026-08-12
|
||||
|
||||
Branch: `dev`
|
||||
|
||||
Scope: `hp-help`, Houseplan help factory, localization contract, dialog/overlay lifecycle,
|
||||
keyboard and touch interaction, responsive placement, consumers and regression coverage.
|
||||
|
||||
## Outcome
|
||||
|
||||
The overlay, focus, Escape, outside-click, scroll, Popover/fallback and visual-viewport
|
||||
paths are internally consistent. The shared dialog overlay registry is used correctly,
|
||||
the trigger remains reachable in disabled fieldsets through `legend`, and all seven
|
||||
current call sites have non-empty RU/EN body and ARIA strings.
|
||||
|
||||
Four hardening findings were accepted and fixed locally. No model, saved configuration
|
||||
or user data contract changed.
|
||||
|
||||
## Findings and resolutions
|
||||
|
||||
### CR68-01 — dead trigger is rendered without help content (P1)
|
||||
|
||||
`hp-help` blocked `_openHelp()` when `text` was empty, but `render()` still returned a
|
||||
focusable button. The result was the exact reported defect: a visible help glyph that
|
||||
could not open any explanation.
|
||||
|
||||
Resolution: `hp-help` renders nothing unless both trimmed `text` and `ariaLabel` exist.
|
||||
The same predicate guards opening and closes an already-open surface if either value is
|
||||
removed dynamically.
|
||||
|
||||
### CR68-02 — missing translation could be displayed as a key (P1)
|
||||
|
||||
The card factory called `t()` directly. Its intended generic fallback returns the key
|
||||
name when neither dictionary contains a value, so a future incomplete help pair could
|
||||
produce a real trigger with implementation text such as `marker.foo.help`.
|
||||
|
||||
Resolution: the factory now checks the localized value and its derived `.aria` value
|
||||
through `hasTranslation()` before it creates `hp-help`. English fallback remains valid;
|
||||
a genuinely absent or whitespace-only pair produces no host and no layout gap.
|
||||
|
||||
### CR68-03 — accessible name had a hard-coded English fallback (P1)
|
||||
|
||||
Direct use without `ariaLabel` produced `aria-label="Help"`. This violated the issue
|
||||
contract requiring a complete localized accessible name and made an incomplete component
|
||||
look valid to keyboard and screen-reader users.
|
||||
|
||||
Resolution: the fallback was removed. Missing ARIA copy suppresses the affordance just
|
||||
like missing visible copy.
|
||||
|
||||
### CR68-04 — icon did not follow the product icon system (P2)
|
||||
|
||||
The trigger used a font `?`, whose shape and optical alignment depended on the platform
|
||||
font and did not visually mean “question in a circle”.
|
||||
|
||||
Resolution: the glyph is now the shared MDI `help-circle-outline` vector inside the same
|
||||
32/40 px target. It remains decorative because the button already has a full ARIA label.
|
||||
|
||||
### CR68-05 — regression coverage missed incomplete content (P2)
|
||||
|
||||
The smoke covered all open/close and overlay paths but never instantiated an empty or
|
||||
half-configured component, so CR68-01/03 could pass the release gate.
|
||||
|
||||
Resolution: the #68 smoke now asserts that empty body and empty ARIA copy create no
|
||||
trigger, and that restoring a complete pair creates the circled-question SVG.
|
||||
|
||||
## Reviewed without changes
|
||||
|
||||
- Mouse hover timing, keyboard focus, touch click and the second-Escape dialog path.
|
||||
- `aria-describedby` only while open; the visible bubble stays hidden from the
|
||||
accessibility tree to avoid duplicate announcements.
|
||||
- Exclusive transient-surface ownership with the colour/opacity picker and toast.
|
||||
- Popover API path and dialog-owned fallback portal.
|
||||
- Cached dialog scroll-listener cleanup and disconnect cleanup.
|
||||
- Visual viewport placement, flipping and edge clamping.
|
||||
- Existing call sites and RU/EN localization parity.
|
||||
|
||||
## Verification policy
|
||||
|
||||
Per project policy, no tests were run during this local edit. Static type checking,
|
||||
syntax checking and whitespace validation are recorded in the handoff; the updated
|
||||
targeted smoke is intended for the next prerelease gate.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Код-ревью issue #94 — универсальное «Переключить состояние»
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/94
|
||||
- **Проверенная версия:** локальный `dev` после `v1.62.0-beta.3`, включая
|
||||
незакоммиченные исправления #95–#97
|
||||
- **Итог ревью до правок:** changes requested — 2 high, 4 medium, 1 minor
|
||||
- **Итог после локальных правок:** замечания устранены; проверки отложены до
|
||||
ближайшего pre-release по принятому правилу владельца
|
||||
|
||||
## 1. Охват
|
||||
|
||||
Проверены:
|
||||
|
||||
1. нормативный алгоритм и acceptance criteria в
|
||||
`docs/specs/094-universal-state-toggle.md`;
|
||||
2. pure resolver `src/device-toggle.ts`;
|
||||
3. target selection через exact binding, device role и `controls`;
|
||||
4. capability/security/service guards;
|
||||
5. dialog projection, hint, lossless Save и preview;
|
||||
6. обычный click, confirmation re-resolve и обработка ошибок;
|
||||
7. общий cover target для действия и presentation;
|
||||
8. backend schema и import/export round-trip;
|
||||
9. unit/smoke-матрица и архитектурная документация;
|
||||
10. совместимость с визуальной непрерывностью #73 и локальными правками
|
||||
#95–#97.
|
||||
|
||||
## 2. Найденные и исправленные замечания
|
||||
|
||||
### CR94-01 — High: domain service ошибочно считался capability конкретной entity
|
||||
|
||||
**Было:** `POWER_DOMAINS` разрешал `climate`, `water_heater`, `siren` и
|
||||
`camera`, если нужный service существовал на уровне domain. Но HA публикует
|
||||
services для всего domain; неподдерживающая их конкретная entity всё равно
|
||||
оставалась «исполняемой» в hint, а вызов затем отклонялся Home Assistant.
|
||||
|
||||
Это прямо противоречило §9.1 и mutation gate 6 ТЗ. Home Assistant Core
|
||||
подтверждает entity-level guards:
|
||||
|
||||
- Climate `TURN_OFF=128`, `TURN_ON=256`:
|
||||
https://github.com/home-assistant/core/blob/dev/homeassistant/components/climate/const.py
|
||||
- Water heater `ON_OFF=8`:
|
||||
https://github.com/home-assistant/core/blob/dev/homeassistant/components/water_heater/__init__.py
|
||||
- Siren `TURN_ON=1`, `TURN_OFF=2`:
|
||||
https://github.com/home-assistant/core/blob/dev/homeassistant/components/siren/const.py
|
||||
- Camera `ON_OFF=1`:
|
||||
https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py
|
||||
|
||||
**Исправлено:** введён декларативный `POWER_ADAPTERS` с state semantics,
|
||||
unknown policy и точными feature masks. Feature-gated entity теперь получает
|
||||
команду только при наличии требуемых bits; service catalog остаётся вторым
|
||||
guard. Media player и legacy vacuum включены в тот же реестр.
|
||||
|
||||
**Покрытие:** параметрические unit-матрицы для всех базовых power adapters и
|
||||
для climate, media player, siren, water heater, camera, legacy vacuum — как
|
||||
разрешённые, так и запрещённые/missing-feature варианты; отдельная матрица
|
||||
`unknown` проверяет полный/неполный capability mask.
|
||||
|
||||
### CR94-02 — High: click мог использовать target из сохранённого визуального frame
|
||||
|
||||
**Было:** #73 намеренно может некоторое время показывать последний цельный
|
||||
`_renderDevices` snapshot, но `_clickDevice(ev, d)` разрешал action прямо по
|
||||
переданному `d`. Если binding/controls изменились до атомарной смены frame,
|
||||
нажатие без confirmation могло вызвать прежнюю цель. Confirmation уже делал
|
||||
повторное разрешение, обычный click — нет.
|
||||
|
||||
**Исправлено:** в View действие сначала находит текущий `DevItem` в
|
||||
`this._devices` по стабильному marker id. Action, binding, controls и command
|
||||
разрешаются только из него; исчезнувший marker даёт no-op. Локальная
|
||||
House Plan info-card по-прежнему может использовать видимый snapshot — это
|
||||
безопасная read-only поверхность и намеренный контракт исправления #96.
|
||||
|
||||
**Покрытие:** smoke сохраняет старый `DevItem`, меняет controls, перестраивает
|
||||
live devices и проверяет, что click вызывает только новую группу.
|
||||
|
||||
### CR94-03 — Medium: неизвестный persisted action расходил UI и runtime
|
||||
|
||||
**Было:** неизвестный token на light проецировался как default `toggle`, тогда
|
||||
как `toggleOriginOf()` правильно не признавал его toggle-origin. Селектор мог
|
||||
показать «Переключить состояние», hint оставался пустым, а click был no-op.
|
||||
|
||||
**Исправлено:** light default применяется только к действительно отсутствующему
|
||||
token (`null`, `undefined`, пустая legacy-строка). Неизвестное значение fail-
|
||||
closed проецируется в локальную карточку; backend по-прежнему отклоняет его при
|
||||
записи.
|
||||
|
||||
### CR94-04 — Medium: legacy cover терял identity после disable в HA
|
||||
|
||||
**Было:** legacy `tap_action: cover` искал cover только в active
|
||||
`device.entities`, если рядом оставался хотя бы один активный sibling. После
|
||||
disable cover в HA старое явное намерение превращалось в анонимный no-target и
|
||||
presentation переставал знать прежнюю cover entity.
|
||||
|
||||
**Исправлено:** legacy-cover origin сначала сохраняет приоритет активной cover,
|
||||
а при её отсутствии ищет историческую цель в `allEntities`. Общий resolver
|
||||
возвращает `ha-disabled` и сохраняет тот же cover identity для hint/presentation,
|
||||
но более ранняя disabled registry row не может заслонить рабочую cover. Новый
|
||||
device-role toggle по-прежнему исключает disabled rows.
|
||||
|
||||
### CR94-05 — Medium: пустой service catalog считался поддержкой всех services
|
||||
|
||||
**Было:** отсутствие/пустой `hass.services` давало optimistic `true` для любого
|
||||
service. Это нарушало runtime guard из ТЗ и позволяло построить команду без
|
||||
доказательства её существования.
|
||||
|
||||
**Исправлено:** отсутствующий catalog/domain/service теперь означает
|
||||
`unsupported`. После появления актуального HA snapshot resolver автоматически
|
||||
пересчитывает hint и command. Синтетический HA в `demo/srv/demo.html` теперь
|
||||
публикует явный service catalog, поэтому smoke-среда проверяет тот же fail-closed
|
||||
контракт и не создаёт ложные no-op.
|
||||
|
||||
### CR94-06 — Medium: device binding не выбирал первую действительно поддерживаемую entity роли
|
||||
|
||||
**Было:** resolver выбирал первую entity «подходящего domain», а затем мог
|
||||
остановиться на `unsupported`, хотя следующая равноправная entity той же
|
||||
functional role имела требуемую capability. Это не соответствовало формулировке
|
||||
§8.1 «первая поддерживаемая entity».
|
||||
|
||||
**Исправлено:** проверка идёт по уже выбранной shared functional role.
|
||||
Capability-unsupported peer можно пропустить только внутри неё; missing,
|
||||
unavailable и secure identity сохраняются без retarget. Config/diagnostic
|
||||
switch более слабой роли по-прежнему никогда не подставляется.
|
||||
|
||||
### CR94-07 — Minor: статус ТЗ оставался «готово к реализации»
|
||||
|
||||
**Исправлено:** ТЗ, specs index, архитектура, STATUS, TESTING и RU/EN changelog
|
||||
актуализированы под опубликованную beta.3 и этот локальный hardening pass.
|
||||
|
||||
## 3. Проверенные инварианты без изменений
|
||||
|
||||
- exact `entity:` binding не ищет sibling при unsupported/missing/unavailable;
|
||||
- raw external controls владеют tap только у explicit toggle и не дают fallback
|
||||
на собственную entity контроллера;
|
||||
- passive forced-light marker сохраняет единственное документированное driver-
|
||||
исключение и дедупликацию;
|
||||
- partial group вызывает только отображённое доступное подмножество;
|
||||
- any-on/all-off group semantics соответствует ТЗ;
|
||||
- lock, alarm и cover classes `garage`/`door`/`gate` остаются secure no-op;
|
||||
- cover/valve open/close/stop используют одновременно feature bit и service;
|
||||
- confirmation сравнивает target set, а направление намеренно пересчитывается
|
||||
по текущему state;
|
||||
- legacy `cover` и отсутствующий default-light action сохраняются lossless до
|
||||
явного изменения select;
|
||||
- backend принимает текущие actions и legacy `cover`, неизвестные tokens
|
||||
отклоняет; import/export сохраняет action-поля без преобразования;
|
||||
- отдельного `cover` в текущем UI нет;
|
||||
- right-click, long press, touch/pinch и confirmation UX этим проходом не
|
||||
менялись.
|
||||
|
||||
## 4. Изменённые файлы
|
||||
|
||||
- `src/device-toggle.ts`
|
||||
- `src/houseplan-card.ts`
|
||||
- `test/device-toggle.test.mjs`
|
||||
- `demo/smoke_controls.mjs`
|
||||
- `demo/srv/demo.html`
|
||||
- `docs/specs/094-universal-state-toggle.md`
|
||||
- `docs/specs/README.md`
|
||||
- `docs/ARCHITECTURE.md`
|
||||
- `docs/STATUS.md`
|
||||
- `docs/TESTING.md`
|
||||
- `docs/CHANGELOG.md`
|
||||
- `docs/CHANGELOG.ru.md`
|
||||
|
||||
## 5. Проверка
|
||||
|
||||
Локально выполнены только read-only/static проверки ревью:
|
||||
|
||||
- `git diff --check`;
|
||||
- `npm run typecheck`;
|
||||
- `node --check test/device-toggle.test.mjs`;
|
||||
- `node --check demo/smoke_controls.mjs`;
|
||||
- поиск всех consumers `resolveToggleIntent`, `projectedTapAction`,
|
||||
`toggleCoverEntity`, `sameToggleCommandTargets`;
|
||||
- сверка backend schema/import-export;
|
||||
- сверка capability flags с официальным Home Assistant Core.
|
||||
|
||||
Unit, browser smoke, backend tests, build и generated bundles **не запускались**
|
||||
по правилу проекта: локальные правки делаются без тестов, минимальный целевой
|
||||
прогон выполняется при следующем pre-release.
|
||||
@@ -16,6 +16,16 @@ writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
|
||||
is the quickest route to the author and other users. Bugs and concrete feature
|
||||
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
|
||||
## 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.
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
```bash
|
||||
@@ -25,6 +35,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
|
||||
@@ -40,7 +51,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,805 @@
|
||||
# Процесс работы над 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
|
||||
```
|
||||
|
||||
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
|
||||
порождает событие, событие запускает ревью (§10.4). Остальные ставит исполнитель.
|
||||
|
||||
### 2.1 Новое — заведение задачи
|
||||
|
||||
- **Кто:** любой — владелец, агент, пользователь (Telegram, GitHub).
|
||||
- **Вход:** проблема в пользовательских терминах; как проявляется или зачем нужно.
|
||||
Решение **не требуется** и не приветствуется.
|
||||
- **Запрещено:** ставить приоритет, оценивать, писать ТЗ, начинать код.
|
||||
|
||||
### 2.2 Аналитика и оценка
|
||||
|
||||
Задача разбирается, продуктовое «да» ещё не дано.
|
||||
|
||||
- **Кто:** агент-аналитик готовит, владелец решает.
|
||||
- **Чек-лист**, результат — комментарием в 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. лёгкий трек — да/нет по критериям §5.
|
||||
- **Приоритет и ценность — поля владельца.** Агент предлагает, владелец
|
||||
утверждает; иначе агенты приоритизируют сами и P1 разрастается.
|
||||
- **Выход:** «ТЗ в работе» либо «Отклонено» с записанной причиной.
|
||||
|
||||
### 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` и получает
|
||||
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
|
||||
|
||||
---
|
||||
|
||||
## 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): CI Validate зелёный на точном SHA тега.
|
||||
|
||||
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
|
||||
Упавший предрелизный гейт автор чинит и повторно прогоняет; зелёный прогон
|
||||
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
|
||||
|
||||
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
|
||||
Performance зелёные на точном SHA; статусов issue не касается.
|
||||
|
||||
---
|
||||
|
||||
## 9. Метки — канонический статус
|
||||
|
||||
Статус читается из меток: их видно в списке issue и их читает любой токен с
|
||||
доступом к Issues, в отличие от Project v2, который требует отдельного скоупа.
|
||||
Project v2 остаётся человеческим представлением и синхронизируется по меткам.
|
||||
|
||||
**Имена меток английские** (решение владельца 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), `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`, а не от начала истории — иначе в него
|
||||
попали бы все нарушения, совершённые до появления гейта.
|
||||
|
||||
Проверка статуса 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`, то есть заведомо вне рабочего множества.
|
||||
|
||||
Не реализовано и остаётся долгом:
|
||||
|
||||
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 закрывает релиз-менеджер после выпуска беты, не исполнитель.
|
||||
```
|
||||
@@ -15,6 +15,11 @@ room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wa
|
||||
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
|
||||
right on your Lovelace dashboard.
|
||||
|
||||
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
|
||||
> on phones and tablets. The editors are designed primarily for a desktop
|
||||
> browser with a mouse and keyboard; individual editing operations on touch
|
||||
> devices may be awkward, limited, or unavailable.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Try it live — no install needed
|
||||
@@ -39,8 +44,9 @@ right on your Lovelace dashboard.
|
||||
you drag, so the drawing and the photo of your plan finally line up.
|
||||
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
|
||||
whole groups of lights (works for dumb switches and stateless remotes too).
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
|
||||
of its own color that spills through doorways and open zone boundaries.
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly
|
||||
the floor it can see: through doorways and open boundaries, stopped by walls,
|
||||
columns and partitions, which cast real shadows.
|
||||
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
|
||||
the day (white noon → golden hour → deep night), while windows on exterior
|
||||
walls cast real wedges of sunlight into the rooms; optional cloud cover
|
||||
@@ -57,8 +63,10 @@ right on your Lovelace dashboard.
|
||||
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
|
||||
drives the plan in real time, pouring its path out from under itself;
|
||||
current and previous cleanup runs are recorded server-side. Calibration is
|
||||
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
|
||||
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
|
||||
one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic
|
||||
source picker also covers registry-less map cameras without silently
|
||||
rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack
|
||||
dreame-vacuum and Valetudo.
|
||||
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
|
||||
**server-side** — one shared plan for every user and screen, synced live.
|
||||
|
||||
@@ -101,9 +109,10 @@ Assistant integration:
|
||||
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
|
||||
|
||||
**One sentence:** House Plan is the shared, area-aware live map of your home —
|
||||
not a drawing app for furniture. If you want to sketch sofas and walls from
|
||||
scratch, a draw-centric card may fit better; if you already have a plan image
|
||||
and a real HA areas registry, House Plan keeps every tablet on the same layout.
|
||||
not a general-purpose CAD package. Its Background editor covers practical
|
||||
decor, labels and furniture; if you need unrestricted architectural drafting,
|
||||
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
|
||||
every tablet on the same live layout.
|
||||
|
||||
Key advantages in short:
|
||||
|
||||
@@ -112,11 +121,17 @@ Key advantages in short:
|
||||
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
|
||||
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
|
||||
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
|
||||
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
|
||||
a light is shining, a socket is powering, a fan is spinning, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
|
||||
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
|
||||
that mode is the best available fallback. Orange = open / unlocked.
|
||||
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
|
||||
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
|
||||
A translucent icon = unavailable. Dark = idle.
|
||||
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
|
||||
furniture, edit physical styles and transform every object with the same
|
||||
selection model. The plan image has its own move/resize/rotate tool, numeric
|
||||
properties and shared Undo/Redo.
|
||||
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
|
||||
|
||||
---
|
||||
@@ -198,7 +213,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

|
||||
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
|
||||
|
||||

|
||||
|
||||
@@ -222,15 +237,17 @@ Rooms may not overlap: a click strictly inside an existing room, or an outline t
|
||||
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
|
||||
|
||||
|
||||
### Doors, windows and locks
|
||||
### Doors, windows, gates and locks
|
||||
|
||||
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
|
||||
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
|
||||
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
|
||||
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
|
||||
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
|
||||
|
||||
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
|
||||
draws itself in as the real door opens; a window opens its two casements. While open, the moving
|
||||
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
|
||||
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
|
||||
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
|
||||
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
|
||||
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
|
||||
on the opening shows a status card with both states instead.
|
||||
|
||||
@@ -241,7 +258,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
|
||||
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
|
||||
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
|
||||
|
||||
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
|
||||
|
||||
@@ -261,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
|
||||
|
||||
@@ -282,6 +302,16 @@ You can also place a **single entity** (not just a whole device): start typing i
|
||||
|
||||
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
|
||||
|
||||
To represent a dumb physical lamp controlled by a smart relay, place a virtual
|
||||
point where the lamp really is and set **Light source → Always**. Manual colour,
|
||||
brightness and radius stay available even though the point has no HA entity.
|
||||
Then open the relay and add that plan source under **Controls other light
|
||||
sources**. The relay continues to show the aggregate working state, while Glow,
|
||||
room fill and statistics belong to the lamp's position. An unlinked passive
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -327,7 +357,7 @@ Services → House Plan**.
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
|
||||
|
||||
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · **[Продуктовый аудит и план улучшений](docs/PRODUCT-IMPROVEMENT-PLAN.ru.md)**
|
||||
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
@@ -15,6 +15,11 @@
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
|
||||
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
|
||||
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
|
||||
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
|
||||
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
@@ -31,14 +36,15 @@
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
|
||||
освещает ровно тот пол, который видит: через проёмы и открытые границы,
|
||||
а стены, колонны и перегородки его не пропускают и дают настоящие тени.
|
||||
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
@@ -55,8 +61,10 @@
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
|
||||
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
|
||||
(по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
|
||||
выбор источника поддерживают registry-less камеры карт и не подменяют молча
|
||||
сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
|
||||
(Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
@@ -99,9 +107,10 @@ YAML/SVG, либо конфиг живёт в YAML карточки. House Plan
|
||||
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
|
||||
|
||||
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
|
||||
редактор мебели. Если нужно с нуля нарисовать диваны и стены — лучше
|
||||
draw-карточка; если есть картинка плана и зоны в HA — House Plan держит один
|
||||
и тот же layout на всех планшетах.
|
||||
универсальная CAD-система. Редактор подложки покрывает практический декор,
|
||||
надписи и мебель; для свободного архитектурного черчения лучше отдельный
|
||||
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
|
||||
на всех планшетах.
|
||||
|
||||
Ключевые преимущества коротко:
|
||||
|
||||
@@ -110,12 +119,14 @@ draw-карточка; если есть картинка плана и зоны
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
|
||||
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
|
||||
Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
|
||||
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
@@ -199,7 +210,7 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
|
||||
|
||||

|
||||
|
||||
@@ -223,15 +234,15 @@ title: План дома
|
||||
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
|
||||
|
||||
|
||||
### Двери, окна и замки
|
||||
### Двери, окна, ворота и замки
|
||||
|
||||
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
|
||||
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
|
||||
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
|
||||
окно 120 см), датчик открытия и — для двери — **замок**.
|
||||
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
|
||||
|
||||
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
|
||||
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
|
||||
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
|
||||
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
|
||||
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
|
||||
переключить — клик по проёму показывает карточку с обоими статусами.
|
||||
|
||||
@@ -242,7 +253,7 @@ title: План дома
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти скрытые устройства, откройте редактор устройств и нажмите **«Показать скрытые»**: они отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||
@@ -329,7 +340,7 @@ title: План дома
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто автоматически — откройте редактор устройств, нажмите **«Показать скрытые»**, откройте его диалог, нажмите **«Показать»**, затем **«Сохранить»**.
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
# Ревью ТЗ `docs/specs/089-isometric-view-stage1.md`
|
||||
|
||||
Дата ревью: 2026-08-11
|
||||
Issue: [#89](https://github.com/Matysh/houseplan-card/issues/89)
|
||||
Проверенная версия ТЗ: локальный `dev`, после `v1.62.0-beta.1`, SHA `2cf5c27`
|
||||
|
||||
## Итог
|
||||
|
||||
Направление выбрано правильно: фиксированная SVG-проекция, отсутствие новой
|
||||
модели данных, каноническая геометрия стен, плоские редакторы, скрытая поставка
|
||||
через Labs и обязательные golden/performance gates хорошо соответствуют
|
||||
текущей архитектуре House Plan.
|
||||
|
||||
Однако статус **«готово к реализации» пока преждевременен**. В ТЗ остаются
|
||||
восемь блокирующих неоднозначностей. Главная из них — документ описывает
|
||||
проекцию точек, но не определяет переход между тремя реально существующими
|
||||
системами координат и не задаёт новый контракт viewport/frame. Если начать
|
||||
реализацию буквально по текущему тексту, наиболее вероятный результат —
|
||||
прыжок масштаба при переключении, рассинхронизация HTML-маркеров и SVG, неверный
|
||||
warm-remount либо двойное обратное преобразование pointer events.
|
||||
|
||||
Рекомендация: внести блокеры B1–B8 и существенные замечания M1–M8 в ТЗ, после
|
||||
чего документ можно переводить в `approved`. Переписывать продуктовую часть
|
||||
или менять выбранный renderer не требуется.
|
||||
|
||||
## Что уже зафиксировано хорошо
|
||||
|
||||
1. Labs не меняет backend, schema/config/layout и не попадает в backup.
|
||||
2. Плоский вид остаётся default и fallback; редакторы остаются плоскими.
|
||||
3. CSS 3D, WebGL и многократное клонирование SVG явно запрещены.
|
||||
4. Источник wall geometry — `wallBodiesGeometry()`, а не новый параллельный
|
||||
контур.
|
||||
5. Проёмы должны быть настоящими разрывами masonry geometry.
|
||||
6. Glow сохраняет один регион на источник и один blur на слой по `LIGHT.md`.
|
||||
7. Кэш не должен зависеть только от `_cfgEpoch`.
|
||||
8. У stage 1 нет публичного обещания и пользовательской миграции.
|
||||
|
||||
---
|
||||
|
||||
## Блокирующие замечания
|
||||
|
||||
### B1 — Не определены системы координат, pivot и projected frame
|
||||
|
||||
**Где:** §4, §7, §8, AC 8.
|
||||
**Критичность:** blocker.
|
||||
|
||||
`projectPoint(p, z, cam)` и `unprojectPoint(screen, cam)` недостаточны для
|
||||
существующего renderer. Сейчас House Plan различает как минимум:
|
||||
|
||||
- plan/model units (`room`, `wall`, `marker`);
|
||||
- координаты SVG scene/viewBox (`_view`);
|
||||
- client pixels внутри `.stage` (`_screenToVb()`).
|
||||
|
||||
В объёмном виде plan units и scene units перестают совпадать. Кроме того,
|
||||
`IsoCamera` не содержит pivot/origin и масштаба оси Z. Проекция вокруг `(0, 0)`
|
||||
сместит план при смене пространства, а старый `_baseVb()` не включает поднятые
|
||||
верхние грани и начнёт обрезать стены.
|
||||
|
||||
**Что добавить в ТЗ:**
|
||||
|
||||
```ts
|
||||
type PlanPoint = readonly [number, number];
|
||||
type ScenePoint = readonly [number, number];
|
||||
|
||||
interface IsoCamera {
|
||||
rotDeg: number;
|
||||
tiltDeg: number;
|
||||
xyScale: number;
|
||||
zScale: number;
|
||||
origin: PlanPoint;
|
||||
}
|
||||
|
||||
projectPlanPoint(p: PlanPoint, zUnits: number, cam: IsoCamera): ScenePoint;
|
||||
unprojectFloorPoint(p: ScenePoint, cam: IsoCamera): PlanPoint; // только z=0
|
||||
clientToScenePoint(client: readonly [number, number], stageRect: DOMRectReadOnly,
|
||||
view: ViewRect): ScenePoint;
|
||||
projectedFrame(input: IsoFrameInput, cam: IsoCamera): ViewRect;
|
||||
```
|
||||
|
||||
Нормативно определить:
|
||||
|
||||
1. `projectPoint` возвращает **scene**, а не screen/client coordinates.
|
||||
2. `unprojectFloorPoint` инвертирует только плоскость `z=0`; высотная грань не
|
||||
имеет единственной plan-точки.
|
||||
3. Pivot — одна фиксированная plan-space константа (рекомендуемо
|
||||
`[NORM_W / 2, NORM_W / 2]`), а не центр viewport/content frame и не
|
||||
положение курсора. Иначе появление far marker или переключение `_showFar`
|
||||
сдвинет уже построенные стены без изменения их геометрии.
|
||||
4. Wall height сначала переводится из общей константы в plan units, затем
|
||||
применяется `zScale`; выбранные значения фиксируются ADR.
|
||||
5. `fit`, pan clamp, home arrow, far-object hint и initial view используют
|
||||
`projectedFrame`, включающий floor content и поднятые wall faces.
|
||||
6. Projected frame не зависит от текущего zoom/pan и входит в geometry cache.
|
||||
|
||||
Без этого нельзя проверить «не меняет фокус плана скачком» и «не обрезает
|
||||
объекты».
|
||||
|
||||
### B2 — Не задано преобразование viewport при flat ↔ iso и при входе в редактор
|
||||
|
||||
**Где:** §7, §10, AC 7–9.
|
||||
**Критичность:** blocker.
|
||||
|
||||
Текущий `_view` хранит прямоугольник именно в координатах текущего SVG. Его
|
||||
нельзя без преобразования перенести из flat scene в iso scene. Текущий
|
||||
`_viewModeSnap` также хранит `cx/cy` в flat units. Требование «не менять zoom и
|
||||
фокус» сейчас не имеет алгоритма.
|
||||
|
||||
**Добавить нормативный алгоритм:**
|
||||
|
||||
1. Перед сменой проекции получить логический центр пола:
|
||||
- flat: центр `_view` уже является plan point;
|
||||
- iso: центр `_view` пропустить через `unprojectFloorPoint`.
|
||||
2. Построить target frame и target fit.
|
||||
3. Сохранить тот же scalar zoom.
|
||||
4. Спроецировать логический центр в target scene и вызвать `_applyView()` с
|
||||
этим scene center.
|
||||
5. Не переиспользовать raw `x/y/w/h` между видами.
|
||||
6. Вход в editor выполняет тот же iso → flat переход; выход — flat → прежний
|
||||
view kind. Смена пространства внутри editor сбрасывает старый snapshot по
|
||||
существующему правилу.
|
||||
|
||||
Предпочтение вида (`flat|iso`) и viewport — разные сущности. В localStorage
|
||||
пишется только предпочтение и существующий scalar zoom; raw viewport остаётся
|
||||
runtime/warm state.
|
||||
|
||||
### B3 — ТЗ не совместимо с `docs/WARM-REMOUNT.md`
|
||||
|
||||
**Где:** §7 «Непрерывность», §10.
|
||||
**Критичность:** blocker.
|
||||
|
||||
#73 переносит через `warmBoot` точный `_view`, `_viewModeSnap`, mode и
|
||||
fingerprint кадра. После введения iso один и тот же `ViewRect` имеет два разных
|
||||
смысла. Если новый экземпляр восстановит iso rectangle в flat mode (например,
|
||||
флаг снят/истёк) либо наоборот, получится именно тот скачок/пустой кадр, который
|
||||
#73 устраняет.
|
||||
|
||||
**Добавить:**
|
||||
|
||||
- warm viewport хранит `projection: 'flat'|'iso'` и `logicalCenter`;
|
||||
- raw `_view` усыновляется только при совпадении space, projection и активного
|
||||
Labs contract;
|
||||
- при несовпадении восстанавливаются scalar zoom + logical center через
|
||||
алгоритм B2, а не чужой rectangle;
|
||||
- frame fingerprint включает effective projection и iso geometry fingerprint;
|
||||
- выключение/expiry Labs никогда не может воскресить iso DOM из memo;
|
||||
- отдельный smoke: iso → remount → тот же iso frame; iso → снять flag →
|
||||
remount → корректный flat frame без veil/flash.
|
||||
|
||||
### B4 — Правило «все попадания через unproject» технически неверно
|
||||
|
||||
**Где:** §7 Pointer, §11 smoke Pointer.
|
||||
**Критичность:** blocker.
|
||||
|
||||
SVG сам hit-тестирует элементы внутри трансформированного `<g>`. Room hover и
|
||||
SVG opening symbols не нужно вручную unproject-ить: это даст двойное
|
||||
преобразование. HTML marker также получает click как обычный DOM-элемент.
|
||||
Кроме того, marker drag выполняется в Device editor, а по этому же ТЗ все
|
||||
редакторы плоские; smoke «перетаскивание маркера в объёмном виде» противоречит
|
||||
scope.
|
||||
|
||||
**Заменить правило на:**
|
||||
|
||||
- SVG/HTML interactive children используют нативный DOM/SVG hit-test;
|
||||
- pan и zoom anchor работают в scene coordinates;
|
||||
- `client → scene → unprojectFloor` применяется только там, где stage event
|
||||
действительно должен получить plan coordinate;
|
||||
- в stage 1 iso mode не создаёт/редактирует geometry и не перетаскивает
|
||||
markers, поэтому editor `_svgPoint()` остаётся flat;
|
||||
- тесты кликают реальные room/device/opening DOM targets и проверяют action;
|
||||
отдельный unit проверяет `client → scene → floor` для будущего использования.
|
||||
|
||||
### B5 — Kiosk UX противоречит фактическому DOM
|
||||
|
||||
**Где:** §3, §7, AC 2/4.
|
||||
**Критичность:** blocker.
|
||||
|
||||
ТЗ обещает кнопку «в режиме просмотра (и в киоске) рядом с шапкой». В текущем
|
||||
kiosk вся `.hdr.kioskhide` имеет `display:none`; такой кнопки физически не
|
||||
будет. Одновременно §7 говорит, что скрытая панель не должна лишить пользователя
|
||||
возврата в flat.
|
||||
|
||||
Для скрытого stage 1 рекомендуется закрепить простой вариант:
|
||||
|
||||
1. Кнопка существует только в обычном View под активным Labs.
|
||||
2. Kiosk читает последнее per-space предпочтение этого браузера.
|
||||
3. `hp-labs=-iso` или `hp-labs=off` — обязательный аварийный путь: kiosk сразу
|
||||
становится flat и не может восстановить iso из warm memo.
|
||||
4. В kiosk нет новой панели/диалога stage 1.
|
||||
5. Smoke покрывает загрузку kiosk с сохранённым `iso` и возврат в flat через
|
||||
URL operation.
|
||||
|
||||
Если владельцу нужен переключатель прямо в kiosk, его надо отдельно поместить
|
||||
в существующий long-press kiosk dialog; «рядом с шапкой» всё равно неверно.
|
||||
|
||||
### B6 — Грамматика Labs содержит противоречия и ломает комбинированный hash
|
||||
|
||||
**Где:** §2.2–2.4.
|
||||
**Критичность:** blocker.
|
||||
|
||||
Не определено:
|
||||
|
||||
- кто сильнее при одновременных `?hp-labs=` и `#hp-labs=`;
|
||||
- является URL полным replacement или операциями над storage;
|
||||
- что делает `iso,-iso`, `off,iso`, повторный параметр;
|
||||
- §2.2 требует не удалять параметр из URL, а §2.3 говорит, что `off` «очищает
|
||||
и то, и другое»;
|
||||
- как `#space=x&hp-labs=iso` сохраняет существующий deep link;
|
||||
- что происходит при `history.back()`/`popstate`.
|
||||
|
||||
**Предлагаемый точный контракт:**
|
||||
|
||||
1. База — валидный набор из storage.
|
||||
2. Query operations применяются слева направо, затем hash operations слева
|
||||
направо; hash сильнее, потому что именно он реактивен внутри Lovelace.
|
||||
3. `id` добавляет, `-id` удаляет, `off` очищает набор в этой позиции; следующие
|
||||
токены снова могут добавлять.
|
||||
4. Повторные `hp-labs` обрабатываются в порядке появления.
|
||||
5. Если в URL был хотя бы один известный operation или `off`, итог пишется в
|
||||
storage. Неизвестные значения сами по себе storage не переписывают.
|
||||
6. URL никогда не переписывается механизмом Labs. Из §2.3 убрать слова об
|
||||
очистке URL: `off` очищает **effective set и storage**, но остаётся видимым.
|
||||
7. Hash разбирается общим helper вместе с `space`; оба порядка параметров и
|
||||
percent-encoding тестируются. `_hashSpace()` не остаётся вторым regex parser.
|
||||
8. `hashchange` реактивен; `popstate` перечитывает query/hash, если URL реально
|
||||
сменился без reload.
|
||||
|
||||
### B7 — Не определена топология side faces и смысл «нет торцов в проёме»
|
||||
|
||||
**Где:** §5, AC 3/5/6.
|
||||
**Критичность:** blocker.
|
||||
|
||||
`wallBodiesGeometry().geom` — MultiPolygon с внешними и внутренними rings, уже
|
||||
после union, junction patches и opening cuts. «Граничные рёбра» недостаточно:
|
||||
нужно определить winding, outward normal, holes, culling и порядок отрисовки.
|
||||
Фраза «без торцов внутри проёма» двусмысленна. При полном разрыве стены
|
||||
вертикальные jamb faces по краям проёма являются корректной частью объёма;
|
||||
запретить их — значит получить визуально обрезанную плёнку вместо стены.
|
||||
|
||||
**Добавить:**
|
||||
|
||||
- faces строятся непосредственно из rings канонического MultiPolygon после
|
||||
union/cuts; исходные room edges для extrusion не используются;
|
||||
- winding нормализуется один раз, outward normal учитывает outer/hole ring;
|
||||
- face видима по знаку dot product normal и фиксированного view direction;
|
||||
- для фиксированной камеры задаётся детерминированный stable depth order;
|
||||
- opening slot создаёт две exposed jamb faces по концам разрыва — они нужны;
|
||||
- запрещены face/полоса, пересекающая сам gap, и cap на floor тоннеля;
|
||||
- на stage 1 дверь, окно и ворота являются full-height gaps осознанно, так как
|
||||
модель не хранит высоту подоконника;
|
||||
- opening никогда не вырезает coincident partition/column — сохраняется
|
||||
текущий порядок union extras после room opening cuts;
|
||||
- top face использует whole geometry с `fill-rule:evenodd`;
|
||||
- unit fixtures включают outer ring, hole, multipolygon, T/X join, opening у
|
||||
угла и coincident independent body.
|
||||
|
||||
### B8 — Fallback может зациклить exception и оставить кнопку во лжи
|
||||
|
||||
**Где:** §9, AC 10.
|
||||
**Критичность:** blocker.
|
||||
|
||||
«Вернуться в flat на этом кадре» не отвечает на вопросы: будет ли следующий
|
||||
Lit render снова падать, что показывает `aria-pressed`, сохраняется ли `iso` в
|
||||
localStorage и когда разрешён retry.
|
||||
|
||||
**Добавить state machine:**
|
||||
|
||||
- `desiredView` — сохранённое предпочтение;
|
||||
- `effectiveView` — реально нарисованный `flat|iso`;
|
||||
- исключение в pure geometry/iso template ловится на границе
|
||||
`renderIsoScene()`, для `(space, geometryFingerprint)` ставится session latch;
|
||||
- при latch effective view = flat, iso geometry больше не вызывается на каждом
|
||||
HA state update;
|
||||
- конфиг/layout и сохранённое предпочтение не меняются автоматически;
|
||||
- кнопка отражает `effectiveView` (`aria-pressed=false`), явное повторное
|
||||
нажатие или новый geometry fingerprint очищает latch и делает один retry;
|
||||
- console error содержит issue, space, fingerprint и короткий reason, но без
|
||||
config/entity payload; один раз на latch;
|
||||
- ошибка HTML overlay projection также входит в эту границу, иначе получится
|
||||
«стены flat, markers iso».
|
||||
|
||||
---
|
||||
|
||||
## Существенные замечания
|
||||
|
||||
### M1 — Spike ADR должен фиксировать больше, чем выбор renderer
|
||||
|
||||
Сейчас D6 требует ADR, но его обязательные решения не перечислены. ADR должен
|
||||
закрыть до основной реализации:
|
||||
|
||||
- формулу и pivot проекции;
|
||||
- camera constants, wall-height units и zScale;
|
||||
- top/side fill, stroke, hatch и side shading в light/dark theme;
|
||||
- ring normalization, face visibility и depth order;
|
||||
- z-order floor → Glow/sun/decor → faces/top → screen-facing HTML overlays;
|
||||
- осознанное правило stage 1: markers/room cards всегда выше wall faces и не
|
||||
получают геометрическую occlusion;
|
||||
- projected frame и flat↔iso viewport conversion;
|
||||
- результат проверки SVG filter/clip/mix-blend на Chromium, Firefox, WebKit;
|
||||
- причины отказа от проигравшего прототипа.
|
||||
|
||||
До ADR issue остаётся в статусе spike/implementation-prep, не renderer-ready.
|
||||
|
||||
### M2 — Fingerprint перечисляет не все входы iso geometry
|
||||
|
||||
В §8 добавить как минимум:
|
||||
|
||||
- `room_drafts` и их segment thickness;
|
||||
- нормализованные `openCuts`/virtual intervals;
|
||||
- canonical opening cuts;
|
||||
- partitions и columns с shape/angle/diameter;
|
||||
- `cell_cm`, grid pitch, coordinate scale/NORM;
|
||||
- camera constants и wall-height constant;
|
||||
- версию алгоритма projection/faces.
|
||||
|
||||
Массивы должны сериализоваться детерминированно, числа — нормализоваться как в
|
||||
существующих geometry fingerprints. Display state (`hover`, HA states,
|
||||
`show_borders`) не должен инвалидировать geometry cache. `show_borders:false`
|
||||
просто не рисует cached top/sides, но physics остаётся прежней.
|
||||
|
||||
### M3 — `since`/`expires` требуют точной version semantics
|
||||
|
||||
В проекте нет зависимости `semver`; строкового сравнения допускать нельзя.
|
||||
Зафиксировать parser `major.minor.patch[-prerelease]`, fail-closed для
|
||||
некорректной registry entry и инвариант `since < expires`.
|
||||
|
||||
Рекомендуемое продуктовое правило: сравнивать numeric core, поэтому
|
||||
`1.65.0-beta.1` уже достигает `expires: 1.65.0` и не тащит мёртвый флаг в новый
|
||||
release cycle. Добавить тесты `1.64.9`, `1.65.0-beta.1`, `1.65.0`, malformed.
|
||||
|
||||
### M4 — Не определён runtime owner механизма Labs
|
||||
|
||||
Нужно указать, что availability flags глобальны для загруженного JS-модуля, а
|
||||
effective `flat|iso` остаётся состоянием конкретной карточки/пространства.
|
||||
Один module-level resolver/subscription не должен создавать по listener на
|
||||
каждый render.
|
||||
|
||||
`window.__hpLabs` должен иметь нормативную форму, например frozen sorted array:
|
||||
|
||||
```ts
|
||||
Object.freeze(['iso'])
|
||||
```
|
||||
|
||||
При изменении URL property заменяется новым frozen array, все подключённые
|
||||
карточки получают update. Нельзя отдавать внутренний mutable `Set`.
|
||||
|
||||
### M5 — Scope `houseplan-space-card` не указан
|
||||
|
||||
В репозитории есть второй renderer: `src/space-card.ts` + `src/space-render.ts`.
|
||||
Текущий текст можно прочитать как требование объёмного вида для обеих карточек.
|
||||
|
||||
Рекомендация для stage 1: явно записать, что `houseplan-space-card` остаётся
|
||||
flat и Labs `iso` на него не влияет. Его поддержка — отдельный будущий scope.
|
||||
Иначе придётся сразу заводить вторую композицию сцены, что противоречит цели
|
||||
скрытого первого этапа.
|
||||
|
||||
### M6 — Performance contract не совпадает с существующей инфраструктурой
|
||||
|
||||
`compare.mjs` использует profile-specific budgets, noise allowance,
|
||||
relative+absolute thresholds и exact same runner. Просто потребовать «≤20% по
|
||||
трём полям» недостаточно; `longTask.maxSingleMs` особенно нестабилен около
|
||||
нуля, а `modelReadyMs` почти не измеряет переключение renderer.
|
||||
|
||||
Добавить отдельный профиль `large-house-isometric-v1`:
|
||||
|
||||
- текущий benchmark harness запускает candidate bundle с `hp-labs=iso` и
|
||||
переключает view; тот же harness запускает base bundle, который игнорирует
|
||||
неизвестный flag и остаётся flat;
|
||||
- profile id в обоих reports одинаков, runtime/browser/fingerprint проверяются
|
||||
существующим fail-closed контрактом;
|
||||
- отдельный reviewed budget JSON задаёт 20% relative allowance **плюс**
|
||||
абсолютный noise allowance;
|
||||
- обязательные метрики: first stable iso frame, view toggle, pan/zoom,
|
||||
HA-state update, space switch, long-task count/total/max, heap growth,
|
||||
iso-cache entries/growth, rendered devices;
|
||||
- candidate-only prerelease smoke получает абсолютные ceilings;
|
||||
- перед завершением этапа выполняется exact-SHA full performance workflow, а
|
||||
не локальное сравнение с другой машиной.
|
||||
|
||||
Фразу «flat не должен подорожать вообще» заменить на проверяемое: при
|
||||
выключенном флаге iso geometry/cache/DOM отсутствуют и нет дополнительного
|
||||
прохода по room/device collections; timing находится внутри noise allowance.
|
||||
|
||||
### M7 — Golden coverage слишком мала для новой системы координат
|
||||
|
||||
Две картинки не покрывают заявленный scope. Минимальная матрица stage 1:
|
||||
|
||||
1. desktop dark: mixed walls + openings + Glow/sun + devices;
|
||||
2. desktop light: theme/shading/filter parity;
|
||||
3. mobile portrait или узкий kiosk: fit, marker/label alignment, no clipping;
|
||||
4. `show_borders:false`: стены не нарисованы, room fill/Glow сохраняются;
|
||||
5. remount/toggle sequence проверяется smoke, а финальный кадр — golden при
|
||||
необходимости.
|
||||
|
||||
Существующие flat baselines действительно не принимаются заново, если diff не
|
||||
нулевой. Новые baselines принимаются только из полного Linux CI artifact по
|
||||
действующему HP-QA-01 контракту.
|
||||
|
||||
### M8 — A11y toggle contract неполон
|
||||
|
||||
Для кнопки добавить:
|
||||
|
||||
- `aria-pressed="true|false"` по `effectiveView`;
|
||||
- стабильный accessible name «Объёмный вид» / `Volumetric view`;
|
||||
- focus остаётся на той же кнопке после переключения;
|
||||
- active visual state не кодируется только цветом;
|
||||
- DOM/tab order устройств и room actions совпадает с flat;
|
||||
- stage 1 не добавляет projection animation: swap атомарный. Если анимация
|
||||
будет добавлена через #82, `prefers-reduced-motion` делает её мгновенной.
|
||||
|
||||
---
|
||||
|
||||
## Замечания к тестам и формулировкам
|
||||
|
||||
### T1 — «innerHTML до и после ветки» нужно сделать воспроизводимым
|
||||
|
||||
Обычный тест не может сравнить текущий commit с кодом до ветки. Разделить
|
||||
контракт:
|
||||
|
||||
- в одном candidate build сравнить no-param и unknown-param: нет iso nodes,
|
||||
нет дополнительных WS/HTTP и config/layout writes;
|
||||
- golden гарантирует нулевой diff существующих flat scenes между revisions;
|
||||
- unit spy подтверждает, что iso geometry builder не вызывался;
|
||||
- чтение собственного Labs localStorage не считать сетевым/сторным изменением,
|
||||
но при отсутствии URL оно не должно переписывать ключ.
|
||||
|
||||
### T2 — Мутанты должны быть исполнимыми
|
||||
|
||||
Пункт «отдельная формула проекции HTML» нельзя надёжно поймать текстовым
|
||||
поиском. Нормативный mutant: внести controlled offset только в overlay mapping;
|
||||
smoke должен увидеть расхождение anchor больше 1 CSS px. Для cache mutant тест
|
||||
меняет geometry in-place без `_cfgEpoch`; iso faces обязаны обновиться. Для
|
||||
layer-copy mutant тест проверяет upper bound DOM face count как `O(E)`.
|
||||
|
||||
Для каждого из пяти mutants сохранить команду/patch id и имя краснеющего теста
|
||||
в PR/issue evidence; ручной тезис «проверено» недостаточен.
|
||||
|
||||
### T3 — Opening wording
|
||||
|
||||
В AC 6 заменить «без швов и торцов внутри проёма» на:
|
||||
|
||||
> Проём является full-height gap. Внутри gap нет wall top/side полосы;
|
||||
> вертикальные jamb faces на двух границах masonry разрыва являются ожидаемыми.
|
||||
|
||||
Это снимает конфликт с B7 и делает golden однозначным.
|
||||
|
||||
### T4 — Первый запуск и сохранённое предпочтение
|
||||
|
||||
В §9/§10 уточнить:
|
||||
|
||||
- без записи `houseplan_card_view_v1[space]` effective view всегда flat, даже
|
||||
при активном Labs;
|
||||
- toggle в обычном View пишет `flat|iso` per space;
|
||||
- при неактивном/expired flag сохранённое `iso` игнорируется, не меняет DOM и
|
||||
не попадает в warm memo;
|
||||
- вход/выход editor не перезаписывает предпочтение;
|
||||
- fallback B8 не перезаписывает предпочтение автоматически.
|
||||
|
||||
### T5 — Backlog — канонический источник
|
||||
|
||||
Issue #89 сейчас говорит «черновик продуктового и технического решения», тогда
|
||||
как файл говорит «готово к реализации». По `AGENTS.md` Issue/Project являются
|
||||
каноническими. После принятия новой редакции:
|
||||
|
||||
- добавить в body issue ссылку на stage 1 spec как нормативную;
|
||||
- синхронизировать scope/acceptance criteria issue с утверждённой редакцией;
|
||||
- оставить Project `Todo` до фактического начала, затем перевести в
|
||||
`In progress`;
|
||||
- не закрывать #89 после одного spike ADR: закрытие только после всех AC этапа.
|
||||
|
||||
---
|
||||
|
||||
## Рекомендуемая новая структура нормативных разделов
|
||||
|
||||
Чтобы не раздувать основной текст, достаточно добавить четыре подраздела:
|
||||
|
||||
1. **§4.4 Coordinate spaces and viewport** — B1, B2.
|
||||
2. **§5.1 Wall-face topology and visual tokens** — B7, M1.
|
||||
3. **§7.1 Native hit testing and warm continuity** — B3, B4, B5.
|
||||
4. **§2.2.1 Labs operation precedence and version lifecycle** — B6, M3, M4.
|
||||
|
||||
Остальные замечания можно встроить в §8–§13.
|
||||
|
||||
## Definition of Ready после следующей итерации
|
||||
|
||||
ТЗ можно считать готовым к реализации, когда:
|
||||
|
||||
- [ ] определены plan/scene/client spaces, camera pivot/zScale и projected frame;
|
||||
- [ ] записан алгоритм flat↔iso viewport conversion;
|
||||
- [ ] обновлён warm-remount contract;
|
||||
- [ ] исправлено pointer rule и убран iso marker-drag smoke;
|
||||
- [ ] выбран однозначный kiosk escape contract;
|
||||
- [ ] полностью определена Labs grammar и expiry semantics;
|
||||
- [ ] определены ring/face/jamb/depth rules;
|
||||
- [ ] определена fallback state machine;
|
||||
- [ ] ADR имеет обязательный список решений;
|
||||
- [ ] fingerprint содержит все входы;
|
||||
- [ ] указан scope `houseplan-space-card`;
|
||||
- [ ] создан исполнимый performance profile/budget plan;
|
||||
- [ ] расширена golden/a11y/mutant matrix;
|
||||
- [ ] issue #89 ссылается на утверждённое ТЗ и не противоречит ему.
|
||||
|
||||
После этого оценка stage 1 остаётся **L/XL с высоким риском**, но работа станет
|
||||
декомпозируемой и проверяемой; менять выбранную продуктовую концепцию не нужно.
|
||||
@@ -1,6 +1,7 @@
|
||||
"""House Plan: server-side house plan configuration + Lovelace card serving."""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
@@ -23,7 +24,7 @@ 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, create_data
|
||||
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,16 +33,27 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanContentView, HouseplanUploadView
|
||||
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
hass.http.register_view(HouseplanImportPreviewView())
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
|
||||
data = create_data(hass)
|
||||
# Home Assistant's installation id never leaves the instance. Exports
|
||||
# carry only a salted SHA-256 fingerprint so same-instance internal files
|
||||
# can be distinguished from cross-instance references.
|
||||
try:
|
||||
from homeassistant.helpers import instance_id as ha_instance_id
|
||||
|
||||
value = ha_instance_id.async_get(hass)
|
||||
data.instance_id = str(await value if inspect.isawaitable(value) else value)
|
||||
except Exception: # noqa: BLE001 - old HA/test harness fallback
|
||||
data.instance_id = str(entry.entry_id)
|
||||
# test-before-setup: storage must be readable, otherwise retry later
|
||||
try:
|
||||
await data.store.async_load()
|
||||
@@ -126,10 +138,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
cfg = stored.get("config")
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
layout = lay_stored.get("layout") or {}
|
||||
lay_meta = {
|
||||
k: v for k, v in lay_stored.items()
|
||||
if k not in ("layout", "rev", "geom_pending")
|
||||
}
|
||||
pending = {
|
||||
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
|
||||
}
|
||||
@@ -137,15 +145,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
if merged:
|
||||
lay_rev = int(lay_stored.get("rev", 0))
|
||||
if merged != pending: # 1. the durable intent, before anything moves
|
||||
await data.store.async_save(
|
||||
{**lay_meta, "layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
await async_save_layout_state(
|
||||
data, lay_stored, layout, lay_rev,
|
||||
metadata={"geom_pending": merged}, remove=("geom_pending",),
|
||||
)
|
||||
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})
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await data.store.async_save({**lay_meta, "layout": layout, "rev": lay_rev + 1})
|
||||
await async_save_layout_state(
|
||||
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
||||
)
|
||||
@@ -157,6 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
# config and layout store writes. The target was persisted before either
|
||||
# visible half changed, so setup can always converge on the requested pair.
|
||||
optimize_revs: tuple[int, int] | None = None
|
||||
recovered_import = False
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
@@ -167,30 +179,57 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
target_layout = pending["layout"]
|
||||
config_rev = int(stored.get("rev", 0))
|
||||
layout_rev = int(lay_stored.get("rev", 0))
|
||||
if stored.get("config") != target_config:
|
||||
config_rev += 1
|
||||
target_config_rev = int(pending.get(
|
||||
"config_rev", config_rev + (stored.get("config") != target_config)
|
||||
))
|
||||
target_layout_rev = int(pending.get(
|
||||
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
|
||||
))
|
||||
if stored.get("config") != target_config or config_rev < target_config_rev:
|
||||
config_rev = max(config_rev, target_config_rev)
|
||||
await data.config_store.async_save({
|
||||
"config": target_config,
|
||||
"rev": config_rev,
|
||||
})
|
||||
if lay_stored.get("layout", {}) != target_layout:
|
||||
layout_rev += 1
|
||||
layout_meta = {
|
||||
k: v for k, v in lay_stored.items()
|
||||
if k not in ("layout", "rev", "optimize_pending", "optimize_backup")
|
||||
}
|
||||
if not pending.get("clear_backup") and "optimize_backup" in lay_stored:
|
||||
layout_meta["optimize_backup"] = lay_stored["optimize_backup"]
|
||||
await data.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": target_layout,
|
||||
"rev": layout_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")
|
||||
replace_metadata = isinstance(exact_metadata, dict)
|
||||
metadata = dict(exact_metadata) if replace_metadata else None
|
||||
if not replace_metadata and not pending.get("clear_backup") \
|
||||
and "optimize_backup" in lay_stored:
|
||||
metadata = {"optimize_backup": lay_stored["optimize_backup"]}
|
||||
remove_metadata = ["optimize_pending", "optimize_backup"]
|
||||
if pending.get("clear_backup"):
|
||||
# A recovered whole-plan undo replaces the complete layout;
|
||||
# a point-wise repair snapshot from the replaced layout must
|
||||
# not survive and later restore coordinates into the new pair.
|
||||
remove_metadata.append("repair_backup")
|
||||
await async_save_layout_state(
|
||||
data,
|
||||
lay_stored,
|
||||
target_layout,
|
||||
layout_rev,
|
||||
metadata=metadata,
|
||||
remove=tuple(remove_metadata),
|
||||
replace_metadata=replace_metadata,
|
||||
)
|
||||
optimize_revs = (config_rev, layout_rev)
|
||||
_LOGGER.warning("House Plan: completed an interrupted plan optimization")
|
||||
recovered_import = str(pending.get("kind") or "").startswith("import")
|
||||
_LOGGER.warning(
|
||||
"House Plan: completed an interrupted %s",
|
||||
str(pending.get("kind") or "plan optimization").replace("_", " "),
|
||||
)
|
||||
if optimize_revs is not None:
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
|
||||
if recovered_import:
|
||||
await recorder.async_refresh()
|
||||
current = (await data.config_store.async_load() or {}).get("config") or {}
|
||||
live_ids = {str(marker.get("id")) for marker in current.get("markers") or []}
|
||||
for marker_id in list(recorder.book.data):
|
||||
if marker_id not in live_ids:
|
||||
await recorder.async_delete(marker_id)
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
|
||||
@@ -45,7 +45,21 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.59.2"
|
||||
VERSION = "1.63.0-beta.1"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
# user have different compatibility lifecycles.
|
||||
PLAN_MODEL_VERSION = 6
|
||||
EXPORT_VERSION = 1
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
MAX_IMPORT_PREVIEWS_PER_USER = 3
|
||||
# Parsed documents are larger than their wire representation. Keep the
|
||||
# original three-preview memory ceiling global as well as per user so turning
|
||||
# off the admin-only policy cannot multiply it by the number of household
|
||||
# accounts.
|
||||
MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -31,6 +31,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"has_plan": bool(s.get("plan_url")),
|
||||
"rooms": len(s.get("rooms", [])),
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"room_drafts": len(s.get("room_drafts", [])),
|
||||
"partitions": len(s.get("partitions", [])),
|
||||
"wall_columns": len(s.get("wall_columns", [])),
|
||||
}
|
||||
for s in config.get("spaces", [])
|
||||
],
|
||||
|
||||
@@ -64,6 +64,16 @@ def migrate_space(space: dict[str, Any]) -> bool:
|
||||
if room.get("poly"):
|
||||
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
|
||||
|
||||
for draft in space.get("room_drafts") or []:
|
||||
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
|
||||
|
||||
for part in space.get("partitions") or []:
|
||||
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
|
||||
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
|
||||
|
||||
for column in space.get("wall_columns") or []:
|
||||
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
|
||||
|
||||
for op in space.get("openings") or []:
|
||||
op["x"] = dx + float(op.get("x", 0)) * kx
|
||||
op["y"] = dy + float(op.get("y", 0)) * ky
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -22,10 +23,13 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, PLANS_DIR,
|
||||
MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import ImportFailure, create_preview
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .store import get_data
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -52,6 +56,69 @@ _MIME = {
|
||||
}
|
||||
|
||||
|
||||
class HouseplanImportPreviewView(HomeAssistantView):
|
||||
"""Upload a bounded JSON backup and return a server-side preview token."""
|
||||
|
||||
url = "/api/houseplan/import/preview"
|
||||
name = "api:houseplan:import-preview"
|
||||
requires_auth = True
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
user = request.get("hass_user")
|
||||
if not may_write(hass, user):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
runtime = get_data(hass)
|
||||
if runtime is None:
|
||||
return web.json_response({"error": "not_ready"}, status=503)
|
||||
policy = request.query.get("duplicate_policy", "skip")
|
||||
if policy not in ("skip", "virtual"):
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
declared = request.content_length
|
||||
if declared is not None and declared > MAX_EXPORT_BYTES:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
blocks: list[bytes] = []
|
||||
size = 0
|
||||
async for block in request.content.iter_chunked(_CHUNK):
|
||||
size += len(block)
|
||||
if size > MAX_EXPORT_BYTES:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
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,
|
||||
)
|
||||
)
|
||||
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)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan import preview failed")
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
class HouseplanContentView(HomeAssistantView):
|
||||
"""Authenticated read access to plans and marker files (audit B1).
|
||||
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.59.2"
|
||||
"version": "1.63.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Small registry projection shared by import HTTP and WebSocket previews."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
def import_registry_snapshot(hass: HomeAssistant) -> dict[str, set[str]]:
|
||||
"""Return non-sensitive target inventory used only for preview counts."""
|
||||
from homeassistant.helpers import area_registry as ar
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
entities = list(er.async_get(hass).entities.values())
|
||||
active_entity: set[str] = set()
|
||||
disabled_entity: set[str] = set()
|
||||
entities_by_device: dict[str, list[Any]] = {}
|
||||
for entry in entities:
|
||||
entity_id = str(entry.entity_id)
|
||||
if getattr(entry, "disabled_by", None) is None:
|
||||
active_entity.add(entity_id)
|
||||
else:
|
||||
disabled_entity.add(entity_id)
|
||||
if entry.device_id:
|
||||
entities_by_device.setdefault(str(entry.device_id), []).append(entry)
|
||||
|
||||
# Synthetic/runtime entities may legitimately have no registry row.
|
||||
active_entity.update(str(state.entity_id) for state in hass.states.async_all())
|
||||
|
||||
active_device: set[str] = set()
|
||||
disabled_device: set[str] = set()
|
||||
for entry in dr.async_get(hass).devices.values():
|
||||
device_id = str(entry.id)
|
||||
children = entities_by_device.get(device_id, [])
|
||||
disabled = getattr(entry, "disabled_by", None) is not None or (
|
||||
bool(children)
|
||||
and all(getattr(child, "disabled_by", None) is not None for child in children)
|
||||
)
|
||||
(disabled_device if disabled else active_device).add(device_id)
|
||||
return {
|
||||
"active_device": active_device,
|
||||
"disabled_device": disabled_device,
|
||||
"active_entity": active_entity,
|
||||
"disabled_entity": disabled_entity - active_entity,
|
||||
"areas": {str(entry.id) for entry in ar.async_get(hass).areas.values()},
|
||||
}
|
||||
@@ -54,6 +54,11 @@ class HouseplanData:
|
||||
# directly — a test that fakes a 24 h jump proves the timer fires, not that
|
||||
# the work happens, and those are different claims.
|
||||
sweep: Callable[[], Awaitable[None]] | None = None
|
||||
# Stable HA instance id used only through a one-way export fingerprint.
|
||||
instance_id: str = ""
|
||||
# Parsed import candidates are short-lived, user-bound and memory-only.
|
||||
# dict keeps insertion order, which lets the preview service evict oldest.
|
||||
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
@@ -79,3 +84,58 @@ def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
||||
"""The loaded config entry, or None."""
|
||||
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
return entries[0] if entries else None
|
||||
|
||||
|
||||
OPTIMIZE_BACKUP = "optimize_backup"
|
||||
OPTIMIZE_PENDING = "optimize_pending"
|
||||
LAYOUT_STORE_CORE_KEYS = frozenset({"layout", "rev"})
|
||||
|
||||
|
||||
def layout_store_payload(
|
||||
stored: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
rev: int,
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
remove: tuple[str, ...] = (),
|
||||
replace_metadata: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Build one layout-store write without silently dropping metadata.
|
||||
|
||||
Layout used to be saved by several independent dict comprehensions. Every
|
||||
new metadata key therefore had to be added to every caller or was lost on
|
||||
the next drag. All writers now express only the metadata they intentionally
|
||||
add/remove and this helper preserves the rest.
|
||||
"""
|
||||
excluded = {*LAYOUT_STORE_CORE_KEYS, *remove}
|
||||
out = {} if replace_metadata else {
|
||||
key: value for key, value in stored.items() if key not in excluded
|
||||
}
|
||||
if metadata:
|
||||
out.update(metadata)
|
||||
out["layout"] = layout
|
||||
out["rev"] = rev
|
||||
return out
|
||||
|
||||
|
||||
async def async_save_layout_state(
|
||||
runtime: HouseplanData,
|
||||
stored: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
rev: int,
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
remove: tuple[str, ...] = (),
|
||||
replace_metadata: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist layout and return the exact store document written."""
|
||||
payload = layout_store_payload(
|
||||
stored,
|
||||
layout,
|
||||
rev,
|
||||
metadata=metadata,
|
||||
remove=remove,
|
||||
replace_metadata=replace_metadata,
|
||||
)
|
||||
await runtime.store.async_save(payload)
|
||||
return payload
|
||||
|
||||
@@ -27,6 +27,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"config_rev": cfg_raw.get("rev", 0),
|
||||
"spaces": len(config.get("spaces", [])),
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
|
||||
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
|
||||
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ class TrailBook:
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete(self, marker: str) -> bool:
|
||||
"""Forget every stored run of one plan marker."""
|
||||
return self.data.pop(marker, None) is not None
|
||||
|
||||
|
||||
class TrailRecorder:
|
||||
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
|
||||
@@ -106,6 +110,9 @@ class TrailRecorder:
|
||||
self._unsub_track = None
|
||||
self._unsub_save = None
|
||||
self._last_fire = 0.0
|
||||
# One active incident per saved marker/source. `reason` is mutable so
|
||||
# missing↔disabled changes do not create warning storms.
|
||||
self._source_health: dict[tuple[str, str], str] = {}
|
||||
# HP-1540-05: config/set fires refresh as a detached task; two of them
|
||||
# interleaving across the awaited load both subscribed and the loser's
|
||||
# unsub handle was overwritten — a leak until HA restart
|
||||
@@ -130,27 +137,24 @@ class TrailRecorder:
|
||||
return
|
||||
cfg = stored.get("config") or {}
|
||||
pairs: dict[str, list[tuple[str, str]]] = {}
|
||||
health_pairs: set[tuple[str, str]] = set()
|
||||
for m in cfg.get("markers") or []:
|
||||
if m.get("removed") is True:
|
||||
continue
|
||||
v = m.get("vacuum") or {}
|
||||
src = v.get("source")
|
||||
if not src or v.get("live") is False:
|
||||
continue
|
||||
marker_id = str(m.get("id"))
|
||||
health_pairs.add((marker_id, str(src)))
|
||||
vac = self._vacuum_entity(m)
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(src, []).append((str(m.get("id")), vac))
|
||||
pairs.setdefault(src, []).append((marker_id, vac))
|
||||
self._refresh_source_health(health_pairs)
|
||||
self.pairs = pairs
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
# deduplicated: two markers of one robot share source AND vacuum
|
||||
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
|
||||
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
|
||||
if ents:
|
||||
self._unsub_track = async_track_state_change_event(
|
||||
self.hass, sorted(ents), self._on_state
|
||||
)
|
||||
self._resubscribe()
|
||||
# A run already in progress (HA restarted mid-cleanup, or the user
|
||||
# just finished calibrating) must start recording NOW, not at the
|
||||
# next state change — otherwise the first seconds of the path are
|
||||
@@ -158,6 +162,93 @@ class TrailRecorder:
|
||||
for src in self.pairs:
|
||||
self._sample(src, time.time())
|
||||
|
||||
def _source_failure_reason(self, source: str) -> str | None:
|
||||
"""Classify only refresh-time health evidence.
|
||||
|
||||
A registry row or exact live state proves existence. No registry access
|
||||
is neutral: it can neither create a loss incident nor recover one.
|
||||
"""
|
||||
registry = er.async_get(self.hass)
|
||||
state = self.hass.states.get(source)
|
||||
if registry is None or not hasattr(registry, "async_get"):
|
||||
return None if state is not None else "unverified"
|
||||
entry = registry.async_get(source)
|
||||
if entry is not None and getattr(entry, "disabled_by", None) is not None:
|
||||
return "disabled"
|
||||
# Registry-less YAML entities are valid: exact live state is stronger
|
||||
# evidence than a missing registry row.
|
||||
if entry is not None or state is not None:
|
||||
return None
|
||||
return "missing"
|
||||
|
||||
def _refresh_source_health(self, expected: set[tuple[str, str]]) -> None:
|
||||
"""Refresh deduplicated source incidents during config refresh/restart.
|
||||
|
||||
`unavailable` and unsupported-but-existing states count as proven
|
||||
recovery. There is intentionally no registry subscription in Stage 1;
|
||||
the next config refresh or restart observes a later transition.
|
||||
"""
|
||||
for key in list(self._source_health):
|
||||
if key not in expected:
|
||||
del self._source_health[key]
|
||||
for marker_id, source in sorted(expected):
|
||||
key = (marker_id, source)
|
||||
reason = self._source_failure_reason(source)
|
||||
previous = self._source_health.get(key)
|
||||
# Limited/unavailable registry evidence is neutral: keep an
|
||||
# existing incident as-is, and never create or recover one.
|
||||
if reason == "unverified":
|
||||
continue
|
||||
if reason is None:
|
||||
if previous is not None:
|
||||
_LOGGER.info(
|
||||
"Vacuum source recovered: marker=%s source=%s (was %s)",
|
||||
marker_id, source, previous,
|
||||
)
|
||||
del self._source_health[key]
|
||||
continue
|
||||
if previous is None:
|
||||
_LOGGER.warning(
|
||||
"Vacuum source %s: marker=%s source=%s",
|
||||
reason, marker_id, source,
|
||||
)
|
||||
self._source_health[key] = reason
|
||||
|
||||
async def async_delete(self, marker: str) -> bool:
|
||||
"""Stop and erase one marker without racing subscription refresh/save."""
|
||||
async with self._refresh_lock:
|
||||
# The trail book owns deletion. When it has no such marker, this
|
||||
# is a no-op and must not silently damage the live tracking graph.
|
||||
removed = self.book.delete(marker)
|
||||
if not removed:
|
||||
return False
|
||||
for src in list(self.pairs):
|
||||
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
|
||||
if kept:
|
||||
self.pairs[src] = kept
|
||||
else:
|
||||
del self.pairs[src]
|
||||
self._resubscribe()
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
return True
|
||||
|
||||
def _resubscribe(self) -> None:
|
||||
"""Replace the state subscription for the current pair graph."""
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
# deduplicated: two markers of one robot share source AND vacuum
|
||||
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
|
||||
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
|
||||
if ents and not self._closed:
|
||||
self._unsub_track = async_track_state_change_event(
|
||||
self.hass, sorted(ents), self._on_state
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
|
||||
# not re-subscribe after this cleanup has already run
|
||||
|
||||
@@ -4,6 +4,7 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -19,6 +20,228 @@ _SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# The name length the content view will accept back in a request. Anything a
|
||||
# generated name must fit inside, collision tag included (HP-1460-01).
|
||||
MAX_FILENAME = 120
|
||||
MARKER_CONTROL_PREFIX = "marker:"
|
||||
_CONTROL_ENTITY_ID_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+\Z")
|
||||
|
||||
|
||||
class MarkerControlError(ValueError):
|
||||
"""Semantic marker-link error with a stable public code."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
VALUE_BADGE_ATTRIBUTES = {
|
||||
"current_temperature", "temperature", "current_humidity", "humidity",
|
||||
"current_position", "percentage", "brightness", "volume_level",
|
||||
"battery_level", "fan_speed",
|
||||
}
|
||||
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(
|
||||
marker: dict, marker_id: str, old_by_id: dict[str, dict], old_markers: list[dict],
|
||||
new_ids: set[str], consumed_old_ids: set[str], validate_all: bool,
|
||||
) -> dict | None:
|
||||
"""Match the previous marker across the binding-stable id rename path."""
|
||||
old_marker = old_by_id.get(marker_id)
|
||||
if old_marker is not None:
|
||||
consumed_old_ids.add(marker_id)
|
||||
return old_marker
|
||||
if validate_all:
|
||||
return None
|
||||
binding = marker.get("binding")
|
||||
matches = [
|
||||
old for old in old_markers
|
||||
if binding not in (None, "virtual")
|
||||
and old.get("binding") == binding
|
||||
and str(old.get("id")) not in new_ids
|
||||
and str(old.get("id")) not in consumed_old_ids
|
||||
]
|
||||
if len(matches) == 1:
|
||||
consumed_old_ids.add(str(matches[0].get("id")))
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def validate_marker_value_badges(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate only new/changed badge data; dormant future/legacy data round-trips."""
|
||||
markers = config.get("markers") or []
|
||||
by_id = {str(marker.get("id")): marker for marker in markers}
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
|
||||
new_ids = set(by_id)
|
||||
consumed_old_ids: set[str] = set()
|
||||
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
|
||||
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
badge = marker.get("value_badge")
|
||||
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
|
||||
if not validate_all and badge == old_badge:
|
||||
continue
|
||||
if badge is None:
|
||||
continue
|
||||
if not isinstance(badge, dict):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
|
||||
if not isinstance(badge.get("enabled"), bool):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
|
||||
if badge.get("position") not in VALUE_BADGE_POSITIONS:
|
||||
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
|
||||
source = badge.get("source")
|
||||
if badge["enabled"] and not isinstance(source, dict):
|
||||
raise MarkerControlError("value_badge_source_required", "Enabled value badge needs a source")
|
||||
if source is None:
|
||||
continue
|
||||
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge source")
|
||||
kind = source["kind"]
|
||||
allowed_fields = {
|
||||
"entity_state": {"kind", "entity_id"},
|
||||
"entity_attribute": {"kind", "entity_id", "attribute"},
|
||||
"derived_lqi": {"kind"},
|
||||
"derived_marker_state": {"kind", "ref"},
|
||||
}[kind]
|
||||
if (known_source_fields & set(source)) - allowed_fields:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Inconsistent value badge source")
|
||||
if kind in {"entity_state", "entity_attribute"}:
|
||||
entity_id = source.get("entity_id")
|
||||
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge entity id")
|
||||
if kind == "entity_attribute":
|
||||
if source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
|
||||
raise MarkerControlError("invalid_value_badge_attribute", "Invalid value badge attribute")
|
||||
if kind == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) or not ref[len(MARKER_CONTROL_PREFIX):]:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid marker value badge target")
|
||||
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
|
||||
if target is None or target.get("removed") is True:
|
||||
raise MarkerControlError("value_badge_marker_missing", "Marker value badge target does not exist")
|
||||
if target.get("is_light") is not True:
|
||||
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:
|
||||
"""Validate newly introduced marker:* edges without rewriting old data.
|
||||
|
||||
Existing broken refs remain editable and round-trip losslessly. Imports use
|
||||
validate_all because their complete candidate graph is new to this plan.
|
||||
"""
|
||||
markers = config.get("markers") or []
|
||||
by_id = {str(marker.get("id")): marker for marker in markers}
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
old_by_id = {
|
||||
str(marker.get("id")): marker for marker in (previous or {}).get("markers") or []
|
||||
}
|
||||
new_ids = set(by_id)
|
||||
consumed_old_ids: set[str] = set()
|
||||
graph: dict[str, list[str]] = {}
|
||||
added: list[tuple[str, str]] = []
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
raw_controls = [
|
||||
ref for ref in marker.get("controls") or [] if isinstance(ref, str)
|
||||
]
|
||||
old_controls = [] if validate_all else [
|
||||
ref for ref in (old_marker or {}).get("controls") or [] if isinstance(ref, str)
|
||||
]
|
||||
refs = [
|
||||
ref for ref in raw_controls
|
||||
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
|
||||
]
|
||||
graph[marker_id] = [ref[len(MARKER_CONTROL_PREFIX):] for ref in refs]
|
||||
old_refs = [
|
||||
ref for ref in old_controls
|
||||
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
|
||||
]
|
||||
remaining = list(old_refs)
|
||||
for ref in refs:
|
||||
if ref in remaining:
|
||||
remaining.remove(ref)
|
||||
else:
|
||||
added.append((marker_id, ref[len(MARKER_CONTROL_PREFIX):]))
|
||||
new_counts, old_counts = Counter(refs), Counter(old_refs)
|
||||
if any(count > 1 and count > old_counts[ref] for ref, count in new_counts.items()):
|
||||
raise MarkerControlError("duplicate_marker_control", "Duplicate marker light target")
|
||||
remaining_controls = list(old_controls)
|
||||
for ref in raw_controls:
|
||||
if ref in remaining_controls:
|
||||
remaining_controls.remove(ref)
|
||||
elif not ref.startswith(MARKER_CONTROL_PREFIX) and not _CONTROL_ENTITY_ID_RE.fullmatch(ref):
|
||||
raise MarkerControlError("invalid_marker_control", f"Invalid entity target: {ref}")
|
||||
|
||||
def reaches(start: str, wanted: str) -> bool:
|
||||
stack, seen = [start], set()
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node == wanted:
|
||||
return True
|
||||
if node in seen:
|
||||
continue
|
||||
seen.add(node)
|
||||
stack.extend(graph.get(node, []))
|
||||
return False
|
||||
|
||||
for controller, target in added:
|
||||
if not target:
|
||||
raise MarkerControlError("invalid_marker_control", "Marker target id is empty")
|
||||
if target == controller:
|
||||
raise MarkerControlError("marker_control_self", "A marker cannot control itself")
|
||||
target_marker = by_id.get(target)
|
||||
if target_marker is None or target_marker.get("removed") is True:
|
||||
raise MarkerControlError("marker_control_missing", f"Marker target does not exist: {target}")
|
||||
if target_marker.get("is_light") is not True:
|
||||
raise MarkerControlError("marker_control_not_light", f"Marker target is not a forced light: {target}")
|
||||
if reaches(target, controller):
|
||||
raise MarkerControlError("marker_control_cycle", "Marker light controls contain a cycle")
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
@@ -62,6 +285,19 @@ def _finite(value):
|
||||
return f
|
||||
|
||||
|
||||
# Persisted colours deliberately use one small, browser-independent format.
|
||||
# Keep this exact contract in sync with src/color.ts.
|
||||
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
|
||||
# match the whole string exactly, in parity with the frontend validator.
|
||||
_COLOR = vol.Match(r"\A#[0-9a-fA-F]{6}\Z")
|
||||
_CUSTOM_FILL = vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# generous caps: the product targets 20-200 devices and a handful of floors
|
||||
MAX_SPACES = 50
|
||||
MAX_ROOMS = 400
|
||||
@@ -69,6 +305,10 @@ MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_WALLS = 500
|
||||
MAX_ROOM_DRAFTS = 200
|
||||
MAX_DRAFT_SEGMENTS = 2000
|
||||
MAX_PARTITIONS = 2000
|
||||
MAX_WALL_COLUMNS = 500
|
||||
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
|
||||
# Every span is a piece of a shared boundary, so there can never be more of
|
||||
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
|
||||
@@ -95,6 +335,8 @@ MAX_URL = 2000
|
||||
# they can act on. For scale, a real three-floor home with ~200 devices stores
|
||||
# about 70 KB, so this is ~30x headroom.
|
||||
MAX_CONFIG_BYTES = 2 * 1024 * 1024
|
||||
CELL_CM_MIN = 0.1
|
||||
CELL_CM_MAX = 1000.0
|
||||
|
||||
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
@@ -204,7 +446,9 @@ ROOM_SCHEMA = vol.All(
|
||||
None,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
|
||||
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp", "custom", "glow"])),
|
||||
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
|
||||
vol.Optional("glow"): vol.Any(bool, None),
|
||||
vol.Optional("temp_source"): vol.Any(str, None),
|
||||
vol.Optional("hum_source"): vol.Any(str, None),
|
||||
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
@@ -243,11 +487,13 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("show_borders"): bool,
|
||||
vol.Optional("show_names"): bool,
|
||||
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("room_color"): _COLOR,
|
||||
# per-space background around the plan; absent = inherit the global one
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "custom", "glow"]),
|
||||
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
|
||||
vol.Optional("glow_enabled"): bool,
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
@@ -283,6 +529,7 @@ MAX_DECOR_UNIT = 16
|
||||
# could mean on a 1000-unit canvas, the rest is garbage insurance.
|
||||
DECOR_TEXT_SCALE_MIN = 0.15
|
||||
DECOR_TEXT_SCALE_MAX = 20.0
|
||||
DECOR_TEXT_CM_MAX = 2000.0
|
||||
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
|
||||
# the backend must accept a plan written by a NEWER card, and a card that has
|
||||
# learnt a new symbol must not have to wait for the integration to be updated
|
||||
@@ -298,7 +545,11 @@ _FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("color"): _COLOR,
|
||||
vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
|
||||
# Physical centimetres are canonical. `width` remains accepted so plans
|
||||
# written by older cards keep their exact appearance until edited.
|
||||
vol.Optional("width_cm"): vol.All(_finite, vol.Range(min=0.1, max=100)),
|
||||
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
|
||||
@@ -307,14 +558,18 @@ _NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM,
|
||||
vol.Optional("line_style"): vol.In(["solid", "dashed"])},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
# sizes are extents — negative/zero is garbage, not "canvas slack"
|
||||
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("fill"): bool},
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Optional("fill"): bool,
|
||||
vol.Optional("fill_color"): _COLOR,
|
||||
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
@@ -326,8 +581,11 @@ DECOR_SCHEMA = vol.Any(
|
||||
# written before that keeps it, and it is read as the scale it
|
||||
# used to render at. Kept in the schema so it stays BOUNDED.
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"]),
|
||||
# size and rotation of the block (docs/LIVE-TEXT.md §block).
|
||||
# Both optional: a shape without them is the old one exactly.
|
||||
# Canonical physical font size plus the legacy scale. Both are
|
||||
# accepted so older plans remain pixel-identical until edited
|
||||
# or explicitly optimized.
|
||||
vol.Optional("size_cm"): vol.All(
|
||||
_finite, vol.Range(min=0.1, max=DECOR_TEXT_CM_MAX)),
|
||||
vol.Optional("scale"): vol.All(
|
||||
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
@@ -374,10 +632,107 @@ WALL_SCHEMA = vol.All(
|
||||
_wall_endpoints_pair,
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
|
||||
def _room_draft_segments(value: dict) -> dict:
|
||||
"""An open draft has exactly one thickness per consecutive edge."""
|
||||
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
|
||||
raise vol.Invalid("room draft segments must match consecutive point pairs")
|
||||
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
|
||||
raise vol.Invalid("room draft consecutive points must differ")
|
||||
return value
|
||||
|
||||
|
||||
ROOM_DRAFT_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
|
||||
vol.Required("segments"): vol.All(
|
||||
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
|
||||
extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(min=1, max=499),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_room_draft_segments,
|
||||
)
|
||||
|
||||
def _partition_nonzero(value: dict) -> dict:
|
||||
if value["a"] == value["b"]:
|
||||
raise vol.Invalid("partition endpoints must differ")
|
||||
return value
|
||||
|
||||
|
||||
PARTITION_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("a"): POINT,
|
||||
vol.Required("b"): POINT,
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_partition_nonzero,
|
||||
)
|
||||
|
||||
def _strict_wall_column(value: dict) -> dict:
|
||||
"""Reject shape-inapplicable or non-canonical column fields."""
|
||||
if value["shape"] == "circle" and "angle" in value:
|
||||
raise vol.Invalid("angle is allowed only for square wall columns")
|
||||
if value["shape"] == "square" and value.get("angle", 0) >= 90:
|
||||
raise vol.Invalid("square wall column angle must be in [0, 90)")
|
||||
return value
|
||||
|
||||
|
||||
WALL_COLUMN_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("shape"): vol.In(["square", "circle"]),
|
||||
vol.Required("center"): POINT,
|
||||
# Outer side for a square, outer diameter for a circle.
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=150)),
|
||||
vol.Optional("angle"): vol.All(
|
||||
_finite, vol.Range(min=0, max=90)
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_strict_wall_column,
|
||||
)
|
||||
|
||||
|
||||
def _space_geometry_invariants(value: dict) -> dict:
|
||||
"""All stored geometry shares ids; draft segments also have a space cap."""
|
||||
seen: set[str] = set()
|
||||
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
|
||||
for item in value.get(key, []):
|
||||
item_id = item.get("id")
|
||||
if not item_id:
|
||||
continue
|
||||
if item_id in seen:
|
||||
raise vol.Invalid("geometry object ids must be unique within a space")
|
||||
seen.add(item_id)
|
||||
draft_segments = sum(
|
||||
len(item.get("segments", [])) for item in value.get("room_drafts", [])
|
||||
)
|
||||
if draft_segments > MAX_DRAFT_SEGMENTS:
|
||||
raise vol.Invalid("too many saved room-draft segments")
|
||||
return value
|
||||
|
||||
|
||||
SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
|
||||
vol.Required("title"): str,
|
||||
# Physical grid scale. It feeds every px/cell -> centimetres migration,
|
||||
# so NaN/Infinity or an absurd value must not be allowed to manufacture
|
||||
# invalid decor sizes later.
|
||||
vol.Optional("cell_cm"): vol.All(
|
||||
_finite, vol.Range(min=CELL_CM_MIN, max=CELL_CM_MAX)
|
||||
),
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
@@ -389,10 +744,10 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
|
||||
# scaled UNIFORMLY on the canvas. All three are optional and their
|
||||
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
|
||||
# and an old config validates unchanged. The offset is a normalised
|
||||
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
|
||||
# resized per axis and rotated. Every transform field is optional; its
|
||||
# complete absence is the pre-v1.58.0 behaviour exactly, and an old
|
||||
# config validates unchanged. The offset is a normalised
|
||||
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
|
||||
# the scale is a positive multiplier in a range a human could mean.
|
||||
vol.Optional("plan_x"): vol.Any(None, _COORD),
|
||||
@@ -400,6 +755,17 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("plan_scale"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
# New writes may stretch each axis independently and rotate. The old
|
||||
# uniform field remains a read-compatible fallback for both axes.
|
||||
vol.Optional("plan_scale_x"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
vol.Optional("plan_scale_y"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
vol.Optional("plan_angle"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=-360.0, max=360.0))
|
||||
),
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
@@ -407,7 +773,7 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("type"): vol.Any("door", "window", "gate"),
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
@@ -427,6 +793,15 @@ SPACE_SCHEMA = vol.Schema(
|
||||
# (midpoint + direction), thickness always in centimetres. Optional —
|
||||
# a space without `walls` validates and renders exactly as before.
|
||||
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
|
||||
vol.Optional("room_drafts"): vol.All(
|
||||
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
|
||||
),
|
||||
vol.Optional("partitions"): vol.All(
|
||||
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
|
||||
),
|
||||
vol.Optional("wall_columns"): vol.All(
|
||||
[WALL_COLUMN_SCHEMA], vol.Length(max=MAX_WALL_COLUMNS)
|
||||
),
|
||||
# Open (virtual) wall stretches: a piece of a shared boundary that the
|
||||
# user opened. Optional and bounded — a space without `open_spans`
|
||||
# validates exactly as before, and the legacy `rooms[].open_to` index
|
||||
@@ -443,7 +818,7 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Remove("segments"): object,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
), _space_geometry_invariants)
|
||||
MARKER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
@@ -456,6 +831,9 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
# A binding-level tombstone: not rendered or aggregated, but retained
|
||||
# so automatic discovery does not put a deleted device straight back.
|
||||
vol.Optional("removed"): bool,
|
||||
vol.Optional("name"): _TEXT_OR_NONE,
|
||||
vol.Optional("icon"): _TEXT_OR_NONE,
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
@@ -488,16 +866,48 @@ MARKER_SCHEMA = vol.Schema(
|
||||
),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("glow_color"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Optional("bri"): vol.Any(
|
||||
None,
|
||||
vol.All(_finite, vol.Range(min=0.01, max=1.0)),
|
||||
),
|
||||
}
|
||||
),
|
||||
),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
# Explicit leading entity for composite Always sources. It is kept
|
||||
# literally when temporarily absent; runtime falls back without
|
||||
# deleting the user's choice.
|
||||
# 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(
|
||||
{
|
||||
# Required semantically for changed/new data. Optional here
|
||||
# keeps old/future configs readable until the user edits it.
|
||||
vol.Optional("enabled"): object,
|
||||
vol.Optional("position"): object,
|
||||
vol.Optional("source"): vol.Any(
|
||||
None,
|
||||
vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
|
||||
# offered, but remains accepted while old stores migrate to icon_ripple.
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
|
||||
vol.Optional("ripple_color"): vol.Any(
|
||||
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
|
||||
),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", "static_icon", None),
|
||||
vol.Optional("ripple_color"): vol.Any(None, _COLOR),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
@@ -516,11 +926,14 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
@@ -528,7 +941,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ import binascii
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -24,25 +26,41 @@ from .const import (
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
content_manifest,
|
||||
create_export,
|
||||
get_candidate,
|
||||
live_layout,
|
||||
prepare_apply,
|
||||
revalidate_candidate,
|
||||
)
|
||||
from .plans import (
|
||||
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .store import (
|
||||
LAYOUT_STORE_CORE_KEYS,
|
||||
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
HouseplanData,
|
||||
async_save_layout_state,
|
||||
get_data,
|
||||
get_entry,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_OPTIMIZE_BACKUP = "optimize_backup"
|
||||
_OPTIMIZE_PENDING = "optimize_pending"
|
||||
|
||||
|
||||
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
|
||||
"""An optimization can be undone only before any later plan edit."""
|
||||
"""An optimization can be undone before any later ordinary plan edit."""
|
||||
backup = layout_data.get(_OPTIMIZE_BACKUP)
|
||||
if not isinstance(backup, dict):
|
||||
return False
|
||||
@@ -55,17 +73,46 @@ def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[
|
||||
return False
|
||||
|
||||
|
||||
def _optimizer_backup_after_layout_maintenance(
|
||||
layout_data: dict[str, Any], new_layout_rev: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Carry a one-deep plan snapshot across explicit layout maintenance.
|
||||
|
||||
Geometry repair is part of plan maintenance, not an ordinary user edit:
|
||||
losing the Optimize/Import undo there makes the advertised safety net
|
||||
disappear. The snapshot must also follow the new layout revision or the
|
||||
freshness guard will correctly, but unhelpfully, classify it as stale.
|
||||
"""
|
||||
backup = layout_data.get(_OPTIMIZE_BACKUP)
|
||||
if not isinstance(backup, dict):
|
||||
return {}
|
||||
return {
|
||||
_OPTIMIZE_BACKUP: {
|
||||
**backup,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _undo_kind(config_data: dict[str, Any], layout_data: dict[str, Any]) -> str | None:
|
||||
if not _optimizer_backup_is_current(config_data, layout_data):
|
||||
return None
|
||||
backup = layout_data.get(_OPTIMIZE_BACKUP)
|
||||
return str(backup.get("kind") or "optimize") if isinstance(backup, dict) else None
|
||||
|
||||
|
||||
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
|
||||
"""Free a snapshot made stale by a later ordinary config edit."""
|
||||
data = await rt.store.async_load() or {}
|
||||
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
|
||||
return
|
||||
await rt.store.async_save({
|
||||
**{
|
||||
k: v for k, v in data.items()
|
||||
if k not in (_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
|
||||
}
|
||||
})
|
||||
await async_save_layout_state(
|
||||
rt,
|
||||
data,
|
||||
data.get("layout") or {},
|
||||
int(data.get("rev", 0)),
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -73,6 +120,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_delete)
|
||||
websocket_api.async_register_command(hass, ws_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
@@ -87,6 +135,9 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
websocket_api.async_register_command(hass, ws_export_create)
|
||||
websocket_api.async_register_command(hass, ws_import_revalidate)
|
||||
websocket_api.async_register_command(hass, ws_import_apply)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -107,9 +158,351 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
return may_write(hass, getattr(connection, "user", None))
|
||||
|
||||
|
||||
def _connection_user_id(connection) -> str:
|
||||
return str(getattr(getattr(connection, "user", None), "id", ""))
|
||||
|
||||
|
||||
def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
|
||||
connection.send_error(msg_id, err.code, err.message)
|
||||
|
||||
|
||||
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_STORE_CORE_KEYS
|
||||
}
|
||||
|
||||
|
||||
async def _persist_pair_intent(
|
||||
rt: HouseplanData,
|
||||
pending: dict[str, Any],
|
||||
) -> None:
|
||||
"""Make a target pair recoverable before either visible half moves."""
|
||||
stored = await rt.store.async_load() or {}
|
||||
metadata = dict(pending.get("final_metadata") or {})
|
||||
metadata[_OPTIMIZE_PENDING] = pending
|
||||
await async_save_layout_state(
|
||||
rt,
|
||||
stored,
|
||||
stored.get("layout") or {},
|
||||
int(stored.get("rev", 0)),
|
||||
metadata=metadata,
|
||||
replace_metadata=True,
|
||||
)
|
||||
|
||||
|
||||
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"]),
|
||||
})
|
||||
stored = await rt.store.async_load() or {}
|
||||
await async_save_layout_state(
|
||||
rt,
|
||||
stored,
|
||||
pending["layout"],
|
||||
int(pending["layout_rev"]),
|
||||
metadata=dict(pending.get("final_metadata") or {}),
|
||||
replace_metadata=True,
|
||||
)
|
||||
|
||||
|
||||
async def _commit_import_pair(
|
||||
rt: HouseplanData,
|
||||
pending: dict[str, Any],
|
||||
rollback: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit, retry once, or restore the before-pair before returning.
|
||||
|
||||
A Store write may raise after the bytes reached disk, so recovery always
|
||||
reloads and converges an explicit pair instead of guessing which half won.
|
||||
If the target cannot be completed, a rollback intent replaces it before we
|
||||
restore the old pair; setup will therefore restore, never unexpectedly
|
||||
finish an import that was reported as failed.
|
||||
"""
|
||||
try:
|
||||
await _persist_pair_intent(rt, pending)
|
||||
await _converge_pair(rt, pending)
|
||||
return
|
||||
except Exception: # noqa: BLE001 - one retry handles fail-after-write too
|
||||
_LOGGER.warning("House Plan import pair write failed; retrying target", exc_info=True)
|
||||
try:
|
||||
await _persist_pair_intent(rt, pending)
|
||||
await _converge_pair(rt, pending)
|
||||
return
|
||||
except Exception: # noqa: BLE001 - target is no longer the recovery policy
|
||||
_LOGGER.exception("House Plan import target retry failed; restoring previous pair")
|
||||
try:
|
||||
await _persist_pair_intent(rt, rollback)
|
||||
await _converge_pair(rt, rollback)
|
||||
except Exception as rollback_error: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"House Plan import rollback could not finish; rollback intent remains for setup"
|
||||
)
|
||||
raise ImportFailure(
|
||||
"commit_failed", "Import failed; the previous plan is pending recovery"
|
||||
) from rollback_error
|
||||
raise ImportFailure("commit_failed", "Import failed and the previous plan was restored")
|
||||
|
||||
|
||||
# ---------------- portable backup / transfer ----------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/export/create",
|
||||
vol.Required("kind"): vol.In(["full", "space"]),
|
||||
vol.Optional("space_id"): str,
|
||||
vol.Optional("card_version", default=""): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Build a consistent full or one-space JSON snapshot."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only editors may export House Plan")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
document, filename = await hass.async_add_executor_job(
|
||||
partial(
|
||||
create_export,
|
||||
rt,
|
||||
config_data,
|
||||
layout_data,
|
||||
kind=msg["kind"],
|
||||
space_id=msg.get("space_id"),
|
||||
card_version=msg.get("card_version", ""),
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
)
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan export failed")
|
||||
connection.send_error(msg["id"], "invalid_config", "Could not create export")
|
||||
return
|
||||
connection.send_result(msg["id"], {"document": document, "filename": filename})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/import/revalidate",
|
||||
vol.Required("token"): str,
|
||||
vol.Optional("duplicate_policy", default="skip"): vol.In(["skip", "virtual"]),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_import_revalidate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Re-evaluate a space candidate after its duplicate policy changes."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
try:
|
||||
registry_snapshot = import_registry_snapshot(hass)
|
||||
except Exception: # noqa: BLE001 - summary must not block revalidation
|
||||
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
|
||||
registry_snapshot = None
|
||||
result = await hass.async_add_executor_job(
|
||||
partial(
|
||||
revalidate_candidate,
|
||||
candidate,
|
||||
config_data,
|
||||
layout_data,
|
||||
duplicate_policy=msg["duplicate_policy"],
|
||||
registry_snapshot=registry_snapshot,
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
)
|
||||
result["token"] = msg["token"]
|
||||
result["expires_at"] = datetime.fromtimestamp(
|
||||
candidate["expires"], UTC
|
||||
).isoformat().replace("+00:00", "Z")
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/import/apply",
|
||||
vol.Required("token"): str,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
vol.Optional("duplicate_policy"): vol.In(["skip", "virtual"]),
|
||||
vol.Optional("confirm_missing_content", default=False): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Commit exactly the previewed candidate as one crash-resumable pair."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
kind = ""
|
||||
details: dict[str, Any] = {}
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if (
|
||||
msg["expected_config_rev"] != config_rev
|
||||
or msg["expected_layout_rev"] != layout_rev
|
||||
or candidate.get("config_rev") != config_rev
|
||||
or candidate.get("layout_rev") != layout_rev
|
||||
):
|
||||
raise ImportFailure("conflict", "Plan changed after the preview")
|
||||
kind = str(candidate["document"]["kind"])
|
||||
requested_policy = msg.get("duplicate_policy", candidate.get("duplicate_policy"))
|
||||
if kind == "space" and requested_policy != candidate.get("duplicate_policy"):
|
||||
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
|
||||
target_config, target_layout, details = await hass.async_add_executor_job(
|
||||
partial(
|
||||
prepare_apply,
|
||||
candidate,
|
||||
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}},
|
||||
layout_data.get("layout") or {},
|
||||
duplicate_policy=candidate.get("duplicate_policy"),
|
||||
confirm_missing_content=msg["confirm_missing_content"],
|
||||
)
|
||||
)
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
target_config,
|
||||
config_data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
raise ImportFailure(
|
||||
"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
|
||||
if kind == "full":
|
||||
backup = {
|
||||
"kind": "import",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"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 replaced_metadata
|
||||
}
|
||||
if backup is not None:
|
||||
final_metadata[_OPTIMIZE_BACKUP] = backup
|
||||
pending = {
|
||||
"kind": "import",
|
||||
"config": target_config,
|
||||
"layout": target_layout,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"final_metadata": final_metadata,
|
||||
}
|
||||
rollback = {
|
||||
"kind": "import_rollback",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"config_rev": config_rev,
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
}
|
||||
await _commit_import_pair(rt, pending, rollback)
|
||||
# A token becomes single-use only after both durable halves land.
|
||||
get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan import commit failed")
|
||||
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
|
||||
return
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
if kind == "full":
|
||||
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
live_marker_ids = {
|
||||
str(marker.get("id")) for marker in target_config.get("markers") or []
|
||||
}
|
||||
if recorder is not None:
|
||||
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
|
||||
if marker_id not in live_marker_ids:
|
||||
try:
|
||||
await recorder.async_delete(marker_id)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan: removing orphan import trail failed")
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"kind": kind,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"counts": details.get("counts", {}),
|
||||
"space_id": details.get("space_id"),
|
||||
"can_undo": kind == "full",
|
||||
})
|
||||
|
||||
|
||||
# ---------------- layout ----------------
|
||||
|
||||
|
||||
def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Drop positions which a deleted marker can no longer own.
|
||||
|
||||
HA device ids may be layout-only because auto-discovered devices need no
|
||||
marker entry. Virtual ids are different: every live virtual marker is
|
||||
explicit, so a missing `v_*` owner is stale data. The prefix itself is
|
||||
only a legacy naming convention, however; an explicit real marker remains
|
||||
authoritative even when its id happens to begin with `v_`.
|
||||
"""
|
||||
return live_layout(config, layout)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
@@ -124,6 +517,7 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
"layout": data.get("layout", {}),
|
||||
"rev": int(data.get("rev", 0)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
|
||||
"undo_kind": _undo_kind(config_data, data),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -151,6 +545,7 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
data = await rt.store.async_load() or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
@@ -158,10 +553,12 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||
)
|
||||
return
|
||||
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
|
||||
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
|
||||
"layout": msg["layout"], "rev": new_rev})
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
@@ -183,6 +580,39 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
# A stale browser may still finish a drag after another client deleted
|
||||
# the marker. Its tombstone is the server-side authority: acknowledge
|
||||
# but ignore the late point so re-adding starts without a zombie
|
||||
# position.
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
config = config_data.get("config") or {}
|
||||
markers = config.get("markers") or []
|
||||
deleted = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
|
||||
for m in markers
|
||||
)
|
||||
live_virtual = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
|
||||
and m.get("binding") == "virtual"
|
||||
for m in markers
|
||||
)
|
||||
live_explicit = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
|
||||
for m in markers
|
||||
)
|
||||
orphan_virtual = (
|
||||
msg["device_id"].startswith("v_")
|
||||
and not live_virtual
|
||||
and not live_explicit
|
||||
)
|
||||
if deleted or orphan_virtual:
|
||||
data = await rt.store.async_load() or {}
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"ignored": "removed" if deleted else "missing_virtual",
|
||||
"rev": int(data.get("rev", 0)),
|
||||
})
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
@@ -190,9 +620,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
|
||||
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
|
||||
"layout": layout, "rev": new_rev})
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
@@ -204,6 +635,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
|
||||
@@ -236,6 +668,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:
|
||||
@@ -245,7 +683,11 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
for key, pos in (backup.get("positions") or {}).items():
|
||||
restored[key] = pos
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": restored, "rev": new_rev})
|
||||
await async_save_layout_state(
|
||||
rt, data, restored, new_rev,
|
||||
metadata=_optimizer_backup_after_layout_maintenance(data, new_rev),
|
||||
remove=("repair_backup",),
|
||||
)
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
|
||||
"restored": len(backup.get("positions") or {})})
|
||||
@@ -277,10 +719,14 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
# the backup rides the same store write: either both are durable or
|
||||
# neither — the deletion-shy rules of this project apply to positions
|
||||
# too
|
||||
await rt.store.async_save({
|
||||
"layout": new_layout, "rev": new_rev,
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
})
|
||||
await async_save_layout_state(
|
||||
rt, data, new_layout, new_rev,
|
||||
metadata={
|
||||
**_optimizer_backup_after_layout_maintenance(data, new_rev),
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
},
|
||||
remove=("repair_backup",),
|
||||
)
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
|
||||
|
||||
@@ -588,9 +1034,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
|
||||
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
|
||||
"layout": layout, "rev": new_rev})
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
if new_rev is not None:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
@@ -621,6 +1068,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
"rev": data.get("rev", 0),
|
||||
"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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -666,6 +1114,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",
|
||||
@@ -715,6 +1187,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
# Marker-to-marker light links are a semantic graph layered on top of
|
||||
# the lossless controls array. Validate only edges introduced by this
|
||||
# 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))
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
# plan and then delete it from the same dialog, and two clients can do
|
||||
# the same thing in either order — the lock serialises them but says
|
||||
@@ -810,6 +1292,17 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
)
|
||||
return
|
||||
|
||||
# Optimization is a normal configuration write with an additional
|
||||
# layout transaction. It must enforce the same marker-link semantics
|
||||
# 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))
|
||||
return
|
||||
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
@@ -826,6 +1319,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = {
|
||||
"kind": "optimize",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"created": int(time.time()),
|
||||
@@ -835,29 +1329,23 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
pending = {
|
||||
"config": msg["config"],
|
||||
"layout": msg["layout"],
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": False,
|
||||
}
|
||||
layout_meta = {
|
||||
k: v for k, v in layout_data.items()
|
||||
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
|
||||
}
|
||||
|
||||
# Intent first. A setup-time finisher completes whichever half a crash
|
||||
# interrupted; until then the visible layout/revision remain unchanged.
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"rev": layout_rev,
|
||||
_OPTIMIZE_BACKUP: backup,
|
||||
_OPTIMIZE_PENDING: pending,
|
||||
})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, layout_data.get("layout", {}), layout_rev,
|
||||
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 rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": msg["layout"],
|
||||
"rev": new_layout_rev,
|
||||
_OPTIMIZE_BACKUP: backup,
|
||||
})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, msg["layout"], new_layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"),
|
||||
)
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
@@ -887,6 +1375,7 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
if rt is None:
|
||||
return
|
||||
|
||||
restored_kind = "optimize"
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
@@ -903,36 +1392,50 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
return
|
||||
|
||||
backup = layout_data[_OPTIMIZE_BACKUP]
|
||||
restored_kind = str(backup.get("kind") or "optimize")
|
||||
restored_config = backup.get("config") or DEFAULT_CONFIG
|
||||
restored_layout = backup.get("layout") or {}
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
pending = {
|
||||
"kind": "import_undo" if restored_kind == "import" else "optimize_undo",
|
||||
"config": restored_config,
|
||||
"layout": restored_layout,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": True,
|
||||
}
|
||||
layout_meta = {
|
||||
k: v for k, v in layout_data.items()
|
||||
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
|
||||
}
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"rev": layout_rev,
|
||||
_OPTIMIZE_BACKUP: backup,
|
||||
_OPTIMIZE_PENDING: pending,
|
||||
})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, layout_data.get("layout", {}), layout_rev,
|
||||
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 rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": restored_layout,
|
||||
"rev": new_layout_rev,
|
||||
})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, restored_layout, new_layout_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
|
||||
)
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
if restored_kind == "import":
|
||||
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
live_marker_ids = {
|
||||
str(marker.get("id")) for marker in restored_config.get("markers") or []
|
||||
}
|
||||
if recorder is not None:
|
||||
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
|
||||
if marker_id not in live_marker_ids:
|
||||
try:
|
||||
await recorder.async_delete(marker_id)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan: removing orphan undo trail failed")
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
@@ -1022,3 +1525,22 @@ async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConn
|
||||
"""Current + previous cleanup runs per marker, raw robot coordinates."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/trail/delete",
|
||||
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=256)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
|
||||
"""Permanently forget one deleted marker's current and previous runs."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete trails")
|
||||
return
|
||||
if _runtime(hass, connection, msg["id"]) is None:
|
||||
return
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
removed = await rec.async_delete(msg["marker_id"]) if rec else False
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env node
|
||||
/** Isolated 1/10/30/60-pool performance profiles for #19 and #55. */
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { launch } from './serve.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const profile = valueArg('profile') || 'large-light-blend-v1';
|
||||
if (!['large-light-blend-v1', 'large-house-glow-overlay-v1'].includes(profile))
|
||||
throw new Error(`unknown Glow profile: ${profile}`);
|
||||
const parsedSamples = Number(valueArg('samples'));
|
||||
const parsedWarmups = Number(valueArg('warmups'));
|
||||
const samples = Math.max(1, Math.min(20, Number.isFinite(parsedSamples) && parsedSamples > 0 ? parsedSamples : 7));
|
||||
const warmups = Math.max(0, Math.min(5, Number.isFinite(parsedWarmups) && parsedWarmups >= 0 ? parsedWarmups : 1));
|
||||
const requestedVariants = valueArg('variants')?.split(',').map(Number);
|
||||
if (requestedVariants?.some((count) => ![1, 10, 30, 60].includes(count)))
|
||||
throw new Error(`invalid Glow variants: ${valueArg('variants')}`);
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const additiveFixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/glow/additive-pools.json', import.meta.url), 'utf8',
|
||||
));
|
||||
additiveFixture.sourceIds = Object.keys(additiveFixture.ha.states)
|
||||
.filter((entityId) => entityId.startsWith('light.'));
|
||||
additiveFixture.roomCount = additiveFixture.config.spaces
|
||||
.reduce((sum, space) => sum + space.rooms.length, 0);
|
||||
additiveFixture.deviceCount = Object.keys(additiveFixture.ha.devices).length;
|
||||
|
||||
const makeOverlayFixture = () => {
|
||||
const large = makeLargeHouseFixture();
|
||||
const firstSpace = large.config.spaces[0].id;
|
||||
const sourceDeviceIds = Object.entries(large.layout)
|
||||
.filter(([, position]) => position.s === firstSpace)
|
||||
.slice(0, 60)
|
||||
.map(([deviceId]) => deviceId);
|
||||
const sourceIds = [];
|
||||
sourceDeviceIds.forEach((deviceId, index) => {
|
||||
for (const [entityId, entity] of Object.entries(large.entities)) {
|
||||
if (entity.device_id !== deviceId) continue;
|
||||
delete large.entities[entityId];
|
||||
delete large.states[entityId];
|
||||
}
|
||||
const entityId = `light.glow_overlay_${String(index + 1).padStart(3, '0')}`;
|
||||
large.entities[entityId] = {
|
||||
entity_id: entityId, device_id: deviceId, platform: 'houseplan_perf',
|
||||
config_entry_id: 'perf_entry', disabled_by: null,
|
||||
};
|
||||
large.states[entityId] = {
|
||||
entity_id: entityId, state: 'on',
|
||||
attributes: {
|
||||
friendly_name: `Overlay light ${index + 1}`,
|
||||
brightness: 96 + (index % 5) * 32,
|
||||
rgb_color: index % 2 ? [255, 154, 72] : [92, 156, 255],
|
||||
},
|
||||
};
|
||||
sourceIds.push(entityId);
|
||||
});
|
||||
// The shared large-house fixture already contains a few ordinary lights.
|
||||
// Keep them as devices but turn them off so the profile's pool cardinality
|
||||
// is exactly the declared 1/10/30/60, not N plus an unrelated background lamp.
|
||||
for (const [entityId, state] of Object.entries(large.states)) {
|
||||
if (entityId.startsWith('light.') && !sourceIds.includes(entityId)) {
|
||||
large.states[entityId] = { ...state, state: 'off' };
|
||||
}
|
||||
}
|
||||
for (const space of large.config.spaces) {
|
||||
space.settings = { ...(space.settings || {}), fill_mode: 'temp', glow_enabled: true };
|
||||
}
|
||||
return {
|
||||
fixture: 'large-house-glow-overlay-v1', variants: [1, 10, 30, 60],
|
||||
config: large.config, layout: large.layout,
|
||||
ha: { devices: large.devices, entities: large.entities, areas: large.areas, states: large.states },
|
||||
sourceIds,
|
||||
roomCount: large.counts.rooms,
|
||||
deviceCount: large.counts.devices,
|
||||
};
|
||||
};
|
||||
const fixture = profile === 'large-light-blend-v1' ? additiveFixture : makeOverlayFixture();
|
||||
if (requestedVariants?.length) fixture.variants = [...new Set(requestedVariants)];
|
||||
const viewport = { width: 1280, height: 900 };
|
||||
|
||||
const { page, browser } = await launch(
|
||||
viewport, 1,
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{}, resolve(targetRoot, 'demo/srv'),
|
||||
);
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
});
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addStyleTag({
|
||||
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
|
||||
});
|
||||
const chromium = await browser.version();
|
||||
let buildFingerprint;
|
||||
try {
|
||||
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
|
||||
} catch (error) {
|
||||
await browser.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
try {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const sample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({ fixture, profile, sample, cardContract }) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('Glow benchmark timed out');
|
||||
await new Promise((done) => setTimeout(done, 10));
|
||||
}
|
||||
};
|
||||
const observeLongTasks = () => {
|
||||
const entries = [];
|
||||
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask'))
|
||||
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
|
||||
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
||||
observer.observe({ type: 'longtask', buffered: false });
|
||||
return { stop: async () => {
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
entries.push(...observer.takeRecords());
|
||||
observer.disconnect();
|
||||
const values = entries.map((entry) => entry.duration);
|
||||
return {
|
||||
supported: true,
|
||||
count: values.length,
|
||||
maxMs: Number((values.length ? Math.max(...values) : 0).toFixed(2)),
|
||||
totalMs: Number(values.reduce((sum, value) => sum + value, 0).toFixed(2)),
|
||||
};
|
||||
}};
|
||||
};
|
||||
const forceGc = async () => {
|
||||
if (typeof globalThis.gc !== 'function') return false;
|
||||
globalThis.gc(); await frame(); globalThis.gc(); await frame();
|
||||
return true;
|
||||
};
|
||||
const configFor = () => {
|
||||
const config = structuredClone(fixture.config);
|
||||
if (profile === 'large-light-blend-v1') {
|
||||
const settings = config.spaces[0].settings;
|
||||
settings.fill_mode = 'glow';
|
||||
delete settings.glow_enabled;
|
||||
}
|
||||
return config;
|
||||
};
|
||||
const statesFor = (count, brightnessDelta = 0) => {
|
||||
const active = new Set(fixture.sourceIds.slice(0, count));
|
||||
const sources = new Set(fixture.sourceIds);
|
||||
return Object.fromEntries(Object.entries(fixture.ha.states).map(([entityId, state]) => {
|
||||
if (!sources.has(entityId)) return [entityId, state];
|
||||
return [entityId, {
|
||||
...state,
|
||||
state: active.has(entityId) ? 'on' : 'off',
|
||||
attributes: {
|
||||
...state.attributes,
|
||||
brightness: Math.max(1, Math.min(255, Number(state.attributes.brightness) + brightnessDelta)),
|
||||
},
|
||||
}];
|
||||
}));
|
||||
};
|
||||
const connection = {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
};
|
||||
const hassFor = (states) => ({
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'glow-perf', name: 'Glow performance', is_admin: true },
|
||||
devices: fixture.ha.devices, entities: fixture.ha.entities,
|
||||
areas: fixture.ha.areas, states, floors: {}, connection,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: configFor(), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout), rev: 1 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.ha.devices);
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.ha.entities);
|
||||
if (message.type === 'config_entries/get')
|
||||
return [{ entry_id: 'glow_fixture', domain: 'houseplan_fixture', title: 'Glow fixture' }];
|
||||
if (message.type === 'manifest/list')
|
||||
return [{ domain: 'houseplan_fixture', name: 'House Plan Glow Fixture' }];
|
||||
return { ok: true };
|
||||
},
|
||||
callService: async () => undefined,
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
const cacheSnapshot = (card) => ({
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
});
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = document.getElementById('host');
|
||||
const result = { sample, longTasks: {}, renderCounts: {}, poolCounts: {} };
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor(statesFor(1));
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
|
||||
if ('_glowScreenBlend' in card) {
|
||||
const probeDeadline = performance.now() + 2500;
|
||||
while (!card._glowScreenBlend && performance.now() < probeDeadline)
|
||||
await new Promise((done) => setTimeout(done, 10));
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
for (const count of fixture.variants) {
|
||||
// Mount cost is not part of this profile. Prime each source-count
|
||||
// state on the same full plan, then measure only the following HA tick.
|
||||
card.hass = hassFor(statesFor(count));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
let renders = 0;
|
||||
const originalUpdate = card.performUpdate.bind(card);
|
||||
card.performUpdate = () => { renders++; return originalUpdate(); };
|
||||
const longTasks = observeLongTasks();
|
||||
const started = performance.now();
|
||||
card.hass = hassFor(statesFor(count, 1));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
result[`stateUpdate${count}Ms`] = Number((performance.now() - started).toFixed(2));
|
||||
result.longTasks[`stateUpdate${count}`] = await longTasks.stop();
|
||||
result.renderCounts[count] = renders;
|
||||
result.poolCounts[count] = card.renderRoot.querySelectorAll('.glow-pool, .glowlayer circle').length;
|
||||
}
|
||||
window.__card = card;
|
||||
await forceGc();
|
||||
const cacheBefore = cacheSnapshot(card);
|
||||
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
|
||||
for (let index = 0; index < 5; index++) {
|
||||
card.hass = hassFor(statesFor(60, index % 2));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
await forceGc();
|
||||
const cacheEntries = cacheSnapshot(card);
|
||||
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
|
||||
result.cacheEntries = cacheEntries;
|
||||
result.cacheGrowth = Object.fromEntries(
|
||||
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
|
||||
);
|
||||
result.heapGrowthBytes = heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore;
|
||||
result.preciseGc = typeof globalThis.gc === 'function';
|
||||
result.renderedDevices = card._devices?.length ?? 0;
|
||||
result.screenBlend = card._glowScreenBlend === true;
|
||||
return result;
|
||||
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
|
||||
const captureStarted = performance.now();
|
||||
await page.screenshot({ type: 'png' });
|
||||
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
|
||||
if (sample >= 0) rows.push(row);
|
||||
}
|
||||
} finally {
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => undefined);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const metricNames = [
|
||||
...fixture.variants.map((count) => `stateUpdate${count}Ms`), 'screenshotCaptureMs',
|
||||
];
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildFingerprint,
|
||||
runtime: {
|
||||
node: process.version, chromium, platform: process.platform, arch: process.arch,
|
||||
viewport, deviceScaleFactor: 1, cpuThrottleRate: 4, reducedMotion: true,
|
||||
},
|
||||
fixture: {
|
||||
id: fixture.fixture, variants: fixture.variants,
|
||||
rooms: fixture.roomCount, devices: fixture.deviceCount,
|
||||
},
|
||||
samples,
|
||||
warmups,
|
||||
summary: summarizeTimings(rows, metricNames),
|
||||
longTasks: summarizeLongTasks(rows),
|
||||
rows,
|
||||
};
|
||||
const text = `${JSON.stringify(report, null, 2)}\n`;
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, text, 'utf8');
|
||||
console.log(output);
|
||||
} else process.stdout.write(text);
|
||||
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env node
|
||||
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
|
||||
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';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
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'].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const viewport = { width: 1440, height: 1000 };
|
||||
|
||||
const { page, browser } = await launch(
|
||||
viewport,
|
||||
1,
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{},
|
||||
resolve(targetRoot, 'demo/srv'),
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addStyleTag({
|
||||
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
});
|
||||
const chromium = await browser.version();
|
||||
let buildFingerprint;
|
||||
try {
|
||||
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
|
||||
} catch (error) {
|
||||
await browser.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
try {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric }) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('large-house benchmark timed out');
|
||||
await new Promise((done) => setTimeout(done, 10));
|
||||
}
|
||||
};
|
||||
const startLongTaskWindow = () => {
|
||||
const entries = [];
|
||||
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
|
||||
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
|
||||
}
|
||||
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
||||
observer.observe({ type: 'longtask', buffered: false });
|
||||
return {
|
||||
stop: async () => {
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
entries.push(...observer.takeRecords());
|
||||
observer.disconnect();
|
||||
const durations = entries.map((entry) => entry.duration);
|
||||
return {
|
||||
supported: true,
|
||||
count: durations.length,
|
||||
maxMs: Number((durations.length ? Math.max(...durations) : 0).toFixed(2)),
|
||||
totalMs: Number(durations.reduce((sum, value) => sum + value, 0).toFixed(2)),
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
const duration = async (action) => {
|
||||
const longTasks = startLongTaskWindow();
|
||||
const started = performance.now();
|
||||
await action();
|
||||
await frame();
|
||||
return {
|
||||
ms: Number((performance.now() - started).toFixed(2)),
|
||||
longTasks: await longTasks.stop(),
|
||||
};
|
||||
};
|
||||
const forceGc = async () => {
|
||||
if (typeof globalThis.gc !== 'function') return false;
|
||||
globalThis.gc();
|
||||
await frame();
|
||||
globalThis.gc();
|
||||
await frame();
|
||||
return true;
|
||||
};
|
||||
const cacheSnapshot = (card) => ({
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 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,
|
||||
});
|
||||
const connection = {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
};
|
||||
const hassFor = (states) => ({
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout), rev: 1 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
|
||||
if (message.type === 'config_entries/get')
|
||||
return [{ entry_id: 'perf_entry', domain: 'houseplan_perf', title: 'Synthetic performance fixture' }];
|
||||
if (message.type === 'manifest/list') return [{ domain: 'houseplan_perf', name: 'House Plan Performance' }];
|
||||
return { ok: true };
|
||||
},
|
||||
callService: async () => undefined,
|
||||
connection,
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
|
||||
const loadLongTasks = startLongTaskWindow();
|
||||
const loadStarted = performance.now();
|
||||
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();
|
||||
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
await until(() => card._booting === false);
|
||||
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;
|
||||
});
|
||||
|
||||
const firstEntity = Object.keys(fixture.states)[0];
|
||||
const nextStates = {
|
||||
...fixture.states,
|
||||
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
|
||||
};
|
||||
const stateUpdate = await duration(async () => {
|
||||
card.hass = hassFor(nextStates);
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const resizePreview = await duration(async () => {
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
await card.updateComplete;
|
||||
const room = card._rszRooms()[0];
|
||||
const pointerId = 777;
|
||||
const quietEvent = {
|
||||
pointerId,
|
||||
stopPropagation: () => undefined,
|
||||
preventDefault: () => undefined,
|
||||
target: null,
|
||||
};
|
||||
card._rszEdgeDown(quietEvent, room.id, 1);
|
||||
const plan = card._rszDrag?.plan;
|
||||
if (!plan) throw new Error('large-house resize plan was not created');
|
||||
const target = [
|
||||
plan.a[0] + plan.n[0] * card._gridPitch,
|
||||
plan.a[1] + plan.n[1] * card._gridPitch,
|
||||
];
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const view = card._viewOr(card._baseVb());
|
||||
card._rszMove({
|
||||
...quietEvent,
|
||||
clientX: rect.left + ((target[0] - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((target[1] - view.y) / view.h) * rect.height,
|
||||
});
|
||||
await card.updateComplete;
|
||||
card._rszCancelDrag();
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const panZoom = await duration(async () => {
|
||||
stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const settingsDialog = await duration(async () => {
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
});
|
||||
card._settingsDialog = null;
|
||||
await card.updateComplete;
|
||||
|
||||
const switchCycle = await duration(async () => {
|
||||
for (let index = 0; index < 12; index++) {
|
||||
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
|
||||
await card.updateComplete;
|
||||
// A user cannot produce twelve tab clicks in one JavaScript task.
|
||||
// Yield between interactions so Long Task entries describe one
|
||||
// switch, while switchCycleMs still measures the complete cycle.
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
}
|
||||
});
|
||||
|
||||
await forceGc();
|
||||
const cacheBefore = cacheSnapshot(card);
|
||||
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
|
||||
for (let round = 0; round < 4; round++) {
|
||||
for (let index = 0; index < 12; index++) {
|
||||
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
}
|
||||
await forceGc();
|
||||
}
|
||||
const cacheEntries = cacheSnapshot(card);
|
||||
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
|
||||
const cacheGrowth = Object.fromEntries(
|
||||
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
|
||||
);
|
||||
|
||||
const result = {
|
||||
sample,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
panZoomMs: panZoom.ms,
|
||||
settingsDialogMs: settingsDialog.ms,
|
||||
switchCycleMs: switchCycle.ms,
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
panZoom: panZoom.longTasks,
|
||||
settingsDialog: settingsDialog.longTasks,
|
||||
switchCycle: switchCycle.longTasks,
|
||||
},
|
||||
cacheEntries,
|
||||
cacheGrowth,
|
||||
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
|
||||
preciseGc: typeof globalThis.gc === 'function',
|
||||
renderedDevices: card._devices?.length ?? 0,
|
||||
};
|
||||
card.remove();
|
||||
await frame();
|
||||
return result;
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const metricNames = [
|
||||
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildFingerprint,
|
||||
runtime: {
|
||||
node: process.version,
|
||||
chromium,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
viewport,
|
||||
deviceScaleFactor: 1,
|
||||
reducedMotion: true,
|
||||
},
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
samples,
|
||||
warmups,
|
||||
summary: summarizeTimings(rows, metricNames),
|
||||
longTasks: summarizeLongTasks(rows),
|
||||
rows,
|
||||
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`;
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, text, 'utf8');
|
||||
console.log(output);
|
||||
} else {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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()) {
|
||||
// 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(
|
||||
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
|
||||
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
|
||||
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Deterministic, fictional high-load fixture shared by performance and future
|
||||
* visual-regression tooling. Nothing here depends on a real HA installation.
|
||||
*/
|
||||
|
||||
const FLOOR_COUNT = 3;
|
||||
const ROOMS_PER_FLOOR = 20;
|
||||
const DEVICE_COUNT = 200;
|
||||
const OPENING_COUNT = 100;
|
||||
const PARTITION_COUNT = 60;
|
||||
const COLUMN_COUNT = 40;
|
||||
const DECOR_COUNT = 500;
|
||||
|
||||
const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
const roomGrid = (floor) => {
|
||||
const rooms = [];
|
||||
const left = 0.04;
|
||||
const top = 0.04;
|
||||
const width = 0.92 / 5;
|
||||
const height = 0.92 / 4;
|
||||
for (let row = 0; row < 4; row++) {
|
||||
for (let column = 0; column < 5; column++) {
|
||||
const index = row * 5 + column;
|
||||
const x1 = round(left + column * width);
|
||||
const y1 = round(top + row * height);
|
||||
const x2 = round(x1 + width);
|
||||
const y2 = round(y1 + height);
|
||||
rooms.push({
|
||||
id: `perf-room-${floor}-${index}`,
|
||||
name: `Room ${floor + 1}.${index + 1}`,
|
||||
area: `perf_area_${floor}_${index}`,
|
||||
poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
|
||||
});
|
||||
}
|
||||
}
|
||||
return rooms;
|
||||
};
|
||||
|
||||
const wallSegments = (rooms) => {
|
||||
const unique = new Map();
|
||||
for (const room of rooms) {
|
||||
room.poly.forEach((a, index) => {
|
||||
const b = room.poly[(index + 1) % room.poly.length];
|
||||
const forward = `${a.join(',')}/${b.join(',')}`;
|
||||
const reverse = `${b.join(',')}/${a.join(',')}`;
|
||||
if (!unique.has(reverse)) unique.set(forward, { a, b });
|
||||
});
|
||||
}
|
||||
return [...unique.values()];
|
||||
};
|
||||
|
||||
const makeOpenings = (floor, walls, count) => walls.slice(0, count).map((wall, index) => {
|
||||
const horizontal = Math.abs(wall.b[0] - wall.a[0]) >= Math.abs(wall.b[1] - wall.a[1]);
|
||||
return {
|
||||
id: `perf-opening-${floor}-${index}`,
|
||||
type: index % 7 === 0 ? 'window' : index % 11 === 0 ? 'gate' : 'door',
|
||||
x: round((wall.a[0] + wall.b[0]) / 2),
|
||||
y: round((wall.a[1] + wall.b[1]) / 2),
|
||||
angle: horizontal ? 0 : 90,
|
||||
length: horizontal ? 0.045 : 0.055,
|
||||
};
|
||||
});
|
||||
|
||||
const makePartitions = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
|
||||
const room = rooms[index % rooms.length];
|
||||
const [a, , c] = room.poly;
|
||||
const y = round(a[1] + (c[1] - a[1]) * (0.35 + (index % 3) * 0.12));
|
||||
return {
|
||||
id: `perf-partition-${floor}-${index}`,
|
||||
a: [round(a[0] + 0.035), y],
|
||||
b: [round(c[0] - 0.035), y],
|
||||
cm: 10 + (index % 3) * 5,
|
||||
};
|
||||
});
|
||||
|
||||
const makeColumns = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
|
||||
const room = rooms[(index * 3) % rooms.length];
|
||||
const [a, , c] = room.poly;
|
||||
return {
|
||||
id: `perf-column-${floor}-${index}`,
|
||||
shape: index % 3 === 0 ? 'circle' : 'square',
|
||||
center: [
|
||||
round(a[0] + (c[0] - a[0]) * (0.28 + (index % 2) * 0.44)),
|
||||
round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 1) % 2) * 0.44)),
|
||||
],
|
||||
cm: 25 + (index % 4) * 5,
|
||||
...(index % 3 === 0 ? {} : { angle: (index % 6) * 15 }),
|
||||
};
|
||||
});
|
||||
|
||||
const makeDecor = (floor, count) => Array.from({ length: count }, (_, index) => {
|
||||
const column = index % 25;
|
||||
const row = Math.floor(index / 25);
|
||||
const x = round(0.02 + column * 0.039);
|
||||
const y = round(0.018 + (row % 20) * 0.048);
|
||||
if (index % 10 === 0) {
|
||||
return {
|
||||
id: `perf-decor-${floor}-${index}`,
|
||||
kind: 'text', x, y, text: `F${floor + 1}-${index}`, size_cm: 14,
|
||||
color: '#59636e', opacity: 0.75,
|
||||
};
|
||||
}
|
||||
if (index % 3 === 0) {
|
||||
return {
|
||||
id: `perf-decor-${floor}-${index}`,
|
||||
kind: 'rect', x, y, w: 0.022, h: 0.018, angle: (index % 12) * 5,
|
||||
color: '#687681', opacity: 0.55, width_cm: 1.5,
|
||||
fill: index % 2 === 0, fill_color: '#75838e', fill_opacity: 0.12,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: `perf-decor-${floor}-${index}`,
|
||||
kind: 'line', x1: x, y1: y, x2: round(x + 0.025), y2: round(y + (index % 2 ? 0.012 : 0)),
|
||||
color: '#687681', opacity: 0.6, width_cm: 1.2,
|
||||
...(index % 9 === 0 ? { line_style: 'dashed' } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const entityKinds = [
|
||||
['light', 'on'],
|
||||
['switch', 'off'],
|
||||
['sensor', '21.5'],
|
||||
['binary_sensor', 'off'],
|
||||
['climate', 'heat'],
|
||||
['media_player', 'playing'],
|
||||
['cover', 'closed'],
|
||||
['fan', 'on'],
|
||||
['lock', 'locked'],
|
||||
['vacuum', 'docked'],
|
||||
];
|
||||
|
||||
const makeRuntime = (spaces) => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {};
|
||||
const areas = {};
|
||||
const layout = {};
|
||||
const roomRefs = spaces.flatMap((space) => space.rooms.map((room) => ({ space, room })));
|
||||
for (const { room } of roomRefs) areas[room.area] = { area_id: room.area, name: room.name };
|
||||
for (let index = 0; index < DEVICE_COUNT; index++) {
|
||||
const { space, room } = roomRefs[index % roomRefs.length];
|
||||
const [domain, baseState] = entityKinds[index % entityKinds.length];
|
||||
const deviceId = `perf-device-${index}`;
|
||||
const entityId = `${domain}.perf_${index}`;
|
||||
devices[deviceId] = {
|
||||
id: deviceId,
|
||||
name: `Synthetic ${domain} ${index + 1}`,
|
||||
model: `PERF-${String(index + 1).padStart(3, '0')}`,
|
||||
area_id: room.area,
|
||||
identifiers: [['houseplan_perf', deviceId]],
|
||||
config_entries: ['perf_entry'],
|
||||
entry_type: null,
|
||||
via_device_id: null,
|
||||
disabled_by: null,
|
||||
};
|
||||
entities[entityId] = {
|
||||
entity_id: entityId,
|
||||
device_id: deviceId,
|
||||
platform: 'houseplan_perf',
|
||||
config_entry_id: 'perf_entry',
|
||||
disabled_by: null,
|
||||
};
|
||||
const attributes = { friendly_name: devices[deviceId].name };
|
||||
if (domain === 'sensor') Object.assign(attributes, {
|
||||
device_class: 'temperature', unit_of_measurement: '°C', state_class: 'measurement',
|
||||
});
|
||||
if (domain === 'binary_sensor') attributes.device_class = index % 2 ? 'motion' : 'occupancy';
|
||||
if (domain === 'climate') Object.assign(attributes, { current_temperature: 21.5, temperature: 22 });
|
||||
states[entityId] = { entity_id: entityId, state: index % 4 === 0 && domain === 'light' ? 'off' : baseState, attributes };
|
||||
const [a, , c] = room.poly;
|
||||
layout[deviceId] = {
|
||||
s: space.id,
|
||||
x: round(a[0] + (c[0] - a[0]) * (0.2 + (index % 4) * 0.2)),
|
||||
y: round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 2) % 3) * 0.22)),
|
||||
};
|
||||
}
|
||||
return { devices, entities, states, areas, layout };
|
||||
};
|
||||
|
||||
export const LARGE_HOUSE_COUNTS = Object.freeze({
|
||||
floors: FLOOR_COUNT,
|
||||
rooms: FLOOR_COUNT * ROOMS_PER_FLOOR,
|
||||
devices: DEVICE_COUNT,
|
||||
openings: OPENING_COUNT,
|
||||
partitions: PARTITION_COUNT,
|
||||
columns: COLUMN_COUNT,
|
||||
decor: DECOR_COUNT,
|
||||
});
|
||||
|
||||
export const makeLargeHouseFixture = () => {
|
||||
let openingsLeft = OPENING_COUNT;
|
||||
let partitionsLeft = PARTITION_COUNT;
|
||||
let columnsLeft = COLUMN_COUNT;
|
||||
let decorLeft = DECOR_COUNT;
|
||||
const spaces = Array.from({ length: FLOOR_COUNT }, (_, floor) => {
|
||||
const rooms = roomGrid(floor);
|
||||
const segments = wallSegments(rooms);
|
||||
const floorsRemaining = FLOOR_COUNT - floor;
|
||||
const openingCount = Math.ceil(openingsLeft / floorsRemaining);
|
||||
const partitionCount = Math.ceil(partitionsLeft / floorsRemaining);
|
||||
const columnCount = Math.ceil(columnsLeft / floorsRemaining);
|
||||
const decorCount = Math.ceil(decorLeft / floorsRemaining);
|
||||
openingsLeft -= openingCount;
|
||||
partitionsLeft -= partitionCount;
|
||||
columnsLeft -= columnCount;
|
||||
decorLeft -= decorCount;
|
||||
return {
|
||||
id: `perf-floor-${floor + 1}`,
|
||||
title: `Performance floor ${floor + 1}`,
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
|
||||
rooms,
|
||||
walls: segments.map((wall, index) => ({
|
||||
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
|
||||
})),
|
||||
openings: makeOpenings(floor, segments, openingCount),
|
||||
partitions: makePartitions(floor, rooms, partitionCount),
|
||||
wall_columns: makeColumns(floor, rooms, columnCount),
|
||||
decor: makeDecor(floor, decorCount),
|
||||
};
|
||||
});
|
||||
const runtime = makeRuntime(spaces);
|
||||
const lightMarkers = Object.entries(runtime.entities)
|
||||
.filter(([entityId]) => entityId.startsWith('light.'))
|
||||
.map(([_entityId, entity]) => ({
|
||||
id: entity.device_id,
|
||||
binding: `device:${entity.device_id}`,
|
||||
is_light: true,
|
||||
}));
|
||||
return {
|
||||
config: { spaces, markers: lightMarkers, settings: { glow_radius_cm: 300 } },
|
||||
...runtime,
|
||||
counts: LARGE_HOUSE_COUNTS,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
/** Deterministic fictional scenes for HP-QA-01 golden-image coverage. */
|
||||
|
||||
const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
// Golden fixtures must use the same persisted wall-key contract as real plan
|
||||
// data. Arbitrary labels make every configured wall look virtual to the
|
||||
// renderer, which lets a visually ineffective baseline pass unnoticed.
|
||||
const WALL_KEY_PITCH = 1 / 240;
|
||||
export const fixtureWallKey = (a, b) => {
|
||||
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
|
||||
const mx = quantize((a[0] + b[0]) / 2);
|
||||
const my = quantize((a[1] + b[1]) / 2);
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-12) { dx = 1; dy = 0; }
|
||||
else { 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 bucket = Math.round(angle * 1800) / 1800;
|
||||
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
|
||||
const uniqueEdges = (rooms) => {
|
||||
const edges = new Map();
|
||||
for (const room of rooms) {
|
||||
room.poly.forEach((a, index) => {
|
||||
const b = room.poly[(index + 1) % room.poly.length];
|
||||
const forward = `${a.join(',')}/${b.join(',')}`;
|
||||
const reverse = `${b.join(',')}/${a.join(',')}`;
|
||||
if (!edges.has(reverse) && !edges.has(forward)) edges.set(forward, { a, b });
|
||||
});
|
||||
}
|
||||
return [...edges.values()];
|
||||
};
|
||||
|
||||
const wallsFor = (prefix, rooms, thickness) => uniqueEdges(rooms).map((edge, index) => ({
|
||||
key: fixtureWallKey(edge.a, edge.b),
|
||||
a: edge.a,
|
||||
b: edge.b,
|
||||
cm: typeof thickness === 'function' ? thickness(edge, index) : thickness,
|
||||
}));
|
||||
|
||||
const geometryRooms = [
|
||||
{ id: 'geo-nw', name: 'NW', area: 'golden_geo_nw', poly: [[0.06, 0.08], [0.48, 0.08], [0.48, 0.48], [0.06, 0.48]] },
|
||||
{ id: 'geo-ne', name: 'NE', area: 'golden_geo_ne', poly: [[0.48, 0.08], [0.94, 0.08], [0.94, 0.48], [0.48, 0.48]] },
|
||||
{ id: 'geo-sw', name: 'SW', area: 'golden_geo_sw', poly: [[0.06, 0.48], [0.48, 0.48], [0.48, 0.92], [0.06, 0.92]] },
|
||||
{ id: 'geo-se', name: 'SE', area: 'golden_geo_se', poly: [[0.48, 0.48], [0.94, 0.48], [0.94, 0.92], [0.48, 0.92]] },
|
||||
{ id: 'geo-nested', name: 'Nested', area: 'golden_geo_nested',
|
||||
poly: [[0.72, 0.14], [0.84, 0.26], [0.72, 0.38], [0.60, 0.26]] },
|
||||
];
|
||||
|
||||
const lightingRooms = [
|
||||
{ id: 'light-left', name: 'Light source room', area: 'golden_light_left',
|
||||
poly: [[0.07, 0.10], [0.50, 0.10], [0.50, 0.88], [0.07, 0.88]] },
|
||||
{ id: 'light-right', name: 'Receiving room', area: 'golden_light_right',
|
||||
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
|
||||
];
|
||||
|
||||
const geometrySpace = {
|
||||
id: 'golden-geometry',
|
||||
title: 'Geometry matrix',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'none', show_borders: true, show_names: true,
|
||||
room_color: '#2d8fce', room_opacity: 0.16,
|
||||
},
|
||||
rooms: geometryRooms,
|
||||
walls: wallsFor('geo', geometryRooms, (edge, index) => {
|
||||
const vertical = Math.abs(edge.a[0] - edge.b[0]) < 1e-9;
|
||||
if (vertical && Math.abs(edge.a[0] - 0.48) < 1e-9) return 25;
|
||||
return index % 4 === 0 ? 10 : 15;
|
||||
}),
|
||||
open_spans: [{ a: [0.48, 0.15], b: [0.48, 0.27] }],
|
||||
openings: [
|
||||
{ id: 'geo-window', type: 'window', x: 0.26, y: 0.08, angle: 0, length: 0.12 },
|
||||
{ id: 'geo-door', type: 'door', x: 0.48, y: 0.37, angle: 90, length: 0.12 },
|
||||
{ id: 'geo-gate', type: 'gate', x: 0.72, y: 0.92, angle: 0, length: 0.2 },
|
||||
{ id: 'geo-diagonal-window', type: 'window', x: 0.78, y: 0.20, angle: 45, length: 0.08 },
|
||||
],
|
||||
partitions: [
|
||||
{ id: 'geo-partition-h', a: [0.14, 0.68], b: [0.40, 0.68], cm: 12 },
|
||||
{ id: 'geo-partition-v', a: [0.75, 0.56], b: [0.75, 0.82], cm: 20 },
|
||||
],
|
||||
wall_columns: [
|
||||
{ id: 'geo-column-square', shape: 'square', center: [0.63, 0.67], cm: 35, angle: 30 },
|
||||
{ id: 'geo-column-circle', shape: 'circle', center: [0.86, 0.72], cm: 40 },
|
||||
],
|
||||
decor: [
|
||||
{ id: 'geo-axis-h', kind: 'line', x1: 0.04, y1: 0.5, x2: 0.96, y2: 0.5,
|
||||
color: '#5d6a73', opacity: 0.35, width_cm: 0.8, line_style: 'dashed' },
|
||||
],
|
||||
};
|
||||
|
||||
const lightingSpace = {
|
||||
id: 'golden-lighting',
|
||||
title: 'Lighting matrix',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'none', glow_enabled: true, show_borders: true, show_names: true,
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'static',
|
||||
},
|
||||
rooms: lightingRooms,
|
||||
walls: wallsFor('light', lightingRooms, (edge) => (
|
||||
Math.abs(edge.a[0] - 0.5) < 1e-9 && Math.abs(edge.b[0] - 0.5) < 1e-9 ? 25 : 15
|
||||
)),
|
||||
openings: [
|
||||
{ id: 'light-window', type: 'window', x: 0.27, y: 0.10, angle: 0, length: 0.14 },
|
||||
{ id: 'light-door', type: 'door', x: 0.50, y: 0.54, angle: 90, length: 0.15 },
|
||||
{ id: 'light-gate', type: 'gate', x: 0.74, y: 0.88, angle: 0, length: 0.22 },
|
||||
],
|
||||
partitions: [
|
||||
{ id: 'light-partition', a: [0.70, 0.22], b: [0.70, 0.70], cm: 18 },
|
||||
],
|
||||
wall_columns: [
|
||||
{ id: 'light-column', shape: 'circle', center: [0.38, 0.64], cm: 45 },
|
||||
],
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = () => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {
|
||||
'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 24 },
|
||||
},
|
||||
};
|
||||
// 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 }]),
|
||||
);
|
||||
const add = (id, domain, area, x, y, state, attributes = {}) => {
|
||||
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
|
||||
devices[id] = {
|
||||
id, name: `Golden ${id}`, model: `GOLDEN-${id.toUpperCase()}`, area_id: area,
|
||||
identifiers: [['houseplan_golden', id]], config_entries: ['golden_entry'],
|
||||
entry_type: null, via_device_id: null, disabled_by: null,
|
||||
};
|
||||
entities[entityId] = {
|
||||
entity_id: entityId, device_id: id, platform: 'houseplan_golden',
|
||||
config_entry_id: 'golden_entry', disabled_by: null,
|
||||
};
|
||||
states[entityId] = { entity_id: entityId, state, attributes: { friendly_name: devices[id].name, ...attributes } };
|
||||
layout[id] = { s: 'golden-lighting', x: round(x), y: round(y) };
|
||||
};
|
||||
add('golden-light-one', 'light', 'golden_light_left', 0.20, 0.34, 'on', { rgb_color: [255, 196, 112] });
|
||||
add('golden-light-two', 'light', 'golden_light_left', 0.35, 0.72, 'on', { color_temp_kelvin: 2700 });
|
||||
add('golden-light-three', 'light', 'golden_light_right', 0.82, 0.30, 'off');
|
||||
add('golden-presence', 'binary_sensor', 'golden_light_right', 0.82, 0.62, 'on', { device_class: 'occupancy' });
|
||||
add('golden-climate', 'climate', 'golden_light_right', 0.60, 0.28, 'heat', {
|
||||
current_temperature: 22.4, temperature: 23, hvac_action: 'heating',
|
||||
});
|
||||
add('golden-left-temperature', 'sensor', 'golden_light_left', 0.19, 0.54, '17', {
|
||||
device_class: 'temperature', unit_of_measurement: '°C',
|
||||
});
|
||||
add('golden-right-temperature', 'sensor', 'golden_light_right', 0.81, 0.48, '29', {
|
||||
device_class: 'temperature', unit_of_measurement: '°C',
|
||||
});
|
||||
add('golden-left-linkquality', 'sensor', 'golden_light_left', 0.34, 0.54, '35', {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
return { devices, entities, states, layout, areas };
|
||||
};
|
||||
|
||||
export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
spaces: 2,
|
||||
rooms: geometryRooms.length + lightingRooms.length,
|
||||
openings: geometrySpace.openings.length + lightingSpace.openings.length,
|
||||
partitions: geometrySpace.partitions.length + lightingSpace.partitions.length,
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
});
|
||||
|
||||
export const makeVisualMatrixFixture = () => ({
|
||||
config: {
|
||||
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
|
||||
// A persisted marker is part of the fixture contract for scenarios that
|
||||
// override per-source Glow controls. The device/layout alone are not a
|
||||
// saved marker configuration and must not be silently treated as one.
|
||||
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
|
||||
settings: {
|
||||
glow_radius_cm: 360,
|
||||
north_deg: 0,
|
||||
sun_rays: true,
|
||||
bg_mode: 'static',
|
||||
fill_colors: {
|
||||
glow_base: { c: '#1b2530', a: 0.78 },
|
||||
glow_light: { c: '#ffd27b', a: 0.70 },
|
||||
wall_fill: { c: '#d7d9dc', a: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
...runtime(),
|
||||
counts: VISUAL_MATRIX_COUNTS,
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
# HP-QA-01 golden images
|
||||
|
||||
This layer catches visual regressions that DOM smokes cannot: wall seams and
|
||||
end caps, thick opening tunnels, Glow/sun clipping, hover contours, editor
|
||||
chrome, the open contextual tray at wide/medium/narrow widths in English and
|
||||
Russian (selection, tool options, group and palette), long dialog
|
||||
titles/footers, mobile clipping, themes and zoom/remount. The desktop and
|
||||
mobile device-dialog scenarios use a real light and make the complete
|
||||
source-role, Glow colour, brightness and radius controls visible; capturing
|
||||
only the top of that section fails the scenario before comparison.
|
||||
The Glow matrix also keeps one deliberately opaque custom-fill scene with a
|
||||
single source and two doorways: it makes hard spill wedges and fully unlit
|
||||
radial spokes visible instead of hiding them under a translucent room fill.
|
||||
|
||||
## Safety contract
|
||||
|
||||
- A build fingerprint embedded by Rollup must match `src/`, Rollup/TypeScript
|
||||
configuration and locked package inputs; stale committed
|
||||
demo bundles fail before the first screenshot.
|
||||
- Chromium, viewport, locale, timezone, colour profile, font rendering,
|
||||
animations and caret are controlled by the runner.
|
||||
- `capture` writes only to ignored `artifacts/golden/`; it never changes a
|
||||
baseline and never claims a missing baseline passed. Any scenario runtime
|
||||
error makes capture fail, including the initial no-baseline CI run.
|
||||
- `verify` requires every image plus a matching matrix manifest and fails on
|
||||
missing/different/error scenarios, browser mismatch or a baseline whose hash
|
||||
no longer matches the reviewed manifest.
|
||||
- `accept` requires `--reviewed`, a complete candidate report and current
|
||||
source fingerprint. It validates the whole set before copying anything and
|
||||
is the only command allowed to update baselines.
|
||||
|
||||
## Workflow
|
||||
|
||||
Build and copy the exact current source first:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
npm run golden:capture
|
||||
```
|
||||
|
||||
Review `artifacts/golden/actual/` and, when existing references are present,
|
||||
`artifacts/golden/diff/`. If every image is intentional:
|
||||
|
||||
```bash
|
||||
npm run golden:accept -- --reviewed
|
||||
npm run golden:verify
|
||||
```
|
||||
|
||||
Never accept images merely to make CI green. A matrix/framing change increments
|
||||
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
|
||||
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
|
||||
Future updates must still use the `golden-images` artifact produced by the Linux
|
||||
CI job as the review set: desktop font rasterisation can differ from the CI
|
||||
environment even with the same pinned Chromium. Pass its unpacked root via
|
||||
`--from=...` when accepting it locally.
|
||||
|
||||
Scenarios may also declare a semantic pixel region (for example, a receiving
|
||||
room that must contain warm light). `golden:capture` and `golden:verify` reject
|
||||
the capture before baseline comparison when that visual precondition is empty;
|
||||
a reviewed but meaningless PNG therefore cannot become the contract.
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
||||
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const reviewed = process.argv.includes('--reviewed');
|
||||
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
|
||||
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
|
||||
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
|
||||
|
||||
const reportPath = resolve(from, 'golden-report.json');
|
||||
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
|
||||
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
||||
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
|
||||
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
|
||||
if (report.buildFingerprint !== sourceFingerprint(ROOT))
|
||||
throw new Error('candidate screenshots were not captured from the current frontend source');
|
||||
if (typeof report.chromium !== 'string' || !report.chromium)
|
||||
throw new Error('candidate report does not identify its Chromium build');
|
||||
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
|
||||
|
||||
const byId = new Map(report.results.map((result) => [result.id, result]));
|
||||
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
|
||||
mkdirSync(baselineRoot, { recursive: true });
|
||||
const hashes = {};
|
||||
const candidates = [];
|
||||
for (const scenario of GOLDEN_SCENARIOS) {
|
||||
const result = byId.get(scenario.id);
|
||||
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
|
||||
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
|
||||
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
|
||||
if (!result?.actualSha256 || !existsSync(candidate))
|
||||
throw new Error(`review candidate missing: ${scenario.id}`);
|
||||
const bytes = readFileSync(candidate);
|
||||
const digest = createHash('sha256').update(bytes).digest('hex');
|
||||
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
|
||||
candidates.push({ scenario, candidate });
|
||||
hashes[scenario.id] = digest;
|
||||
}
|
||||
// Validate the complete set first: a broken report must never leave a half-
|
||||
// updated baseline directory behind.
|
||||
for (const { scenario, candidate } of candidates)
|
||||
copyFileSync(candidate, resolve(baselineRoot, `${scenario.id}.png`));
|
||||
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
|
||||
schema: 1,
|
||||
matrixVersion: GOLDEN_MATRIX_VERSION,
|
||||
acceptedAt: new Date().toISOString(),
|
||||
sourceFingerprint: report.buildFingerprint,
|
||||
chromium: report.chromium,
|
||||
scenarios: hashes,
|
||||
}, null, 2)}\n`, 'utf8');
|
||||
console.log(`Accepted ${GOLDEN_SCENARIOS.length} reviewed golden baselines.`);
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 17,
|
||||
"acceptedAt": "2026-08-13T14:30:05.929Z",
|
||||
"sourceFingerprint": "66f31850eafc963848acdc4e56e363a364bbb9b189bf4ade73d3c111ae2fc375",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"isometric-geometry-view-dark": "6db601f322fe55e33a1a89fe62f5d3a7d53f12bac4a68c7911b12f6382ef4fca",
|
||||
"isometric-geometry-view-light": "c4c005fa55f64280abf9fccccdf7dc8efc3497bd626572bc11f45cc3cddaa159",
|
||||
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "36c83bbe39809f346ebb5a0f4ed633c938e2fed028defaa48097c281db8e23a3",
|
||||
"isometric-large-warm-remount-dark": "798d312671dffebf59034a39f2865a65ece27a84b150e77bc59038bb2567d07c",
|
||||
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
|
||||
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
|
||||
"geometry-plan-editor-dark": "6b06213324c5ff50c7176451307a33d8ac31ee636c96698f8efe61e8db763bae",
|
||||
"opening-placement-door-thick-wall-dark": "24f472818f2246eeaa6568648ca4a6c42d9d84723fbcdd879435253399e2995d",
|
||||
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
|
||||
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
|
||||
"tray-wide-selection-en": "468ac6acfd7fcdcbfa947e032843ff117a32ae01b4ec30937a6cddecff534504",
|
||||
"tray-wide-tool-ru": "388e03d7bf7a2391d0581e8446d0692048b9792e80bf3b9dd9bca86222fe9418",
|
||||
"tray-medium-group-en": "190d5c356461ad2aa8b63b132416e8958a285047f8dea92301213678c7ac5a92",
|
||||
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
|
||||
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
|
||||
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
|
||||
"geometry-diagonal-45-opening-dark": "3736a75163d47a71f60c45cd554d604f27cfbc81119b5b55e8134da61a2a0f0a",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"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",
|
||||
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
|
||||
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
|
||||
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
|
||||
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
|
||||
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
|
||||
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
|
||||
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
|
||||
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
|
||||
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
|
||||
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
|
||||
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
|
||||
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
|
||||
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
|
||||
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
|
||||
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
|
||||
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 321 KiB |
@@ -0,0 +1,562 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
|
||||
|
||||
const themeVars = {
|
||||
dark: {
|
||||
'--primary-color': '#3ea6ff', '--primary-text-color': '#e6e7eb',
|
||||
'--secondary-text-color': '#9aa4ad', '--card-background-color': '#202126',
|
||||
'--ha-card-background': '#202126', '--divider-color': '#3a3d45',
|
||||
},
|
||||
light: {
|
||||
'--primary-color': '#0b73b8', '--primary-text-color': '#202124',
|
||||
'--secondary-text-color': '#5f6368', '--card-background-color': '#ffffff',
|
||||
'--ha-card-background': '#ffffff', '--divider-color': '#d7d9de',
|
||||
},
|
||||
};
|
||||
|
||||
async function stableEnvironment(page, scenario) {
|
||||
await page.setViewportSize(scenario.viewport);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: scenario.theme });
|
||||
await page.evaluate(({ variables, theme }) => {
|
||||
let style = document.getElementById('hp-golden-stability');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'hp-golden-stability';
|
||||
style.textContent = `
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
caret-color: transparent !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
html, body { width: 100%; min-height: 100%; overflow: hidden; }
|
||||
body { background: var(--hp-golden-page-bg) !important;
|
||||
font-family: Arial, sans-serif !important; }
|
||||
#host { width: min(100%, 1120px) !important; margin: 0 auto !important;
|
||||
padding: 8px !important; box-sizing: border-box !important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
for (const [name, value] of Object.entries(variables))
|
||||
document.documentElement.style.setProperty(name, value);
|
||||
document.documentElement.style.setProperty(
|
||||
'--hp-golden-page-bg', theme === 'light' ? '#eef1f4' : '#11151b',
|
||||
);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
|
||||
}
|
||||
|
||||
/** 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 } },
|
||||
});
|
||||
}
|
||||
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}`);
|
||||
return space;
|
||||
};
|
||||
if (scenario.deviceName) {
|
||||
if (!scenario.deviceId || !fixture.devices?.[scenario.deviceId])
|
||||
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' || typeof scenario.showBorders === 'boolean') {
|
||||
const space = requireSpace();
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
|
||||
...(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 } : {}),
|
||||
};
|
||||
}
|
||||
if (scenario.extraOpenings?.length) {
|
||||
const space = requireSpace();
|
||||
const known = new Set((space.openings || []).map((opening) => opening.id));
|
||||
for (const opening of scenario.extraOpenings) {
|
||||
if (!opening?.id || known.has(opening.id))
|
||||
throw new Error(`golden extraOpening has missing/duplicate id: ${opening?.id || '<empty>'}`);
|
||||
if (!['door', 'window', 'gate'].includes(opening.type))
|
||||
throw new Error(`golden extraOpening has unknown type: ${opening.type}`);
|
||||
known.add(opening.id);
|
||||
}
|
||||
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)
|
||||
&& Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
|
||||
for (const replacement of scenario.wallReplacements) {
|
||||
const index = (space.walls || []).findIndex((wall) => (
|
||||
samePoint(wall.a, replacement.match?.a) && samePoint(wall.b, replacement.match?.b)
|
||||
) || (
|
||||
samePoint(wall.a, replacement.match?.b) && samePoint(wall.b, replacement.match?.a)
|
||||
));
|
||||
if (index < 0 || !replacement.segments?.length)
|
||||
throw new Error(`golden wallReplacement cannot find a valid wall in ${space.id}`);
|
||||
space.walls.splice(index, 1, ...structuredClone(replacement.segments));
|
||||
}
|
||||
}
|
||||
if (scenario.hideOpenings) {
|
||||
const space = requireSpace();
|
||||
space.settings = { ...(space.settings || {}), hide_openings: true };
|
||||
}
|
||||
if (scenario.roomGlow) {
|
||||
const space = requireSpace();
|
||||
const unknown = new Set(Object.keys(scenario.roomGlow));
|
||||
for (const room of space.rooms) {
|
||||
if (!(room.id in scenario.roomGlow)) continue;
|
||||
unknown.delete(room.id);
|
||||
room.settings = { ...(room.settings || {}), glow: scenario.roomGlow[room.id] };
|
||||
}
|
||||
if (unknown.size) throw new Error(`golden roomGlow references missing room(s): ${[...unknown].join(', ')}`);
|
||||
}
|
||||
if (scenario.roomCustomFill) {
|
||||
const space = requireSpace();
|
||||
const unknown = new Set(Object.keys(scenario.roomCustomFill));
|
||||
for (const room of space.rooms) {
|
||||
if (!(room.id in scenario.roomCustomFill)) continue;
|
||||
unknown.delete(room.id);
|
||||
room.settings = { ...(room.settings || {}), custom_fill: scenario.roomCustomFill[room.id] };
|
||||
}
|
||||
if (unknown.size)
|
||||
throw new Error(`golden roomCustomFill references missing room(s): ${[...unknown].join(', ')}`);
|
||||
}
|
||||
if (scenario.allLightsOff) {
|
||||
for (const [entityId, state] of Object.entries(fixture.states || {})) {
|
||||
if (!entityId.startsWith('light.')) continue;
|
||||
fixture.states[entityId] = { ...state, state: 'off' };
|
||||
}
|
||||
}
|
||||
if (scenario.stateOverrides) {
|
||||
for (const [entityId, override] of Object.entries(scenario.stateOverrides)) {
|
||||
const current = fixture.states?.[entityId];
|
||||
if (!current) throw new Error(`golden stateOverride references missing entity: ${entityId}`);
|
||||
fixture.states[entityId] = {
|
||||
...current,
|
||||
...structuredClone(override),
|
||||
attributes: { ...(current.attributes || {}), ...(override.attributes || {}) },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (scenario.markerOverrides) {
|
||||
const ids = new Set(scenario.markerOverrides.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 = [
|
||||
...(fixture.config.markers || []).filter((marker) => !ids.has(marker.id)),
|
||||
...structuredClone(scenario.markerOverrides),
|
||||
];
|
||||
}
|
||||
if (scenario.layoutOverrides) {
|
||||
const missing = Object.keys(scenario.layoutOverrides).filter((id) => !(id in (fixture.layout || {})));
|
||||
if (missing.length) throw new Error(`golden layoutOverrides reference missing item(s): ${missing.join(', ')}`);
|
||||
fixture.layout = { ...(fixture.layout || {}), ...structuredClone(scenario.layoutOverrides) };
|
||||
}
|
||||
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export async function prepareGoldenScenario(page, scenario) {
|
||||
await stableEnvironment(page, scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
|
||||
return page.evaluate(async ({ fixture, scenario }) => {
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error(`golden scenario timed out: ${scenario.id}`);
|
||||
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' },
|
||||
user: { id: 'golden', name: 'Golden fixture', is_admin: true },
|
||||
devices: fixture.devices || {}, entities: fixture.entities || {},
|
||||
areas: fixture.areas || {}, states: fixture.states || {},
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout || {}), rev: 1 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices || {});
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities || {});
|
||||
if (message.type === 'config_entries/get')
|
||||
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden fixture' }];
|
||||
if (message.type === 'manifest/list')
|
||||
return [{ domain: 'houseplan_golden', name: 'House Plan Golden' }];
|
||||
return { ok: true };
|
||||
},
|
||||
callService: async () => undefined,
|
||||
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
const mount = async () => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig(cardConfig);
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor();
|
||||
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
|
||||
await card.updateComplete;
|
||||
const expectedDevices = Object.keys(fixture.devices || {}).length;
|
||||
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
|
||||
await until(() => card._booting === false);
|
||||
await frame();
|
||||
return card;
|
||||
};
|
||||
|
||||
let card = await mount();
|
||||
if (scenario.warmRemount) {
|
||||
card.remove();
|
||||
await wait(0);
|
||||
card = await mount();
|
||||
}
|
||||
window.__goldenCard = card;
|
||||
if (scenario.space && card._space !== scenario.space) {
|
||||
card._pickSpace(scenario.space);
|
||||
await card.updateComplete;
|
||||
}
|
||||
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.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') {
|
||||
card._physicalSel = { kind: 'partition', id: 'geo-partition-h' };
|
||||
expectedKind = 'selection';
|
||||
} else if (scenario.editorTray === 'plan-tool') {
|
||||
card._physicalSel = null;
|
||||
card._tool = 'draw';
|
||||
expectedKind = 'tool';
|
||||
} else if (scenario.editorTray === 'decor-selection') {
|
||||
card._decorTool = 'select';
|
||||
card._decorSel = 'geo-axis-h';
|
||||
expectedKind = 'selection';
|
||||
} else if (scenario.editorTray === 'decor-tool') {
|
||||
card._decorSel = null;
|
||||
card._decorTool = 'line';
|
||||
expectedKind = 'tool';
|
||||
} else if (scenario.editorTray === 'furniture-palette') {
|
||||
card._decorSel = null;
|
||||
card._furnPalette = null;
|
||||
card._editorSecondary.openPalette();
|
||||
card._decorTool = 'furniture';
|
||||
expectedKind = 'palette';
|
||||
} else if (scenario.editorTray === 'group') {
|
||||
const group = {
|
||||
id: 'golden-group', label: 'Arrange', icon: 'mdi:shape-outline', items: [
|
||||
{ id: 'align', label: 'Align', icon: 'mdi:format-align-center', role: 'command', invoke: () => undefined },
|
||||
{ id: 'distribute', label: 'Distribute', icon: 'mdi:format-horizontal-align-center', role: 'command', invoke: () => undefined },
|
||||
],
|
||||
};
|
||||
Object.defineProperty(card, '_editorToolbarGroups', {
|
||||
configurable: true,
|
||||
get: () => [group],
|
||||
});
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._editorSecondary.toggleGroup(card._editorToolbarGroups, group.id);
|
||||
expectedKind = 'group';
|
||||
} else {
|
||||
throw new Error(`unknown golden editor tray: ${scenario.editorTray}`);
|
||||
}
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const tray = card.renderRoot.querySelector(
|
||||
`.editor-secondary-host.open .editor-secondary.kind-${expectedKind}`,
|
||||
);
|
||||
if (!tray) throw new Error(`golden editor tray did not open: ${scenario.editorTray}`);
|
||||
}
|
||||
if (scenario.hoverRoom) {
|
||||
const room = card._spaceModel().rooms.find((item) => item.id === scenario.hoverRoom);
|
||||
if (!room) throw new Error(`golden hover room missing: ${scenario.hoverRoom}`);
|
||||
card._hoverRoom = { space: card._space, room };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
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);
|
||||
await card.updateComplete;
|
||||
if (scenario.deviceLightControls) {
|
||||
card._setMarkerLightRole('always');
|
||||
await card.updateComplete;
|
||||
card._setMarkerGlowMode('fixed');
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
const roleGroup = dialog?.querySelector('input[name="marker-light-role"]')?.closest('fieldset');
|
||||
const glowGroup = dialog?.querySelector('input[name="marker-glow-mode"]')?.closest('fieldset');
|
||||
const roleInputs = roleGroup?.querySelectorAll('input[name="marker-light-role"]');
|
||||
const glowInputs = glowGroup?.querySelectorAll('input[name="marker-glow-mode"]');
|
||||
const color = glowGroup?.querySelector('hp-color-opacity');
|
||||
const brightness = glowGroup?.querySelector('input[type="range"]');
|
||||
const radius = dialog?.querySelector('#marker-glow-radius');
|
||||
if (!body || !roleGroup || !glowGroup || roleInputs?.length !== 3 || glowInputs?.length !== 3
|
||||
|| !roleInputs[1]?.checked || !glowInputs[2]?.checked
|
||||
|| !color || color.disabled || !brightness || brightness.disabled || !radius || radius.disabled)
|
||||
throw new Error('golden device light-source controls are incomplete');
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const roleRect = roleGroup.getBoundingClientRect();
|
||||
body.scrollTop += roleRect.top - bodyRect.top - 8;
|
||||
await frame();
|
||||
const visibleBody = body.getBoundingClientRect();
|
||||
const visibleRole = roleGroup.getBoundingClientRect();
|
||||
const visibleRadius = radius.getBoundingClientRect();
|
||||
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
|
||||
throw new Error('golden viewport does not show the complete device light-source controls');
|
||||
}
|
||||
if (scenario.openHelp) {
|
||||
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
|
||||
await help?.updateComplete;
|
||||
const trigger = help?.renderRoot?.querySelector('.trigger');
|
||||
if (!trigger) throw new Error(`golden help trigger missing: ${scenario.openHelp}`);
|
||||
trigger.click();
|
||||
await help.updateComplete;
|
||||
await frame();
|
||||
const surface = help.renderRoot?.querySelector('.tooltip:popover-open')
|
||||
|| card.renderRoot.querySelector('hp-dialog')?.renderRoot
|
||||
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
|
||||
if (trigger.getAttribute('aria-expanded') !== 'true' || !surface?.getBoundingClientRect().width)
|
||||
throw new Error(`golden help surface did not open: ${scenario.openHelp}`);
|
||||
}
|
||||
if (scenario.focusDialogClose) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
await dialog?.updateComplete;
|
||||
dialog?.renderRoot?.querySelector('.close')?.focus();
|
||||
}
|
||||
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
|
||||
const full = scenario.dialog === 'backup-full';
|
||||
card._backupImportDialog = {
|
||||
filename: full ? 'houseplan-full-2026-08-11.json' : 'houseplan-space-ground.json',
|
||||
size: 12345,
|
||||
token: 'golden-token',
|
||||
preview: {
|
||||
kind: full ? 'full' : 'space', source: full ? 'foreign' : 'same',
|
||||
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
|
||||
counts: { spaces: 1, rooms: 4, markers: 12, layout: 15 },
|
||||
duplicates: full ? 0 : 2,
|
||||
confirmation_required: full,
|
||||
content: full
|
||||
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
|
||||
: [{ url: 'https://example.test/ground.svg', state: 'external' }],
|
||||
},
|
||||
expectedConfigRev: 1, expectedLayoutRev: 1,
|
||||
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
|
||||
};
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
} else if (scenario.dialog === 'decor-color') {
|
||||
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);
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const picker = dialog?.querySelector('hp-color-opacity');
|
||||
await picker?.updateComplete;
|
||||
const trigger = picker?.renderRoot?.querySelector('.trigger');
|
||||
if (!trigger) throw new Error('golden decor color trigger missing');
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
}
|
||||
await document.fonts?.ready;
|
||||
await frame();
|
||||
return {
|
||||
space: card._space,
|
||||
mode: card._mode,
|
||||
devices: card._devices.length,
|
||||
dialog: !!card.renderRoot.querySelector('hp-dialog'),
|
||||
helpOpen: [...card.renderRoot.querySelectorAll('hp-help')]
|
||||
.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((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);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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 = 18;
|
||||
|
||||
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',
|
||||
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: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ 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',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
// HP-UX-11 visual contract: every adaptive width is captured in both
|
||||
// languages while the six materially different tray contents are open.
|
||||
{ id: 'tray-wide-selection-en', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
editorTray: 'plan-selection', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'tray-wide-tool-ru', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
editorTray: 'plan-tool', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'tray-medium-group-en', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
editorTray: 'group', language: 'en', theme: 'dark',
|
||||
viewport: { width: 760, height: 820 }, ...page },
|
||||
{ id: 'tray-medium-selection-ru', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
|
||||
editorTray: 'decor-selection', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 760, height: 820 }, ...page },
|
||||
{ id: 'tray-narrow-palette-en', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
|
||||
editorTray: 'furniture-palette', language: 'en', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'tray-narrow-tool-ru', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
|
||||
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 },
|
||||
{ id: 'openings-filled-tunnel-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#66717c', a: 0.55 }, glowEnabled: false,
|
||||
hideOpenings: true,
|
||||
wallReplacements: [{
|
||||
match: { a: [0.5, 0.1], b: [0.5, 0.88] },
|
||||
segments: [
|
||||
{ key: fixtureWallKey([0.5, 0.1], [0.5, 0.5]), a: [0.5, 0.1], b: [0.5, 0.5], cm: 18 },
|
||||
{ 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, 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',
|
||||
fillMode: 'temp', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-fill-lqi-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'lqi', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-temp-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-temp-glow-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true, theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-custom-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
roomCustomFill: { 'light-right': { c: '#8f5f48', a: 0.56 } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-opaque-glow-two-doorways-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#3f4854', a: 1 }, glowEnabled: true, sunRays: false,
|
||||
allLightsOff: true,
|
||||
stateOverrides: {
|
||||
'light.golden_light_one': { state: 'on', attributes: { rgb_color: [255, 196, 112], brightness: 255 } },
|
||||
},
|
||||
extraOpenings: [
|
||||
{ id: 'light-door-second', type: 'door', x: 0.50, y: 0.32, angle: 90, length: 0.13 },
|
||||
],
|
||||
layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },
|
||||
// The golden is protection only if the receiving half actually contains
|
||||
// rendered light. A data-only check once let a visually empty baseline pass.
|
||||
warmPixelRegion: { x: 0.5, y: 0, w: 0.5, h: 1, minPixels: 2500, minRedBlueDelta: 25 },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-custom-glow-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
roomCustomFill: { 'light-right': { c: '#8f5f48', a: 0.56 } },
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-temp-glow-no-sources-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true, allLightsOff: true,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-temp-glow-room-override-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true, roomGlow: { 'light-left': true, 'light-right': false },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-manual-auto-spill-overlap-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true,
|
||||
markerOverrides: [{
|
||||
id: 'golden-light-two', binding: 'device:golden-light-two',
|
||||
glow_color: { c: '#3a8fff', bri: 0.35 }, glow_radius_cm: 420,
|
||||
}],
|
||||
layoutOverrides: { 'golden-light-two': { s: 'golden-lighting', x: 0.39, y: 0.57 } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hover-over-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
zoom: 2.5, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'large-house-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
|
||||
warmRemount: true, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'device-dialog-desktop-en', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
deviceName: 'Living room light controller with an intentionally long title',
|
||||
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
|
||||
focusDialogClose: true, language: 'en', theme: 'dark', viewport: { width: 1180, height: 1200 }, ...page },
|
||||
{ id: 'device-dialog-mobile-ru', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
|
||||
{ id: 'device-help-popover-light-ru', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
|
||||
language: 'ru', theme: 'light', viewport: { width: 760, height: 900 }, ...page },
|
||||
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'decor-color', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-full', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-space', language: 'ru', theme: 'light',
|
||||
viewport: { width: 390, height: 820 }, ...page },
|
||||
]);
|
||||
@@ -0,0 +1,25 @@
|
||||
// The name must NOT end with `manifest.json`: the HACS submission check globs
|
||||
// `*manifest.json` over the whole clone of the default branch and refuses a
|
||||
// repository with more than one match (test/repo-hygiene.test.mjs).
|
||||
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
|
||||
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
|
||||
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
|
||||
if (mode === 'verify' && scenarioFilter)
|
||||
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
|
||||
};
|
||||
|
||||
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
|
||||
* entry may survive after a scenario is removed or renamed. */
|
||||
export const goldenScenarioSetsMatch = (expected, indexed, baselineFiles) => {
|
||||
const normalized = (values) => [...new Set(values)].sort();
|
||||
const wanted = normalized(expected);
|
||||
return JSON.stringify(normalized(indexed)) === JSON.stringify(wanted)
|
||||
&& JSON.stringify(normalized(baselineFiles)) === JSON.stringify(wanted);
|
||||
};
|
||||
|
||||
export const goldenRunFailed = (mode, manifestValid, results) => {
|
||||
if (results.some((result) => result.status === 'error')) return true;
|
||||
return mode === 'verify'
|
||||
&& (!manifestValid || results.some((result) => result.status !== 'passed'));
|
||||
};
|
||||
@@ -0,0 +1,693 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launch } from '../serve.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from './harness.mjs';
|
||||
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
||||
import {
|
||||
assertGoldenInvocation,
|
||||
GOLDEN_BASELINE_MANIFEST,
|
||||
goldenRunFailed,
|
||||
goldenScenarioSetsMatch,
|
||||
} from './policy.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice(7) || 'capture';
|
||||
const scenarioFilter = process.argv.find((arg) => arg.startsWith('--scenario='))?.slice(11) || '';
|
||||
assertGoldenInvocation(mode, scenarioFilter);
|
||||
const scenarios = scenarioFilter
|
||||
? GOLDEN_SCENARIOS.filter((scenario) => scenario.id === scenarioFilter)
|
||||
: GOLDEN_SCENARIOS;
|
||||
if (!scenarios.length) throw new Error(`unknown golden scenario: ${scenarioFilter}`);
|
||||
|
||||
const artifactRoot = resolve(ROOT, 'artifacts/golden');
|
||||
const actualRoot = resolve(artifactRoot, 'actual');
|
||||
const diffRoot = resolve(artifactRoot, 'diff');
|
||||
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
|
||||
mkdirSync(actualRoot, { recursive: true });
|
||||
mkdirSync(diffRoot, { recursive: true });
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
async function comparePng(page, actual, baseline, threshold) {
|
||||
return page.evaluate(async ({ actual64, baseline64, threshold }) => {
|
||||
const decode = async (base64) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
};
|
||||
const [actualImage, baselineImage] = await Promise.all([decode(actual64), decode(baseline64)]);
|
||||
if (actualImage.width !== baselineImage.width || actualImage.height !== baselineImage.height) {
|
||||
return {
|
||||
dimensionsMatch: false,
|
||||
actualSize: [actualImage.width, actualImage.height],
|
||||
baselineSize: [baselineImage.width, baselineImage.height],
|
||||
differingPixels: null,
|
||||
diffRatio: 1,
|
||||
maxObservedDelta: 255,
|
||||
passed: false,
|
||||
diffPngBase64: null,
|
||||
};
|
||||
}
|
||||
const width = actualImage.width;
|
||||
const height = actualImage.height;
|
||||
const canvas = document.createElement('canvas');
|
||||
const baselineCanvas = document.createElement('canvas');
|
||||
const diffCanvas = document.createElement('canvas');
|
||||
for (const item of [canvas, baselineCanvas, diffCanvas]) {
|
||||
item.width = width;
|
||||
item.height = height;
|
||||
}
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const baselineContext = baselineCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const diffContext = diffCanvas.getContext('2d');
|
||||
context.drawImage(actualImage, 0, 0);
|
||||
baselineContext.drawImage(baselineImage, 0, 0);
|
||||
const actualData = context.getImageData(0, 0, width, height).data;
|
||||
const baselineData = baselineContext.getImageData(0, 0, width, height).data;
|
||||
const diff = diffContext.createImageData(width, height);
|
||||
let differingPixels = 0;
|
||||
let maxObservedDelta = 0;
|
||||
for (let offset = 0; offset < actualData.length; offset += 4) {
|
||||
let delta = 0;
|
||||
for (let channel = 0; channel < 4; channel++)
|
||||
delta = Math.max(delta, Math.abs(actualData[offset + channel] - baselineData[offset + channel]));
|
||||
maxObservedDelta = Math.max(maxObservedDelta, delta);
|
||||
if (delta > threshold.maxChannelDelta) {
|
||||
differingPixels++;
|
||||
diff.data[offset] = 255;
|
||||
diff.data[offset + 1] = 0;
|
||||
diff.data[offset + 2] = 180;
|
||||
diff.data[offset + 3] = 255;
|
||||
} else {
|
||||
const gray = Math.round((actualData[offset] + actualData[offset + 1] + actualData[offset + 2]) / 9);
|
||||
diff.data[offset] = gray;
|
||||
diff.data[offset + 1] = gray;
|
||||
diff.data[offset + 2] = gray;
|
||||
diff.data[offset + 3] = 90;
|
||||
}
|
||||
}
|
||||
diffContext.putImageData(diff, 0, 0);
|
||||
const diffRatio = differingPixels / (width * height);
|
||||
return {
|
||||
dimensionsMatch: true,
|
||||
actualSize: [width, height],
|
||||
baselineSize: [width, height],
|
||||
differingPixels,
|
||||
diffRatio,
|
||||
maxObservedDelta,
|
||||
passed: diffRatio <= threshold.maxDiffRatio,
|
||||
diffPngBase64: differingPixels
|
||||
? diffCanvas.toDataURL('image/png').slice('data:image/png;base64,'.length)
|
||||
: null,
|
||||
};
|
||||
}, {
|
||||
actual64: actual.toString('base64'),
|
||||
baseline64: baseline.toString('base64'),
|
||||
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) {
|
||||
return page.evaluate(async ({ png64, region }) => {
|
||||
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');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
const left = Math.max(0, Math.min(image.width, Math.floor(region.x * image.width)));
|
||||
const top = Math.max(0, Math.min(image.height, Math.floor(region.y * image.height)));
|
||||
const right = Math.max(left, Math.min(image.width, Math.ceil((region.x + region.w) * image.width)));
|
||||
const bottom = Math.max(top, Math.min(image.height, Math.ceil((region.y + region.h) * image.height)));
|
||||
const pixels = context.getImageData(left, top, right - left, bottom - top).data;
|
||||
let warm = 0;
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
if (pixels[offset] - pixels[offset + 2] > region.minRedBlueDelta) warm++;
|
||||
}
|
||||
return { warm, bounds: [left, top, right, bottom] };
|
||||
}, { png64: png.toString('base64'), region });
|
||||
}
|
||||
|
||||
/** Semantic guard for issue #68: the reviewed bubble must contain rendered
|
||||
* glyph pixels, not just an empty surface or a stale open-state flag. */
|
||||
async function countHelpTextPixels(page, png, clip, spec) {
|
||||
return page.evaluate(async ({ png64, clip, spec }) => {
|
||||
const card = window.__goldenCard;
|
||||
const help = card?.renderRoot?.querySelector(`hp-help[data-help-key="${spec.key}"]`);
|
||||
const surface = help?.renderRoot?.querySelector('.tooltip:popover-open')
|
||||
|| card?.renderRoot?.querySelector('hp-dialog')?.renderRoot
|
||||
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
|
||||
if (!surface) throw new Error(`semantic golden help missing: ${spec.key}`);
|
||||
const bounds = surface.getBoundingClientRect();
|
||||
const match = getComputedStyle(surface).color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
if (!match || match.length !== 3) throw new Error(`semantic golden help has invalid text color: ${spec.key}`);
|
||||
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');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
||||
const originX = clip?.x || 0, originY = clip?.y || 0;
|
||||
const left = Math.max(0, Math.ceil(bounds.left - originX) + 5);
|
||||
const top = Math.max(0, Math.ceil(bounds.top - originY) + 5);
|
||||
const right = Math.min(image.width - 1, Math.floor(bounds.right - originX) - 5);
|
||||
const bottom = Math.min(image.height - 1, Math.floor(bounds.bottom - originY) - 5);
|
||||
let textPixels = 0;
|
||||
for (let y = top; y <= bottom; y++) {
|
||||
for (let x = left; x <= right; x++) {
|
||||
const offset = (y * image.width + x) * 4;
|
||||
const distance = Math.abs(pixels[offset] - match[0])
|
||||
+ Math.abs(pixels[offset + 1] - match[1])
|
||||
+ Math.abs(pixels[offset + 2] - match[2]);
|
||||
if (pixels[offset + 3] > 240 && distance <= 90) textPixels++;
|
||||
}
|
||||
}
|
||||
return { textPixels, bounds: [left, top, right, bottom] };
|
||||
}, { png64: png.toString('base64'), clip, spec });
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const tunnel = card?.renderRoot?.querySelector(
|
||||
`.opening-tunnels[data-layer="data"] [data-hp="opening-tunnel"][data-id="${spec.openingId}"]`,
|
||||
);
|
||||
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');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
const originX = clip?.x || 0, originY = clip?.y || 0;
|
||||
// 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;
|
||||
const rgb = (x, y) => {
|
||||
const offset = (y * image.width + x) * 4;
|
||||
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
|
||||
};
|
||||
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 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 });
|
||||
}
|
||||
|
||||
let baselineManifest = null;
|
||||
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
||||
if (existsSync(baselineManifestPath)) {
|
||||
try { baselineManifest = JSON.parse(readFileSync(baselineManifestPath, 'utf8')); }
|
||||
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,
|
||||
browserArgs,
|
||||
browserContext,
|
||||
);
|
||||
const chromium = await browser.version();
|
||||
const results = [];
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
let buildFingerprint = null;
|
||||
try {
|
||||
buildFingerprint = await assertFreshDemoBundle(page, ROOT);
|
||||
for (const scenario of scenarios) {
|
||||
const result = {
|
||||
id: scenario.id,
|
||||
threshold: scenario.threshold,
|
||||
status: 'error',
|
||||
actualSha256: null,
|
||||
};
|
||||
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 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;
|
||||
result.warmPixelBounds = sample.bounds;
|
||||
if (sample.warm < scenario.warmPixelRegion.minPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: ${sample.warm} warm pixels, expected at least `
|
||||
+ `${scenario.warmPixelRegion.minPixels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.helpTextRegion) {
|
||||
const sample = await countHelpTextPixels(page, actual, clip, scenario.helpTextRegion);
|
||||
result.helpTextPixels = sample.textPixels;
|
||||
result.helpPixelBounds = sample.bounds;
|
||||
if (sample.textPixels < scenario.helpTextRegion.minPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: help ${scenario.helpTextRegion.key} contains `
|
||||
+ `${sample.textPixels} text pixels, expected at least ${scenario.helpTextRegion.minPixels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.tunnelContinuity) {
|
||||
const sample = await inspectTunnelContinuity(
|
||||
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} `
|
||||
+ `has a ${sample.maxJump}-channel local jump, expected at most `
|
||||
+ `${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;
|
||||
if (!existsSync(baselinePath)) {
|
||||
result.status = 'missing-baseline';
|
||||
} else {
|
||||
const baseline = readFileSync(baselinePath);
|
||||
const expectedBaselineSha256 = baselineManifest?.scenarios?.[scenario.id];
|
||||
result.baselineSha256 = sha256(baseline);
|
||||
if (!expectedBaselineSha256 || result.baselineSha256 !== expectedBaselineSha256) {
|
||||
result.status = 'invalid-baseline';
|
||||
result.error = expectedBaselineSha256
|
||||
? 'baseline PNG does not match its reviewed manifest hash'
|
||||
: 'baseline PNG is not listed in the reviewed manifest';
|
||||
results.push(result);
|
||||
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
||||
continue;
|
||||
}
|
||||
const comparison = await comparePng(page, actual, baseline, scenario.threshold);
|
||||
Object.assign(result, comparison);
|
||||
result.status = comparison.passed ? 'passed' : 'different';
|
||||
if (comparison.diffPngBase64) {
|
||||
const diffPath = resolve(diffRoot, `${scenario.id}.png`);
|
||||
writeFileSync(diffPath, Buffer.from(comparison.diffPngBase64, 'base64'));
|
||||
result.diff = diffPath;
|
||||
}
|
||||
delete result.diffPngBase64;
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
results.push(result);
|
||||
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const expectedScenarioIds = GOLDEN_SCENARIOS.map((scenario) => scenario.id);
|
||||
const indexedScenarioIds = Object.keys(baselineManifest?.scenarios || {});
|
||||
const baselineScenarioIds = readdirSync(baselineRoot)
|
||||
.filter((name) => name.endsWith('.png'))
|
||||
.map((name) => name.slice(0, -'.png'.length));
|
||||
const manifestValid = !!baselineManifest
|
||||
&& !baselineManifest.invalid
|
||||
&& baselineManifest.matrixVersion === GOLDEN_MATRIX_VERSION
|
||||
&& baselineManifest.chromium === chromium
|
||||
&& expectedScenarioIds.every((id) => typeof baselineManifest.scenarios?.[id] === 'string')
|
||||
&& goldenScenarioSetsMatch(expectedScenarioIds, indexedScenarioIds, baselineScenarioIds);
|
||||
const report = {
|
||||
schema: 1,
|
||||
mode,
|
||||
generatedAt: new Date().toISOString(),
|
||||
matrixVersion: GOLDEN_MATRIX_VERSION,
|
||||
buildFingerprint,
|
||||
chromium,
|
||||
baselineManifest: baselineManifest ? {
|
||||
present: true,
|
||||
valid: manifestValid,
|
||||
matrixVersion: baselineManifest.matrixVersion ?? null,
|
||||
chromium: baselineManifest.chromium ?? null,
|
||||
} : { present: false, valid: false, matrixVersion: null, chromium: null },
|
||||
results,
|
||||
};
|
||||
writeFileSync(resolve(artifactRoot, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (goldenRunFailed(mode, manifestValid, results)) process.exitCode = 1;
|
||||
@@ -0,0 +1,167 @@
|
||||
# Large-house performance gate
|
||||
|
||||
`benchmark_large_house.mjs` exercises the deterministic `large-house-v1`
|
||||
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.
|
||||
|
||||
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
|
||||
high-tail guard rather than a population estimate:
|
||||
|
||||
- model readiness and first stable render;
|
||||
- space switch, HA state update, pan/zoom and opening the settings dialog;
|
||||
- a shared-wall room-resize preview which is cancelled before persistence;
|
||||
- a twelve-switch navigation cycle;
|
||||
- Long Tasks for every measured window;
|
||||
- heap growth after four additional navigation rounds with forced GC;
|
||||
- hot-cache size and growth after the same warmed cycles.
|
||||
|
||||
Every report is tied to the source fingerprint embedded by Rollup. A stale
|
||||
bundle is a hard failure.
|
||||
|
||||
## CI contracts
|
||||
|
||||
Ordinary pushes, pull requests and prereleases use the blocking
|
||||
`performance_smoke` job in `validate.yml`. It builds only the candidate and
|
||||
measures the heaviest 60-source `large-house-glow-overlay-v1` state after one
|
||||
warm-up, with three recorded samples. `compare.mjs --absolute-only` enforces the
|
||||
reviewed hard timing, Long Task, heap, cache and rendered-device ceilings from
|
||||
`budgets-glow-smoke.json`; it deliberately makes no noisy base-relative claim.
|
||||
This is a catastrophic-regression guard, not a performance trend detector.
|
||||
|
||||
The dedicated `performance.yml` workflow is the full comparison. It runs on
|
||||
every `main` promotion, weekly and on manual dispatch for an important beta or
|
||||
performance-sensitive change. It checks out the candidate and its base SHA,
|
||||
builds both, and runs them sequentially with the same Node.js 22 process
|
||||
family, pinned Playwright Chromium and hosted runner. `compare.mjs` then
|
||||
applies two limits:
|
||||
|
||||
1. a relative regression allowance against the base-SHA report;
|
||||
2. an absolute safety ceiling from `budgets.json`.
|
||||
|
||||
The tighter limit wins. The absolute values are catastrophic safety ceilings,
|
||||
not normal-performance targets; the base-relative comparison catches smaller
|
||||
regressions. Small fast operations receive an absolute noise
|
||||
allowance so normal scheduler jitter does not become a false regression. Heap,
|
||||
Long Tasks, warmed-cache growth and the expected rendered-device count are
|
||||
gated separately. Long-Task maximum/count/total checks use the same
|
||||
relative-plus-absolute policy as timings. Both raw reports and the comparison
|
||||
are always uploaded as the `full-performance` artifact, and the table is
|
||||
written to the GitHub job summary. Stable release assets require both exact-SHA
|
||||
`Validate` and exact-SHA `Full Performance`; prereleases require only
|
||||
`Validate`.
|
||||
|
||||
This base-vs-candidate design intentionally does not compare timings captured
|
||||
on different machines or different Chromium builds. A runtime/profile mismatch
|
||||
fails closed.
|
||||
|
||||
Before the base checkout, CI fetches the complete commit graph and verifies the
|
||||
requested comparison revision. A `main` push uses `github.event.before`, which
|
||||
must both exist and remain an ancestor of the candidate; this catches the
|
||||
unreachable SHA left by a force-push. A manual run may name an explicit tag,
|
||||
branch or SHA, while an empty manual input and the weekly run use the candidate
|
||||
parent. An unusable requested revision falls back with a warning to the direct
|
||||
parent, then to the newest reachable semver release. If no safe comparison
|
||||
exists, the job fails closed instead of comparing against an arbitrary commit.
|
||||
|
||||
## Private card contract
|
||||
|
||||
The candidate benchmark runner is also executed against the base bundle, so
|
||||
every private `houseplan-card` field or method it reads is an explicit API of
|
||||
the performance harness. `card-contract.mjs` lists that surface for the
|
||||
large-house and Glow profiles. Each runner verifies it immediately after card
|
||||
creation and fails with the exact missing names or invalid runtime types before
|
||||
waiting for readiness or recording timings. Required caches must be real
|
||||
`Map` instances and must never be converted from missing/invalid values to
|
||||
plausible zeroes.
|
||||
|
||||
`fields` are required in every supported comparison base. `optionalFields` are
|
||||
newer members whose absence has an explicit safe fallback in the runner; if an
|
||||
optional member exists, its declared `fieldTypes` contract still applies. Add a
|
||||
new safely degradable field to `optionalFields` until every supported base has
|
||||
it, then promote it to `fields`. A member without a truthful fallback must be
|
||||
introduced through a compatibility revision before the benchmark consumes it.
|
||||
|
||||
Rename a consumed private member in two revisions:
|
||||
|
||||
1. teach the contract and every reader to understand both the old and proposed
|
||||
name while production still exposes the old name; land that compatibility
|
||||
revision so it can become a comparison base;
|
||||
2. rename the production member and prefer the new name while retaining the
|
||||
old reader fallback. Remove the fallback only after all supported comparison
|
||||
bases expose the new member.
|
||||
|
||||
This sequencing keeps the current harness capable of profiling both source
|
||||
trees. A one-step rename that merely edits the candidate reader is forbidden:
|
||||
it would make the same runner incompatible with its base bundle.
|
||||
|
||||
## Local diagnostics
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
A local report is diagnostic only; it cannot replace the CI comparison.
|
||||
|
||||
To reproduce the comparison against another checkout using one harness and one
|
||||
browser installation:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --target-root=../base --samples=7 --output=artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --output=artifacts/performance/candidate.json
|
||||
npm run benchmark:compare
|
||||
```
|
||||
|
||||
## Changing budgets
|
||||
|
||||
Budget changes require an explicit review of recent CI artifacts and a written
|
||||
rationale in the change. Do not loosen a threshold merely to make a single red
|
||||
run pass. A new fixture profile gets a new profile id instead of silently
|
||||
changing the meaning of `large-house-v1`.
|
||||
|
||||
The `cleanFloor` entry ceiling is 160: the reviewed fixture currently warms
|
||||
120 deterministic room/physical-body entries, and the extra 40 slots allow a
|
||||
legitimate fixture extension without weakening the separate zero-growth gate.
|
||||
|
||||
## Glow profiles
|
||||
|
||||
Both Glow profiles run deterministic 1/10/30/60-pool variants at DPR 1 and
|
||||
Chromium CPU throttling x4, but deliberately exercise different fixtures:
|
||||
|
||||
- `large-light-blend-v1` compares the isolated screen group with the previous
|
||||
normal-layer implementation on the shared frontend/backend schema fixture
|
||||
`test/fixtures/glow/additive-pools.json`;
|
||||
- `large-house-glow-overlay-v1` measures simultaneous temperature fill and
|
||||
independent Glow on the existing 60-room/200-device large-house fixture,
|
||||
without changing `large-house-v1`.
|
||||
|
||||
```bash
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --output=artifacts/performance/glow.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --output=artifacts/performance/overlay.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
```
|
||||
|
||||
Reports include per-variant state-update timings, render/pool counts, Long
|
||||
Tasks, screenshot time, heap and cache growth. The first CI comparison against
|
||||
a base SHA that predates `glow_enabled` bootstraps only the overlay profile's
|
||||
relative baseline from the candidate; its absolute ceilings still gate that
|
||||
introduction. Every subsequent revision compares both profiles to the real
|
||||
base SHA.
|
||||
|
||||
The initial absolute ceilings are intentionally conservative bootstrap limits;
|
||||
they must be reviewed against the first paired Ubuntu artifacts before the
|
||||
feature is promoted from beta. Same-runner relative checks in the full workflow
|
||||
remain the primary regression signal; the candidate-only smoke only guards
|
||||
against catastrophic failures.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-glow-overlay-v1",
|
||||
"minimumSamples": 3,
|
||||
"timings": {
|
||||
"stateUpdate60Ms": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 2200
|
||||
},
|
||||
"screenshotCaptureMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3200
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 2700,
|
||||
"maxCountP95": 12,
|
||||
"maxTotalP95Ms": 5500
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 80,
|
||||
"glowClip": 128,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-glow-overlay-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 750 },
|
||||
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1100 },
|
||||
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
|
||||
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
|
||||
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3200 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 2700,
|
||||
"maxSingleRegressionRatio": 0.5,
|
||||
"maxSingleNoiseAllowanceMs": 200,
|
||||
"maxCountP95": 12,
|
||||
"maxCountRegressionRatio": 0.5,
|
||||
"countNoiseAllowance": 2,
|
||||
"maxTotalP95Ms": 5500,
|
||||
"maxTotalRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 250
|
||||
},
|
||||
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
|
||||
"cacheEntries": { "cleanFloor": 80, "glowClip": 128, "wallUnion": 1, "openingTunnel": 1, "openingWallIndex": 1 },
|
||||
"cacheGrowth": { "cleanFloor": 0, "glowClip": 0, "wallUnion": 0, "openingTunnel": 0, "openingWallIndex": 0 },
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -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,27 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-light-blend-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 700 },
|
||||
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1400 },
|
||||
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2000 },
|
||||
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3000 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 2500,
|
||||
"maxSingleRegressionRatio": 0.5,
|
||||
"maxSingleNoiseAllowanceMs": 200,
|
||||
"maxCountP95": 12,
|
||||
"maxCountRegressionRatio": 0.5,
|
||||
"countNoiseAllowance": 2,
|
||||
"maxTotalP95Ms": 5000,
|
||||
"maxTotalRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 250
|
||||
},
|
||||
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
|
||||
"cacheEntries": { "cleanFloor": 8, "glowClip": 200, "wallUnion": 1, "openingTunnel": 1, "openingWallIndex": 1 },
|
||||
"cacheGrowth": { "cleanFloor": 0, "glowClip": 0, "wallUnion": 0, "openingTunnel": 0, "openingWallIndex": 0 },
|
||||
"renderedDevices": 60
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 200,
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"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
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Private houseplan-card surface consumed by the performance runners.
|
||||
*
|
||||
* The candidate runner profiles both the candidate bundle and a bundle built
|
||||
* from the comparison SHA. Keep these lists explicit so a private rename in
|
||||
* either tree fails before measurements instead of silently reporting zeroes.
|
||||
*/
|
||||
const CACHE_FIELDS = Object.freeze([
|
||||
'_cleanFloorCache',
|
||||
'_glowClipCache',
|
||||
'_wallUnionCache',
|
||||
'_openingTunnelCache',
|
||||
'_openingWallIndexCache',
|
||||
]);
|
||||
|
||||
export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
label: 'large-house-v1',
|
||||
methods: Object.freeze([
|
||||
'_baseVb',
|
||||
'_openSettingsDialog',
|
||||
'_pickSpace',
|
||||
'_rszCancelDrag',
|
||||
'_rszEdgeDown',
|
||||
'_rszMove',
|
||||
'_rszRooms',
|
||||
'_setMode',
|
||||
'_viewOr',
|
||||
]),
|
||||
fields: Object.freeze([
|
||||
'_booting',
|
||||
...CACHE_FIELDS,
|
||||
'_devices',
|
||||
'_gridPitch',
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_rszDrag',
|
||||
'_settingsDialog',
|
||||
'_tool',
|
||||
]),
|
||||
// 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', '_setProjection']),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_model: 'array',
|
||||
_isoGeometryCache: 'map',
|
||||
_setProjection: 'function',
|
||||
_tool: 'string',
|
||||
}),
|
||||
});
|
||||
|
||||
export const GLOW_CARD_CONTRACT = Object.freeze({
|
||||
label: 'Glow performance profiles',
|
||||
methods: Object.freeze([]),
|
||||
fields: Object.freeze([
|
||||
...CACHE_FIELDS,
|
||||
'_devices',
|
||||
'_loadOk',
|
||||
]),
|
||||
// Additive blending was introduced after the first supported performance
|
||||
// bases. Its absence is safe: the runner keeps the historical normal blend.
|
||||
optionalFields: Object.freeze(['_glowScreenBlend']),
|
||||
fieldTypes: Object.freeze({
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_glowScreenBlend: 'boolean',
|
||||
_loadOk: 'boolean',
|
||||
}),
|
||||
});
|
||||
|
||||
/** Single fail-fast implementation injected into both browser runners. Keep
|
||||
* this function self-contained: runners serialize it with `toString()`. */
|
||||
export function assertCardContract(card, contract) {
|
||||
const matches = (value, expected) => {
|
||||
if (expected === 'array') return Array.isArray(value);
|
||||
if (expected === 'map') return value instanceof Map;
|
||||
return typeof value === expected;
|
||||
};
|
||||
const missingMethods = contract.methods
|
||||
.filter((name) => typeof card[name] !== 'function')
|
||||
.map((name) => `${name}()`);
|
||||
const missingFields = contract.fields
|
||||
.filter((name) => !(name in card) || card[name] === undefined);
|
||||
const invalidFields = [...contract.fields, ...(contract.optionalFields || [])]
|
||||
.filter((name) => name in card && contract.fieldTypes?.[name]
|
||||
&& !matches(card[name], contract.fieldTypes[name]))
|
||||
.map((name) => `${name}:${contract.fieldTypes[name]}`);
|
||||
const missing = [...missingMethods, ...missingFields];
|
||||
if (missing.length || invalidFields.length) {
|
||||
const details = [
|
||||
missing.length ? `missing private API: ${missing.join(', ')}` : '',
|
||||
invalidFields.length ? `invalid private API types: ${invalidFields.join(', ')}` : '',
|
||||
].filter(Boolean).join('; ');
|
||||
throw new Error(
|
||||
`${contract.label} harness is incompatible with this houseplan-card bundle; ${details}. `
|
||||
+ 'Update the explicit candidate/base compatibility contract before profiling.',
|
||||
);
|
||||
}
|
||||
}
|
||||