Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d477564ab | ||
|
|
29d72b3344 | ||
|
|
64bd17ebfe | ||
|
|
e189867fa9 | ||
|
|
b70b7d7c8d | ||
|
|
0ae5b5177a | ||
|
|
4dc0a538cc | ||
|
|
557bc1fd85 | ||
|
|
b306dedb92 | ||
|
|
98ac71b902 | ||
|
|
45b2a9d394 | ||
|
|
88e4cf50e3 | ||
|
|
57a4a0ed69 | ||
|
|
d77d1953c7 | ||
|
|
e76f3909d9 | ||
|
|
a3396a6c99 | ||
|
|
d95255ada2 | ||
|
|
0c85307bf4 | ||
|
|
90a5b3f6e6 | ||
|
|
0cf6cf4016 | ||
|
|
49d4854221 | ||
|
|
b6afa6e501 | ||
|
|
5fbb9339c6 | ||
|
|
7a662d93ed | ||
|
|
dffe77e32d | ||
|
|
fc5973c223 | ||
|
|
2b80641db8 | ||
|
|
be5eeb41c3 | ||
|
|
73a2fc66d3 | ||
|
|
5d6c1992e4 | ||
|
|
90b185f603 | ||
|
|
2a7e09ee39 | ||
|
|
f52b2449ea | ||
|
|
a91a231e61 | ||
|
|
615045cb18 | ||
|
|
1ee302d9e5 | ||
|
|
b3ca0ca2dd | ||
|
|
196fa09696 | ||
|
|
9d2256c090 | ||
|
|
b69743c0bd | ||
|
|
c41292d49f | ||
|
|
f1b9bbf33e | ||
|
|
de2155787d | ||
|
|
1f28734875 | ||
|
|
c49611967d | ||
|
|
9cf5cdf3bd | ||
|
|
8c12474531 | ||
|
|
bd60edd238 | ||
|
|
77c28076a2 | ||
|
|
ee7d486924 | ||
|
|
3bf9e2d6d3 | ||
|
|
e0c27fb750 | ||
|
|
672570330b | ||
|
|
afba49a3fa | ||
|
|
87cab133ac | ||
|
|
0f7b0dff14 | ||
|
|
b7f82d54dd | ||
|
|
276bf193a2 | ||
|
|
565acb6046 | ||
|
|
a6004fc5f3 | ||
|
|
aae9efb9ff | ||
|
|
ce07db748e | ||
|
|
5ff97858f8 | ||
|
|
855b62729d | ||
|
|
0a955f5df5 | ||
|
|
198484f762 | ||
|
|
2558d68f06 | ||
|
|
7ab12294fc | ||
|
|
23c65e449e | ||
|
|
1856302cd3 | ||
|
|
523bd4569d | ||
|
|
f6956fa472 | ||
|
|
fc0c616698 | ||
|
|
daccaf7ea5 | ||
|
|
12e5728a17 | ||
|
|
65d38b2403 | ||
|
|
21a1993015 | ||
|
|
dc032dcb3e | ||
|
|
152a6ca5cc | ||
|
|
fcf0ce80eb | ||
|
|
a4fe939f63 | ||
|
|
248c926a17 | ||
|
|
0f5c3d8967 | ||
|
|
ea2ec19b71 | ||
|
|
282e52cc73 | ||
|
|
46d43a29be | ||
|
|
0466a02dfd | ||
|
|
69207f5757 | ||
|
|
6b77d598fd | ||
|
|
fcc4125c6e | ||
|
|
41e6eef941 | ||
|
|
13ab4b12b7 | ||
|
|
34d53bf2d1 | ||
|
|
04a1de091c | ||
|
|
9bdf531c1a | ||
|
|
1ef21037a8 | ||
|
|
16b4c2d6d3 | ||
|
|
9ee60993ff | ||
|
|
dfb4efaa1c | ||
|
|
5d09e722e4 | ||
|
|
5763c6aec0 | ||
|
|
570da3dd1a | ||
|
|
ca2a00231b | ||
|
|
833c9a2a33 | ||
|
|
50bc1d6955 | ||
|
|
ba47395337 | ||
|
|
33f16dd3cc | ||
|
|
ef5bfa4bcd | ||
|
|
b4898583d7 | ||
|
|
7320937f62 | ||
|
|
d80805acec | ||
|
|
896ceecf3a | ||
|
|
7cb1eee386 | ||
|
|
da22b87fe1 | ||
|
|
41b8e71248 | ||
|
|
58b1651570 | ||
|
|
793d06c75e | ||
|
|
d13ecf1237 | ||
|
|
a63aef0239 | ||
|
|
b04e4aacce | ||
|
|
c81d1d9775 | ||
|
|
6af6d7b388 | ||
|
|
7dd5b4798a | ||
|
|
5108202419 | ||
|
|
2c7879131f | ||
|
|
6dd9fb03c3 | ||
|
|
b286d7ba43 | ||
|
|
fe0c2e0c83 | ||
|
|
aac9232bbc | ||
|
|
de30066be7 | ||
|
|
6ef0804bc4 | ||
|
|
356b260035 | ||
|
|
3d0fe7d756 | ||
|
|
5adada070f | ||
|
|
5bd0c4a033 | ||
|
|
588f1188c3 | ||
|
|
17bfdaaed0 | ||
|
|
07ba2ffbd1 | ||
|
|
5692a2880f | ||
|
|
d7e97687ce | ||
|
|
444562e47c | ||
|
|
046efe9613 | ||
|
|
d513f13afa | ||
|
|
a7bee0f5fb | ||
|
|
4b6b4abc70 | ||
|
|
8d787530b1 | ||
|
|
fe03eae213 | ||
|
|
cb68492c99 | ||
|
|
f8b3445dc3 | ||
|
|
1850eb18b5 | ||
|
|
5254fb9ef5 | ||
|
|
67c000f17b | ||
|
|
8ef0b6a1fa | ||
|
|
5d594e70c8 | ||
|
|
c0d61ca3d2 | ||
|
|
137e160f1a | ||
|
|
66c2eec1f9 | ||
|
|
338700bdcb | ||
|
|
029aba6b88 | ||
|
|
0cd5145df0 | ||
|
|
5784a0e271 | ||
|
|
2a883217c1 | ||
|
|
d5e114524c | ||
|
|
9e7ae520a8 | ||
|
|
4b443d8cf5 | ||
|
|
dc76c5b2b8 | ||
|
|
8d8263202f | ||
|
|
cc9387163e | ||
|
|
f70a973834 | ||
|
|
36b4e1cf31 | ||
|
|
5016aa01cc | ||
|
|
28a4cb5eec | ||
|
|
9e56842bc1 | ||
|
|
1d8edf4a01 | ||
|
|
aa51ed2bf5 | ||
|
|
a158d75712 | ||
|
|
05ef318133 | ||
|
|
b39f99b3a4 | ||
|
|
85a064430b | ||
|
|
070c276e26 | ||
|
|
1a7488d695 | ||
|
|
1ce62613ed | ||
|
|
b8a31e90ca | ||
|
|
5da19f1793 | ||
|
|
a8ccb2b90f | ||
|
|
8935a736cb | ||
|
|
411a0a20a9 | ||
|
|
89647c10bd | ||
|
|
ed4ac972b3 | ||
|
|
ee9b28ed98 | ||
|
|
658f284b44 | ||
|
|
169fed01f0 | ||
|
|
7230e4248a | ||
|
|
0ec0785dfd | ||
|
|
e846833ed6 | ||
|
|
ed0fdd4859 | ||
|
|
112ec0eb66 | ||
|
|
775e55f0ed | ||
|
|
3f9ef807d8 | ||
|
|
f0ed526a59 | ||
|
|
4203a01b04 | ||
|
|
aab5c3ca95 | ||
|
|
a2867df9a2 | ||
|
|
2202b20730 | ||
|
|
85702afc0c | ||
|
|
e5587aed7c | ||
|
|
aed0b5b65a | ||
|
|
c53702cdfa | ||
|
|
a6a3df9310 | ||
|
|
ec9824f227 | ||
|
|
260767e2f8 | ||
|
|
3a1f2b2d86 | ||
|
|
6eea4d1c7a | ||
|
|
a54addb694 | ||
|
|
6a7ef48ae8 | ||
|
|
6190234c51 | ||
|
|
39ba4e2b88 | ||
|
|
15cfa96553 | ||
|
|
4aad9facdf | ||
|
|
81026539d3 | ||
|
|
f672de674f | ||
|
|
68992d5582 | ||
|
|
f29c4c5b79 | ||
|
|
d5bfa0e3e1 | ||
|
|
f62dd6b632 | ||
|
|
6b07b70d7c | ||
|
|
62ecb8566e | ||
|
|
818ad2d2bb | ||
|
|
6c668d4bfe | ||
|
|
a1fbea07d2 |
@@ -31,8 +31,13 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
|
||||
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
|
||||
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
|
||||
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
|
||||
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
|
||||
concurrency:
|
||||
group: mutation-gate
|
||||
group: mutation-gate-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -88,5 +93,104 @@ jobs:
|
||||
- name: Тёплый test-build для инкрементальной компиляции мутантов
|
||||
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
|
||||
# Вывод шарда сохраняется артефактом (#472): строки
|
||||
# `FAIL <id>: тест остался зелёным…` — единственное место, где названо,
|
||||
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
|
||||
# `tee` не съел код выхода раннера.
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
set -o pipefail
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
- name: Сохранить лог шарда
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-shard-${{ matrix.shard }}
|
||||
path: artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
|
||||
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
|
||||
# каждый такой отказ был бы шумом, который снова перестанут читать.
|
||||
#
|
||||
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
|
||||
# validate.yml, job с actions: read): перечислены все три.
|
||||
report:
|
||||
name: "Отказ расписания: issue и Telegram"
|
||||
needs: mutants
|
||||
if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать логи шардов
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
- name: Собрать отчёт
|
||||
id: report
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
# SHA — того дерева, которое чекаутили и гоняли (dev), а не
|
||||
# github.sha: для расписания это вершина default-ветки main, и отчёт
|
||||
# называл бы «dev @ <SHA main>» (ревью r1). Образец — process.yml.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
mkdir -p artifacts
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--logs=artifacts/mutation-logs --shards=4 \
|
||||
--run-url="$RUN_URL" --ref=dev --sha="$SHA" \
|
||||
--body-out=artifacts/mutation-report.md \
|
||||
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
|
||||
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
|
||||
# получает комментарий, новое заводится только если открытого нет.
|
||||
- name: Issue — создать или дописать
|
||||
id: issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
MARKER: ${{ steps.report.outputs.marker }}
|
||||
TITLE: ${{ steps.report.outputs.title }}
|
||||
run: |
|
||||
existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \
|
||||
--json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty')
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md
|
||||
url="${{ github.server_url }}/$REPO/issues/$existing"
|
||||
else
|
||||
url=$(gh issue create --repo "$REPO" --title "$TITLE" \
|
||||
--label infra --label process --label tests \
|
||||
--body-file artifacts/mutation-report.md)
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "issue: $url"
|
||||
# Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ:
|
||||
# issue уже заведено, а Telegram — второй адресат, не единственный.
|
||||
- name: Telegram
|
||||
if: always() && steps.issue.outcome == 'success'
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
ISSUE_URL: ${{ steps.issue.outputs.url }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено"
|
||||
exit 0
|
||||
fi
|
||||
TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt)
|
||||
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
|
||||
|
||||
@@ -36,7 +36,9 @@ jobs:
|
||||
profile:
|
||||
- large-house
|
||||
- isometric
|
||||
- isometric-stage3
|
||||
- plan-snap
|
||||
- interaction
|
||||
- blend
|
||||
- overlay
|
||||
- space-default
|
||||
@@ -186,10 +188,18 @@ jobs:
|
||||
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
|
||||
;;
|
||||
isometric-stage3)
|
||||
npm run benchmark:isometric-stage3-dense -- --allow-stage2-base --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-baseline.json
|
||||
npm run benchmark:isometric-stage3-dense -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-candidate.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
;;
|
||||
interaction)
|
||||
npm run benchmark:large-house-interaction -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/interaction-baseline.json
|
||||
npm run benchmark:large-house-interaction -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/interaction-candidate.json
|
||||
;;
|
||||
blend)
|
||||
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
|
||||
@@ -230,11 +240,17 @@ jobs:
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-comparison.json
|
||||
;;
|
||||
isometric-stage3)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-isometric-stage3-dense.json --baseline=../artifacts/performance/isometric-stage3-baseline.json --candidate=../artifacts/performance/isometric-stage3-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-stage3-comparison.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
;;
|
||||
interaction)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-interaction.json --baseline=../artifacts/performance/interaction-baseline.json --candidate=../artifacts/performance/interaction-candidate.json --output=../artifacts/performance/interaction-comparison.json
|
||||
;;
|
||||
blend)
|
||||
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
|
||||
;;
|
||||
|
||||
@@ -43,9 +43,19 @@ jobs:
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
# Мелкий checkout: guard остаётся лёгким, но ему нужен
|
||||
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
|
||||
# inline-shell не покрывается тестами (#454). Node на раннере
|
||||
# предустановлен, setup-node не нужен.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||||
@@ -87,19 +97,78 @@ jobs:
|
||||
#
|
||||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||||
# имени документа в теле комментария; документа нет — вердикт не
|
||||
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
|
||||
# работу досрочно: из двух ошибок выбрана обратимая.
|
||||
# имени документа — раньше по подстроке маркера в теле комментария,
|
||||
# теперь по имени документа ЭТОЙ задачи, `<MARKER>-<NUM>`: голая
|
||||
# подстрока протекала на прозе. #454 поймала это на себе — разбор
|
||||
# чужих задач в комментарии содержал `CODE-REVIEW`, и первый же
|
||||
# код-ревью получил заход r3.
|
||||
#
|
||||
# Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ
|
||||
# ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария,
|
||||
# только если ревьюер сам назвал имя файла, — то есть прежний счёт
|
||||
# зависел от формулировки. На #449 первый спек-вердикт файла не
|
||||
# назвал, заход r2 получил номер r1, и документ второго раунда лёг
|
||||
# ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна
|
||||
# ровно здесь: номер захода входит в имя файла, и повтор номера — не
|
||||
# лишний заход, а потеря артефакта.
|
||||
#
|
||||
# Поэтому рядом встаёт второй источник — опубликованные документы:
|
||||
# их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся
|
||||
# МАКСИМУМ двух источников: недосчёт возможен только при отказе
|
||||
# обоих, перерасчёт невозможен по построению.
|
||||
attempt=1; spent=0; spent_list=""
|
||||
if [ -n "$stage" ]; then
|
||||
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
|
||||
# Блокирующим считается вердикт, у которого в строке вердикта стоит
|
||||
# «жёлтый» или «красный». Регистр и окружение слова не важны.
|
||||
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
|
||||
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
|
||||
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
|
||||
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
|
||||
comments=$(mktemp)
|
||||
gh issue view "$NUM" --repo "$REPO" --json comments > "$comments"
|
||||
|
||||
# Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту.
|
||||
# Её нет у задач, размеченных до появления конвейера; тогда счёт по
|
||||
# файлам даёт ноль и работает страховка по комментариям.
|
||||
branch=""; newest=""
|
||||
for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \
|
||||
--jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do
|
||||
date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true)
|
||||
if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then
|
||||
newest="$date"; branch="$ref"
|
||||
fi
|
||||
done
|
||||
target="${branch:-dev}"
|
||||
names=$(mktemp); docs=$(mktemp -d)
|
||||
if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \
|
||||
> "$names" 2>/dev/null; then
|
||||
: > "$names"
|
||||
echo "::warning::список docs/reviews на $target не получен — счёт по файлам отключён"
|
||||
fi
|
||||
# Каталог перечисляется одним ответом до 1000 записей; за этой
|
||||
# границей ответ молча обрежется, и счёт по файлам занизится.
|
||||
if [ "$(grep -c . "$names")" -ge 1000 ]; then
|
||||
echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен"
|
||||
fi
|
||||
# Тела нужны только своим документам этапа: их единицы.
|
||||
for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do
|
||||
gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \
|
||||
-H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name"
|
||||
done
|
||||
list=$(mktemp)
|
||||
counters=$(node scripts/review-doc-guard.mjs --counters \
|
||||
--marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \
|
||||
--comments="$comments" --spent-list="$list")
|
||||
spent_list=$(cat "$list")
|
||||
# Пустой ответ означает, что скрипт не отработал. Тогда остаются
|
||||
# значения по умолчанию (заход 1, циклов 0): guard обязан
|
||||
# продолжить работу, а не встать.
|
||||
new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p')
|
||||
new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p')
|
||||
new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p')
|
||||
case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac
|
||||
case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac
|
||||
# Перечень учтённого обязан сходиться с числом: если цикл виден
|
||||
# только документом, ссылка на комментарий его не объяснит.
|
||||
if [ -n "$new_blocking" ]; then
|
||||
spent_list="$spent_list
|
||||
- документы: $new_blocking"
|
||||
fi
|
||||
echo "ветка материала: ${branch:-нет, читался dev}"
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
|
||||
@@ -61,20 +61,26 @@ jobs:
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
- name: "Процесс: process.yml идентичен в main и dev"
|
||||
- name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev"
|
||||
id: workflow_sync
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
if diff <(git show origin/main:.github/workflows/process.yml) \
|
||||
<(git show origin/dev:.github/workflows/process.yml); then
|
||||
echo "main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
|
||||
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
exit 1
|
||||
fi
|
||||
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
|
||||
# умолчанию — та же ловушка, что у process.yml. Сверяются оба.
|
||||
status=0
|
||||
for file in process.yml mutation-gate.yml; do
|
||||
if diff <(git show "origin/main:.github/workflows/$file") \
|
||||
<(git show "origin/dev:.github/workflows/$file"); then
|
||||
echo "$file: main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: $file в main и dev различаются."
|
||||
echo "Файл исполняется из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit $status
|
||||
|
||||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||||
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
|
||||
@@ -158,7 +164,7 @@ jobs:
|
||||
}
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "process.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
exit $fail
|
||||
@@ -182,6 +188,9 @@ jobs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
# Диффозависимые профили перф-смока (#473 §5).
|
||||
perf_iso: ${{ steps.classify.outputs.perf_iso }}
|
||||
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
|
||||
@@ -237,7 +246,7 @@ jobs:
|
||||
run: |
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
echo "dev: без фильтров, всё true"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
zero=$(printf '%040d' 0)
|
||||
@@ -261,7 +270,7 @@ jobs:
|
||||
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "все тяжёлые job запускаются без фильтра путей (#347)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
|
||||
@@ -271,17 +280,14 @@ jobs:
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "база не определена — полный прогон без классификации"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
|
||||
{
|
||||
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
|
||||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
|
||||
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
|
||||
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
@@ -298,6 +304,10 @@ jobs:
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
name: "Переиспользование: это дерево уже проверено"
|
||||
# `changes` нужна ради набора профилей перф-смока (#473): ключ
|
||||
# performance_smoke включает набор, иначе glow-only результат засчитался бы
|
||||
# прогону, которому нужен изометрический профиль.
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
@@ -307,6 +317,7 @@ jobs:
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -314,12 +325,21 @@ jobs:
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
env:
|
||||
PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
|
||||
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# Набор профилей перф-смока — часть ключа (#473 AC5).
|
||||
set=glow
|
||||
[ "$PERF_ISO" = "true" ] && set="$set-iso"
|
||||
[ "$PERF_INTERACTION" = "true" ] && set="$set-interaction"
|
||||
echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT"
|
||||
echo "performance_smoke set: $set"
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
@@ -341,7 +361,7 @@ jobs:
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
@@ -711,13 +731,14 @@ jobs:
|
||||
name: "Перф-смок: бюджет времени кадра"
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: [frontend, reuse]
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
|
||||
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -757,10 +778,26 @@ jobs:
|
||||
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
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
|
||||
# Диффозависимые профили (#473 §5): изометрия — когда дифф задел
|
||||
# `src/iso-*`, взаимодействие — когда живой путь или оркестраторы кадра.
|
||||
# Абсолютные потолки — `hardMaxMs` полных профилей: регрессия #160
|
||||
# (9 870 мс против 3 500) ловится с одного образца, шум — нет.
|
||||
- name: Изометрический профиль по диффу (#473)
|
||||
if: needs.changes.outputs.perf_iso == 'true'
|
||||
run: |
|
||||
npm run benchmark:large-house -- --profile=large-house-isometric-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/isometric-candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-isometric-smoke.json --candidate=artifacts/performance-smoke/isometric-candidate.json --output=artifacts/performance-smoke/isometric-comparison.json
|
||||
- name: Профиль взаимодействия по диффу (#473)
|
||||
if: needs.changes.outputs.perf_interaction == 'true'
|
||||
run: |
|
||||
npm run benchmark:large-house -- --profile=large-house-interaction-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/interaction-candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-interaction-smoke.json --candidate=artifacts/performance-smoke/interaction-candidate.json --output=artifacts/performance-smoke/interaction-comparison.json
|
||||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
run: npm run benchmark:junction-limits
|
||||
- name: Быстрый commit точки стены (#461)
|
||||
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -782,7 +819,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}
|
||||
|
||||
backend:
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
|
||||
@@ -344,9 +344,27 @@ whole review round that a local run would have caught immediately.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
|
||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
||||
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||
the beta gate is CI at the exact SHA.
|
||||
|
||||
**Verifying and capturing are different things (#455).** `golden:verify` is
|
||||
advisory and legal anywhere, Windows included: it reports differences and
|
||||
accepts nothing. **Capturing** frames is refused outside Linux
|
||||
before the browser even starts — `golden:capture` through
|
||||
`demo/golden/policy.mjs`, documentation screenshots through
|
||||
`npm run docs:capture` (use that script, not a bare `node
|
||||
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
|
||||
capture script invalidates the committed screenshot index). The refusal prints
|
||||
the WSL command. The reason is not policy but physics: Windows
|
||||
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
|
||||
so no frame ever matches an accepted baseline byte for byte, no environment
|
||||
witness can exist, and acceptance would refuse anyway (#401 accepts any
|
||||
environment that proves itself with byte-identical undeclared frames — Linux is
|
||||
simply the only one we have). The deliberate override is
|
||||
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
|
||||
manifest. Baselines are still accepted only via
|
||||
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
|
||||
index records the platform next to the Chromium build.
|
||||
|
||||
**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.
|
||||
@@ -399,21 +417,20 @@ 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
|
||||
## Alpha experiments
|
||||
|
||||
`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.
|
||||
experiments. One browser-local switch controls the complete set known to the
|
||||
installed build: `hp_alpha=1` in query or the shared hash grammar enables and
|
||||
persists it, while `hp_alpha=0` disables and persists it. Do not add per-feature
|
||||
URL/storage keys or a YAML/config switch. The legacy `hp-labs` grammar and
|
||||
`houseplan_card_labs_v1` storage are ignored and never migrated.
|
||||
|
||||
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`.
|
||||
A new registry entry needs a unique lowercase id, issue, summary and
|
||||
unit/browser coverage; it does not get `since` or `expires`. Invalid or duplicate
|
||||
entries fail closed. Alpha capabilities 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
|
||||
|
||||
@@ -661,6 +661,17 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
|
||||
при следующей задаче (#237). Пересъёмка — `npm run build && node
|
||||
demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
|
||||
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
|
||||
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
|
||||
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
|
||||
`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против
|
||||
абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`).
|
||||
Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс
|
||||
против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом.
|
||||
Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в
|
||||
ключ переиспользования `performance_smoke`. Ревьюер по-прежнему принимает
|
||||
зелёный Validate на SHA как подтверждение дешёвых гейтов — смок его часть.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
@@ -94,17 +94,46 @@ House Plan is in the HACS default catalog — no custom repository needed.
|
||||
2. Restart Home Assistant.
|
||||
3. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
The card is registered automatically. After installing or updating House Plan,
|
||||
restart Home Assistant and fully reload the page: `Ctrl+F5` on Windows/Linux or
|
||||
`Cmd+Shift+R` on macOS.
|
||||
|
||||
#### Storage mode (Home Assistant default)
|
||||
|
||||
No YAML is normally needed. If automatic registration did not make the card
|
||||
available, open **Settings → Dashboards → menu ⋮ → Resources → Add
|
||||
resource**, enter `/houseplan_files/houseplan-card.js`, and select **JavaScript
|
||||
module**.
|
||||
|
||||
#### YAML resources mode (Home Assistant 2026.2+)
|
||||
|
||||
To manage resources in `configuration.yaml` independently of the dashboard
|
||||
mode, use:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Do not use the on-disk path inside `custom_components`; Home Assistant does not
|
||||
serve that path as a JavaScript module.
|
||||
#### Legacy Home Assistant 2024.6–2026.1
|
||||
|
||||
Only for a full-YAML dashboard that is already managed in YAML, use:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` changes the dashboard itself to YAML mode. Do not switch a storage
|
||||
dashboard to legacy YAML just for House Plan; use the Storage mode instructions
|
||||
above instead. Do not use the on-disk path inside `custom_components`; Home
|
||||
Assistant does not serve that path as a JavaScript module.
|
||||
|
||||
### Manual installation
|
||||
|
||||
|
||||
@@ -98,17 +98,46 @@ House Plan входит в основной каталог HACS — пользо
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
Карточка регистрируется автоматически. После установки или обновления
|
||||
House Plan перезапустите Home Assistant и полностью перезагрузите
|
||||
страницу: `Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS.
|
||||
|
||||
#### Режим Storage (по умолчанию в Home Assistant)
|
||||
|
||||
Обычно YAML не нужен. Если авторегистрация не сделала карточку доступной,
|
||||
откройте **Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить
|
||||
ресурс**, укажите `/houseplan_files/houseplan-card.js` и выберите тип
|
||||
**JavaScript-модуль**.
|
||||
|
||||
#### YAML-ресурсы в Home Assistant 2026.2+
|
||||
|
||||
Чтобы управлять ресурсами в `configuration.yaml` независимо от режима
|
||||
самой панели, используйте:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
|
||||
публикует его как JavaScript-модуль.
|
||||
#### Home Assistant 2024.6–2026.1: устаревший режим
|
||||
|
||||
Только для панели, которая уже полностью управляется через YAML:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` переводит в YAML-режим саму панель. Не переключайте
|
||||
storage-панель в устаревший YAML только ради House Plan; используйте
|
||||
инструкцию для Storage выше. Не используйте путь к файлу внутри
|
||||
`custom_components`: Home Assistant не публикует его как JavaScript-модуль.
|
||||
|
||||
### Вручную
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -16,9 +15,11 @@ from .const import (
|
||||
ASSETS_DIR,
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
VERSION,
|
||||
)
|
||||
from .frontend_registration import (
|
||||
async_remove_frontend_registration,
|
||||
async_setup_frontend_registration,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
@@ -99,49 +100,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
)
|
||||
)
|
||||
|
||||
# Static paths cannot be unregistered — register once per HA run.
|
||||
if not hass.data[DOMAIN].get("static_registered"):
|
||||
hass.data[DOMAIN]["static_registered"] = True
|
||||
static_paths = []
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the entry and manifest-gated JS chunks stay public —
|
||||
# Lovelace modules must be loadable without an auth header.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
return True
|
||||
|
||||
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
|
||||
# resources before rendering dashboards, so the card is available even on a cold
|
||||
# start of the mobile app). If the resource registry is unavailable (YAML-mode
|
||||
# Lovelace, old versions) — fall back to extra_module_url.
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}"
|
||||
registered = await _register_lovelace_resource(hass, module_url)
|
||||
if not registered:
|
||||
add_extra_js_url(hass, module_url)
|
||||
# Tell the user exactly where the card lives — the #1 support issue is people adding a
|
||||
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
|
||||
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
|
||||
if registered:
|
||||
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
|
||||
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
|
||||
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
|
||||
module_url, module_url,
|
||||
)
|
||||
# NOTE (audit B1): plans and marker files are no longer static. They are
|
||||
# served by HouseplanContentView, which requires auth. Only the manifest-
|
||||
# gated frontend bundle stays public so Lovelace can load it. Registration
|
||||
# failures are observable and recoverable, but never skip storage migration,
|
||||
# repairs or housekeeping below.
|
||||
await async_setup_frontend_registration(hass, entry, card_path)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
@@ -319,61 +283,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
|
||||
"""Clean up on integration removal: drop our Lovelace resource entry."""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
|
||||
await resources.async_delete_item(item["id"])
|
||||
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
|
||||
except Exception as err: # noqa: BLE001 — best-effort cleanup
|
||||
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
|
||||
"""Register (or update) the card in the Lovelace resource registry.
|
||||
|
||||
Returns True on success. Idempotent: if a resource with our path exists —
|
||||
update the URL on version change; otherwise create it. Any exception → False
|
||||
(fall back to extra_module_url).
|
||||
"""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return False
|
||||
# the resource registry must be loaded
|
||||
if hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
elif hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
# only storage mode allows creating items
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return False
|
||||
base = FRONTEND_URL
|
||||
existing = [
|
||||
item for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == base
|
||||
]
|
||||
if existing:
|
||||
item = existing[0]
|
||||
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
|
||||
await resources.async_update_item(item["id"], {"url": module_url})
|
||||
return True
|
||||
await resources.async_create_item({"res_type": "module", "url": module_url})
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 — any failure → fallback
|
||||
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
|
||||
return False
|
||||
async def async_remove_entry(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Clean up frontend registration and notice on integration removal."""
|
||||
await async_remove_frontend_registration(hass, entry)
|
||||
|
||||
@@ -51,7 +51,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.71.0"
|
||||
VERSION = "1.73.0-beta.1"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
|
||||
@@ -1,125 +1,215 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "b58e7cdef9cffc5c835789c5b3ebc59d270d8170d1928c8a2b224b40569c939e",
|
||||
"fingerprint": "884182570484410d7ab1a7212b07de9f2b4e5683c0d859f4e9d3feaae9318c87",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js",
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 292741,
|
||||
"initialViewGzipBytes": 299595,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/de-B00cun6t.js",
|
||||
"houseplan-assets/editor-Cv6TOit6.js",
|
||||
"houseplan-assets/fr-BMO7ooc0.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-xreJqGLn.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js"
|
||||
"houseplan-assets/backdrop-pick-BnsHKWaS.js",
|
||||
"houseplan-assets/de-3MG52yOu.js",
|
||||
"houseplan-assets/editor-BWu14hMI.js",
|
||||
"houseplan-assets/fr-w8JLXMgW.js",
|
||||
"houseplan-assets/guard-BR4mAQsM.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Ak2xnYks.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BjgK4V8E.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CKKJLQ66.js",
|
||||
"houseplan-assets/iso-scene-render-DXu89PMK.js",
|
||||
"houseplan-assets/live-interaction-runtime-D5KHv4hp.js",
|
||||
"houseplan-assets/zigbee-topology-BUFPOnoy.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-Jy73PIeo.js"
|
||||
],
|
||||
"lazyGzipBytes": 223395,
|
||||
"lazyGzipBytes": 264984,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/editor-Cv6TOit6.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-xreJqGLn.js"
|
||||
"houseplan-assets/backdrop-pick-BnsHKWaS.js",
|
||||
"houseplan-assets/editor-BWu14hMI.js",
|
||||
"houseplan-assets/guard-BR4mAQsM.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Ak2xnYks.js",
|
||||
"houseplan-assets/zigbee-topology-BUFPOnoy.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 167358,
|
||||
"lazyEditorGzipBytes": 187115,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js"
|
||||
"houseplan-assets/backdrop-pick-BnsHKWaS.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BjgK4V8E.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14239,
|
||||
"lazyOnboardingGzipBytes": 14328,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-B00cun6t.js",
|
||||
"houseplan-assets/fr-BMO7ooc0.js"
|
||||
"houseplan-assets/de-3MG52yOu.js",
|
||||
"houseplan-assets/fr-w8JLXMgW.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49079,
|
||||
"lazyLocaleGzipBytes": 50178,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/guard-BR4mAQsM.js",
|
||||
"houseplan-assets/iso-scene-render-DXu89PMK.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 13467,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"sha256": "e7e88a03ea2fbbdb148c38de69b4796464e72708025193793c86fc143508aab2",
|
||||
"rawBytes": 21418,
|
||||
"gzipBytes": 7281,
|
||||
"path": "houseplan-assets/backdrop-pick-BnsHKWaS.js",
|
||||
"sha256": "a1a1925a3567418f9f3e3b6541987ecf947ab98d672476a7ecd24468180ed8c7",
|
||||
"rawBytes": 21592,
|
||||
"gzipBytes": 7359,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-B00cun6t.js",
|
||||
"sha256": "63d4a5f686bdf72fb90a1265fbf13ca4044fe8f9c044f027e0f6829dc70f8c1a",
|
||||
"rawBytes": 82140,
|
||||
"gzipBytes": 24809,
|
||||
"path": "houseplan-assets/de-3MG52yOu.js",
|
||||
"sha256": "d85a610248c36842c44cc0b851c62f8aeb8e9431cd71c12b9900d8372b17f8dd",
|
||||
"rawBytes": 84260,
|
||||
"gzipBytes": 25368,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-Cv6TOit6.js",
|
||||
"sha256": "04a98bcb607339183d4c441f81c2d9f67bb174da26cd23ca8279df5ce48dd133",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1583,
|
||||
"path": "houseplan-assets/editor-BWu14hMI.js",
|
||||
"sha256": "e793d2607239b96cbf314e36e4dd1c03017b5c9c7ae47b9d710dd7f47423011d",
|
||||
"rawBytes": 3837,
|
||||
"gzipBytes": 1589,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-BMO7ooc0.js",
|
||||
"sha256": "90d0488056c3c2be3c7f231a6e97686f0e54e5713528e062984bc6d36087822d",
|
||||
"rawBytes": 84267,
|
||||
"gzipBytes": 24270,
|
||||
"path": "houseplan-assets/fr-w8JLXMgW.js",
|
||||
"sha256": "78da22924a9d38e19e0787ce4c171bfba7df6c18f5460ccd4a2726bc1c415d8e",
|
||||
"rawBytes": 86301,
|
||||
"gzipBytes": 24810,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-BzvSZLQf.js",
|
||||
"sha256": "73ff73117f8baa0c1125bfdf84dd0042fdf6b3d1f2b149e3963430cedbcc71ea",
|
||||
"rawBytes": 1039623,
|
||||
"gzipBytes": 291943,
|
||||
"path": "houseplan-assets/guard-BR4mAQsM.js",
|
||||
"sha256": "31e7c009b3f724ca776293fef20b8ccc3d9f58d6afecb7d1d20be169e827d0a0",
|
||||
"rawBytes": 518,
|
||||
"gzipBytes": 371,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CODdCIWj.js",
|
||||
"sha256": "6011cb913b94cd0bb918c28928886a514af39a2162214423e467faf4b8ac31fe",
|
||||
"rawBytes": 1072122,
|
||||
"gzipBytes": 298799,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-B00cun6t.js",
|
||||
"houseplan-assets/editor-Cv6TOit6.js",
|
||||
"houseplan-assets/fr-BMO7ooc0.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-xreJqGLn.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js"
|
||||
"houseplan-assets/de-3MG52yOu.js",
|
||||
"houseplan-assets/editor-BWu14hMI.js",
|
||||
"houseplan-assets/fr-w8JLXMgW.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Ak2xnYks.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BjgK4V8E.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CKKJLQ66.js",
|
||||
"houseplan-assets/iso-scene-render-DXu89PMK.js",
|
||||
"houseplan-assets/live-interaction-runtime-D5KHv4hp.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-xreJqGLn.js",
|
||||
"sha256": "b2ae41efa27b191388b09eaf02e9e4b302c2031238c6e90037d71c9422179792",
|
||||
"rawBytes": 601522,
|
||||
"gzipBytes": 158494,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-Ak2xnYks.js",
|
||||
"sha256": "f124de9f93faf2aac6f6dcc8d7dfc5d40e7f4d887274843446f6aadb19dae52a",
|
||||
"rawBytes": 640495,
|
||||
"gzipBytes": 170365,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/backdrop-pick-BnsHKWaS.js",
|
||||
"houseplan-assets/guard-BR4mAQsM.js",
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js",
|
||||
"houseplan-assets/zigbee-topology-BUFPOnoy.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-Jy73PIeo.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-BjgK4V8E.js",
|
||||
"sha256": "7c7a9304466c21ec27ec027f5b3947e0c307538a016f20f4a407e9a43a81a18c",
|
||||
"rawBytes": 28071,
|
||||
"gzipBytes": 6969,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-BnsHKWaS.js",
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js",
|
||||
"sha256": "f78f4fdbc5e390829ef57e8e09d2ad856639138d83f3c62c997c2cbb71115f6c",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6958,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CKKJLQ66.js",
|
||||
"sha256": "e6be2493d605f81e321c5ccd18cfba70dd86ed1816ea54deefa77be48c5e40dd",
|
||||
"rawBytes": 11034,
|
||||
"gzipBytes": 3732,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js",
|
||||
"houseplan-assets/zigbee-topology-BUFPOnoy.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-Jy73PIeo.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-DXu89PMK.js",
|
||||
"sha256": "96f20eb2404fd8f6c116de5bfff6a2b109bcd0a250e918eecdf0968df3c6ed9c",
|
||||
"rawBytes": 38593,
|
||||
"gzipBytes": 13096,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/guard-BR4mAQsM.js",
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-D5KHv4hp.js",
|
||||
"sha256": "da0e12640b26ae3ebe05677db6238847813d5c8a7ec0dc7c7149922bc7deac66",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2129,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-BUFPOnoy.js",
|
||||
"sha256": "b497afdec2059b962c9f1a329e7f417650bfa5b28e684544c1ae415340fd208f",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7431,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-Jy73PIeo.js",
|
||||
"sha256": "ba8f56e776f1d47bea1c211a262154c31c4e13483857e3397ad9f951a8d7f8ce",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1765,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js",
|
||||
"houseplan-assets/zigbee-topology-BUFPOnoy.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "a4f11751d4f28af28debedad8298a810cd999c7bc6ed66f22d6fec1b8ebaae3f",
|
||||
"sha256": "6e4f5e3ebbe429b4d330f3aff542aa4867fd87f0f249a5185c7429a00670b6d5",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 798,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/houseplan-card-CODdCIWj.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="884182570484410d7ab1a7212b07de9f2b4e5683c0d859f4e9d3feaae9318c87";import{t as e,l as o,x as t,dK as s,A as a,dL as i,dM as l,dN as n,dJ as r,b as c}from"./houseplan-card-CODdCIWj.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b58e7cdef9cffc5c835789c5b3ebc59d270d8170d1928c8a2b224b40569c939e";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-BzvSZLQf.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="884182570484410d7ab1a7212b07de9f2b4e5683c0d859f4e9d3feaae9318c87";import{dH as r,dI as t,dJ as e}from"./houseplan-card-CODdCIWj.js";const s={},a=r(class extends t{constructor(){super(...arguments),this.ot=s}render(r,t){return t()}update(r,[t,s]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return e}else if(this.ot===t)return e;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,s)}});export{a as i};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="884182570484410d7ab1a7212b07de9f2b4e5683c0d859f4e9d3feaae9318c87";import{n as t,b as n}from"./zigbee-topology-BUFPOnoy.js";import{F as e}from"./houseplan-card-CODdCIWj.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b58e7cdef9cffc5c835789c5b3ebc59d270d8170d1928c8a2b224b40569c939e";try{await import("./houseplan-assets/houseplan-card-BzvSZLQf.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="884182570484410d7ab1a7212b07de9f2b4e5683c0d859f4e9d3feaae9318c87";try{await import("./houseplan-assets/houseplan-card-CODdCIWj.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Register the House Plan card with the Home Assistant frontend."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import frontend, persistent_notification
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
from .const import DOMAIN, FRONTEND_URL, VERSION
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_REGISTRATION_KEY = "frontend_registration"
|
||||
FRONTEND_STATIC_REGISTERED_KEY = "static_registered"
|
||||
FRONTEND_FALLBACK_URLS_KEY = "frontend_fallback_urls"
|
||||
FRONTEND_RELOAD_NOTICE_DATA_KEY = "frontend_reload_notice_created"
|
||||
FRONTEND_RELOAD_NOTICE_ID = "houseplan_frontend_reload_notice"
|
||||
FRONTEND_RETRY_DELAY_SECONDS = 1.0
|
||||
|
||||
_NOTICE_TITLE_KEY = "component.houseplan.issues.frontend_reload_notice.title"
|
||||
_NOTICE_DESCRIPTION_KEY = (
|
||||
"component.houseplan.issues.frontend_reload_notice.description"
|
||||
)
|
||||
_NOTICE_TITLE_FALLBACK = "House Plan card connected"
|
||||
_NOTICE_DESCRIPTION_FALLBACK = (
|
||||
"The House Plan card is connected. Fully reload this page to use the latest "
|
||||
"frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you "
|
||||
"manage dashboard resources manually in storage mode, open Settings → "
|
||||
"Dashboards → Resources."
|
||||
)
|
||||
|
||||
RegistrationStatus = Literal[
|
||||
"not_attempted",
|
||||
"created",
|
||||
"updated",
|
||||
"existing",
|
||||
"registry_pending",
|
||||
"yaml_fallback",
|
||||
"transient_error",
|
||||
"error_fallback",
|
||||
]
|
||||
ResourceLoader = Literal[
|
||||
"none",
|
||||
"lovelace_resource",
|
||||
"extra_module_url",
|
||||
"lovelace_resource_with_session_fallback",
|
||||
]
|
||||
ReloadNoticeStatus = Literal[
|
||||
"created",
|
||||
"already_created",
|
||||
"pending_frontend",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegistrationOutcome:
|
||||
"""Result of one Lovelace resource registry attempt."""
|
||||
|
||||
status: RegistrationStatus
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FrontendRegistrationState:
|
||||
"""Observable state and lifecycle handles for the current config-entry run."""
|
||||
|
||||
card_file_present: bool
|
||||
static_path_registered: bool = False
|
||||
resource_status: RegistrationStatus = "not_attempted"
|
||||
loader: ResourceLoader = "none"
|
||||
module_url: str | None = None
|
||||
retry_pending: bool = False
|
||||
retry_attempted: bool = False
|
||||
last_error: str | None = None
|
||||
first_reload_notice: ReloadNoticeStatus = "pending_frontend"
|
||||
active: bool = True
|
||||
fallback_added: bool = False
|
||||
_cancel_started: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_cancel_timer: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_retry_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
||||
_notice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
|
||||
|
||||
@callback
|
||||
def cancel(self) -> None:
|
||||
"""Prevent all delayed work for this setup generation."""
|
||||
self.active = False
|
||||
self.retry_pending = False
|
||||
if self._cancel_started is not None:
|
||||
self._cancel_started()
|
||||
self._cancel_started = None
|
||||
if self._cancel_timer is not None:
|
||||
self._cancel_timer()
|
||||
self._cancel_timer = None
|
||||
if self._retry_task is not None and not self._retry_task.done():
|
||||
self._retry_task.cancel()
|
||||
self._retry_task = None
|
||||
|
||||
|
||||
def _safe_error(phase: str, err: BaseException) -> str:
|
||||
"""Return a support-safe failure fingerprint without message or path data."""
|
||||
return f"{phase}:{type(err).__name__}"
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
"""Return the Lovelace resource collection across supported HA versions."""
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
def _is_current(hass: HomeAssistant, state: FrontendRegistrationState) -> bool:
|
||||
"""Return whether a delayed operation still belongs to the loaded entry."""
|
||||
return (
|
||||
state.active
|
||||
and hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY) is state
|
||||
)
|
||||
|
||||
|
||||
def _owned_fallback_urls(hass: HomeAssistant) -> set[str]:
|
||||
"""Return exact extra-module URLs owned for this Home Assistant run."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
urls = domain_data.get(FRONTEND_FALLBACK_URLS_KEY)
|
||||
if not isinstance(urls, set):
|
||||
urls = set()
|
||||
domain_data[FRONTEND_FALLBACK_URLS_KEY] = urls
|
||||
return urls
|
||||
|
||||
|
||||
def _active_extra_module_urls(hass: HomeAssistant) -> frozenset[str] | None:
|
||||
"""Return HA's current module URLs, or None when the API is unknowable.
|
||||
|
||||
``add_extra_js_url`` is intentionally idempotent and has no ownership
|
||||
return value. Looking before adding is therefore the only way to avoid
|
||||
claiming (and later removing) an identical URL configured by the user.
|
||||
Both the oldest supported HA and current HA expose ``UrlManager.urls``;
|
||||
unknown future/legacy shapes fail closed and are never claimed.
|
||||
"""
|
||||
key = getattr(frontend, "DATA_EXTRA_MODULE_URL", None)
|
||||
if key is None:
|
||||
return None
|
||||
manager = hass.data.get(key)
|
||||
if manager is None:
|
||||
# Frontend setup normally creates the manager before this integration.
|
||||
# If it is genuinely absent, no configured URL can predate our add;
|
||||
# add_extra_js_url will either create/use its supported path or fail.
|
||||
return frozenset()
|
||||
urls = getattr(manager, "urls", None)
|
||||
if not isinstance(urls, (set, frozenset, list, tuple)):
|
||||
return None
|
||||
return frozenset(url for url in urls if isinstance(url, str))
|
||||
|
||||
|
||||
async def _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing: list[dict],
|
||||
canonical_id: str,
|
||||
*,
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
"""Best-effort remove legacy duplicates after choosing one authority."""
|
||||
remover = getattr(resources, "async_delete_item", None)
|
||||
if remover is None:
|
||||
return
|
||||
for item in existing:
|
||||
if item.get("id") == canonical_id:
|
||||
continue
|
||||
if not is_active():
|
||||
return
|
||||
try:
|
||||
await remover(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - canonical registration still succeeded
|
||||
_LOGGER.debug(
|
||||
"Could not remove a duplicate House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def async_register_lovelace_resource(
|
||||
hass: HomeAssistant,
|
||||
module_url: str,
|
||||
*,
|
||||
is_active: Callable[[], bool] | None = None,
|
||||
) -> RegistrationOutcome:
|
||||
"""Create or update the canonical Lovelace resource entry."""
|
||||
active = is_active or (lambda: True)
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return RegistrationOutcome("yaml_fallback")
|
||||
|
||||
try:
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
elif hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
|
||||
existing = [
|
||||
item
|
||||
for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL
|
||||
]
|
||||
if existing:
|
||||
# Prefer an already-current entry when legacy duplicates exist. This
|
||||
# preserves its stable id while the remaining same-base entries are
|
||||
# removed best effort below.
|
||||
item = next(
|
||||
(candidate for candidate in existing if candidate.get("url") == module_url),
|
||||
existing[0],
|
||||
)
|
||||
if item.get("url") == module_url and item.get("type") == "module":
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("existing")
|
||||
if not hasattr(resources, "async_update_item"):
|
||||
return RegistrationOutcome(
|
||||
"transient_error", "registry_update:UnsupportedOperation"
|
||||
)
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_update_item(
|
||||
item["id"], {"url": module_url, "res_type": "module"}
|
||||
)
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("updated")
|
||||
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_create_item(
|
||||
{"url": module_url, "res_type": "module"}
|
||||
)
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource")
|
||||
return RegistrationOutcome("created")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - typed fallback is intentional
|
||||
_LOGGER.debug("Could not register the House Plan Lovelace resource", exc_info=True)
|
||||
return RegistrationOutcome("transient_error", _safe_error("registry", err))
|
||||
|
||||
|
||||
def _add_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> None:
|
||||
"""Add this setup's exact versioned extra-module URL, best effort."""
|
||||
if state.module_url is None or not _is_current(hass, state):
|
||||
return
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
active_before = _active_extra_module_urls(hass)
|
||||
if active_before is not None and state.module_url in active_before:
|
||||
# The exact URL predates this setup. It is a working loader, but it is
|
||||
# not ours to remove after registry recovery or on uninstall.
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
try:
|
||||
frontend.add_extra_js_url(hass, state.module_url)
|
||||
except Exception as err: # noqa: BLE001 - frontend setup must remain available
|
||||
state.loader = "none"
|
||||
state.last_error = _safe_error("fallback_add", err)
|
||||
_LOGGER.warning("Could not add the House Plan frontend fallback", exc_info=True)
|
||||
return
|
||||
if active_before is not None:
|
||||
owned_urls.add(state.module_url)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"House Plan could not prove ownership of the frontend fallback; "
|
||||
"it will remain until Home Assistant restarts"
|
||||
)
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
|
||||
|
||||
def _remove_owned_fallback_url(
|
||||
hass: HomeAssistant,
|
||||
state: FrontendRegistrationState,
|
||||
module_url: str,
|
||||
) -> bool:
|
||||
"""Remove one exact integration-owned fallback when HA supports it."""
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if module_url not in owned_urls:
|
||||
return True
|
||||
remover = getattr(frontend, "remove_extra_js_url", None)
|
||||
if remover is None:
|
||||
return False
|
||||
try:
|
||||
remover(hass, module_url)
|
||||
except KeyError:
|
||||
# Another lifecycle path already removed the exact URL.
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 - registry remains authoritative
|
||||
state.last_error = _safe_error("fallback_remove", err)
|
||||
_LOGGER.debug("Could not remove the House Plan frontend fallback", exc_info=True)
|
||||
return False
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
|
||||
|
||||
def _remove_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> bool:
|
||||
"""Remove this setup's exact fallback when the running HA supports it."""
|
||||
if state.module_url is None:
|
||||
return True
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
removed = _remove_owned_fallback_url(hass, state, state.module_url)
|
||||
state.fallback_added = not removed
|
||||
return removed
|
||||
|
||||
# A pre-existing/user-owned or uninspectable exact URL must never be
|
||||
# removed. Report the combined loader while it is known (or conservatively
|
||||
# assumed) to remain active in this HA document lifecycle.
|
||||
active = _active_extra_module_urls(hass)
|
||||
state.fallback_added = active is None or state.module_url in active
|
||||
return not state.fallback_added
|
||||
|
||||
|
||||
async def _async_create_reload_notice(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Create and persist the one-time localized hard-reload instruction."""
|
||||
# Initial fallback setup and the one-second retry may overlap when loading a
|
||||
# translation is slow. Serialize the persisted one-shot contract rather
|
||||
# than relying only on the stable notification ID (which prevents two UI
|
||||
# rows but would still perform and persist two creates).
|
||||
async with state._notice_lock:
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True:
|
||||
if state.first_reload_notice != "created":
|
||||
state.first_reload_notice = "already_created"
|
||||
return
|
||||
if (
|
||||
not _is_current(hass, state)
|
||||
or not state.card_file_present
|
||||
or not state.static_path_registered
|
||||
or state.loader == "none"
|
||||
):
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
return
|
||||
|
||||
title = _NOTICE_TITLE_FALLBACK
|
||||
description = _NOTICE_DESCRIPTION_FALLBACK
|
||||
try:
|
||||
translations = await async_get_translations(
|
||||
hass,
|
||||
hass.config.language,
|
||||
"issues",
|
||||
integrations={DOMAIN},
|
||||
)
|
||||
title = translations.get(_NOTICE_TITLE_KEY, title)
|
||||
description = translations.get(_NOTICE_DESCRIPTION_KEY, description)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - built-in English fallback is intentional
|
||||
_LOGGER.debug(
|
||||
"Could not load the House Plan reload notice translation",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
try:
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
description,
|
||||
title=title,
|
||||
notification_id=FRONTEND_RELOAD_NOTICE_ID,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - do not consume flag on failure
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not create the House Plan reload notice", exc_info=True
|
||||
)
|
||||
return
|
||||
try:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, FRONTEND_RELOAD_NOTICE_DATA_KEY: True},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - roll back the unpersisted notice
|
||||
# A stable ID prevents duplicate rows, but dismissing the notice is
|
||||
# still important: without a durable flag, displaying it would make
|
||||
# System Health claim that the one-shot contract is pending while a
|
||||
# user-visible notice already exists. A later setup/retry can now
|
||||
# attempt the complete create+persist operation honestly.
|
||||
try:
|
||||
persistent_notification.async_dismiss(
|
||||
hass, FRONTEND_RELOAD_NOTICE_ID
|
||||
)
|
||||
except Exception: # noqa: BLE001 - stable ID remains a safe fallback
|
||||
_LOGGER.debug(
|
||||
"Could not roll back an unpersisted House Plan reload notice",
|
||||
exc_info=True,
|
||||
)
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not persist the House Plan reload notice state",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
state.first_reload_notice = "created"
|
||||
|
||||
|
||||
async def _async_retry_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Perform the single delayed resource-registry retry."""
|
||||
if not _is_current(hass, state) or state.module_url is None:
|
||||
return
|
||||
state.retry_pending = False
|
||||
state.retry_attempted = True
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
elif outcome.status == "yaml_fallback":
|
||||
state.resource_status = "yaml_fallback"
|
||||
state.last_error = outcome.last_error
|
||||
_add_fallback(hass, state)
|
||||
else:
|
||||
state.resource_status = "error_fallback"
|
||||
state.last_error = outcome.last_error or "registry_retry:Unavailable"
|
||||
_add_fallback(hass, state)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
|
||||
|
||||
def _schedule_retry(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Schedule exactly one lifecycle-bound retry after HA start plus one second."""
|
||||
state.retry_pending = True
|
||||
|
||||
@callback
|
||||
def _after_started(_event=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_started = None
|
||||
|
||||
@callback
|
||||
def _after_delay(_now=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_timer = None
|
||||
state._retry_task = entry.async_create_background_task(
|
||||
hass,
|
||||
_async_retry_registration(hass, entry, state),
|
||||
"houseplan frontend resource retry",
|
||||
)
|
||||
|
||||
state._cancel_timer = async_call_later(
|
||||
hass, FRONTEND_RETRY_DELAY_SECONDS, _after_delay
|
||||
)
|
||||
|
||||
state._cancel_started = async_at_started(hass, _after_started)
|
||||
|
||||
|
||||
async def async_setup_frontend_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
card_path: Path,
|
||||
) -> FrontendRegistrationState:
|
||||
"""Register static/card resources without blocking the rest of entry setup."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
previous = domain_data.get(FRONTEND_REGISTRATION_KEY)
|
||||
if isinstance(previous, FrontendRegistrationState):
|
||||
previous.cancel()
|
||||
|
||||
card_file_present = card_path.is_file()
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}" if card_file_present else None
|
||||
active_fallbacks = _active_extra_module_urls(hass)
|
||||
fallback_added = module_url is not None and (
|
||||
module_url in _owned_fallback_urls(hass)
|
||||
or (active_fallbacks is not None and module_url in active_fallbacks)
|
||||
)
|
||||
state = FrontendRegistrationState(
|
||||
card_file_present=card_file_present,
|
||||
loader="extra_module_url" if fallback_added else "none",
|
||||
module_url=module_url,
|
||||
first_reload_notice=(
|
||||
"already_created"
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True
|
||||
else "pending_frontend"
|
||||
),
|
||||
fallback_added=fallback_added,
|
||||
)
|
||||
domain_data[FRONTEND_REGISTRATION_KEY] = state
|
||||
entry.async_on_unload(state.cancel)
|
||||
|
||||
if domain_data.get(FRONTEND_STATIC_REGISTERED_KEY):
|
||||
state.static_path_registered = True
|
||||
elif card_file_present:
|
||||
try:
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
except ImportError: # Home Assistant versions before the async API
|
||||
hass.http.register_static_path(
|
||||
FRONTEND_URL, str(card_path), cache_headers=False
|
||||
)
|
||||
else:
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)]
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - setup continues without frontend
|
||||
state.last_error = _safe_error("static_path", err)
|
||||
_LOGGER.warning("Could not register the House Plan static path", exc_info=True)
|
||||
else:
|
||||
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = True
|
||||
state.static_path_registered = True
|
||||
|
||||
if not card_file_present:
|
||||
_LOGGER.warning("houseplan-card.js was not found next to the integration")
|
||||
return state
|
||||
if not state.static_path_registered or state.module_url is None:
|
||||
return state
|
||||
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return state
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan card auto-registered as a Lovelace resource: %s",
|
||||
state.module_url,
|
||||
)
|
||||
else:
|
||||
_add_fallback(hass, state)
|
||||
if outcome.status in {"registry_pending", "transient_error"}:
|
||||
_schedule_retry(hass, entry, state)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card uses the frontend extra-module fallback: %s",
|
||||
state.module_url,
|
||||
)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
return state
|
||||
|
||||
|
||||
@callback
|
||||
def get_frontend_registration_state(
|
||||
hass: HomeAssistant,
|
||||
) -> FrontendRegistrationState | None:
|
||||
"""Return current frontend registration state for System Health."""
|
||||
state = hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY)
|
||||
return state if isinstance(state, FrontendRegistrationState) else None
|
||||
|
||||
|
||||
async def async_remove_frontend_registration(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Remove all House Plan resource entries and its one-time notice."""
|
||||
state = get_frontend_registration_state(hass)
|
||||
if state is not None:
|
||||
state.cancel()
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
if domain_data.get(FRONTEND_REGISTRATION_KEY) is state:
|
||||
domain_data.pop(FRONTEND_REGISTRATION_KEY, None)
|
||||
|
||||
# Fallback ownership belongs to this HA run, not to the latest config-entry
|
||||
# generation. A bundle-missing reload can therefore have no current URL
|
||||
# while an earlier exact URL is still registered. Uninstall cleans every
|
||||
# URL that this integration itself added, and never pattern-matches foreign
|
||||
# extra-module entries.
|
||||
cleanup_state = state or FrontendRegistrationState(card_file_present=False)
|
||||
for module_url in tuple(_owned_fallback_urls(hass)):
|
||||
_remove_owned_fallback_url(hass, cleanup_state, module_url)
|
||||
|
||||
try:
|
||||
persistent_notification.async_dismiss(hass, FRONTEND_RELOAD_NOTICE_ID)
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not dismiss the House Plan reload notice", exc_info=True)
|
||||
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] != FRONTEND_URL:
|
||||
continue
|
||||
try:
|
||||
await resources.async_delete_item(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - continue best-effort cleanup
|
||||
_LOGGER.debug(
|
||||
"Could not remove one House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
_LOGGER.debug("House Plan Lovelace resource removed")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not remove the House Plan Lovelace resource", exc_info=True)
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"domain": "houseplan",
|
||||
"name": "House Plan",
|
||||
"after_dependencies": [
|
||||
"lovelace"
|
||||
],
|
||||
"codeowners": [
|
||||
"@Matysh"
|
||||
],
|
||||
@@ -16,5 +19,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.71.0"
|
||||
"version": "1.73.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .frontend_registration import get_frontend_registration_state
|
||||
from .store import get_data
|
||||
|
||||
|
||||
@@ -23,7 +24,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
cfg_raw = await data.config_store.async_load() or {}
|
||||
layout_raw = await data.store.async_load() or {}
|
||||
config = cfg_raw.get("config", {})
|
||||
return {
|
||||
result = {
|
||||
"config_rev": cfg_raw.get("rev", 0),
|
||||
"spaces": len(config.get("spaces", [])),
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
@@ -33,3 +34,42 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
frontend = get_frontend_registration_state(hass)
|
||||
if frontend is None:
|
||||
result.update(
|
||||
{
|
||||
"card_file": "missing",
|
||||
"static_path": "not_registered",
|
||||
"resource_status": "not_attempted",
|
||||
"resource_loader": "none",
|
||||
"resource_url": "unavailable",
|
||||
"resource_retry": "not_needed",
|
||||
"resource_error": "none",
|
||||
"first_reload_notice": "pending_frontend",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
result.update(
|
||||
{
|
||||
"card_file": "present" if frontend.card_file_present else "missing",
|
||||
"static_path": (
|
||||
"registered"
|
||||
if frontend.static_path_registered
|
||||
else "not_registered"
|
||||
),
|
||||
"resource_status": frontend.resource_status,
|
||||
"resource_loader": frontend.loader,
|
||||
"resource_url": frontend.module_url or "unavailable",
|
||||
"resource_retry": (
|
||||
"pending"
|
||||
if frontend.retry_pending
|
||||
else "attempted"
|
||||
if frontend.retry_attempted
|
||||
else "not_needed"
|
||||
),
|
||||
"resource_error": frontend.last_error or "none",
|
||||
"first_reload_notice": frontend.first_reload_notice,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Grundrissbild fehlt",
|
||||
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House-Plan-Karte verbunden",
|
||||
"description": "Die House-Plan-Karte ist verbunden. Laden Sie diese Seite vollständig neu, um die aktuelle Benutzeroberfläche zu verwenden (`Ctrl+F5` unter Windows/Linux oder `Cmd+Shift+R` unter macOS). Wenn Sie Dashboard-Ressourcen im Speichermodus manuell verwalten, öffnen Sie Einstellungen → Dashboards → Ressourcen."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Datei der House-Plan-Karte",
|
||||
"static_path": "Statischer Frontend-Pfad",
|
||||
"resource_status": "Status der Ressourcenregistrierung",
|
||||
"resource_loader": "Lademethode der Frontend-Ressource",
|
||||
"resource_url": "URL der Frontend-Ressource",
|
||||
"resource_retry": "Wiederholung der Ressourcenregistrierung",
|
||||
"resource_error": "Fehler der Frontend-Registrierung",
|
||||
"first_reload_notice": "Erster Hinweis zum Neuladen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "L’image du plan est introuvable",
|
||||
"description": "Le fichier de plan `{file}` pour l’espace `{space}` est introuvable dans `config/houseplan/plans/`. Ouvrez les paramètres de l’espace dans la carte House Plan et téléversez de nouveau le plan."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Carte House Plan connectée",
|
||||
"description": "La carte House Plan est connectée. Rechargez complètement cette page pour utiliser l’interface la plus récente (`Ctrl+F5` sous Windows/Linux ou `Cmd+Shift+R` sous macOS). Si vous gérez manuellement les ressources du tableau de bord en mode stockage, ouvrez Paramètres → Tableaux de bord → Ressources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Fichier de la carte House Plan",
|
||||
"static_path": "Chemin statique de l’interface",
|
||||
"resource_status": "État de l’enregistrement de la ressource",
|
||||
"resource_loader": "Mode de chargement de la ressource",
|
||||
"resource_url": "URL de la ressource de l’interface",
|
||||
"resource_retry": "Nouvelle tentative d’enregistrement",
|
||||
"resource_error": "Erreur d’enregistrement de l’interface",
|
||||
"first_reload_notice": "Premier avis de rechargement"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Файл плана этажа не найден",
|
||||
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Карточка House Plan подключена",
|
||||
"description": "Карточка House Plan подключена. Полностью перезагрузите эту страницу, чтобы использовать актуальный интерфейс (`Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS). Если вы вручную управляете ресурсами панели в режиме хранения, откройте Настройки → Панели → Ресурсы."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Файл карточки House Plan",
|
||||
"static_path": "Статический путь интерфейса",
|
||||
"resource_status": "Статус регистрации ресурса",
|
||||
"resource_loader": "Способ загрузки ресурса",
|
||||
"resource_url": "URL ресурса интерфейса",
|
||||
"resource_retry": "Повтор регистрации ресурса",
|
||||
"resource_error": "Ошибка регистрации интерфейса",
|
||||
"first_reload_notice": "Первое уведомление о перезагрузке"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, 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';
|
||||
@@ -8,6 +9,10 @@ import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
import {
|
||||
ISOMETRIC_STAGE3_DENSE_PROFILE,
|
||||
makeIsometricStage3DenseFixture,
|
||||
} from './performance/isometric-stage3-dense-fixture.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));
|
||||
@@ -15,14 +20,37 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
||||
if (![
|
||||
'large-house-v1', 'large-house-isometric-v1', ISOMETRIC_STAGE3_DENSE_PROFILE,
|
||||
'large-house-plan-snap-v1', 'large-house-interaction-v1',
|
||||
].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const stage3Dense = profile === ISOMETRIC_STAGE3_DENSE_PROFILE;
|
||||
const allowStage2Base = process.argv.includes('--allow-stage2-base');
|
||||
if (allowStage2Base && !stage3Dense)
|
||||
throw new Error('--allow-stage2-base is valid only for the Stage 3 dense profile');
|
||||
const requireStage3 = stage3Dense && !allowStage2Base;
|
||||
const isometric = profile === 'large-house-isometric-v1' || stage3Dense;
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const interaction = profile === 'large-house-interaction-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresIsoStructuralBuildCounter = requiresIsometric
|
||||
&& readFileSync(resolve(targetRoot, 'src/houseplan-card.ts'), 'utf8')
|
||||
.includes('private _isoStructuralBuildCount');
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
|
||||
const fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture();
|
||||
const fixtureCounts = stage3Dense ? fixture.counts : LARGE_HOUSE_COUNTS;
|
||||
const sourceSha = (() => {
|
||||
try {
|
||||
return execFileSync('git', ['-C', targetRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (isometric && !sourceSha)
|
||||
throw new Error(`${profile} requires an exact git source SHA`);
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
space.room_drafts = [0, 1].map((draft) => {
|
||||
@@ -71,7 +99,8 @@ try {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace,
|
||||
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -113,6 +142,15 @@ try {
|
||||
longTasks: await longTasks.stop(),
|
||||
};
|
||||
};
|
||||
// Current candidates split Stage 3 out of the initial graph. Comparison
|
||||
// bundles remain valid: their older monolithic renderer has no preload
|
||||
// hook and is already available synchronously.
|
||||
const ensureIsoRuntime = async (card) => {
|
||||
if (typeof card._ensureIsoSceneRuntime !== 'function') return;
|
||||
if (!(await card._ensureIsoSceneRuntime()))
|
||||
throw new Error(`${profile} isometric runtime did not load`);
|
||||
await card.updateComplete;
|
||||
};
|
||||
const forceGc = async () => {
|
||||
if (typeof globalThis.gc !== 'function') return false;
|
||||
globalThis.gc();
|
||||
@@ -131,15 +169,128 @@ try {
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
|
||||
});
|
||||
const isoStructuralBuildCount = (card) => Number.isFinite(card._isoStructuralBuildCount)
|
||||
? card._isoStructuralBuildCount : null;
|
||||
const stage3Snapshot = (card) => {
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
// Count the interactive HTML owners, not the diagnostic SVG
|
||||
// ground/tether/plate primitives which deliberately repeat kind/id.
|
||||
const overlayRoots = [...card.renderRoot.querySelectorAll(
|
||||
'[data-hp-iso-overlay-kind][data-hp-iso-floor][data-hp-iso-visual]',
|
||||
)];
|
||||
const overlaySelectors = {
|
||||
device: '[data-hp="device"]',
|
||||
'room-label': '[data-hp="room-label"]',
|
||||
'opening-lock': '.oplock',
|
||||
};
|
||||
const totalRootsByKind = Object.fromEntries(Object.entries(overlaySelectors)
|
||||
.map(([kind, selector]) => [kind, card.renderRoot.querySelectorAll(selector).length]));
|
||||
const stage3RootsByKind = Object.fromEntries(Object.keys(overlaySelectors)
|
||||
.map((kind) => [kind, overlayRoots.filter((root) =>
|
||||
root.getAttribute('data-hp-iso-overlay-kind') === kind).length]));
|
||||
const openingSurfaceCounts = Object.fromEntries(
|
||||
fixture.stage3Dense.expectedOpeningKinds.map((kind) => [kind,
|
||||
card.renderRoot.querySelectorAll(`.iso-opening-panel[data-kind="${kind}"]`).length,
|
||||
]),
|
||||
);
|
||||
const vacuumRoots = [...card.renderRoot.querySelectorAll('.vacpuck, .vactrail')];
|
||||
const materialDefinitions = card.renderRoot.querySelectorAll('[data-hp-iso-material-def]');
|
||||
const definitionCounts = {
|
||||
total: materialDefinitions.length,
|
||||
patterns: card.renderRoot.querySelectorAll('pattern[data-hp-iso-material-def]').length,
|
||||
filters: card.renderRoot.querySelectorAll('filter[data-hp-iso-material-def]').length,
|
||||
};
|
||||
const pulseDeviceId = fixture.stage3Dense?.pulseDeviceIdsBySpace?.[card._space] || '';
|
||||
const pulseRoot = card.renderRoot.querySelector(
|
||||
`.dev[data-id="${CSS.escape(pulseDeviceId)}"]`,
|
||||
);
|
||||
const facetCounts = {
|
||||
value: card.renderRoot.querySelectorAll('.dev .value-badge').length,
|
||||
lqi: card.renderRoot.querySelectorAll('.dev .lqi').length,
|
||||
new: card.renderRoot.querySelectorAll('.dev .newdot').length,
|
||||
pulse: pulseRoot?.querySelectorAll('.device-pulse, .activity-dot').length ?? 0,
|
||||
roomMetrics: card.renderRoot.querySelectorAll('.roomlabel .rlmetrics').length,
|
||||
};
|
||||
const rawBuilds = stage?.getAttribute('data-hp-iso-structural-builds');
|
||||
const structuralBuilds = rawBuilds == null ? null : Number(rawBuilds);
|
||||
return {
|
||||
effectiveProjection: typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null,
|
||||
isoStageRevision: stage?.getAttribute('data-hp-iso-stage') ?? null,
|
||||
structuralBuilds: Number.isFinite(structuralBuilds) ? structuralBuilds : null,
|
||||
renderedOverlayCount: overlayRoots.length,
|
||||
raisedOverlayCount: overlayRoots
|
||||
.filter((root) => root.getAttribute('data-hp-iso-raised') === 'true').length,
|
||||
nudgedOverlayCount: overlayRoots
|
||||
.filter((root) => root.getAttribute('data-hp-iso-nudged') === 'true').length,
|
||||
raisedVacuumCount: vacuumRoots.filter((root) =>
|
||||
root.getAttribute('data-hp-iso-raised') === 'true'
|
||||
|| root.hasAttribute('data-hp-iso-overlay-kind')).length,
|
||||
overlayKinds: [...new Set(overlayRoots
|
||||
.map((root) => root.getAttribute('data-hp-iso-overlay-kind'))
|
||||
.filter(Boolean))].sort(),
|
||||
totalRootsByKind,
|
||||
stage3RootsByKind,
|
||||
openingSurfaceCounts,
|
||||
materialDefinitionCount: definitionCounts.total,
|
||||
definitionCounts,
|
||||
facetCounts,
|
||||
pulseDiagnostics: pulseRoot ? {
|
||||
id: pulseRoot.getAttribute('data-id'),
|
||||
state: pulseRoot.getAttribute('data-state'),
|
||||
className: pulseRoot.className,
|
||||
ariaLabel: pulseRoot.getAttribute('aria-label'),
|
||||
} : null,
|
||||
};
|
||||
};
|
||||
const assertStage3Snapshot = (snapshot, label) => {
|
||||
if (!requireStage3) return;
|
||||
const expectedKinds = fixture.stage3Dense.expectedOverlayKinds;
|
||||
const missingKinds = expectedKinds.filter((kind) => !snapshot.overlayKinds.includes(kind));
|
||||
const failures = [];
|
||||
if (snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');
|
||||
if (snapshot.isoStageRevision !== '3') failures.push('Stage 3 revision marker is absent');
|
||||
if (snapshot.structuralBuilds == null) failures.push('structural build counter is absent');
|
||||
if (snapshot.renderedOverlayCount < 1) failures.push('no raised overlay roots rendered');
|
||||
if (snapshot.raisedOverlayCount !== snapshot.renderedOverlayCount)
|
||||
failures.push('one or more Stage 3 overlay roots remained on the floor plane');
|
||||
if (snapshot.nudgedOverlayCount < 1) failures.push('dense fixture produced no bounded nudge');
|
||||
if (snapshot.raisedVacuumCount !== 0) failures.push('floor-bound vacuum was marked as raised');
|
||||
if (missingKinds.length) failures.push(`missing overlay kinds: ${missingKinds.join(', ')}`);
|
||||
for (const kind of expectedKinds) {
|
||||
const total = snapshot.totalRootsByKind[kind];
|
||||
const stage3 = snapshot.stage3RootsByKind[kind];
|
||||
if (total < 1) failures.push(`no rendered ${kind} roots`);
|
||||
else if (stage3 !== total)
|
||||
failures.push(`${kind} Stage 3 roots ${stage3}/${total}`);
|
||||
}
|
||||
for (const kind of fixture.stage3Dense.expectedOpeningKinds) {
|
||||
if (!(snapshot.openingSurfaceCounts[kind] > 0))
|
||||
failures.push(`no Stage 3 ${kind} opening surfaces`);
|
||||
}
|
||||
for (const [facet, count] of Object.entries(snapshot.facetCounts)) {
|
||||
if (!(count > 0)) failures.push(`no observable ${facet} facet${facet === 'pulse'
|
||||
? ` (${JSON.stringify(snapshot.pulseDiagnostics)})` : ''}`);
|
||||
}
|
||||
if (snapshot.materialDefinitionCount < 1
|
||||
|| snapshot.materialDefinitionCount > fixture.stage3Dense.materialDefinitionLimit) {
|
||||
failures.push(`material definitions ${snapshot.materialDefinitionCount}`
|
||||
+ ` exceed 1..${fixture.stage3Dense.materialDefinitionLimit}`);
|
||||
}
|
||||
if (!(snapshot.definitionCounts.patterns > 0)) failures.push('no shared texture pattern');
|
||||
if (!(snapshot.definitionCounts.filters > 0)) failures.push('no shared shadow filter');
|
||||
if (failures.length)
|
||||
throw new Error(`Stage 3 ${label} contract failed: ${failures.join('; ')}`);
|
||||
};
|
||||
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
if (isometric) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
|
||||
localStorage.setItem('houseplan_card_alpha_v1', '1');
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
|
||||
fixture.config.spaces.map((space) => [space.id, 'iso']),
|
||||
)));
|
||||
history.replaceState(null, '', '?hp-labs=iso');
|
||||
history.replaceState(null, '', '?hp_alpha=1');
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
@@ -151,10 +302,10 @@ try {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
};
|
||||
const hassFor = (states) => ({
|
||||
const hassBase = {
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
@@ -178,17 +329,19 @@ try {
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
};
|
||||
const hassFor = (states) => ({ ...hassBase, states });
|
||||
|
||||
const loadLongTasks = startLongTaskWindow();
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
if (requiresIsometric) {
|
||||
if (requiresIsometric && window.__hpAlpha !== true) {
|
||||
if (typeof card._onLabsSnapshot !== 'function')
|
||||
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
|
||||
// The product flag expires at 1.65.0. Performance keeps exercising
|
||||
// the dormant renderer without changing the public registry contract.
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
// Comparison bundles before #448 do not understand hp_alpha. Preserve
|
||||
// the cross-version benchmark only for that base; current candidates
|
||||
// must activate through the real URL/storage contract above.
|
||||
card._onLabsSnapshot({ alpha: true, active: Object.freeze(['iso']), space: '' });
|
||||
}
|
||||
card.hass = hassFor(fixture.states);
|
||||
// launch() warms its own bootstrap card, but each measured sample creates
|
||||
@@ -196,6 +349,7 @@ try {
|
||||
// construction; monolithic stable bundles remain immediate (#380).
|
||||
if (!await window.__hpEnsureHarnessEditorRuntime(card))
|
||||
throw new Error('large-house editor runtime did not preload');
|
||||
if (isometric) await ensureIsoRuntime(card);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
|| !(card._isoGeometryCache instanceof Map))) {
|
||||
@@ -206,14 +360,52 @@ try {
|
||||
await frame();
|
||||
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
await until(() => card._booting === false);
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const initialProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
if (requiresIsometric && initialProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback instead of effective Iso`);
|
||||
const initialIsoFingerprint = card.renderRoot
|
||||
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
|
||||
if (requiresIsometric && !initialIsoFingerprint)
|
||||
throw new Error(`${profile} has no rendered Iso geometry fingerprint`);
|
||||
const initialIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
|
||||
if (requiresIsoStructuralBuildCounter && initialIsoStructuralBuilds == null)
|
||||
throw new Error(`${profile} has no structural build counter`);
|
||||
const initialStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (initialStage3) assertStage3Snapshot(initialStage3, 'initial render');
|
||||
const loadLongTaskResult = await loadLongTasks.stop();
|
||||
let fullRenderCount = 0;
|
||||
let diagnosticsScanCount = 0;
|
||||
let physicalPreflightCount = 0;
|
||||
let physicalPreflightMs = 0;
|
||||
const fullRenderReasons = [];
|
||||
if (interaction) {
|
||||
const renderBody = card._renderBody.bind(card);
|
||||
card._renderBody = (...args) => { fullRenderCount++; return renderBody(...args); };
|
||||
const bindingStatus = card._bindingStatus.bind(card);
|
||||
card._bindingStatus = (...args) => { diagnosticsScanCount++; return bindingStatus(...args); };
|
||||
const checkSpacePhysicalGeometry = card._checkSpacePhysicalGeometry.bind(card);
|
||||
card._checkSpacePhysicalGeometry = (...args) => {
|
||||
physicalPreflightCount++;
|
||||
const started = performance.now();
|
||||
try { return checkSpacePhysicalGeometry(...args); }
|
||||
finally { physicalPreflightMs += performance.now() - started; }
|
||||
};
|
||||
const willUpdate = card.willUpdate.bind(card);
|
||||
card.willUpdate = (changed) => {
|
||||
fullRenderReasons.push([...changed.keys()].map(String));
|
||||
return willUpdate(changed);
|
||||
};
|
||||
}
|
||||
const viewToggle = isometric ? await duration(async () => {
|
||||
if (typeof card._setProjection === 'function') {
|
||||
card._setProjection('flat');
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await ensureIsoRuntime(card);
|
||||
await card.updateComplete;
|
||||
} else {
|
||||
// Comparison SHAs before #89 intentionally ignore the Labs operation.
|
||||
@@ -226,7 +418,19 @@ try {
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const firstEntity = Object.keys(fixture.states)[0];
|
||||
const steadyStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (steadyStage3) assertStage3Snapshot(steadyStage3, 'steady floor');
|
||||
const steadyIsoFingerprint = card.renderRoot
|
||||
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
|
||||
if (requiresIsometric && !steadyIsoFingerprint)
|
||||
throw new Error(`${profile} lost Iso geometry after the space switch`);
|
||||
const steadyIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
|
||||
if (requiresIsoStructuralBuildCounter && steadyIsoStructuralBuilds == null)
|
||||
throw new Error(`${profile} lost its structural build counter after the space switch`);
|
||||
|
||||
const firstEntity = stage3Dense
|
||||
? fixture.stage3Dense.stateEntityId
|
||||
: Object.keys(fixture.states)[0];
|
||||
const nextStates = {
|
||||
...fixture.states,
|
||||
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
|
||||
@@ -235,6 +439,464 @@ try {
|
||||
card.hass = hassFor(nextStates);
|
||||
await card.updateComplete;
|
||||
});
|
||||
const afterStateStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (afterStateStage3) assertStage3Snapshot(afterStateStage3, 'HA-only update');
|
||||
const afterStateFingerprint = card.renderRoot
|
||||
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
|
||||
if (requiresIsometric && afterStateFingerprint !== steadyIsoFingerprint)
|
||||
throw new Error(`${profile} rebuilt Iso geometry for an HA-only state update`);
|
||||
const afterStateIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
|
||||
if (requiresIsoStructuralBuildCounter
|
||||
&& afterStateIsoStructuralBuilds !== steadyIsoStructuralBuilds) {
|
||||
throw new Error(`${profile} performed a structural rebuild for an HA-only state update`);
|
||||
}
|
||||
if (requireStage3 && afterStateStage3.structuralBuilds !== steadyStage3.structuralBuilds)
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for an HA-only state update`);
|
||||
|
||||
let stage3States = nextStates;
|
||||
const openingUpdate = stage3Dense ? await duration(async () => {
|
||||
const entityId = fixture.stage3Dense.contactEntityId;
|
||||
const current = stage3States[entityId];
|
||||
stage3States = {
|
||||
...stage3States,
|
||||
[entityId]: { ...current, state: current.state === 'on' ? 'off' : 'on' },
|
||||
};
|
||||
card.hass = hassFor(stage3States);
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
const afterOpeningStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (afterOpeningStage3) assertStage3Snapshot(afterOpeningStage3, 'opening update');
|
||||
if (stage3Dense && requiresIsometric && afterOpeningStage3?.effectiveProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback during an opening update`);
|
||||
if (requireStage3 && afterOpeningStage3.structuralBuilds !== steadyStage3.structuralBuilds)
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for an opening update`);
|
||||
|
||||
const overlayInteraction = stage3Dense ? await duration(async () => {
|
||||
const overlay = card.renderRoot.querySelector(requireStage3
|
||||
? '[data-hp-iso-overlay-kind="device"][data-hp-iso-floor][data-hp-iso-visual]'
|
||||
: '[data-hp="device"]');
|
||||
if (!overlay) throw new Error(`${profile} dense fixture rendered no interactive device`);
|
||||
overlay.dispatchEvent(new PointerEvent('pointerover', {
|
||||
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
overlay.focus?.();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
overlay.dispatchEvent(new PointerEvent('pointerout', {
|
||||
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
overlay.blur?.();
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
const afterInteractionStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (afterInteractionStage3)
|
||||
assertStage3Snapshot(afterInteractionStage3, 'hover/focus update');
|
||||
if (stage3Dense && requiresIsometric && afterInteractionStage3?.effectiveProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback during a hover/focus update`);
|
||||
if (requireStage3
|
||||
&& afterInteractionStage3.structuralBuilds !== steadyStage3.structuralBuilds) {
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for a hover/focus update`);
|
||||
}
|
||||
|
||||
let interactionDiagnostics = null;
|
||||
let interactionTimings = null;
|
||||
let interactionLongTasks = null;
|
||||
const interactionSeries = interaction ? await duration(async () => {
|
||||
const deltas = {};
|
||||
const timings = {};
|
||||
interactionLongTasks = {};
|
||||
const diagnosticsBefore = diagnosticsScanCount;
|
||||
const reasonsBefore = fullRenderReasons.length;
|
||||
const beforeIrrelevant = fullRenderCount;
|
||||
const intakeBefore = card._hassSequence;
|
||||
const irrelevantStarted = performance.now();
|
||||
let currentStates = nextStates;
|
||||
for (let index = 0; index < 30; index++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
'sensor.performance_unrelated': {
|
||||
entity_id: 'sensor.performance_unrelated', state: `${sample}:${index}`,
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.irrelevantHaTicksMs = performance.now() - irrelevantStarted;
|
||||
deltas.irrelevantFullRenders = fullRenderCount - beforeIrrelevant;
|
||||
deltas.irrelevantHassIntakes = card._hassSequence - intakeBefore;
|
||||
|
||||
const relevantBefore = fullRenderCount;
|
||||
const relevantStarted = performance.now();
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: {
|
||||
...currentStates[firstEntity],
|
||||
state: currentStates[firstEntity].state === 'on' ? 'off' : 'on',
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.relevantHaTickMs = performance.now() - relevantStarted;
|
||||
deltas.relevantOutsideGestureFullRenders = fullRenderCount - relevantBefore;
|
||||
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const device = card.renderRoot.querySelector('[data-hp="device"]');
|
||||
const room = card.renderRoot.querySelector('[data-hp="room"]');
|
||||
const heavy = card.renderRoot.querySelector('.wallbodies');
|
||||
if (!stage || !device || !room || !heavy)
|
||||
throw new Error('interaction profile has no stage/device/room/heavy scene');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const hoverBefore = fullRenderCount;
|
||||
const hoverLongTasks = startLongTaskWindow();
|
||||
const hoverStarted = performance.now();
|
||||
device.dispatchEvent(new PointerEvent('pointerover', {
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
device.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 100 + index, clientY: rect.top + 100 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
deltas.tooltipVisible = !!liveTip && !liveTip.hidden && !!liveTip.textContent?.trim();
|
||||
device.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
room.dispatchEvent(new PointerEvent('pointerenter', {
|
||||
clientX: rect.left + 300, clientY: rect.top + 300,
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
room.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 300 + index, clientY: rect.top + 300 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
room.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 20 + index, clientY: rect.top + 20,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
await card.updateComplete;
|
||||
timings.hoverSeriesMs = performance.now() - hoverStarted;
|
||||
deltas.hoverFullRenders = fullRenderCount - hoverBefore;
|
||||
deltas.hoverTargets = ['device', 'room', 'miss'];
|
||||
interactionLongTasks.hoverSeries = await hoverLongTasks.stop();
|
||||
|
||||
const cameraSvg = card.renderRoot.querySelector('[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg');
|
||||
const initialViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const panLongTasks = startLongTaskWindow();
|
||||
const panStarted = performance.now();
|
||||
const panMoveRenders = [];
|
||||
const panTerminalRenders = [];
|
||||
let relevantDuringGestureFullRenders = 0;
|
||||
let relevantDuringGestureIntakes = 0;
|
||||
for (let dragIndex = 0; dragIndex < 4; dragIndex++) {
|
||||
const pointerId = 992 + dragIndex;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 200, clientY: rect.top + 200, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const panMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 205 + index * 3, clientY: rect.top + 202 + index,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if (dragIndex === 0 && index === 10) {
|
||||
const deferredBefore = fullRenderCount;
|
||||
const deferredIntakeBefore = card._hassSequence;
|
||||
for (let tick = 0; tick < 3; tick++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: { ...currentStates[firstEntity], state: `gesture-${tick}` },
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
relevantDuringGestureFullRenders += fullRenderCount - deferredBefore;
|
||||
relevantDuringGestureIntakes += card._hassSequence - deferredIntakeBefore;
|
||||
}
|
||||
if ((index + 1) % 10 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const beforeTerminal = fullRenderCount;
|
||||
panMoveRenders.push(beforeTerminal - panMoveBefore);
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 265, clientY: rect.top + 222, button: 0, buttons: 0,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
panTerminalRenders.push(fullRenderCount - beforeTerminal);
|
||||
}
|
||||
timings.panSeriesMs = performance.now() - panStarted;
|
||||
interactionLongTasks.panSeries = await panLongTasks.stop();
|
||||
const movedViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const currentView = card._viewOr(card._baseVb());
|
||||
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
|
||||
const position = currentDevice ? card._pos(currentDevice) : null;
|
||||
const scene = position ? card._scenePoint([position.x, position.y]) : null;
|
||||
const deviceRect = device.getBoundingClientRect();
|
||||
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
|
||||
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
|
||||
const overlayErrorPx = scene ? Math.hypot(
|
||||
deviceRect.left + deviceRect.width / 2 - expectedX,
|
||||
deviceRect.top + deviceRect.height / 2 - expectedY,
|
||||
) : Infinity;
|
||||
|
||||
const cameraLongTasks = startLongTaskWindow();
|
||||
const cameraStarted = performance.now();
|
||||
const cameraBefore = fullRenderCount;
|
||||
const pinchPointerA = 1001, pinchPointerB = 1002;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 360, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerA,
|
||||
pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 460, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerB,
|
||||
pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const pinchMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 360 - index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerA, pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 460 + index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerB, pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
if ((index + 1) % 5 === 0) await frame();
|
||||
}
|
||||
const pinchTerminalBefore = fullRenderCount;
|
||||
for (const pointerId of [pinchPointerA, pinchPointerB]) {
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 410, clientY: rect.top + 300,
|
||||
button: 0, buttons: 0, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'touch', isPrimary: pointerId === pinchPointerA,
|
||||
}));
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const pinchTerminalRenders = fullRenderCount - pinchTerminalBefore;
|
||||
for (let index = 0; index < 4; index++) {
|
||||
stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY: index % 2 ? 120 : -120,
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
await until(() => !card._cameraTransition.active);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.cameraSeriesMs = performance.now() - cameraStarted;
|
||||
interactionLongTasks.cameraSeries = await cameraLongTasks.stop();
|
||||
deltas.cameraMoveFullRenders = pinchTerminalBefore - pinchMoveBefore;
|
||||
deltas.cameraTerminalFullRenders = pinchTerminalRenders;
|
||||
deltas.cameraFullRenders = fullRenderCount - cameraBefore;
|
||||
|
||||
const editorLongTaskWindows = [];
|
||||
const editorConfigBefore = JSON.stringify(card._serverCfg);
|
||||
const editorCallsBefore = wsCalls;
|
||||
const editorPaintBefore = card._liveEditorPaintCount || 0;
|
||||
let editorElapsed = 0;
|
||||
const editorMoveRenders = [];
|
||||
const editorTerminalRenders = [];
|
||||
|
||||
card._setMode('plan', false);
|
||||
card._tool = 'draw';
|
||||
card._path = [[100, 100]];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
let editorStage = card.renderRoot.querySelector('.stage');
|
||||
let editorRect = editorStage.getBoundingClientRect();
|
||||
let editorView = card._viewOr(card._baseVb());
|
||||
const fromScene = (x, y) => ({
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
let editorPartLongTasks = startLongTaskWindow();
|
||||
let partStarted = performance.now();
|
||||
let editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
editorStage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
...fromScene(110 + index * 2, 110 + index),
|
||||
bubbles: true, composed: true, pointerId: 1101,
|
||||
pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
let editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const resizeRoom = card._rszRooms()[0];
|
||||
const resizeEvent = {
|
||||
pointerId: 1102, stopPropagation: () => undefined,
|
||||
preventDefault: () => undefined, target: null,
|
||||
};
|
||||
card._rszEdgeDown(resizeEvent, resizeRoom.id, 1);
|
||||
const resizePlan = card._resize?.plan || card._rszDrag?.plan;
|
||||
if (!resizePlan) throw new Error('interaction resize plan was not created');
|
||||
const resizePreflightBefore = physicalPreflightCount;
|
||||
const resizePreflightMsBefore = physicalPreflightMs;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
// A browser delivers pointerdown and pointermove as separate tasks.
|
||||
// Keep the synthetic harness from attributing both handlers to one
|
||||
// impossible long task while still timing every move-side preflight.
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
const target = [
|
||||
resizePlan.a[0] + resizePlan.n[0] * card._gridPitch * (1 + index / 40),
|
||||
resizePlan.a[1] + resizePlan.n[1] * card._gridPitch * (1 + index / 40),
|
||||
];
|
||||
card._rszMove({
|
||||
...resizeEvent,
|
||||
clientX: editorRect.left + ((target[0] - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((target[1] - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
deltas.resizeLivePreflightChecks = physicalPreflightCount - resizePreflightBefore;
|
||||
deltas.resizeLivePreflightMs = physicalPreflightMs - resizePreflightMsBefore;
|
||||
card._rszCancelDrag();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._setMode('decor', false);
|
||||
card._decorTool = 'select';
|
||||
const decorShape = card._decorList.find((shape) =>
|
||||
shape.kind === 'furniture' || shape.kind === 'rect' || shape.kind === 'ellipse');
|
||||
if (!decorShape) throw new Error('interaction profile has no transformable decor');
|
||||
card._decorSel = decorShape.id;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
card._dtMeasure();
|
||||
await card.updateComplete;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
const box = card._decorBoxOf(decorShape);
|
||||
const cornerX = box.x + box.w, cornerY = box.y + box.h;
|
||||
const decorPointer = (x, y) => ({
|
||||
pointerId: 1103, pointerType: 'mouse', shiftKey: false,
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
stopPropagation: () => undefined, preventDefault: () => undefined,
|
||||
currentTarget: null, target: null,
|
||||
});
|
||||
card._dtStart(decorPointer(cornerX, cornerY), 'scale', [1, 1]);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
card._dtMove(decorPointer(cornerX + index, cornerY + index / 2));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cancelDecorGesture();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
timings.editorSeriesMs = editorElapsed;
|
||||
interactionLongTasks.editorSeries = {
|
||||
supported: editorLongTaskWindows.every((window) => window.supported),
|
||||
count: editorLongTaskWindows.reduce((sum, window) => sum + window.count, 0),
|
||||
maxMs: Math.max(0, ...editorLongTaskWindows.map((window) => window.maxMs)),
|
||||
totalMs: editorLongTaskWindows.reduce((sum, window) => sum + window.totalMs, 0),
|
||||
};
|
||||
deltas.editorMoveFullRenders = editorMoveRenders;
|
||||
deltas.editorTerminalFullRenders = editorTerminalRenders;
|
||||
deltas.editorLivePaints = (card._liveEditorPaintCount || 0) - editorPaintBefore;
|
||||
deltas.editorConfigStable = JSON.stringify(card._serverCfg) === editorConfigBefore;
|
||||
deltas.editorWsWrites = wsCalls - editorCallsBefore;
|
||||
deltas.editorLongTaskParts = editorLongTaskWindows;
|
||||
|
||||
card._setMode('view', false);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
deltas.panMoveFullRenders = panMoveRenders;
|
||||
deltas.terminalFullRenders = panTerminalRenders;
|
||||
deltas.relevantDuringGestureFullRenders = relevantDuringGestureFullRenders;
|
||||
deltas.relevantDuringGestureIntakes = relevantDuringGestureIntakes;
|
||||
deltas.viewBoxMoved = !!initialViewBox && initialViewBox !== movedViewBox;
|
||||
deltas.overlayErrorPx = Number(overlayErrorPx.toFixed(2));
|
||||
deltas.heavyNodeStable = card.renderRoot.querySelector('.wallbodies') === heavy;
|
||||
deltas.diagnosticsScans = diagnosticsScanCount - diagnosticsBefore;
|
||||
deltas.fullRenderReasons = fullRenderReasons.slice(reasonsBefore);
|
||||
interactionTimings = timings;
|
||||
interactionDiagnostics = { supported: requiresInteraction, ...deltas };
|
||||
if (requiresInteraction && (
|
||||
deltas.irrelevantFullRenders !== 0 || deltas.irrelevantHassIntakes !== 30
|
||||
|| deltas.relevantOutsideGestureFullRenders !== 1 || deltas.hoverFullRenders !== 0
|
||||
|| deltas.hoverTargets.join(',') !== 'device,room,miss'
|
||||
|| deltas.panMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.terminalFullRenders.some((count) => count !== 1)
|
||||
|| deltas.relevantDuringGestureFullRenders !== 0 || !deltas.tooltipVisible
|
||||
|| deltas.relevantDuringGestureIntakes !== 3
|
||||
|| deltas.cameraMoveFullRenders !== 0 || deltas.cameraTerminalFullRenders !== 1
|
||||
|| deltas.editorMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.editorTerminalFullRenders.some((count) => count > 1)
|
||||
|| deltas.resizeLivePreflightChecks < 1
|
||||
|| deltas.editorLivePaints < 3 || !deltas.editorConfigStable || deltas.editorWsWrites !== 0
|
||||
|| !deltas.viewBoxMoved || deltas.overlayErrorPx > 1 || !deltas.heavyNodeStable
|
||||
|| deltas.diagnosticsScans !== 0
|
||||
)) throw new Error(`interaction structural contract failed: ${JSON.stringify(interactionDiagnostics)}`);
|
||||
}) : null;
|
||||
|
||||
let planSnapDiagnostics = null;
|
||||
const planSnapPointer = planSnap ? await duration(async () => {
|
||||
@@ -422,16 +1084,29 @@ try {
|
||||
const cacheGrowth = Object.fromEntries(
|
||||
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
|
||||
);
|
||||
const finalProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
if (requiresIsometric && finalProjection !== 'iso')
|
||||
throw new Error(`${profile} ended the sample in Flat fallback`);
|
||||
const finalStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (finalStage3) assertStage3Snapshot(finalStage3, 'final warmed state');
|
||||
|
||||
const result = {
|
||||
sample,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
...(openingUpdate ? { openingUpdateMs: openingUpdate.ms } : {}),
|
||||
...(overlayInteraction ? { overlayInteractionMs: overlayInteraction.ms } : {}),
|
||||
...(planSnapPointer ? {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
...(interactionSeries ? {
|
||||
interactionSeriesMs: interactionSeries.ms,
|
||||
...interactionTimings,
|
||||
interactionDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -441,7 +1116,10 @@ try {
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(openingUpdate ? { openingUpdate: openingUpdate.longTasks } : {}),
|
||||
...(overlayInteraction ? { overlayInteraction: overlayInteraction.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
...(interactionSeries ? interactionLongTasks || {} : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -454,6 +1132,26 @@ try {
|
||||
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
|
||||
preciseGc: typeof globalThis.gc === 'function',
|
||||
renderedDevices: card._devices?.length ?? 0,
|
||||
effectiveProjection: finalProjection,
|
||||
...(isometric ? {
|
||||
isoStructuralBuilds: {
|
||||
supported: steadyIsoStructuralBuilds != null && afterStateIsoStructuralBuilds != null,
|
||||
initial: initialIsoStructuralBuilds,
|
||||
beforeHaUpdate: steadyIsoStructuralBuilds,
|
||||
afterHaUpdate: afterStateIsoStructuralBuilds,
|
||||
haUpdateDelta: steadyIsoStructuralBuilds == null || afterStateIsoStructuralBuilds == null
|
||||
? null : afterStateIsoStructuralBuilds - steadyIsoStructuralBuilds,
|
||||
},
|
||||
} : {}),
|
||||
...(finalStage3 ? {
|
||||
isoStageRevision: finalStage3.isoStageRevision,
|
||||
stage3Diagnostics: {
|
||||
...finalStage3,
|
||||
steadyUpdateStructuralBuildDelta: requireStage3
|
||||
? afterInteractionStage3.structuralBuilds - steadyStage3.structuralBuilds
|
||||
: null,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
card.remove();
|
||||
await frame();
|
||||
@@ -461,6 +1159,8 @@ try {
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -473,10 +1173,15 @@ const metricNames = [
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (stage3Dense) metricNames.splice(3, 0, 'openingUpdateMs', 'overlayInteractionMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
if (interaction) metricNames.splice(2, 0,
|
||||
'interactionSeriesMs', 'hoverSeriesMs', 'panSeriesMs', 'cameraSeriesMs',
|
||||
'editorSeriesMs', 'irrelevantHaTicksMs', 'relevantHaTickMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
sourceSha,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildFingerprint,
|
||||
runtime: {
|
||||
@@ -488,11 +1193,16 @@ const report = {
|
||||
deviceScaleFactor: 1,
|
||||
reducedMotion: true,
|
||||
},
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
fixture: fixtureCounts,
|
||||
samples,
|
||||
warmups,
|
||||
summary: summarizeTimings(rows, metricNames),
|
||||
longTasks: summarizeLongTasks(rows),
|
||||
effectiveProjection: [...new Set(rows.map((row) => row.effectiveProjection))],
|
||||
...(stage3Dense ? {
|
||||
isoStageRevision: [...new Set(rows.map((row) => row.isoStageRevision))],
|
||||
stage3Required: requireStage3,
|
||||
} : {}),
|
||||
rows,
|
||||
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/** #461 production-bundle wall-draw terminal-click performance profile. */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch, finish } from './serve.mjs';
|
||||
import {
|
||||
installWallDrawClickHarness, resetWallDrawClickHarness, runWallDrawClickChain,
|
||||
} from './wall-draw-click-harness.mjs';
|
||||
|
||||
const outputArg = process.argv.find((arg) => arg.startsWith('--output='));
|
||||
const output = outputArg ? resolve(outputArg.slice('--output='.length)) : null;
|
||||
const { page, browser } = await launch({ width: 1200, height: 900 }, 1);
|
||||
await installWallDrawClickHarness(page);
|
||||
|
||||
const run = async (remote) => {
|
||||
const fixture = await resetWallDrawClickHarness(page, remote);
|
||||
// One complete chain warms the browser/JIT; reset removes all of its state.
|
||||
await runWallDrawClickChain(page);
|
||||
await resetWallDrawClickHarness(page, remote);
|
||||
return { fixture, result: await runWallDrawClickChain(page) };
|
||||
};
|
||||
const base = await run(false);
|
||||
const remote = await run(true);
|
||||
const sorted = [...base.result.times].sort((a, b) => a - b);
|
||||
const medianMs = sorted[Math.floor(sorted.length / 2)];
|
||||
const maxMs = Math.max(...base.result.times);
|
||||
const remoteSorted = [...remote.result.times].sort((a, b) => a - b);
|
||||
const remoteMedianMs = remoteSorted[Math.floor(remoteSorted.length / 2)];
|
||||
|
||||
const clickShape = (click) => click.fullSpacePhysicalChecks === 0
|
||||
&& click.localPhysicalChecks === 1
|
||||
&& click.junctionArtifactPasses === 1
|
||||
&& click.configWrites === 1 && click.history === 1 && click.pathPoints === 1;
|
||||
const fixtureShape = base.fixture.spaces === 5 && base.fixture.rooms === 12
|
||||
&& base.fixture.positiveSegments >= 36 && base.fixture.savedDrafts === 4;
|
||||
const remoteFixtureShape = remote.fixture.positiveSegments === base.fixture.positiveSegments * 2;
|
||||
const structural = fixtureShape && remoteFixtureShape
|
||||
&& base.result.first.fullSpacePhysicalChecks === 0
|
||||
&& base.result.first.localPhysicalChecks === 0
|
||||
&& base.result.first.configWrites === 0 && base.result.first.history === 0
|
||||
&& base.result.clicks.length === 7 && base.result.clicks.every(clickShape)
|
||||
&& remote.result.clicks.every(clickShape)
|
||||
&& base.result.metrics.localProofMaxObjects < base.fixture.positiveSegments
|
||||
&& remote.result.metrics.localProofMaxObjects === base.result.metrics.localProofMaxObjects
|
||||
&& base.result.ids.length === 7 && new Set(base.result.ids).size === 7
|
||||
&& base.result.terminal.fullSpacePhysicalChecks >= 1
|
||||
&& base.result.terminalPartitionCount >= 1 && base.result.activeCleared;
|
||||
const budgets = {
|
||||
medianMs: 150,
|
||||
maxMs: 250,
|
||||
remoteMedianMs: medianMs * 1.5 + 20,
|
||||
};
|
||||
const timing = medianMs <= budgets.medianMs && maxMs <= budgets.maxMs
|
||||
&& remoteMedianMs <= budgets.remoteMedianMs;
|
||||
const report = {
|
||||
issue: 461, profile: 'wall-draw-click-v1',
|
||||
fixture: base.fixture, remoteFixture: remote.fixture,
|
||||
timing: {
|
||||
samplesMs: base.result.times.map((value) => Math.round(value * 10) / 10),
|
||||
medianMs: Math.round(medianMs * 10) / 10,
|
||||
maxMs: Math.round(maxMs * 10) / 10,
|
||||
remoteMedianMs: Math.round(remoteMedianMs * 10) / 10,
|
||||
},
|
||||
budgets: Object.fromEntries(Object.entries(budgets)
|
||||
.map(([key, value]) => [key, Math.round(value * 10) / 10])),
|
||||
counters: base.result.metrics,
|
||||
structural,
|
||||
timingPass: timing,
|
||||
pass: structural && timing,
|
||||
};
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,47 @@
|
||||
/** #54: bounded provider normalization and incident-only hover performance. */
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js';
|
||||
|
||||
const NODES = 500;
|
||||
const SAMPLES = 9;
|
||||
const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 };
|
||||
const hex = (index) => index.toString(16).padStart(16, '0');
|
||||
const rows = Array.from({ length: NODES }, (_, index) => ({
|
||||
ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
|
||||
neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => {
|
||||
const other = index + offset;
|
||||
return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : [];
|
||||
}),
|
||||
}));
|
||||
const registry = {
|
||||
revision: 1, authoritative: true, access: 'full', lastSuccess: Date.now(), entities: {},
|
||||
devices: Object.fromEntries(rows.map((_, index) => [`d${index}`, { id: `d${index}` }])),
|
||||
};
|
||||
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
|
||||
const devices = rows.map((_, index) => ({
|
||||
id: `m${index}`, name: '', model: '', area: '', space: index % 5 ? 'main' : 'remote',
|
||||
icon: '', entities: [], bindingKind: 'device', bindingRef: `d${index}`, bindingStatus: active,
|
||||
}));
|
||||
|
||||
const measured = (fn) => {
|
||||
const samples = [];
|
||||
for (let i = 0; i < SAMPLES; i++) {
|
||||
const started = performance.now(); fn(); samples.push(performance.now() - started);
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length * .9)];
|
||||
};
|
||||
|
||||
let topology; let mapped;
|
||||
const normalizeMs = measured(() => { topology = normalizeZhaTopology(rows); });
|
||||
const mapMs = measured(() => { mapped = mapTopologies([topology], devices, registry); });
|
||||
const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', 'm1'));
|
||||
const repeatedHoverMs = measured(() => {
|
||||
for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`);
|
||||
});
|
||||
const result = { nodes: topology.nodes.length, links: topology.links.length,
|
||||
normalizeMs, mapMs, firstHoverMs, repeatedHoverMs };
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
for (const [name, budget] of Object.entries(BUDGETS)) {
|
||||
if (result[name] > budget) throw new Error(`${name} ${result[name].toFixed(2)} ms > ${budget} ms`);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const CELL_CM = 5;
|
||||
const point = (x, y) => [x / 240, y / 240];
|
||||
|
||||
const roomAt = (id, x, y) => {
|
||||
const poly = [point(x, y), point(x + 24, y), point(x + 24, y + 24), point(x, y + 24)];
|
||||
const wallIds = poly.map((_, index) => `${id}-wall-${index}`);
|
||||
return {
|
||||
room: { id, name: id, area: null, poly, wall_ids: wallIds },
|
||||
segments: poly.map((a, index) => ({
|
||||
id: wallIds[index], a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
walls: poly.map((a, index) => ({
|
||||
key: `${id}-legacy-${index}`, a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const roomGrid = (prefix, xOffset = 0) => Array.from({ length: 12 }, (_, index) =>
|
||||
roomAt(`${prefix}-${index}`, xOffset + 8 + (index % 4) * 32, 8 + Math.floor(index / 4) * 32));
|
||||
|
||||
export const WALL_DRAW_CLICK_POINTS = [
|
||||
point(152, 144), point(176, 144), point(176, 168), point(200, 168),
|
||||
point(200, 144), point(224, 144), point(224, 168), point(236, 168),
|
||||
];
|
||||
|
||||
export function makeWallDrawClickFixture(remoteVariant = false) {
|
||||
const primary = roomGrid('base');
|
||||
const remote = remoteVariant ? roomGrid('remote', 300) : [];
|
||||
const all = [...primary, ...remote];
|
||||
const roomDrafts = Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `saved-draft-${index}`,
|
||||
points: [point(8 + index * 28, 112), point(20 + index * 28, 112), point(20 + index * 28, 124)],
|
||||
segments: [
|
||||
{ id: `saved-draft-${index}-0`, cm: 15 },
|
||||
{ id: `saved-draft-${index}-1`, cm: 15 },
|
||||
],
|
||||
}));
|
||||
const edited = {
|
||||
id: 'edited', title: 'Wall draw click', cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1],
|
||||
rooms: all.map((item) => item.room),
|
||||
wall_segments: all.flatMap((item) => item.segments),
|
||||
walls: all.flatMap((item) => item.walls),
|
||||
room_drafts: roomDrafts,
|
||||
partitions: [], wall_columns: [], openings: [], open_spans: [],
|
||||
};
|
||||
return {
|
||||
model_version: 9,
|
||||
spaces: [edited, ...Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `other-${index}`, title: `Other ${index}`, cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [], walls: [],
|
||||
room_drafts: [], partitions: [], wall_columns: [], openings: [], open_spans: [],
|
||||
}))],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
||||
goldenWitnessRefusal,
|
||||
} from '../../scripts/golden-acceptance.mjs';
|
||||
import {
|
||||
assertCaptureEnvironment, captureEnvironment, environmentNote,
|
||||
} from '../../scripts/capture-environment.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const reviewed = process.argv.includes('--reviewed');
|
||||
@@ -38,6 +41,16 @@ 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');
|
||||
|
||||
// Среда приёмки (#455). Отчёт съёмки платформу не несёт — `run.mjs` входит в
|
||||
// корпус sourceFingerprint, и его правка объявила бы устаревшими бандл,
|
||||
// скриншоты документации и сам индекс эталонов. Но приёмка идёт там же, где
|
||||
// лежат артефакты, поэтому её платформа — честный признак среды кадров.
|
||||
const environment = captureEnvironment();
|
||||
const foreignAllowed = assertCaptureEnvironment({ kind: 'golden', stage: 'accept' });
|
||||
if (foreignAllowed) {
|
||||
console.log(`Чужая среда приёмки разрешена осознанно: ${foreignAllowed}`);
|
||||
}
|
||||
|
||||
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
|
||||
if (refusal) throw new Error(refusal);
|
||||
|
||||
@@ -72,7 +85,18 @@ const witnessCheck = goldenWitnessRefusal({
|
||||
skipWitnesses,
|
||||
skipReason,
|
||||
});
|
||||
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
|
||||
if (witnessCheck.refusal) {
|
||||
// Приписка про среду — то, чего не хватало отказу: «свидетелей 0 из 10» без
|
||||
// неё читается как «объяви больше сцен», и обход в одну команду выглядит
|
||||
// решением (#455).
|
||||
const note = environmentNote({
|
||||
capturedOn: environment.platform,
|
||||
acceptedOn: existsSync(manifestPath)
|
||||
? JSON.parse(readFileSync(manifestPath, 'utf8')).platform || null
|
||||
: null,
|
||||
});
|
||||
throw new Error(note ? `${witnessCheck.refusal}\n${note}` : witnessCheck.refusal);
|
||||
}
|
||||
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
|
||||
// не имеет права оставить каталог эталонов половинным.
|
||||
for (const scenario of GOLDEN_SCENARIOS) {
|
||||
@@ -102,6 +126,9 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
|
||||
acceptedAt: new Date().toISOString(),
|
||||
sourceFingerprint: report.buildFingerprint,
|
||||
chromium: report.chromium,
|
||||
// Платформа рядом с версией браузера: провенанс среды, по которому следующая
|
||||
// приёмка отличит «объявили не то» от «сняли не там» (#455).
|
||||
platform: environment.platform,
|
||||
// #355: след приёмки в артефакте, не только в истории shell.
|
||||
witnesses: skipWitnesses
|
||||
? { skipped: true, reason: skipReason }
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 54,
|
||||
"acceptedAt": "2026-09-03T06:25:02.855Z",
|
||||
"sourceFingerprint": "b69bdfe3df225cf1b96fea0159a03a620e38cb94ec6106f9ee3b0e57cdefd183",
|
||||
"matrixVersion": 56,
|
||||
"acceptedAt": "2026-09-06T07:26:41.441Z",
|
||||
"sourceFingerprint": "d6a3471afc7a85dfa1eb3d7363deca18dabe72bbc653ed884c7fd849259888c2",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "linux",
|
||||
"witnesses": {
|
||||
"count": 132,
|
||||
"count": 142,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"version-mismatch-desktop-dark-en": "8efc4365de5e5f69dc864a36d87144c3a16a2a52f89ec6e5556fc7755f538fb6",
|
||||
"version-mismatch-touch-light-ru": "6fee87758c8373472b57542b8705595113ef07b92014ef6f5b977d4d1aa19808",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "d3d0863fe814d20f5b081de6ac14f8bbf7d559be019808e62cd6dcdc7abcaa70",
|
||||
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
|
||||
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
|
||||
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
|
||||
@@ -20,12 +24,17 @@
|
||||
"split-corner-wall-thin-dark": "9a509f0fa7ee8503c54a508c313b47e10f8044f0a9931791d1cab83467dc3f90",
|
||||
"split-corner-wall-thick-dark": "51ac5e7594386e306f5ebc94e50d6c6d428fd6a4f26a447b28e1cbdd429afda7",
|
||||
"split-zero-divider-taper-dark": "eb43a7ba81ebd8b1f7c7f76306bb6205ea77a5cef3c21739d40ca540f0f7a1bf",
|
||||
"isometric-geometry-view-dark": "0d8427299c77e1e3bb86f37f53128a6640b6666e0b682e5f96085c4f07a657ec",
|
||||
"isometric-geometry-view-light": "a3e592448ad3ad4ff65266f1fd6f31259c01a9baf47392f6b8be9811d74f8dce",
|
||||
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
|
||||
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
|
||||
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
|
||||
"isometric-large-warm-remount-dark": "299401bd4b499876d40678593308b42ac8aa4d3328113f27ad5940fc3621184a",
|
||||
"isometric-geometry-view-dark": "e5789d870fee8822b3f46289e5123a40c715a008fe8e2c05845675c23fa02c6a",
|
||||
"isometric-geometry-view-light": "5883595ebfdd95e8c3ab5664b0618c066dc571be1a081695649c88d66b2c7ac8",
|
||||
"isometric-live-layers-dark": "8380903263d16bfd9308e80de82a97e954f027a8cce610d516c77bb84e86afcb",
|
||||
"isometric-no-borders-dark": "7cd9e7d9797c920e7f3564a24b1fd6143b4da94a342af3b34d5a1cd8d584f11b",
|
||||
"isometric-touch-kiosk-dark": "da39ac12eb151a7cfd434e4d9cce01548a938b82e053c7233e873fd7f6889a50",
|
||||
"isometric-large-warm-remount-dark": "d6d60fd6b021cb9edefbe5d1261cb3770672bf792751df0d0abf111004d83688",
|
||||
"isometric-stage3-overlays-light": "04b730a7cf391cf2b4c654b2c9350a1a86721430b2fbe8d1c6aed019a11fa2b4",
|
||||
"isometric-stage3-overlays-dark": "a3d46b10edb8965ec52622ba560f441f1f65c8b8bd519e837b8b31ff17841368",
|
||||
"isometric-stage3-openings-dark": "32174e262a025c7062bb59e5ea086912f3421f541f26689b467dafd11a6daafe",
|
||||
"isometric-stage3-forced-colors-dark": "73dbf99645482fb02dab0f6e9398212fa08e7dcf4b264ffab49b89b1a0c73134",
|
||||
"isometric-stage3-no-filter-dark": "97c3943e4be79978f15d14c89991699d0c8efdd565d83ab4bbcf2c958fef1a64",
|
||||
"geometry-view-dark-fit": "1ea111a2f68d71c2d2269ee65ac70b586b7d8a3a76f550bb1caf8d154bf42b26",
|
||||
"geometry-view-light-fit": "80bbb618619731dcb9dbd5e872785f32f1e52ff71ce989a2154c5f1218812eda",
|
||||
"vacuum-trail-smoothing-dark": "b33ddbfaed72f51e601576bbf87664a08578fbce5c22c69a11d83cc2644ff312",
|
||||
@@ -57,18 +66,18 @@
|
||||
"orthogonal-strip-cell-5-view-dark": "05cdd744d09025719eda4c5e68cf3ebc5d7d201afd3028da91262d0f4874c126",
|
||||
"orthogonal-strip-cell-1-view-dark": "9c0f91b11d47f1c5df4476774ae6028db21dc15cc6fb81d9f80009e0fa86bffe",
|
||||
"wall-key-roundtrip-view-dark": "cc6f755c03b1a586684627fc1dc27eb1aa28d2673a6c79bcb687da94a6bf9b3b",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"isometric-wall-junctions-dark": "af692b2c01293c59cc74e5731309e6612c913ca1c8384572fd1cb67a19d0f902",
|
||||
"opening-placement-door-thick-wall-dark": "d986d82fe68bec4b0b0556870d3ec7de280a00acb42cf0165a3d64f91f994bed",
|
||||
"opening-placement-passage-thick-wall-dark": "f53908dbdf8cf6681c97317398e8a04cd98ebacdbc81571c2d15d38b2599e049",
|
||||
"opening-placement-passage-thick-wall-light": "f7bdf033bbc511b34269044c450f8c93a3493e27bbbcf758050b7798197efd80",
|
||||
"opening-symbol-room-wall-light": "c487c11f12b2bba1473559a82af3320b7995fa9c8d1b379274b942e431930beb",
|
||||
"opening-symbol-diagonal-partition-dark": "77101350b15845207644b6df86a3bc5f48b4dc873b9cfd7443c0d23507412de3",
|
||||
"opening-symbol-flip-pairs-light": "c2d9102921483da1d0216ed9bc55fc9112d67762f159df8af64a277f0db0acf4",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"isometric-opening-symbol-parity-dark": "e2ead596818f6182bd2478dc3823f31312d361080c99afbb9c7f7d1862cf781c",
|
||||
"geometry-devices-editor-dark": "7c4072fce11f91b2c894bdb5f4f9113e72089306d629c995d38dfd17f2b65cc8",
|
||||
"device-inbox-desktop-en-light": "89587944da06872f610da33e8e39441d108cb34849d8b09d4925bff4d0977565",
|
||||
"device-inbox-desktop-ru-dark": "fe9506408fc198c167f2d4b9cfe91bd9fdfb722b311772457ae44c9d06248680",
|
||||
"device-inbox-narrow-ru-dark": "3ddb3b66a613c6406d3d9ce2d4d479c8d84eff04acd9b5b2dce02c4c777fc738",
|
||||
"device-inbox-desktop-en-light": "a73761da0594ba2138f098d971d74566f9915f59a563fd55295add241145ba36",
|
||||
"device-inbox-desktop-ru-dark": "5a9eb32ffb7f408c6bf8adca9d76b0d01d7b0b514f8b33a01e500f9230541f1a",
|
||||
"device-inbox-narrow-ru-dark": "65c46b7311f850f305147d75a24ddeded67c9e8b18ea362c88b9964c50a5b727",
|
||||
"geometry-decor-editor-dark": "4023e36ff4521011153983538e4a43ad80efd92bf07d523c333982bbd2f80ad6",
|
||||
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
|
||||
"tray-wide-tool-ru": "56c09bb397b76b7e5c6f4d7eaf7ab59e5d96faa409e553c45c6ce7605e9a9418",
|
||||
@@ -133,33 +142,33 @@
|
||||
"large-house-zoom-040-dark": "5601d5ce3b1fe79365984301872a851d6c2e81878ec6f6cc3b88aab811d8609a",
|
||||
"large-house-zoom-250-dark": "7abb72baba00ed035d9f7519b99e1d9f872a6cef81861ed096a511fe9320471f",
|
||||
"large-house-warm-remount-dark": "441c32f130b8f291582482fa6445f9c85f6f13c61b956020d4fa1d6153094efa",
|
||||
"device-dialog-desktop-en": "465175fc9960c7ceb5208cf62f919381d56179ef12a93bee20e5c7af63757e7a",
|
||||
"device-dialog-mobile-ru": "8128783f4e9f9ff301f15db9fa7388b51aa7e8a4a74ac0aaf962b21ecda52c78",
|
||||
"device-dialog-desktop-de": "8440d4e980fac5df53142b6b118e527d9c0272e7515b65faaba6a58bc9441075",
|
||||
"device-dialog-desktop-en": "f3a3a45dad2cc22e692366114a4f87272b39ba261c1509e8a501dbfb82b6584e",
|
||||
"device-dialog-mobile-ru": "7ac796286ab4e65275838b4095ea3f16491311f99fd98cad3bbb475c6061e31c",
|
||||
"device-dialog-desktop-de": "9d39d3e73a3f36171e8cc7c930bcb43bfbfd788e31db0f584962300a2b00ca93",
|
||||
"german-view-mobile-light": "55112787d9a26be66dfd8b724115962c4b891d1f8276b7b5b36b0ff25da48257",
|
||||
"toggle-entity-dialog-desktop-en": "7a8ac3071d8bb3f79f2bfe724d0138e2b309baab3104ef8afb0b2b081c8b82b1",
|
||||
"toggle-entity-dialog-mobile-ru": "d3062e76da27218cac45ca4c76f34b358d9cb98e85288f24e64b7de1bcca949d",
|
||||
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
|
||||
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
|
||||
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
|
||||
"general-color-popover-desktop-en": "5b47ab921f889c9ad41cab195e6efeed283f014d37f5377714b54ef30282ebf6",
|
||||
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
|
||||
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
|
||||
"support-desktop-empty-light-en": "fecabb63521f258acc66ecdf7a438db10c73d0eac39618c55fb51287080f01f5",
|
||||
"support-desktop-preview-dark-en": "a866aa74e3385ac45e837b328d64ed65bc4b108eb9dc4149fcccf393975e72b6",
|
||||
"support-phone-validation-light-ru": "7a461c00631a05872b9aab1f4c0b0b65d1c918273ff06bba25c7bd2e87fba1ca",
|
||||
"support-phone-success-dark-en": "81f83402933a573002581646d0b322f3770f3df41d5334373315ed5ad4974883",
|
||||
"support-relay-error-light-en": "e9a9ad906e106eedfa6e84c592325f1604eb64c89c51135c076ac87a6a0766b2",
|
||||
"support-tablet-preview-dark-ru": "1264e005cd764408edb9b285a5dfbe92ceb90cb925f624b8f65cd47edb96c0c9",
|
||||
"device-ripple-color-popover-mobile-ru": "d9ebf83ad0d2995b46f6174daf6e21ca5404952afa308ddd3a66ecefcee565d0",
|
||||
"space-room-color-popover-desktop-ru": "9bbbcf9c5ea6ff5490b500be8cb3086af9d368d14ca50c304f42a697e296ece1",
|
||||
"backup-full-preview-desktop-en": "0fbd23a92121668001faf5c9ed555a31107704f3074d1791709c8436517d3d1f",
|
||||
"backup-plan-only-export-desktop-en": "ce66fb57edbc87d56355b0d73a2d4203a67c275d5927d400ef1758ac0b607b34",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "101e1a97c6229c693a4edfb4878c6a4564132d6fbcaddd6276cb25aefa429260",
|
||||
"optimize-preflight-dialog-light-ru": "4f34a59baa95b7ad0ad3dbb47f23996537878f6b666dc9111236713f513de97e",
|
||||
"optimize-orphan-references-dark-en": "c8122eda61a52ead0f3609b9872b513004ffbc44c1d4928ec11e5d3b5978af78",
|
||||
"optimize-orphan-references-light-ru": "3cc0b16af015cf695f8bd278dee4d76b160639021a33bc3a28441fb72a5e8e5c",
|
||||
"toggle-entity-dialog-desktop-en": "3ea2bb828b554b6fb544b77fdef2af947e45af632769f4deab03c6adf195fa68",
|
||||
"toggle-entity-dialog-mobile-ru": "2d433f3f1fab9e9d4c3d413c302ac0d3b44a2d46ec9ecbf098c3063da6f1df04",
|
||||
"device-help-popover-light-ru": "b8cb39c77690bb2a2a150a2a77af55411b3f2053741eee86cd5fd19b41b4307f",
|
||||
"decor-color-popover-mobile-ru": "c63b72493040fefdbcf55ce5325630a321b171f62962a345caa89ec5fa837f55",
|
||||
"decor-color-popover-desktop-en": "49bc8317d452fbc9e7e95f7c139423838a5ecde763b1fe328c7b1be6ca20ed98",
|
||||
"general-color-popover-desktop-en": "4b3cd5b7fb405b912cb3506680151b0237b803499bbfbad597c20d3fabc869a7",
|
||||
"settings-help-zoom-200-en-light": "a6f49b4d0a171dc5f74d409c6e7df0e1c57bc52086b3a66dd794efde1236eef8",
|
||||
"settings-help-zoom-200-ru-dark": "0b1f995abec3b14cd5b25e0c0fb0a8d04d08567b0b3e5717463059efa244296d",
|
||||
"support-desktop-empty-light-en": "04d968e28c85096f6eb637f9ae01c2bae86c6d1540a9b3bfb83f0f4992127b4e",
|
||||
"support-desktop-preview-dark-en": "dd3502ce1584109a837ba80f5683ddd5a1ea2063150217121f6e0b8832a0a659",
|
||||
"support-phone-validation-light-ru": "37059c99431d6890d1f1a66444c7e853e51387655cd1ee6474063299281f2b1f",
|
||||
"support-phone-success-dark-en": "024e72048558561b66f13d635fc22e1e3d289d0b39499e6595dc986761daa31f",
|
||||
"support-relay-error-light-en": "ab769c512f94a1dea22f36f57a36796bc3b5bc3ec788f4ca52bfccbc7fc25ab0",
|
||||
"support-tablet-preview-dark-ru": "416604355b3785aecda02f1ad21d031ce4c977180ed8606937b6d189b0afb7b1",
|
||||
"device-ripple-color-popover-mobile-ru": "5b7ff2f212a1731d0d25f57a385fe594b32af8ba3ae0183c40fb285addf9f832",
|
||||
"space-room-color-popover-desktop-ru": "137deea402c850207bd6e217524359757ca13d9783c7728419e89260b81bcc75",
|
||||
"backup-full-preview-desktop-en": "fc3ac9ec9eae728f2524131f3f9e3a55fa4afce43f440ee6f7b04bd3b78e66df",
|
||||
"backup-plan-only-export-desktop-en": "b000db624416d90a8d9e658ec602d278f2bc8df63af2e8eb9562bc2fbff24ed8",
|
||||
"backup-space-preview-mobile-ru": "4575ba8783647518abbbe9903fd915ec0d3caceaa08a70fa70cf8bf8e7b08356",
|
||||
"optimize-preflight-dialog-dark-en": "0e140653af81cf8a965fee00d6f5ce7d0b49c22d329f5237e96e6ba1829252b4",
|
||||
"optimize-preflight-dialog-light-ru": "1fc545b7017c6ae3b0a2b0e1330b037dc0e565113c5892d48be64ca30f34abf6",
|
||||
"optimize-orphan-references-dark-en": "9611469c4d4e542b77abcc52c7c9484237aa18ff58994926be66514a377564f4",
|
||||
"optimize-orphan-references-light-ru": "7ea732b04dfd7aca25107935576326975b3cc7cf5757d844d809ceb4cef0a245",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -35,6 +35,7 @@ const wallUnionIsolationFixture = JSON.parse(readFileSync(
|
||||
const cardVersion = JSON.parse(readFileSync(
|
||||
new URL('../../package.json', import.meta.url), 'utf8',
|
||||
)).version;
|
||||
const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
? makeLargeHouseFixture()
|
||||
@@ -55,8 +56,18 @@ const themeVars = {
|
||||
|
||||
async function stableEnvironment(page, scenario) {
|
||||
await page.setViewportSize(scenario.viewport);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: scenario.theme });
|
||||
await page.evaluate(({ variables, theme }) => {
|
||||
await page.emulateMedia({
|
||||
reducedMotion: 'reduce', colorScheme: scenario.theme,
|
||||
forcedColors: scenario.forcedColors ? 'active' : 'none',
|
||||
});
|
||||
await page.evaluate(({
|
||||
variables, theme, attemptedTarget, attemptKey, disableIsoFilters,
|
||||
}) => {
|
||||
window.__hpGoldenNativeCssSupports ||= CSS.supports.bind(CSS);
|
||||
CSS.supports = disableIsoFilters
|
||||
? ((property, value) => property === 'filter'
|
||||
? false : window.__hpGoldenNativeCssSupports(property, value))
|
||||
: window.__hpGoldenNativeCssSupports;
|
||||
let style = document.getElementById('hp-golden-stability');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
@@ -82,7 +93,16 @@ async function stableEnvironment(page, scenario) {
|
||||
'--hp-golden-page-bg', theme === 'light' ? '#eef1f4' : '#11151b',
|
||||
);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
|
||||
sessionStorage.removeItem(attemptKey);
|
||||
if (attemptedTarget) sessionStorage.setItem(attemptKey, attemptedTarget);
|
||||
}, {
|
||||
variables: themeVars[scenario.theme] || themeVars.dark,
|
||||
theme: scenario.theme,
|
||||
attemptedTarget: scenario.versionRecoveryAttempted
|
||||
? String(scenario.integrationVersion || '').trim() : null,
|
||||
attemptKey: VERSION_RELOAD_ATTEMPT_KEY,
|
||||
disableIsoFilters: scenario.disableIsoFilters === true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
@@ -522,6 +542,43 @@ export function prepareGoldenFixture(scenario) {
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
return space;
|
||||
};
|
||||
if (scenario.stage3Fixture) {
|
||||
const contract = scenario.stage3Fixture;
|
||||
const space = requireSpace();
|
||||
if (contract.roomMetrics) {
|
||||
if (space.id !== 'golden-lighting' || space.rooms.length !== 2)
|
||||
throw new Error(`golden Stage 3 metrics require lighting fixture: ${space.id}`);
|
||||
space.settings = {
|
||||
...(space.settings || {}), show_names: true,
|
||||
label_temp: true, label_hum: true, label_lqi: true, label_light: true,
|
||||
};
|
||||
fixture.layout = {
|
||||
...(fixture.layout || {}),
|
||||
'rl_light-left': { s: space.id, x: 0.465, y: 0.30 },
|
||||
'rl_light-right': { s: space.id, x: 0.535, y: 0.68 },
|
||||
};
|
||||
}
|
||||
if (contract.newDevice) {
|
||||
if (!fixture.devices?.[contract.newDevice])
|
||||
throw new Error(`golden Stage 3 new marker is missing: ${contract.newDevice}`);
|
||||
fixture.config.settings = {
|
||||
...(fixture.config.settings || {}), new_device_ids: [contract.newDevice],
|
||||
};
|
||||
}
|
||||
if (contract.lockOpening) {
|
||||
const opening = (space.openings || []).find((item) => item.id === contract.lockOpening);
|
||||
if (!opening || !['door', 'gate'].includes(opening.type))
|
||||
throw new Error(`golden Stage 3 lock opening is missing: ${contract.lockOpening}`);
|
||||
if (!['locked', 'unlocked'].includes(contract.lockState))
|
||||
throw new Error(`golden Stage 3 lock state is invalid: ${contract.lockState}`);
|
||||
const entityId = `lock.golden_stage3_${contract.lockOpening.replaceAll('-', '_')}`;
|
||||
opening.lock = entityId;
|
||||
fixture.states[entityId] = {
|
||||
entity_id: entityId, state: contract.lockState,
|
||||
attributes: { friendly_name: `Golden Stage 3 ${contract.lockOpening} lock` },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (scenario.roomLabelParity) {
|
||||
const space = requireSpace();
|
||||
if (space.id !== 'golden-lighting' || space.rooms.length !== 2)
|
||||
@@ -568,7 +625,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
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))
|
||||
if (!['door', 'window', 'gate', 'passage'].includes(opening.type))
|
||||
throw new Error(`golden extraOpening has unknown type: ${opening.type}`);
|
||||
known.add(opening.id);
|
||||
}
|
||||
@@ -690,9 +747,11 @@ export function prepareGoldenFixture(scenario) {
|
||||
const deviceId = 'golden-vacuum-trail';
|
||||
const vacuumEntity = 'vacuum.golden_vacuum_trail';
|
||||
const sourceEntity = 'camera.golden_vacuum_trail_map';
|
||||
const vacuumArea = requireSpace().rooms?.find((room) => room.area)?.area;
|
||||
if (!vacuumArea) throw new Error(`golden vacuum trail target has no HA area: ${scenario.space}`);
|
||||
fixture.devices[deviceId] = {
|
||||
id: deviceId, name: 'Golden vacuum trail', model: 'GOLDEN-VACUUM-TRAIL',
|
||||
area_id: 'golden_geo_sw', identifiers: [['houseplan_golden', deviceId]],
|
||||
area_id: vacuumArea, identifiers: [['houseplan_golden', deviceId]],
|
||||
config_entries: ['golden_entry'], entry_type: null, via_device_id: null, disabled_by: null,
|
||||
};
|
||||
fixture.entities[vacuumEntity] = {
|
||||
@@ -717,7 +776,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
fixture.layout[deviceId] = { s: scenario.space, x: 0.12, y: 0.84 };
|
||||
fixture.config.markers = [...(fixture.config.markers || []), {
|
||||
id: deviceId, binding: `device:${deviceId}`, space: scenario.space,
|
||||
area: 'golden_geo_sw',
|
||||
area: vacuumArea,
|
||||
vacuum: {
|
||||
source: sourceEntity, trail_mode: 'always',
|
||||
calibration: { 'golden-trail': [1, 0, 0, 0, 1, 0] },
|
||||
@@ -736,7 +795,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await page.mouse.move(0, 0);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
|
||||
const result = await page.evaluate(async ({ fixture, scenario, cardVersion }) => {
|
||||
const result = await page.evaluate(async ({ fixture, scenario, cardVersion, attemptKey }) => {
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -756,14 +815,21 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
const ensureIsoRuntime = async (card) => {
|
||||
if (typeof card._ensureIsoSceneRuntime !== 'function'
|
||||
|| !(await card._ensureIsoSceneRuntime())) {
|
||||
throw new Error(`golden isometric runtime failed to load: ${scenario.id}`);
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
window.__goldenCard?.remove?.();
|
||||
window.__goldenEditor?.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));
|
||||
history.replaceState(null, '', scenario.alpha ? '?hp_alpha=1' : location.pathname);
|
||||
if (scenario.alpha) {
|
||||
localStorage.setItem('houseplan_card_alpha_v1', '1');
|
||||
}
|
||||
if (scenario.projection && scenario.space) {
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
|
||||
@@ -779,6 +845,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
};
|
||||
const hassFor = () => ({
|
||||
language: scenario.language || 'en', locale: { language: scenario.language || 'en' },
|
||||
...(scenario.stage3Golden
|
||||
? { themes: { darkMode: scenario.theme === 'dark' } }
|
||||
: {}),
|
||||
user: { id: 'golden', name: 'Golden fixture', is_admin: true },
|
||||
devices: fixture.devices || {}, entities: fixture.entities || {},
|
||||
areas: fixture.areas || {}, states: fixture.states || {},
|
||||
@@ -788,8 +857,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
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/config/get') return {
|
||||
config: structuredClone(fixture.config), rev: 1, can_write: true,
|
||||
...(Object.prototype.hasOwnProperty.call(scenario, 'integrationVersion')
|
||||
? { integration_version: scenario.integrationVersion } : {}),
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout || {}), rev: 1 };
|
||||
if (message.type === 'houseplan/trail/get') return scenario.vacuumTrail ? {
|
||||
@@ -818,21 +890,19 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig(cardConfig);
|
||||
host.replaceChildren(card);
|
||||
if (scenario.testOnlyLabsSnapshot) {
|
||||
if (!scenario.labs?.length || typeof card._onLabsSnapshot !== 'function') {
|
||||
throw new Error(`invalid test-only Labs contract: ${scenario.id}`);
|
||||
}
|
||||
// connectedCallback has already received the real (expired) registry
|
||||
// snapshot. Inject only the renderer fixture before hass/model boot so
|
||||
// fit and warm-remount follow the former live-Labs lifecycle exactly.
|
||||
card._onLabsSnapshot({ active: Object.freeze([...scenario.labs]), space: '' });
|
||||
}
|
||||
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);
|
||||
if (scenario.alpha
|
||||
&& (window.__hpAlpha !== true || JSON.stringify(window.__hpLabs) !== '["iso"]')) {
|
||||
throw new Error(`alpha contract did not activate isometric capability: ${scenario.id}`);
|
||||
}
|
||||
if (scenario.projection === 'iso' || scenario.stage3Golden) {
|
||||
await ensureIsoRuntime(card);
|
||||
}
|
||||
// Golden scenarios intentionally call internal editor commands directly.
|
||||
// Preload the lazy runtime for that legacy harness contract; cold-View
|
||||
// loading and retry semantics are covered by smoke_lazy_editor_chunk.
|
||||
@@ -859,6 +929,21 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(scenario, 'integrationVersion')) {
|
||||
const target = String(scenario.integrationVersion || '').trim();
|
||||
await until(() => card._versionRecovery?.relation?.kind === 'mismatch'
|
||||
&& card.renderRoot.querySelector('.version-recovery'));
|
||||
const notice = card._versionRecovery.banner;
|
||||
const rendered = card.renderRoot.querySelector('.version-recovery');
|
||||
if (!target || notice?.backend !== target
|
||||
|| rendered?.getAttribute('data-version-recovery-target') !== target) {
|
||||
throw new Error(`golden version notice target mismatch: ${scenario.id}`);
|
||||
}
|
||||
if (scenario.versionRecoveryAttempted
|
||||
&& sessionStorage.getItem(attemptKey) !== target) {
|
||||
throw new Error(`golden kiosk attempted target missing: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
// #304: the two existing hidden-wall golden scenes also guard the static
|
||||
// architectural overlay in every Plan tool. Finish in draw so the reviewed
|
||||
// pixels stay stable; a missing/changed tool projection fails before capture.
|
||||
@@ -1064,9 +1149,114 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
}
|
||||
if (scenario.projection === 'iso' && typeof card._setProjection === 'function') {
|
||||
card._setProjection('iso');
|
||||
await ensureIsoRuntime(card);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (card._effectiveProjection?.() !== 'iso') {
|
||||
throw new Error(`isometric golden rendered Flat: ${scenario.id}`);
|
||||
}
|
||||
await until(() => card._renderProjection === 'iso');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const structuralBuilds = Number(stage?.getAttribute('data-hp-iso-structural-builds'));
|
||||
if (!scenario.stage3Golden?.noBorders
|
||||
&& (stage?.getAttribute('data-hp-iso-stage') !== '3'
|
||||
|| !Number.isFinite(structuralBuilds) || structuralBuilds < 1)) {
|
||||
throw new Error(`isometric golden did not render the Stage 3 contract: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.stage3Golden) {
|
||||
const contract = scenario.stage3Golden;
|
||||
const expectedDark = scenario.theme === 'dark';
|
||||
const renderedStage = card.renderRoot.querySelector('.stage');
|
||||
if (card.hass?.themes?.darkMode !== expectedDark
|
||||
|| !renderedStage?.classList.contains(expectedDark ? 'theme-dark' : 'theme-light')) {
|
||||
throw new Error(`Stage 3 golden did not use HA darkMode: ${scenario.id}`);
|
||||
}
|
||||
const raisedRoots = [...card.renderRoot.querySelectorAll(
|
||||
'[data-hp-iso-overlay-kind][data-hp-iso-floor][data-hp-iso-visual]',
|
||||
)];
|
||||
const raisedKinds = new Set(raisedRoots.map((root) =>
|
||||
root.getAttribute('data-hp-iso-overlay-kind')).filter(Boolean));
|
||||
const raisedSvg = card.renderRoot.querySelector('[data-hp="iso-raised-overlays"]');
|
||||
if (contract.noBorders) {
|
||||
if (raisedRoots.length || raisedSvg
|
||||
|| card.renderRoot.querySelector('[data-hp-iso-raised="true"]')) {
|
||||
throw new Error(`Stage 3 no-borders golden retained raised overlays: ${scenario.id}`);
|
||||
}
|
||||
} else {
|
||||
const missing = (contract.requiredKinds || []).filter((kind) => !raisedKinds.has(kind));
|
||||
if (missing.length) {
|
||||
throw new Error(`Stage 3 golden is missing raised kinds ${missing.join(',')}: ${scenario.id}`);
|
||||
}
|
||||
if (!raisedRoots.length
|
||||
|| raisedRoots.some((root) => root.getAttribute('data-hp-iso-raised') !== 'true')) {
|
||||
throw new Error(`Stage 3 golden left an interactive overlay on the floor: ${scenario.id}`);
|
||||
}
|
||||
if (contract.requireNudged
|
||||
&& !raisedRoots.some((root) => root.getAttribute('data-hp-iso-nudged') === 'true')) {
|
||||
throw new Error(`Stage 3 golden fixture produced no bounded nudge: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
const materialDefs = card.renderRoot.querySelectorAll('[data-hp-iso-material-def]').length;
|
||||
if (contract.requireMaterialDefs
|
||||
&& (!(materialDefs > 0) || materialDefs > contract.maxMaterialDefs)) {
|
||||
throw new Error(`Stage 3 golden material definitions ${materialDefs} are outside 1..${contract.maxMaterialDefs}: ${scenario.id}`);
|
||||
}
|
||||
if (contract.requireNoMaterialDefs && materialDefs !== 0) {
|
||||
throw new Error(`Stage 3 solid fallback retained material definitions: ${scenario.id}`);
|
||||
}
|
||||
if (contract.requireTetherCues) {
|
||||
const tethers = [...card.renderRoot.querySelectorAll('.iso-overlay-tether')];
|
||||
const paintedFootprint = card.renderRoot.querySelector(
|
||||
'.iso-overlay-plate, .iso-overlay-plate-texture, #hp-iso-overlay-texture',
|
||||
);
|
||||
if (paintedFootprint || !tethers.length) {
|
||||
throw new Error(`Stage 3 golden lost invisible-footprint/tether contract: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (contract.requireGrounding
|
||||
&& card.renderRoot.querySelectorAll('.iso-overlay-ground').length < raisedRoots.length) {
|
||||
throw new Error(`Stage 3 golden lost persistent grounding cues: ${scenario.id}`);
|
||||
}
|
||||
if (contract.requireDenseFacets) {
|
||||
const facets = {
|
||||
value: card.renderRoot.querySelectorAll('.dev .value-badge').length,
|
||||
lqi: card.renderRoot.querySelectorAll('.dev .lqi').length,
|
||||
new: card.renderRoot.querySelectorAll('.dev .newdot').length,
|
||||
pulse: card.renderRoot.querySelectorAll('.dev .device-pulse, .dev .activity-dot').length,
|
||||
roomMetrics: card.renderRoot.querySelectorAll('.roomlabel .rlmetrics').length,
|
||||
};
|
||||
const missing = Object.entries(facets).filter(([, count]) => !(count > 0));
|
||||
if (missing.length) {
|
||||
throw new Error(`Stage 3 dense golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (contract.openingKinds) {
|
||||
const renderedKinds = new Set([...card.renderRoot.querySelectorAll(
|
||||
'[data-hp="iso-openings"] .iso-opening-panel[data-kind]',
|
||||
)].map((node) => node.getAttribute('data-kind')).filter(Boolean));
|
||||
const missing = contract.openingKinds.filter((kind) => !renderedKinds.has(kind));
|
||||
if (missing.length) {
|
||||
throw new Error(`Stage 3 opening golden is missing ${missing.join(',')}: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (contract.requirePassage) {
|
||||
if (!card._openingsR?.some((opening) => opening.type === 'passage')
|
||||
|| card.renderRoot.querySelector(
|
||||
'[data-hp="iso-openings"] .iso-opening-panel[data-kind="passage"]',
|
||||
)) {
|
||||
throw new Error(`Stage 3 passage golden violated negative-volume contract: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (contract.requireVacuumFloor) {
|
||||
await until(() => !!card.renderRoot.querySelector('.vactrail'));
|
||||
const vacuumRoots = [...card.renderRoot.querySelectorAll('.vacpuck, .vactrail')];
|
||||
if (!vacuumRoots.length || vacuumRoots.some((root) =>
|
||||
root.getAttribute('data-hp-iso-raised') === 'true'
|
||||
|| root.hasAttribute('data-hp-iso-overlay-kind'))) {
|
||||
throw new Error(`Stage 3 golden raised a floor-bound vacuum: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
const [zx, zy] = Array.isArray(scenario.zoomCenter) ? scenario.zoomCenter : [500, 500];
|
||||
@@ -1784,6 +1974,20 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
throw new Error(`golden vacuum trail contract is incomplete: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.stage3Golden?.requireLiveLayers) {
|
||||
const live = {
|
||||
glow: card.renderRoot.querySelectorAll('.glowlayer [data-glow-source]').length,
|
||||
sun: card.renderRoot.querySelectorAll('.sunlayer').length,
|
||||
fill: card.renderRoot.querySelectorAll('[data-hp="room"].filled').length,
|
||||
hover: card.renderRoot.querySelectorAll('[data-hp-live-room-hover]').length,
|
||||
decor: card.renderRoot.querySelectorAll('.decorlayer > *').length,
|
||||
vacuum: card.renderRoot.querySelectorAll('.vactrail, .vacpuck').length,
|
||||
};
|
||||
const missing = Object.entries(live).filter(([, count]) => !(count > 0));
|
||||
if (missing.length) {
|
||||
throw new Error(`Stage 3 combined golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
await document.fonts?.ready;
|
||||
// Camera motion is intentionally visible in production, but reviewed
|
||||
// goldens own the settled UI. Never capture a timing-dependent RAF frame.
|
||||
@@ -1809,7 +2013,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
cachedRays: card._sunRaysCache?.rays?.length || 0,
|
||||
} } : {}),
|
||||
};
|
||||
}, { fixture, scenario, cardVersion });
|
||||
}, { fixture, scenario, cardVersion, attemptKey: VERSION_RELOAD_ATTEMPT_KEY });
|
||||
if (scenario.tabDrag) {
|
||||
const drag = await page.evaluate((placement) => {
|
||||
const card = window.__goldenCard;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
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 = 54;
|
||||
export const GOLDEN_MATRIX_VERSION = 56;
|
||||
|
||||
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 } };
|
||||
// The product flag expires at 1.65.0 by contract. Golden keeps the dormant
|
||||
// renderer covered through an explicit card-only snapshot; this never changes
|
||||
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
|
||||
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
|
||||
|
||||
const openingRoomContract = {
|
||||
kind: 'room', wallCm: 30, surface: 'flat',
|
||||
openings: [
|
||||
@@ -68,6 +63,50 @@ const openingIsoContract = {
|
||||
],
|
||||
};
|
||||
|
||||
const stage3DenseMarkers = [
|
||||
{ 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' },
|
||||
{ id: 'golden-presence', binding: 'device:golden-presence', display: 'icon_ripple' },
|
||||
{ id: 'golden-climate', binding: 'device:golden-climate', value_badge: {
|
||||
enabled: true,
|
||||
source: {
|
||||
kind: 'entity_attribute', entity_id: 'climate.golden_climate',
|
||||
attribute: 'current_temperature',
|
||||
},
|
||||
position: 'top',
|
||||
} },
|
||||
{ id: 'golden-left-linkquality', binding: 'device:golden-left-linkquality', value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.golden_left_linkquality' },
|
||||
position: 'bottom',
|
||||
} },
|
||||
{ id: 'golden-right-linkquality', binding: 'device:golden-right-linkquality' },
|
||||
];
|
||||
|
||||
const stage3DenseLayout = {
|
||||
'golden-light-one': { s: 'golden-lighting', x: 0.487, y: 0.22 },
|
||||
'golden-light-two': { s: 'golden-lighting', x: 0.43, y: 0.76 },
|
||||
'golden-presence': { s: 'golden-lighting', x: 0.513, y: 0.22 },
|
||||
'golden-climate': { s: 'golden-lighting', x: 0.56, y: 0.64 },
|
||||
'golden-left-linkquality': { s: 'golden-lighting', x: 0.487, y: 0.43 },
|
||||
'golden-right-linkquality': { s: 'golden-lighting', x: 0.513, y: 0.76 },
|
||||
};
|
||||
|
||||
const stage3VacuumTrail = {
|
||||
maxDeviationCm: 17.5,
|
||||
current: [
|
||||
[[180, 220], [360, 220], [360, 420], [520, 420]],
|
||||
[[650, 240], [780, 240], [780, 500], [650, 620], [850, 760]],
|
||||
],
|
||||
previous: [[160, 760], [300, 650], [420, 740], [560, 600]],
|
||||
};
|
||||
|
||||
const stage3RequiredOverlays = ['device', 'room-label', 'opening-lock'];
|
||||
|
||||
/** Exact existing baseline impact measured against origin/dev with the same
|
||||
* Chromium build. Keep this list reviewable: shared visual fixtures make an
|
||||
* opening-only change visible in editor/dialog captures too. */
|
||||
@@ -165,6 +204,19 @@ const decorLayerProbes = {
|
||||
};
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
// #462 AC12: the card-level version recovery notice must stay compact,
|
||||
// readable and outside layout/fit flow on desktop, narrow touch and the
|
||||
// manual-only kiosk state after this tab already attempted the target.
|
||||
{ id: 'version-mismatch-desktop-dark-en', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', integrationVersion: '0.0.0-golden-backend',
|
||||
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'version-mismatch-touch-light-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', integrationVersion: '0.0.0-golden-backend', touchViewport: true,
|
||||
language: 'ru', theme: 'light', viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'version-mismatch-kiosk-attempted-dark-de', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', integrationVersion: '0.0.0-golden-backend',
|
||||
versionRecoveryAttempted: true, kiosk: true,
|
||||
language: 'de', theme: 'dark', viewport: { width: 820, height: 760 }, ...page },
|
||||
{ id: 'hidden-wall-diagnostics-plan-light', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'before',
|
||||
hiddenWallDiagnostics: true, mode: 'plan', theme: 'light',
|
||||
@@ -200,24 +252,105 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
cornerSplitWall: 'zero-taper', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, 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', ...expiredIsoFixture,
|
||||
alpha: true, 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', ...expiredIsoFixture,
|
||||
alpha: true, 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', ...expiredIsoFixture, showBorders: false, fillMode: 'custom',
|
||||
alpha: true, projection: 'iso', showBorders: false, fillMode: 'custom',
|
||||
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
stage3Golden: { noBorders: 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', ...expiredIsoFixture, kiosk: true,
|
||||
alpha: true, 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', ...expiredIsoFixture, warmRemount: true,
|
||||
alpha: true, projection: 'iso', warmRemount: true,
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
...['light', 'dark'].map((theme) => ({
|
||||
id: `isometric-stage3-overlays-${theme}`, fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', alpha: true, projection: 'iso',
|
||||
stage3Fixture: {
|
||||
roomMetrics: true, lockOpening: 'light-door', lockState: 'locked',
|
||||
newDevice: 'golden-presence',
|
||||
},
|
||||
stage3Golden: {
|
||||
requiredKinds: stage3RequiredOverlays, requireNudged: true,
|
||||
requireMaterialDefs: true, maxMaterialDefs: 12,
|
||||
requireVacuumFloor: theme === 'dark',
|
||||
requireDenseFacets: true,
|
||||
requireTetherCues: true,
|
||||
requireGrounding: true,
|
||||
requireLiveLayers: theme === 'dark',
|
||||
},
|
||||
markerOverrides: stage3DenseMarkers,
|
||||
markerAreaSnapshot: {
|
||||
'golden-light-one': { binding: 'device:golden-light-one', area: 'golden_light_left' },
|
||||
'golden-light-two': { binding: 'device:golden-light-two', area: 'golden_light_left' },
|
||||
'golden-presence': { binding: 'device:golden-presence', area: 'golden_light_right' },
|
||||
'golden-climate': { binding: 'device:golden-climate', area: 'golden_light_right' },
|
||||
'golden-left-linkquality': {
|
||||
binding: 'device:golden-left-linkquality', area: 'golden_light_left',
|
||||
},
|
||||
'golden-right-linkquality': {
|
||||
binding: 'device:golden-right-linkquality', area: 'golden_light_right',
|
||||
},
|
||||
},
|
||||
stateOverrides: {
|
||||
'light.golden_light_one': { attributes: { lqi: 40 } },
|
||||
'light.golden_light_two': { attributes: { lqi: 120 } },
|
||||
'sensor.golden_right_linkquality': { state: '210' },
|
||||
...(theme === 'dark' ? {
|
||||
'sun.sun': { attributes: { azimuth: 270, elevation: 24 } },
|
||||
} : {}),
|
||||
},
|
||||
layoutOverrides: stage3DenseLayout,
|
||||
...(theme === 'dark' ? {
|
||||
vacuumTrail: stage3VacuumTrail,
|
||||
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 },
|
||||
hoverRoom: 'light-left', decorOverride: decorLayerFixture,
|
||||
glowEnabled: true, sunRays: true, northDeg: 90,
|
||||
} : {}),
|
||||
theme, viewport: { width: 1000, height: 900 }, ...stage,
|
||||
})),
|
||||
{ id: 'isometric-stage3-openings-dark', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', alpha: true, projection: 'iso',
|
||||
showNames: false, glowEnabled: false, sunRays: false,
|
||||
stage3Fixture: { lockOpening: 'light-door', lockState: 'unlocked' },
|
||||
extraOpenings: [{
|
||||
id: 'stage3-passage', type: 'passage', x: 0.50, y: 0.30,
|
||||
angle: 90, length: 0.12,
|
||||
}],
|
||||
stage3Golden: {
|
||||
requiredKinds: ['device', 'opening-lock'],
|
||||
requireMaterialDefs: true, maxMaterialDefs: 12,
|
||||
openingKinds: ['door', 'window', 'gate'], requirePassage: true,
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-stage3-forced-colors-dark', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', alpha: true, projection: 'iso',
|
||||
forcedColors: true,
|
||||
stage3Fixture: { roomMetrics: true, lockOpening: 'light-door', lockState: 'locked' },
|
||||
stage3Golden: {
|
||||
requiredKinds: stage3RequiredOverlays, requireNudged: true,
|
||||
requireNoMaterialDefs: true, requireTetherCues: true,
|
||||
},
|
||||
layoutOverrides: stage3DenseLayout,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-stage3-no-filter-dark', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', alpha: true, projection: 'iso',
|
||||
disableIsoFilters: true,
|
||||
stage3Fixture: { roomMetrics: true, lockOpening: 'light-door', lockState: 'locked' },
|
||||
stage3Golden: {
|
||||
requiredKinds: stage3RequiredOverlays, requireNudged: true,
|
||||
requireNoMaterialDefs: true, requireTetherCues: true,
|
||||
},
|
||||
layoutOverrides: stage3DenseLayout,
|
||||
theme: 'dark', viewport: { width: 1000, 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',
|
||||
@@ -348,7 +481,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
@@ -375,7 +508,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
mode: 'view', openingSymbolContract: openingFlipContract,
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-opening-symbol-parity-dark', fixture: 'visual', space: 'golden-opening-symbols',
|
||||
mode: 'view', labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
mode: 'view', alpha: true, projection: 'iso',
|
||||
openingSymbolContract: openingIsoContract,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import {
|
||||
captureEnvironment, foreignCaptureAllowance, foreignCaptureRefusal,
|
||||
} from '../../scripts/capture-environment.mjs';
|
||||
|
||||
// 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 = '') => {
|
||||
/**
|
||||
* Проверка вызова — и заодно проверка среды съёмки (#455).
|
||||
*
|
||||
* Почему здесь, а не в `run.mjs`: `run.mjs` входит в корпус
|
||||
* `sourceFingerprint`, и его правка объявляет устаревшими закоммиченный бандл,
|
||||
* манифест скриншотов документации и индекс эталонов. Этот файл из корпуса
|
||||
* исключён именно как «предикаты, вызываемые до и после съёмки», и уже
|
||||
* вызывается первой строкой `run.mjs` — гейт встаёт без единой правки
|
||||
* фингерпринтуемого файла.
|
||||
*
|
||||
* Платформа — ПАРАМЕТР, а не `process.platform` внутри: иначе юнит стал бы
|
||||
* зелёным на Linux и красным на машине владельца, то есть тестом про хост, а
|
||||
* не про правило.
|
||||
*/
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '', options = {}) => {
|
||||
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');
|
||||
// Диагностический прогон verify в чужой среде законен: он ничего не принимает.
|
||||
if (mode !== 'capture') return;
|
||||
const platform = options.platform ?? captureEnvironment().platform;
|
||||
const allowance = options.allowance ?? foreignCaptureAllowance();
|
||||
const { refusal, allowance: accepted } = foreignCaptureRefusal({
|
||||
platform, kind: 'golden', stage: 'capture', allowance,
|
||||
});
|
||||
if (refusal) throw new Error(refusal);
|
||||
if (accepted) console.log(`Чужая среда съёмки разрешена осознанно: ${accepted}`);
|
||||
};
|
||||
|
||||
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Install one browser-side preload helper for demo harnesses that opt into Iso.
|
||||
*
|
||||
* Current builds keep the isometric renderer in a lazy, fingerprint-checked
|
||||
* runtime. Older comparison bundles are monolithic and expose no preload
|
||||
* method, so absence remains a compatible no-op. A current runtime, however,
|
||||
* must load successfully and settle before a smoke inspects Iso DOM/geometry.
|
||||
*/
|
||||
export async function installHarnessIsoRuntimeHelper(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__hpEnsureHarnessIsoRuntime = async (card = window.__card) => {
|
||||
if (!card) throw new Error('isometric smoke has no card to preload');
|
||||
const ensure = card._ensureIsoSceneRuntime;
|
||||
if (typeof ensure !== 'function') return true;
|
||||
if (!await ensure.call(card)) {
|
||||
throw new Error('isometric smoke runtime did not load');
|
||||
}
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
return true;
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -4,15 +4,30 @@
|
||||
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. Because the
|
||||
product flag expires at 1.65.0, the runner injects an explicit test-only Labs
|
||||
snapshot, 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
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. The runner
|
||||
enables the current candidate through the real `hp_alpha=1` URL/storage
|
||||
contract, selects iso per fixture space, measures the View toggle and records
|
||||
the capped `isoGeometry` cache. A compatibility-only snapshot keeps comparison
|
||||
bundles from before #448 measurable without restoring a legacy user-facing
|
||||
activation path; a comparison SHA predating #89 remains flat while reporting
|
||||
the same profile. The dedicated
|
||||
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
|
||||
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
|
||||
evidence; a local report is diagnostic.
|
||||
|
||||
Issue #160 keeps that historical profile and budget unchanged, then adds the
|
||||
separate `isometric-stage3-dense-v1` profile. Its derived performance-only
|
||||
fixture puts all 200 devices close to walls or corners and adds deterministic
|
||||
value, LQI and new-device badges plus contact/lock-bound door, window and gate
|
||||
examples. The base invocation explicitly allows a Stage 2 renderer so an older
|
||||
`dev` SHA remains measurable; the candidate invocation fails closed unless it
|
||||
reports effective Iso, the Stage 3 revision, all required raised overlay kinds,
|
||||
at least one real nudge, a bounded shared material-definition set and zero
|
||||
structural rebuilds during HA, opening and hover/focus updates. The two extra
|
||||
timing windows use limits no softer than the historical HA-update budget. This
|
||||
does not change the fixture, measured windows or budget of
|
||||
`large-house-isometric-v1`, which remains the #124 regression witness.
|
||||
|
||||
Issue #137 adds `large-house-plan-snap-v1` without changing the meaning or
|
||||
budgets of the original profile. The same 60-room/60-partition fixture gains
|
||||
six saved open outlines, renders the Plan snap overlay, and sends 120 real
|
||||
@@ -23,6 +38,24 @@ websocket traffic change. Its dedicated budget retains every original timing,
|
||||
heap and cache ceiling and adds the measured pointer series plus a one-entry
|
||||
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
|
||||
|
||||
Issue #451 adds `large-house-interaction-v1`. It dispatches 120 real
|
||||
device/room/free-background hover moves, four 20-move pans, pinch/wheel camera input, three editor
|
||||
drag series and 30 unrelated HA snapshots. It also injects relevant snapshots
|
||||
inside and outside a gesture and counts heavy `_renderBody()` passes plus full
|
||||
marker-binding diagnostic scans. Current bundles fail inside the runner unless
|
||||
hover, editor moves and unrelated ticks perform zero full renders, a relevant
|
||||
tick is deferred during pan, each terminal event reconciles at most one full
|
||||
frame, the heavy wall DOM remains identical, and the projected HTML device
|
||||
layer stays within one CSS pixel of its SVG scene position. Older comparison
|
||||
bundles still produce timing baselines; structural assertions become mandatory
|
||||
only when the target source contains the live viewport implementation. Its
|
||||
dedicated budget preserves every existing large-house ceiling and adds the
|
||||
individual interaction timing gates.
|
||||
|
||||
Structural assertions are evaluated independently of timing budgets: a fast
|
||||
run still fails when an interaction performs an unexpected full render or
|
||||
changes the heavy scene DOM.
|
||||
|
||||
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
|
||||
@@ -49,6 +82,16 @@ 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.
|
||||
|
||||
Two more profiles join the smoke only when the diff touches their code path
|
||||
(#473, classified by `scripts/classify-changes.mjs`): `large-house-isometric-v1`
|
||||
for `src/iso-*` and `large-house-interaction-v1` for `src/live-*`,
|
||||
`src/render-*`, `houseplan-render-lifecycle.ts` and `houseplan-card.ts`. Both
|
||||
run `--samples=3 --warmups=1` against `budgets-isometric-smoke.json` and
|
||||
`budgets-interaction-smoke.json`, whose ceilings are the `hardMaxMs` values of
|
||||
the full profiles. The set of profiles is part of the `performance_smoke`
|
||||
reuse key, so a glow-only success never stands in for a run that needed the
|
||||
isometric profile.
|
||||
|
||||
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,
|
||||
@@ -119,11 +162,35 @@ it would make the same runner incompatible with its base bundle.
|
||||
|
||||
## Local diagnostics
|
||||
|
||||
### Wall draw terminal clicks
|
||||
|
||||
`wall-draw-click-v1` (#461) loads the production bundle with five spaces. The
|
||||
edited space contains 12 rooms, 48 positive-thickness wall atoms and four saved
|
||||
open drafts, then places seven consecutive Walls segments after warm-up. A
|
||||
second variant doubles remote, non-interacting room geometry. The runner fails
|
||||
structurally unless every intermediate click performs zero full-space physical
|
||||
checks, exactly one local physical check, reuses its wall artifact in the
|
||||
junction proof, adds one history entry and queues one full-config write. The
|
||||
chain finish must independently return to the full-space barrier.
|
||||
|
||||
The canonical Linux ceilings are 150 ms median and 250 ms maximum for the seven
|
||||
base clicks; the remote median may not exceed `base × 1.5 + 20 ms`. Counters are
|
||||
the primary verdict, so a fast machine cannot hide a route back through the
|
||||
generic barrier. Run against a freshly synchronized bundle:
|
||||
|
||||
```bash
|
||||
npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
|
||||
node demo/smoke_wall_draw_click.mjs
|
||||
```
|
||||
|
||||
Build and copy a fresh demo bundle first, then run:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
|
||||
npm run benchmark:isometric-stage3-dense -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-stage3-local.json
|
||||
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
|
||||
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json
|
||||
```
|
||||
|
||||
A local report is diagnostic only; it cannot replace the CI comparison.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-interaction-v1",
|
||||
"minimumSamples": 3,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"interactionSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"hoverSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"panSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"cameraSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"editorSeriesMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 750
|
||||
},
|
||||
"irrelevantHaTicksMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"relevantHaTickMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 2000
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 7000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxCountP95": 30,
|
||||
"maxTotalP95Ms": 12000
|
||||
},
|
||||
"longTaskWindows": {
|
||||
"hoverSeries": {
|
||||
"maxSingleMs": 150,
|
||||
"maxCountP95": 3,
|
||||
"maxTotalP95Ms": 300
|
||||
},
|
||||
"panSeries": {
|
||||
"maxSingleMs": 150,
|
||||
"maxCountP95": 3,
|
||||
"maxTotalP95Ms": 300
|
||||
},
|
||||
"cameraSeries": {
|
||||
"maxSingleMs": 150,
|
||||
"maxCountP95": 3,
|
||||
"maxTotalP95Ms": 300
|
||||
},
|
||||
"editorSeries": {
|
||||
"maxSingleMs": 150,
|
||||
"maxCountP95": 3,
|
||||
"maxTotalP95Ms": 300
|
||||
}
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1,
|
||||
"wallFaceGraph": 4
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0,
|
||||
"wallFaceGraph": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-isometric-v1",
|
||||
"minimumSamples": 3,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3500
|
||||
},
|
||||
"viewToggleMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1800
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 2200
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 600
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 1200
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 8000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxCountP95": 30,
|
||||
"maxTotalP95Ms": 12000
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 100,
|
||||
"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,52 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "isometric-stage3-dense-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 },
|
||||
"openingUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"overlayInteractionMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"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": 100,
|
||||
"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,139 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-interaction-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 200,
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"interactionSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"hoverSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"panSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"cameraSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"editorSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 750
|
||||
},
|
||||
"irrelevantHaTicksMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 40,
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"relevantHaTickMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 40,
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"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
|
||||
},
|
||||
"longTaskWindows": {
|
||||
"hoverSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"panSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"cameraSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"editorSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 }
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.75,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1,
|
||||
"wallFaceGraph": 4
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0,
|
||||
"wallFaceGraph": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -17,33 +17,55 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
label: 'large-house-v1',
|
||||
methods: Object.freeze([
|
||||
'_baseVb',
|
||||
'_bindingStatus',
|
||||
'_cancelDecorGesture',
|
||||
'_checkSpacePhysicalGeometry',
|
||||
'_decorBoxOf',
|
||||
'_dtMeasure',
|
||||
'_dtMove',
|
||||
'_dtStart',
|
||||
'_openSettingsDialog',
|
||||
'_pickSpace',
|
||||
'_pos',
|
||||
'_renderBody',
|
||||
'_rszCancelDrag',
|
||||
'_rszEdgeDown',
|
||||
'_rszMove',
|
||||
'_rszRooms',
|
||||
'_setMode',
|
||||
'_scenePoint',
|
||||
'_viewOr',
|
||||
]),
|
||||
fields: Object.freeze([
|
||||
'_booting',
|
||||
'_bootSoft',
|
||||
'_cameraTransition',
|
||||
...CACHE_FIELDS,
|
||||
'_continuity',
|
||||
'_cursorPt',
|
||||
'_decorList',
|
||||
'_decorSel',
|
||||
'_decorTool',
|
||||
'_devices',
|
||||
'_gridPitch',
|
||||
'_hassSequence',
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_modeTransitionBusy',
|
||||
'_path',
|
||||
'_serverCfg',
|
||||
'_space',
|
||||
'_settingsDialog',
|
||||
'_tool',
|
||||
]),
|
||||
// A comparison SHA before #89 is intentionally flat; the isometric runner
|
||||
// checks these members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze([
|
||||
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
|
||||
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_wallFaceBatch', '_wallFaceGraphCache',
|
||||
'_activeDraftId', '_draftSegmentCms', '_effectiveProjection', '_ensureIsoSceneRuntime',
|
||||
'_isoGeometryCache', '_isoStructuralBuildCount', '_offerWallFaces',
|
||||
'_liveEditorPaintCount', '_onLabsSnapshot',
|
||||
'_planSnapGeometryCache', '_roomDialog', '_setProjection', '_wallFaceBatch',
|
||||
'_wallFaceGraphCache',
|
||||
]),
|
||||
// #380: v1.68.1 owns the same resize session directly on the card; newer
|
||||
// bundles moved it into ResizeController. A comparison target must expose
|
||||
@@ -54,22 +76,33 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
]),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_bootSoft: 'boolean',
|
||||
_cameraTransition: 'object',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_continuity: 'object',
|
||||
_decorList: 'array',
|
||||
_decorTool: 'string',
|
||||
_draftSegmentCms: 'array',
|
||||
_effectiveProjection: 'function',
|
||||
_ensureIsoSceneRuntime: 'function',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_hassSequence: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_liveEditorPaintCount: 'number',
|
||||
_model: 'array',
|
||||
_onLabsSnapshot: 'function',
|
||||
_offerWallFaces: 'function',
|
||||
_path: 'array',
|
||||
_isoGeometryCache: 'map',
|
||||
_isoStructuralBuildCount: 'number',
|
||||
_planSnapGeometryCache: 'object',
|
||||
_roomDialog: 'boolean',
|
||||
_resize: 'object',
|
||||
_setProjection: 'function',
|
||||
_serverCfg: 'object',
|
||||
_space: 'string',
|
||||
_tool: 'string',
|
||||
_wallFaceGraphCache: 'array',
|
||||
}),
|
||||
|
||||