Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
effb36295d | ||
|
|
7c4cb187fe | ||
|
|
5a7c98264a | ||
|
|
a0f0a52410 | ||
|
|
3c40580396 | ||
|
|
e30c73d5c7 | ||
|
|
081cdbc6f3 | ||
|
|
04e3f5ee43 | ||
|
|
317df72fc8 | ||
|
|
a6d0c0b980 | ||
|
|
eb5e15fb6f | ||
|
|
f6879b3788 | ||
|
|
560ca214b0 | ||
|
|
4c485ed647 | ||
|
|
582066d017 | ||
|
|
3a88f3ad6c | ||
|
|
215d59823b | ||
|
|
4bd30423f2 | ||
|
|
f6cd99cef4 | ||
|
|
2695307ef0 | ||
|
|
b7b805f2af | ||
|
|
1e5f33aa64 | ||
|
|
cebd20a9b5 | ||
|
|
83201da15d | ||
|
|
0d8cfffb92 | ||
|
|
32417d3ae7 | ||
|
|
a488e89889 | ||
|
|
a33203e7a1 | ||
|
|
75478905d5 | ||
|
|
0ea9c0f576 | ||
|
|
fa41d2981c | ||
|
|
79e922203a | ||
|
|
ecf2cc9222 | ||
|
|
53f91d974a | ||
|
|
49f69f60db | ||
|
|
4b8cf6e04d | ||
|
|
fe18f80fc2 | ||
|
|
8e2892b95f | ||
|
|
4032b810b7 | ||
|
|
c9c22916be | ||
|
|
26b0b58320 | ||
|
|
9899d628ac | ||
|
|
9809ec7a04 | ||
|
|
51b4cc7e2c | ||
|
|
567b456890 | ||
|
|
ca6ebc3035 | ||
|
|
fec99b907b | ||
|
|
c38501ef50 | ||
|
|
9126e5b430 | ||
|
|
a48bde0b1d | ||
|
|
4c56b389ca | ||
|
|
dd2b5d743c | ||
|
|
086b3682e9 | ||
|
|
3fafbb392d | ||
|
|
5409f0b2e3 | ||
|
|
c958e2431f | ||
|
|
53847e3997 | ||
|
|
0f513ac88b | ||
|
|
735710f16b | ||
|
|
173707d82d | ||
|
|
cc546ea66d | ||
|
|
d5ad0371aa | ||
|
|
5f90eaf4ef | ||
|
|
d4dd027b0a |
@@ -37,6 +37,7 @@ jobs:
|
||||
- large-house
|
||||
- isometric
|
||||
- plan-snap
|
||||
- interaction
|
||||
- blend
|
||||
- overlay
|
||||
- space-default
|
||||
@@ -190,6 +191,10 @@ jobs:
|
||||
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
|
||||
@@ -235,6 +240,9 @@ jobs:
|
||||
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, а не только в логе прогона.
|
||||
|
||||
@@ -489,6 +489,7 @@ jobs:
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||||
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
|
||||
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
|
||||
@@ -556,10 +557,14 @@ jobs:
|
||||
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
|
||||
ran=$((ran + 1))
|
||||
name=$(basename "$f" .mjs)
|
||||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
if timeout --kill-after=10s 180s node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
echo "ok $name"
|
||||
else
|
||||
status=$?
|
||||
echo "FAIL $name"
|
||||
if [ "$status" -eq 124 ]; then
|
||||
echo "diagnostic smoke-timeout: $name exceeded 180s"
|
||||
fi
|
||||
# Диагностические строки смока печатаются раньше вердикта, и
|
||||
# `tail -20` их срезал: на #411 сам смок сообщал, НАСКОЛЬКО
|
||||
# разошлись кадры (введено в #302), а в логе прогона осталось
|
||||
@@ -756,6 +761,8 @@ jobs:
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -193,6 +193,35 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **Защитный AC доказывается таблицей «чем краснеет» (#435).** Для каждого AC,
|
||||
заявляющего защиту — валидация, гард, лимит, отказ, инвариант, — в документе
|
||||
ревью обязательна строка из трёх столбцов: **AC · чем доказан** (точная
|
||||
команда или имя теста) **· чем краснеет** — мутация, снятая защита или
|
||||
отрицательная проба, с результатом прогона. Пустой третий столбец — находка
|
||||
Medium, а не примечание.
|
||||
|
||||
«Тест умеет падать» без названной мутации и её вывода доказательством не
|
||||
является. Аудит v1.71.0-beta.1 нашёл пять контрактов #51 и #423, где тест
|
||||
оставался зелёным на снятой защите; все пять прошли код-ревью как доказанные,
|
||||
а два теста были записаны в закрытие coverage-ratchet под именами, обещавшими
|
||||
то, чего они не проверяли (#430).
|
||||
|
||||
Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в
|
||||
продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там
|
||||
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
|
||||
достаточно прогона со снятой защитой, приведённого в документе.
|
||||
|
||||
Считаются **защитные AC без названного свидетеля**, а не мутанты на
|
||||
подсистему: у #421 мутанты были, и дыра всё равно проехала. «Сколько мутантов
|
||||
принесла задача» остаётся признаком — у #423 их ноль, и именно у #423 нашёлся
|
||||
тест, спрашивавший регулярку, находит ли она подстроку, которую сам же и
|
||||
вырезал.
|
||||
|
||||
Правило не распространяется на AC, не заявляющие защиту (расположение, текст,
|
||||
формат вывода): там свидетель — обычное сравнение ожидаемого с фактическим, и
|
||||
третий столбец превратился бы в ритуал. И не отменяет «проверено чтением»:
|
||||
тогда во втором столбце стоит «чтением», а не имя теста, и читатель ревью
|
||||
видит разницу.
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Bounded, shared integrity verification for content-addressed decor assets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import stat
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ASSET_INTEGRITY_CACHE_ENTRIES = 256
|
||||
ASSET_HASH_CHUNK_BYTES = 64 * 1024
|
||||
ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS = 30.0
|
||||
_HASS_DATA_KEY = "asset_integrity_verifier"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileSignature:
|
||||
"""File version facts available without reading its content."""
|
||||
|
||||
size: int
|
||||
mtime_ns: int
|
||||
ctime_ns: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CacheEntry:
|
||||
signature: FileSignature
|
||||
digest: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Flight:
|
||||
event: threading.Event
|
||||
digest: str | None = None
|
||||
|
||||
|
||||
def _signature(path: Path) -> FileSignature:
|
||||
current = path.stat()
|
||||
if not stat.S_ISREG(current.st_mode):
|
||||
raise OSError("asset is not a regular file")
|
||||
return FileSignature(
|
||||
size=current.st_size,
|
||||
mtime_ns=current.st_mtime_ns,
|
||||
ctime_ns=current.st_ctime_ns,
|
||||
)
|
||||
|
||||
|
||||
def _stream_sha256(path: Path) -> str:
|
||||
"""Hash a blob without retaining its bytes in memory."""
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(ASSET_HASH_CHUNK_BYTES):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
class AssetIntegrityVerifier:
|
||||
"""Thread-safe LRU digest cache with per-file-version single-flight."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_entries: int = ASSET_INTEGRITY_CACHE_ENTRIES,
|
||||
*,
|
||||
hasher: Callable[[Path], str] | None = None,
|
||||
event_factory: Callable[[], threading.Event] | None = None,
|
||||
) -> None:
|
||||
if max_entries < 1:
|
||||
raise ValueError("max_entries must be positive")
|
||||
self._max_entries = max_entries
|
||||
self._hasher = hasher or _stream_sha256
|
||||
self._event_factory = event_factory or threading.Event
|
||||
self._lock = threading.Lock()
|
||||
self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
|
||||
self._inflight: dict[tuple[str, FileSignature], _Flight] = {}
|
||||
|
||||
def verify(self, path: Path, expected_digest: str) -> bool:
|
||||
"""Return whether one stable file version has the expected digest."""
|
||||
try:
|
||||
canonical = str(path.resolve())
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
try:
|
||||
before = _signature(path)
|
||||
except OSError:
|
||||
with self._lock:
|
||||
self._cache.pop(canonical, None)
|
||||
return False
|
||||
|
||||
key = (canonical, before)
|
||||
with self._lock:
|
||||
cached = self._cache.get(canonical)
|
||||
if cached is not None and cached.signature == before:
|
||||
self._cache.move_to_end(canonical)
|
||||
return cached.digest == expected_digest
|
||||
if cached is not None:
|
||||
self._cache.pop(canonical, None)
|
||||
flight = self._inflight.get(key)
|
||||
owner = flight is None
|
||||
if owner:
|
||||
flight = _Flight(self._event_factory())
|
||||
self._inflight[key] = flight
|
||||
|
||||
assert flight is not None
|
||||
if not owner:
|
||||
if not flight.event.wait(ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS):
|
||||
return False
|
||||
return flight.digest == expected_digest
|
||||
|
||||
digest: str | None = None
|
||||
stable = False
|
||||
try:
|
||||
digest = self._hasher(path)
|
||||
# Never publish a digest for bytes that changed while they were read.
|
||||
stable = _signature(path) == before
|
||||
except Exception as err: # noqa: BLE001 - filesystem/hash seam fails dark
|
||||
_LOGGER.debug("House Plan asset integrity check failed: %s", err)
|
||||
finally:
|
||||
with self._lock:
|
||||
if stable and digest is not None:
|
||||
self._cache[canonical] = _CacheEntry(before, digest)
|
||||
self._cache.move_to_end(canonical)
|
||||
while len(self._cache) > self._max_entries:
|
||||
self._cache.popitem(last=False)
|
||||
flight.digest = digest
|
||||
self._inflight.pop(key, None)
|
||||
flight.event.set()
|
||||
return stable and digest == expected_digest
|
||||
|
||||
|
||||
def get_asset_integrity_verifier(hass: Any) -> AssetIntegrityVerifier:
|
||||
"""Return the single verifier shared by HTTP and WS on this HA instance."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
verifier = domain_data.get(_HASS_DATA_KEY)
|
||||
if not isinstance(verifier, AssetIntegrityVerifier):
|
||||
verifier = AssetIntegrityVerifier()
|
||||
domain_data[_HASS_DATA_KEY] = verifier
|
||||
return verifier
|
||||
@@ -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-beta.1"
|
||||
VERSION = "1.72.0"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
@@ -101,7 +101,8 @@ ERROR_CODES: frozenset[str] = frozenset({
|
||||
"invalid_light_entity", "invalid_marker_control", "invalid_name",
|
||||
"invalid_partition_opening_host",
|
||||
"invalid_partition_opening_jamb_margin", "invalid_passage_fields",
|
||||
"invalid_space_id", "invalid_toggle_entity", "invalid_value_badge",
|
||||
"invalid_space_id", "invalid_toggle_entity", "invalid_vacuum_map_route",
|
||||
"invalid_value_badge",
|
||||
"invalid_value_badge_attribute", "invalid_value_badge_position",
|
||||
"invalid_value_badge_source", "invalid_value_source",
|
||||
"invalid_value_source_attribute", "io_error", "marker_control_cycle",
|
||||
|
||||
@@ -14,6 +14,7 @@ COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
LATTICE_GRID_N = 240
|
||||
LATTICE_NOISE_STEPS = 1e-4
|
||||
DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image")
|
||||
|
||||
|
||||
def canonicalize_number(value: Any) -> Any:
|
||||
@@ -150,7 +151,7 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
kind = decor.get("kind")
|
||||
if kind == "line":
|
||||
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
elif kind in ("rect", "ellipse", "furniture"):
|
||||
elif kind in DECOR_BOX_KINDS:
|
||||
_lattice_fields(decor, ("x", "y", "w", "h"))
|
||||
_scalar_fields(decor, ("angle",))
|
||||
elif kind == "text":
|
||||
|
||||
@@ -10,6 +10,7 @@ import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import stat
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
@@ -372,20 +373,75 @@ def asset_meta_path(root: Path, asset_id: str) -> Path:
|
||||
return root / f"{asset_id}.json"
|
||||
|
||||
|
||||
def _physical_asset_inventory(root: Path) -> list[tuple[Path, int]]:
|
||||
"""Observe regular promoted blobs once, tolerating concurrent removals."""
|
||||
try:
|
||||
if not root.is_dir():
|
||||
return []
|
||||
entries = list(root.iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
inventory: list[tuple[Path, int]] = []
|
||||
for path in entries:
|
||||
if path.suffix not in ASSET_EXTENSIONS or not ASSET_ID_RE.fullmatch(path.stem):
|
||||
continue
|
||||
try:
|
||||
current = path.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if stat.S_ISREG(current.st_mode):
|
||||
inventory.append((path, current.st_size))
|
||||
return sorted(inventory, key=lambda item: item[0].name)
|
||||
|
||||
|
||||
def physical_asset_blobs(root: Path) -> list[Path]:
|
||||
"""Return exact promoted blob files, independently from their sidecars."""
|
||||
return [path for path, _size in _physical_asset_inventory(root)]
|
||||
|
||||
|
||||
def physical_asset_usage(root: Path) -> tuple[int, int]:
|
||||
"""Return promoted blob count and actual bytes used for quota checks."""
|
||||
inventory = _physical_asset_inventory(root)
|
||||
return len(inventory), sum(size for _path, size in inventory)
|
||||
|
||||
|
||||
def _read_catalog_row(root: Path, path: Path) -> dict[str, Any] | None:
|
||||
"""Read one sidecar through the validation shared by list and resolve."""
|
||||
try:
|
||||
row = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
aid = str(row.get("asset_id") or "")
|
||||
ext = row.get("ext")
|
||||
blob = root / f"{aid}{ext}"
|
||||
if (
|
||||
path.stem != aid
|
||||
or not ASSET_ID_RE.fullmatch(aid)
|
||||
or ext not in ASSET_EXTENSIONS
|
||||
or not blob.is_file()
|
||||
):
|
||||
return None
|
||||
return row
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_asset(root: Path, asset_id: str) -> dict[str, Any] | None:
|
||||
"""Read one exact catalog row without scanning unrelated sidecars."""
|
||||
if not ASSET_ID_RE.fullmatch(asset_id):
|
||||
return None
|
||||
return _read_catalog_row(root, asset_meta_path(root, asset_id))
|
||||
|
||||
|
||||
def read_catalog(root: Path) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
if not root.is_dir():
|
||||
return rows
|
||||
for path in root.glob("*.json"):
|
||||
try:
|
||||
row = json.loads(path.read_text(encoding="utf-8"))
|
||||
aid = str(row.get("asset_id") or "")
|
||||
ext = row.get("ext")
|
||||
blob = root / f"{aid}{ext}"
|
||||
if ASSET_ID_RE.fullmatch(aid) and ext in ASSET_EXTENSIONS and blob.is_file():
|
||||
rows.append(row)
|
||||
except (OSError, ValueError, TypeError):
|
||||
continue
|
||||
row = _read_catalog_row(root, path)
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
return sorted(
|
||||
rows,
|
||||
key=lambda row: (str(row.get("created_at", "")), str(row["asset_id"])),
|
||||
|
||||
@@ -1,125 +1,182 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693",
|
||||
"fingerprint": "0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-Dwkibvgo.js",
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 291031,
|
||||
"initialViewGzipBytes": 300154,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
|
||||
"houseplan-assets/de-DJX-vLW1.js",
|
||||
"houseplan-assets/editor-Dvui5H3b.js",
|
||||
"houseplan-assets/fr-DVmfM6Kw.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/de-BRjMWzox.js",
|
||||
"houseplan-assets/editor-DIlG_iy6.js",
|
||||
"houseplan-assets/fr-CB9Rh5BY.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
|
||||
"houseplan-assets/live-interaction-runtime-BuROtNwW.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
|
||||
],
|
||||
"lazyGzipBytes": 214972,
|
||||
"lazyGzipBytes": 250869,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
|
||||
"houseplan-assets/editor-Dvui5H3b.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/editor-DIlG_iy6.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 159157,
|
||||
"lazyEditorGzipBytes": 186626,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14132,
|
||||
"lazyOnboardingGzipBytes": 14325,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-DJX-vLW1.js",
|
||||
"houseplan-assets/fr-DVmfM6Kw.js"
|
||||
"houseplan-assets/de-BRjMWzox.js",
|
||||
"houseplan-assets/fr-CB9Rh5BY.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 48859,
|
||||
"lazyLocaleGzipBytes": 50148,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DWD0ZQTc.js",
|
||||
"sha256": "455450cbc7b74f052fb112680af9ba4be145af8c3a1061907ec541b63957f22f",
|
||||
"rawBytes": 20974,
|
||||
"gzipBytes": 7176,
|
||||
"path": "houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"sha256": "acd02f075bc18ca361cb543749461208b9f2dea2433aa3950a1296c31759de6b",
|
||||
"rawBytes": 21592,
|
||||
"gzipBytes": 7357,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Dwkibvgo.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-DJX-vLW1.js",
|
||||
"sha256": "73d198680c17602addf41855748533ed47233e81e9bdc02a786034aeac0e4e63",
|
||||
"rawBytes": 81798,
|
||||
"gzipBytes": 24701,
|
||||
"path": "houseplan-assets/de-BRjMWzox.js",
|
||||
"sha256": "fdba581311125cadbec7b827e8a4b87f3aba4eab7ff12d4dbf8af58517e9e9a8",
|
||||
"rawBytes": 84232,
|
||||
"gzipBytes": 25351,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-Dvui5H3b.js",
|
||||
"sha256": "11578ded60410e4e879c9342ee74c8a0968477ea2bae98eb18480a0ad02d303c",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1580,
|
||||
"path": "houseplan-assets/editor-DIlG_iy6.js",
|
||||
"sha256": "8c28088bcc9df5581948eeda9518f43eb73ca8ed9918ab0961f3eea114e2f3c9",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Dwkibvgo.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-DVmfM6Kw.js",
|
||||
"sha256": "167bed2822ce7e2b38d47e8dc1d73648d10c04d469979bf05e414000b53487b4",
|
||||
"rawBytes": 83973,
|
||||
"gzipBytes": 24158,
|
||||
"path": "houseplan-assets/fr-CB9Rh5BY.js",
|
||||
"sha256": "95cf4e535042c78eb00bedc08c8c2a9e779236847f3e12dca4757fca435629b7",
|
||||
"rawBytes": 86273,
|
||||
"gzipBytes": 24797,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-Dwkibvgo.js",
|
||||
"sha256": "d16deb0fc6f591040a2f97d51102e3e355da35cb3d1b69aa79472406c28d3ed2",
|
||||
"rawBytes": 1033612,
|
||||
"gzipBytes": 290234,
|
||||
"path": "houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"sha256": "03a04aa491c26e42f8adea0126c8bb970b54ec7a12bbd9475f9279a91a2b7fc0",
|
||||
"rawBytes": 1069135,
|
||||
"gzipBytes": 299358,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-DJX-vLW1.js",
|
||||
"houseplan-assets/editor-Dvui5H3b.js",
|
||||
"houseplan-assets/fr-DVmfM6Kw.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js"
|
||||
"houseplan-assets/de-BRjMWzox.js",
|
||||
"houseplan-assets/editor-DIlG_iy6.js",
|
||||
"houseplan-assets/fr-CB9Rh5BY.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
|
||||
"houseplan-assets/live-interaction-runtime-BuROtNwW.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-9AEP51ly.js",
|
||||
"sha256": "c1b8f90458afedddbbdde98570c517d6e66dfcee08ad4764b1ac2473bdf89fb4",
|
||||
"rawBytes": 574225,
|
||||
"gzipBytes": 150401,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"sha256": "fc089a1e4a1df49c3e1c8e78620a3bd23f3ba7270213db663cd018854c22fe55",
|
||||
"rawBytes": 639580,
|
||||
"gzipBytes": 170261,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
|
||||
"houseplan-assets/houseplan-card-Dwkibvgo.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
|
||||
"sha256": "87d24dae20c92a060e34d1e4ce80d300c947b7ac6b3a86f6025b39750d286d46",
|
||||
"rawBytes": 28071,
|
||||
"gzipBytes": 6968,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-CnVK5oOC.js",
|
||||
"sha256": "bd169896116229e76b37243106bde64755589017639890d36dd19282c872db7a",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6956,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
|
||||
"sha256": "b5cbcfffe86b10e56c078dbf139123a6c16625f8c2396570215f06def12e08ba",
|
||||
"rawBytes": 8626,
|
||||
"gzipBytes": 3236,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DWD0ZQTc.js",
|
||||
"houseplan-assets/houseplan-card-Dwkibvgo.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-BuROtNwW.js",
|
||||
"sha256": "b0d405033a72cab412d7475170818f00729dfedd049f2dcfc3387beefa43ea42",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2126,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DjEvSVKB.js",
|
||||
"sha256": "531006824ca5197709f62fc3bae854d4a7349d3c7f3c3ab85c223d7e31261b9a",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7425,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js",
|
||||
"sha256": "09566891268f0fd002283422cd1b83151520240025fb9e2899cee758e6147e8c",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1765,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "730163666440793e44be0ee5482f7080931370f94555a1d52072d3a54698447e",
|
||||
"sha256": "12e80545e7ee775e0859c8113f922c25f15bef46c5c1866db3cff6d7d3dccdce",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 797,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Dwkibvgo.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-C0qjaV_3.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__="816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693";import{b as e,l as o,t,df as s,A as a,dg as i,dh as l,di as n,E as r,c}from"./houseplan-card-Dwkibvgo.js";class f 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,f={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")},h=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=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.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(h.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)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";import{n as t,b as n}from"./zigbee-topology-DjEvSVKB.js";import{I as e}from"./houseplan-card-C0qjaV_3.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 f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(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(f(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,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=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 f=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&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),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{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="816ff9c893e23b7441e73210f85024741ff6c5bf8dabd2fb0df9e8b8055bc693";try{await import("./houseplan-assets/houseplan-card-Dwkibvgo.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__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";try{await import("./houseplan-assets/houseplan-card-C0qjaV_3.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)
|
||||
@@ -24,6 +24,7 @@ try: # KEY_HASS — the modern way to access hass from the aiohttp application
|
||||
except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
|
||||
from .asset_integrity import get_asset_integrity_verifier
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
@@ -43,8 +44,9 @@ from .decor_assets import (
|
||||
ASSET_ID_RE,
|
||||
DecorAssetError,
|
||||
asset_meta_path,
|
||||
physical_asset_usage,
|
||||
public_asset,
|
||||
read_catalog,
|
||||
read_asset,
|
||||
validate_asset,
|
||||
)
|
||||
from .import_export import ImportFailure, create_preview
|
||||
@@ -176,18 +178,13 @@ class HouseplanContentView(HomeAssistantView):
|
||||
if not str(path).startswith(str(base)):
|
||||
return web.Response(status=404)
|
||||
|
||||
if not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
suffix = path.suffix.lower()
|
||||
if kind == "assets":
|
||||
try:
|
||||
digest = await hass.async_add_executor_job(
|
||||
lambda: hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
)
|
||||
except OSError:
|
||||
return web.Response(status=404)
|
||||
if digest != path.stem:
|
||||
verifier = get_asset_integrity_verifier(hass)
|
||||
if not await hass.async_add_executor_job(verifier.verify, path, path.stem):
|
||||
return web.Response(status=404)
|
||||
elif not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=31536000, immutable"
|
||||
if kind == "assets" else "private, max-age=3600",
|
||||
@@ -269,24 +266,12 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
|
||||
def _store() -> tuple[dict, bool]:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
existing = next(
|
||||
(row for row in read_catalog(root) if row["asset_id"] == validated.asset_id),
|
||||
None,
|
||||
)
|
||||
existing = read_asset(root, validated.asset_id)
|
||||
if existing is not None:
|
||||
existing_blob = root / f"{validated.asset_id}{existing['ext']}"
|
||||
if hashlib.sha256(existing_blob.read_bytes()).hexdigest() != validated.asset_id:
|
||||
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
|
||||
return existing, True
|
||||
rows = read_catalog(root)
|
||||
used = sum(int(row.get("bytes") or 0) for row in rows)
|
||||
if len(rows) >= MAX_DECOR_ASSETS_COUNT or used + len(validated.data) > MAX_DECOR_ASSETS_BYTES:
|
||||
raise DecorAssetError("capacity_exceeded", "The decor image store is full")
|
||||
try:
|
||||
if shutil.disk_usage(root).free - len(validated.data) < MIN_FREE_BYTES:
|
||||
raise DecorAssetError("capacity_exceeded", "Not enough free disk space")
|
||||
except OSError:
|
||||
pass
|
||||
aid = validated.asset_id
|
||||
blob = root / f"{aid}{validated.ext}"
|
||||
meta = asset_meta_path(root, aid)
|
||||
@@ -296,6 +281,33 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
"ext": validated.ext, "width": validated.width, "height": validated.height,
|
||||
"bytes": len(validated.data), "created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
# A hard stop may have promoted the content-addressed blob before
|
||||
# its sidecar. Its exact path and digest prove that no new physical
|
||||
# bytes are needed, so repair remains possible even at full quota.
|
||||
if blob.exists():
|
||||
if not blob.is_file() or hashlib.sha256(blob.read_bytes()).hexdigest() != aid:
|
||||
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
|
||||
fd, meta_temp_name = tempfile.mkstemp(prefix=".asset-meta-", dir=str(root))
|
||||
os.close(fd)
|
||||
meta_temp = Path(meta_temp_name)
|
||||
try:
|
||||
with meta_temp.open("w", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(meta_temp, meta)
|
||||
return row, False
|
||||
finally:
|
||||
meta_temp.unlink(missing_ok=True)
|
||||
|
||||
count, used = physical_asset_usage(root)
|
||||
if count >= MAX_DECOR_ASSETS_COUNT or used + len(validated.data) > MAX_DECOR_ASSETS_BYTES:
|
||||
raise DecorAssetError("capacity_exceeded", "The decor image store is full")
|
||||
try:
|
||||
if shutil.disk_usage(root).free - len(validated.data) < MIN_FREE_BYTES:
|
||||
raise DecorAssetError("capacity_exceeded", "Not enough free disk space")
|
||||
except OSError:
|
||||
pass
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".asset-", dir=str(root))
|
||||
os.close(fd)
|
||||
temp = Path(temp_name)
|
||||
@@ -310,14 +322,6 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
stream.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if blob.exists():
|
||||
# A previous hard stop may have promoted the blob but not
|
||||
# its sidecar. The content hash proves those bytes; finish
|
||||
# the catalog transaction rather than leaving a ghost.
|
||||
if hashlib.sha256(blob.read_bytes()).hexdigest() != aid:
|
||||
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
|
||||
os.replace(meta_temp, meta)
|
||||
return row, True
|
||||
os.replace(temp, blob)
|
||||
promoted_blob = True
|
||||
os.replace(meta_temp, meta)
|
||||
@@ -337,7 +341,8 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
async with runtime.upload_lock:
|
||||
row, reused = await hass.async_add_executor_job(_store)
|
||||
except DecorAssetError as err:
|
||||
return web.json_response({"error": err.code, "message": str(err)}, status=507)
|
||||
status = 507 if err.code == "capacity_exceeded" else 400
|
||||
return web.json_response({"error": err.code, "message": str(err)}, status=status)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan decor asset upload: store failed: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
|
||||
@@ -52,6 +52,7 @@ from .validation import (
|
||||
sanitize_marker_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_vacuum_routes,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
@@ -74,6 +75,13 @@ _IMPORT_ID_NAMESPACES = {
|
||||
}
|
||||
_MAX_IMPORT_LINEAGE_DEPTH = 16
|
||||
_REPORT_EXAMPLE_LIMIT = 24
|
||||
_DECOR_ASSET_MIME_BY_SUFFIX = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
_DECOR_ASSET_MIME_TYPES = frozenset(_DECOR_ASSET_MIME_BY_SUFFIX.values())
|
||||
|
||||
_SPACE_PLAN_FIELDS = (
|
||||
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
|
||||
@@ -98,6 +106,7 @@ _DECOR_KIND_FIELDS = {
|
||||
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
|
||||
"furniture": ("symbol", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
|
||||
"image": ("asset_id", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
|
||||
}
|
||||
|
||||
|
||||
@@ -470,10 +479,9 @@ def content_manifest(config: dict[str, Any], config_root: Path) -> list[dict[str
|
||||
# Identity is extension-neutral: a missing target must compute
|
||||
# exactly the same expected manifest as the source.
|
||||
"url": aid, "asset_id": aid, "storage": "internal",
|
||||
"mime": metadata.get("mime") or {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}.get(blob.suffix if blob else ""),
|
||||
"mime": metadata.get("mime") or _DECOR_ASSET_MIME_BY_SUFFIX.get(
|
||||
blob.suffix if blob else ""
|
||||
),
|
||||
"hash": aid, "exists_at_export": exists,
|
||||
})
|
||||
return out
|
||||
@@ -568,6 +576,23 @@ def create_export(
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
marker.pop("value_source", None)
|
||||
dropped_marker_links += 1
|
||||
# #162: a robot map routed into ANOTHER space cannot travel
|
||||
# with a single-space export — its target would not exist on
|
||||
# import, and a route with a dead space resolves to nothing.
|
||||
# Same neutralisation contract as the marker refs above: drop
|
||||
# it and count it, so the preview states the loss up front.
|
||||
vacuum = marker.get("vacuum")
|
||||
routes = vacuum.get("map_routes") if isinstance(vacuum, dict) else None
|
||||
if isinstance(routes, list):
|
||||
kept_routes = [
|
||||
route for route in routes
|
||||
if isinstance(route, dict) and str(route.get("space")) == str(space_id)
|
||||
]
|
||||
dropped_marker_links += len(routes) - len(kept_routes)
|
||||
# An explicit list is route authority even when filtering
|
||||
# leaves it empty. Turning [] into None would revive any
|
||||
# legacy calibration dictionary after import (#443).
|
||||
vacuum["map_routes"] = kept_routes
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
@@ -802,7 +827,11 @@ def _validate_plan_only_document(
|
||||
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
|
||||
content = document.get("content_manifest")
|
||||
if not isinstance(content, list) or any(
|
||||
not isinstance(item, dict) or item.get("owner") != "space"
|
||||
not isinstance(item, dict)
|
||||
or not (
|
||||
item.get("owner") == "space"
|
||||
or item.get("owner") == "decor" and item.get("kind") == "decor_asset"
|
||||
)
|
||||
for item in content
|
||||
):
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains private content")
|
||||
@@ -1569,6 +1598,7 @@ def build_space_merge(
|
||||
validate_marker_controls(merged_config, current_config)
|
||||
validate_marker_light_entities(merged_config, current_config)
|
||||
validate_marker_value_badges(merged_config, current_config)
|
||||
validate_marker_vacuum_routes(merged_config, current_config)
|
||||
validate_opening_passages(merged_config, current_config)
|
||||
validate_partition_opening_hosts(merged_config, current_config)
|
||||
except (
|
||||
@@ -1639,14 +1669,24 @@ def _content_state(document: dict[str, Any], same_source: bool, config_root: Pat
|
||||
for item in expected:
|
||||
row = dict(item)
|
||||
declared = supplied_by_id[identity(item)]
|
||||
row["exists_at_export"] = declared.get("exists_at_export")
|
||||
declared_exists = declared.get("exists_at_export")
|
||||
row["exists_at_export"] = declared_exists
|
||||
if item.get("kind") == "decor_asset":
|
||||
aid = str(item.get("asset_id") or item.get("url") or "")
|
||||
declared_mime = declared.get("mime")
|
||||
valid_mime = (
|
||||
isinstance(declared_mime, str)
|
||||
and declared_mime in _DECOR_ASSET_MIME_TYPES
|
||||
)
|
||||
missing_mime = declared_exists is False and declared_mime is None
|
||||
if (declared.get("asset_id") != aid or declared.get("hash") != aid
|
||||
or declared.get("mime") not in {
|
||||
"image/png", "image/jpeg", "image/webp", "image/svg+xml",
|
||||
}):
|
||||
or type(declared_exists) is not bool
|
||||
or not (valid_mime or missing_mime)):
|
||||
raise ImportFailure("invalid_content", "Invalid decor asset manifest row")
|
||||
# Preserve the source description for preview/digest purposes. It
|
||||
# never selects or serves target bytes; the exact local hash below
|
||||
# remains authoritative for availability.
|
||||
row["mime"] = declared_mime
|
||||
candidates = sorted((config_root / ASSETS_DIR).glob(f"{aid}.*"))
|
||||
blob = next((path for path in candidates if path.suffix in {".png", ".jpg", ".webp", ".svg"}), None)
|
||||
exists = False
|
||||
@@ -1854,6 +1894,7 @@ def create_preview(
|
||||
validate_marker_controls(incoming_config, validate_all=True)
|
||||
validate_marker_light_entities(incoming_config, validate_all=True)
|
||||
validate_marker_value_badges(incoming_config, validate_all=True)
|
||||
validate_marker_vacuum_routes(incoming_config, validate_all=True)
|
||||
except MarkerControlError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
content, confirmation = _content_state(document, same_source, config_root)
|
||||
|
||||
@@ -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-beta.1"
|
||||
"version": "1.72.0"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@ def _global_settings(value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays"))
|
||||
if not isinstance(value, dict):
|
||||
return out
|
||||
show_room_tooltip = value.get("show_room_tooltip")
|
||||
if isinstance(show_room_tooltip, bool):
|
||||
out["show_room_tooltip"] = show_room_tooltip
|
||||
fill_colors = value.get("fill_colors")
|
||||
if isinstance(fill_colors, dict):
|
||||
out["fill_colors"] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.helpers.event import async_call_later, async_track_state_chan
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
from .vacuum_routes import effective_routes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,14 +32,31 @@ FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
|
||||
def same_run_identity(run: Any, route_id: str, map_id: str) -> bool:
|
||||
"""Whether a stored run and a fresh point belong to the same cleanup.
|
||||
|
||||
Route identity wins when both sides have one: two maps of one robot may
|
||||
share a map id only across different sources, and a route change (new
|
||||
source, map or target space) is a new run by definition (#162). A run
|
||||
written before #162 carries no route id, so it is still matched by map id
|
||||
and keeps resuming exactly as it used to.
|
||||
"""
|
||||
if not isinstance(run, dict):
|
||||
return False
|
||||
stored_route = run.get("route_id")
|
||||
if route_id and isinstance(stored_route, str) and stored_route:
|
||||
return stored_route == route_id
|
||||
return run.get("map_id") == map_id
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float, route_id: str = "") -> bool:
|
||||
"""Whether an ended current run may be reopened for this point.
|
||||
|
||||
Store timestamps are untrusted persisted data. Only finite JSON-number
|
||||
timestamps and a non-negative inclusive grace interval are accepted;
|
||||
malformed values and wall-clock rollback fail closed into a new run.
|
||||
"""
|
||||
if not isinstance(run, dict) or run.get("map_id") != map_id:
|
||||
if not same_run_identity(run, route_id, map_id):
|
||||
return False
|
||||
ended = run.get("ended")
|
||||
if (
|
||||
@@ -84,18 +102,27 @@ class TrailBook:
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
|
||||
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
def on_point(
|
||||
self, marker: str, map_id: str, x: float, y: float, now: float,
|
||||
route_id: str = "", source: str = "",
|
||||
) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now, route_id))
|
||||
if resumed:
|
||||
cur["ended"] = None
|
||||
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
|
||||
if not cur or cur.get("ended") is not None or not same_run_identity(cur, route_id, map_id):
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
rec["previous"] = cur
|
||||
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
|
||||
# #162: the run remembers WHICH route wrote it, so the card can put
|
||||
# it on the right floor without re-deriving the routing itself.
|
||||
if route_id:
|
||||
cur["route_id"] = route_id
|
||||
if source:
|
||||
cur["source"] = source
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
@@ -122,11 +149,33 @@ class TrailBook:
|
||||
"""Forget every stored run of one plan marker."""
|
||||
return self.data.pop(marker, None) is not None
|
||||
|
||||
def drop_unknown_routes(self, marker: str, route_ids: set[str]) -> bool:
|
||||
"""Forget runs whose route no longer exists (deleted or re-targeted).
|
||||
|
||||
Only runs that name a route are touched. A pre-#162 run names none and
|
||||
is adopted by the card instead — deleting it here would destroy history
|
||||
the user can still legitimately see.
|
||||
"""
|
||||
rec = self.data.get(marker)
|
||||
if not isinstance(rec, dict):
|
||||
return False
|
||||
changed = False
|
||||
for slot in ("current", "previous"):
|
||||
run = rec.get(slot)
|
||||
if not isinstance(run, dict):
|
||||
continue
|
||||
stored = run.get("route_id")
|
||||
if isinstance(stored, str) and stored and stored not in route_ids:
|
||||
rec.pop(slot)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
class TrailRecorder:
|
||||
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
|
||||
self.routes_by_marker: dict[str, list[dict[str, Any]]] = {}
|
||||
self.hass = hass
|
||||
self.rt = rt
|
||||
self.store = Store(hass, 1, f"{DOMAIN}.trails")
|
||||
@@ -166,22 +215,35 @@ class TrailRecorder:
|
||||
cfg = stored.get("config") or {}
|
||||
pairs: dict[str, list[tuple[str, str]]] = {}
|
||||
health_pairs: set[tuple[str, str]] = set()
|
||||
routes_by_marker: dict[str, list[dict[str, Any]]] = {}
|
||||
for m in cfg.get("markers") or []:
|
||||
if m.get("removed") is True:
|
||||
continue
|
||||
v = m.get("vacuum") or {}
|
||||
src = v.get("source")
|
||||
if not src or v.get("live") is False:
|
||||
if v.get("live") is False:
|
||||
continue
|
||||
marker_id = str(m.get("id"))
|
||||
health_pairs.add((marker_id, str(src)))
|
||||
# #162: a multi-floor robot may publish each map through its own
|
||||
# camera, so every route source is watched — not only the root
|
||||
# discovery source, which is now just one of them.
|
||||
routes = effective_routes(marker_id, v, str(m.get("space") or ""), src)
|
||||
routes_by_marker[marker_id] = routes
|
||||
sources = {str(route["source"]) for route in routes if route.get("source")}
|
||||
if src:
|
||||
sources.add(str(src))
|
||||
if not sources:
|
||||
continue
|
||||
vac = self._vacuum_entity(m)
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(src, []).append((marker_id, vac))
|
||||
for source in sorted(sources):
|
||||
health_pairs.add((marker_id, source))
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(source, []).append((marker_id, vac))
|
||||
self._refresh_source_health(health_pairs)
|
||||
self.pairs = pairs
|
||||
self.routes_by_marker = routes_by_marker
|
||||
self._resubscribe()
|
||||
# A run already in progress (HA restarted mid-cleanup, or the user
|
||||
# just finished calibrating) must start recording NOW, not at the
|
||||
@@ -261,6 +323,17 @@ class TrailRecorder:
|
||||
for marker in config.get("markers") or []
|
||||
if marker.get("id") is not None and marker.get("removed") is not True
|
||||
}
|
||||
# #162: a route that vanished (deleted, or re-targeted to another
|
||||
# space, which is a new identity) takes its own runs with it, while the
|
||||
# marker and its other routes stay untouched.
|
||||
for marker in config.get("markers") or []:
|
||||
marker_id = str(marker.get("id"))
|
||||
if marker.get("removed") is True or marker_id not in self.book.data:
|
||||
continue
|
||||
vacuum = marker.get("vacuum") or {}
|
||||
routes = effective_routes(
|
||||
marker_id, vacuum, str(marker.get("space") or ""), vacuum.get("source"))
|
||||
self.book.drop_unknown_routes(marker_id, {str(r.get("id")) for r in routes})
|
||||
orphan_ids = set(self.book.data) - live_marker_ids
|
||||
if not orphan_ids:
|
||||
return 0
|
||||
@@ -381,9 +454,26 @@ class TrailRecorder:
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
map_id = resolve_map_id(attrs, st_vac.attributes)
|
||||
changed |= self.book.on_point(marker, map_id, x, y, now)
|
||||
route_id = self._route_id(marker, src, map_id)
|
||||
changed |= self.book.on_point(
|
||||
marker, map_id, x, y, now, route_id=route_id,
|
||||
source=src if route_id else "",
|
||||
)
|
||||
return changed
|
||||
|
||||
def _route_id(self, marker: str, source: str, map_id: str) -> str:
|
||||
"""The route this point belongs to, or "" when routing cannot say.
|
||||
|
||||
Validation keeps (source, map_id) unique inside one marker, so at most
|
||||
one route can match. No match means the map is unmapped: the point is
|
||||
still recorded — raw history is valuable — but it is not filed under a
|
||||
route that does not own it.
|
||||
"""
|
||||
for route in getattr(self, "routes_by_marker", {}).get(marker) or ():
|
||||
if route.get("source") == source and route.get("map_id") == map_id:
|
||||
return str(route.get("id") or "")
|
||||
return ""
|
||||
|
||||
@callback
|
||||
def _on_state(self, event: Any) -> None:
|
||||
eid = event.data.get("entity_id")
|
||||
|
||||
@@ -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": "Первое уведомление о перезагрузке"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Map-to-space routing for multi-floor vacuums (#162).
|
||||
|
||||
Python mirror of ``src/vacuum-routes.ts``. The recorder and the import/export
|
||||
flows must answer "which floor is this map on?" exactly the way the card does,
|
||||
so both sides are driven by the same shared fixtures in
|
||||
``test/fixtures/vacuum-routes/``. Keep the two files in step: a divergence here
|
||||
shows up as a robot drawn on the wrong floor, which is worse than no robot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
VAC_ROUTE_LIMIT = 32
|
||||
VAC_ROUTE_ID_MAX = 128
|
||||
VAC_ROUTE_SOURCE_MAX = 255
|
||||
VAC_ROUTE_MAP_ID_MAX = 255
|
||||
VAC_ROUTE_ERROR = "invalid_vacuum_map_route"
|
||||
|
||||
_ENTITY_ID = re.compile(r"^[a-z_]+\.[a-zA-Z0-9_]+$")
|
||||
|
||||
|
||||
def normalize_route_matrix(raw: Any) -> list[float] | None:
|
||||
"""Six finite numbers, or nothing; a five-number matrix is not almost fine."""
|
||||
if not isinstance(raw, (list, tuple)) or len(raw) != 6:
|
||||
return None
|
||||
out: list[float] = []
|
||||
for value in raw:
|
||||
# bool is an int in Python; a boolean is not a coordinate.
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
number = float(value)
|
||||
if number != number or number in (float("inf"), float("-inf")):
|
||||
return None
|
||||
out.append(number)
|
||||
return out
|
||||
|
||||
|
||||
def is_entity_id_like(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(_ENTITY_ID.match(value))
|
||||
|
||||
|
||||
def validate_marker_routes(
|
||||
marker_id: str, routes: Any, space_ids: set[str] | None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Shape/uniqueness/reference issues for one marker's routes.
|
||||
|
||||
``space_ids`` None means the spaces are not known in this context (import
|
||||
preview), so the referential check is skipped instead of failing every
|
||||
route.
|
||||
"""
|
||||
if routes is None:
|
||||
return []
|
||||
|
||||
def issue(route_id: str, reason: str) -> dict[str, str]:
|
||||
return {
|
||||
"code": VAC_ROUTE_ERROR, "markerId": marker_id,
|
||||
"routeId": route_id, "reason": reason,
|
||||
}
|
||||
|
||||
if not isinstance(routes, list):
|
||||
return [issue("", "not_object")]
|
||||
problems: list[dict[str, str]] = []
|
||||
if len(routes) > VAC_ROUTE_LIMIT:
|
||||
problems.append(issue("", "limit"))
|
||||
seen_ids: set[str] = set()
|
||||
seen_identity: set[str] = set()
|
||||
for raw in routes:
|
||||
if not isinstance(raw, dict):
|
||||
problems.append(issue("", "not_object"))
|
||||
continue
|
||||
route_id = raw.get("id") if isinstance(raw.get("id"), str) else ""
|
||||
if not route_id or len(route_id) > VAC_ROUTE_ID_MAX:
|
||||
problems.append(issue(route_id or "", "id"))
|
||||
continue
|
||||
if route_id in seen_ids:
|
||||
problems.append(issue(route_id, "duplicate_id"))
|
||||
seen_ids.add(route_id)
|
||||
source = raw.get("source")
|
||||
if not is_entity_id_like(source) or len(source) > VAC_ROUTE_SOURCE_MAX:
|
||||
problems.append(issue(route_id, "source"))
|
||||
map_id = raw.get("map_id")
|
||||
# An empty map id is a real id (resolve_map_id): type and length only.
|
||||
if not isinstance(map_id, str) or len(map_id) > VAC_ROUTE_MAP_ID_MAX:
|
||||
problems.append(issue(route_id, "map_id"))
|
||||
space = raw.get("space")
|
||||
if not isinstance(space, str) or not space:
|
||||
problems.append(issue(route_id, "space"))
|
||||
elif space_ids is not None and space not in space_ids:
|
||||
problems.append(issue(route_id, "unknown_space"))
|
||||
calibration = raw.get("calibration")
|
||||
if calibration is not None and normalize_route_matrix(calibration) is None:
|
||||
problems.append(issue(route_id, "calibration"))
|
||||
if isinstance(source, str) and isinstance(map_id, str):
|
||||
identity = source + " " + map_id
|
||||
if identity in seen_identity:
|
||||
problems.append(issue(route_id, "duplicate_identity"))
|
||||
seen_identity.add(identity)
|
||||
return problems
|
||||
|
||||
|
||||
def legacy_route_id(marker_id: str, source: str, map_id: str) -> str:
|
||||
return "legacy:" + marker_id + " " + source + " " + map_id
|
||||
|
||||
|
||||
def effective_routes(
|
||||
marker_id: str, vacuum: Any, dock_space: str, discovered_source: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The routes a marker effectively has, legacy calibration included."""
|
||||
if not isinstance(vacuum, dict):
|
||||
return []
|
||||
explicit = vacuum.get("map_routes")
|
||||
if isinstance(explicit, list):
|
||||
out = []
|
||||
for route in explicit:
|
||||
if not isinstance(route, dict) or not isinstance(route.get("id"), str):
|
||||
continue
|
||||
if not route["id"]:
|
||||
continue
|
||||
out.append({
|
||||
"id": route["id"],
|
||||
"source": str(route.get("source") or ""),
|
||||
"map_id": str(route.get("map_id") or ""),
|
||||
"space": str(route.get("space") or ""),
|
||||
"calibration": normalize_route_matrix(route.get("calibration")),
|
||||
})
|
||||
return out
|
||||
source = vacuum.get("source")
|
||||
if not isinstance(source, str) or not source:
|
||||
source = discovered_source or ""
|
||||
if not source:
|
||||
return []
|
||||
calibration = vacuum.get("calibration")
|
||||
if not isinstance(calibration, dict):
|
||||
return []
|
||||
out = []
|
||||
for map_id, matrix in calibration.items():
|
||||
normalized = normalize_route_matrix(matrix)
|
||||
if normalized is None:
|
||||
continue
|
||||
out.append({
|
||||
"id": legacy_route_id(marker_id, source, str(map_id)),
|
||||
"source": source, "map_id": str(map_id), "space": dock_space,
|
||||
"calibration": normalized,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def resolve_route(
|
||||
routes: list[dict[str, Any]], observed: dict[str, str], space_ids: set[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Pick the one route the robot is on right now — or refuse to pick.
|
||||
|
||||
List order must never decide a floor: two plausible routes are ambiguous,
|
||||
not "the first one".
|
||||
"""
|
||||
matched: list[dict[str, Any]] = []
|
||||
saw_telemetry = False
|
||||
fallback_source = ""
|
||||
fallback_map_id = ""
|
||||
for route in routes:
|
||||
source = route.get("source")
|
||||
if source not in observed:
|
||||
continue
|
||||
seen = observed[source]
|
||||
saw_telemetry = True
|
||||
if not fallback_source:
|
||||
fallback_source = source
|
||||
fallback_map_id = seen
|
||||
if seen == route.get("map_id"):
|
||||
matched.append(route)
|
||||
if len(matched) > 1:
|
||||
return {"kind": "ambiguous", "routeIds": sorted(str(r.get("id")) for r in matched)}
|
||||
if len(matched) == 1:
|
||||
route = matched[0]
|
||||
if space_ids is not None and route.get("space") not in space_ids:
|
||||
return {"kind": "missing_space", "route": route}
|
||||
matrix = normalize_route_matrix(route.get("calibration"))
|
||||
if matrix is None:
|
||||
return {"kind": "needs_calibration", "route": route}
|
||||
return {"kind": "ready", "route": {**route, "calibration": matrix}}
|
||||
if saw_telemetry:
|
||||
return {"kind": "unmapped", "source": fallback_source, "mapId": fallback_map_id}
|
||||
if observed:
|
||||
source = sorted(observed)[0]
|
||||
return {"kind": "unmapped", "source": source, "mapId": observed[source]}
|
||||
return {"kind": "none"}
|
||||
|
||||
|
||||
def adopt_legacy_run(
|
||||
run: Any, routes: list[dict[str, Any]], root_source: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Where a run recorded before #162 belongs (spec 11.3.1).
|
||||
|
||||
Such a run stores only ``{map_id, started, ended, points}``: it carries no
|
||||
source at all. The one surviving witness of the subscription that wrote it
|
||||
is the marker's root ``vacuum.source``, so that is what narrows the
|
||||
candidates; without it only the map id is compared. Two candidates mean the
|
||||
run is not drawn — not that the first one wins.
|
||||
"""
|
||||
if not isinstance(run, dict) or not isinstance(run.get("map_id"), str):
|
||||
return {"kind": "orphan_run"}
|
||||
map_id = run["map_id"]
|
||||
root = root_source if isinstance(root_source, str) else ""
|
||||
candidates = [route for route in routes if route.get("map_id") == map_id]
|
||||
if root:
|
||||
candidates = [route for route in candidates if route.get("source") == root]
|
||||
if len(candidates) == 1:
|
||||
return {"kind": "adopted", "route": candidates[0]}
|
||||
if not candidates:
|
||||
return {"kind": "orphan_run"}
|
||||
return {"kind": "ambiguous_run", "routeIds": sorted(str(r.get("id")) for r in candidates)}
|
||||
@@ -17,6 +17,7 @@ from custom_components.houseplan.coordinate_canonicalization import (
|
||||
canonicalize_layout_geometry,
|
||||
canonicalize_position,
|
||||
)
|
||||
from custom_components.houseplan.vacuum_routes import validate_marker_routes
|
||||
|
||||
# ---------- limits and extension sets ----------
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
@@ -882,6 +883,50 @@ def validate_marker_value_badges(
|
||||
validate_source(source, "value_source")
|
||||
|
||||
|
||||
def validate_marker_vacuum_routes(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate changed vacuum map routes; dormant future data round-trips.
|
||||
|
||||
Change-aware like the badge validator above (#162): an untouched legacy or
|
||||
future-shaped block must not block an unrelated save, but any edit to
|
||||
routing has to leave the marker in a state the resolver can answer for —
|
||||
unique identities, six-number matrices and target spaces that exist.
|
||||
"""
|
||||
markers = config.get("markers") or []
|
||||
spaces = config.get("spaces")
|
||||
space_ids = None
|
||||
if isinstance(spaces, list):
|
||||
space_ids = {
|
||||
str(space.get("id")) for space in spaces
|
||||
if isinstance(space, dict) and isinstance(space.get("id"), str)
|
||||
}
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
|
||||
by_id = {str(marker.get("id")): marker for marker in markers}
|
||||
new_ids = set(by_id)
|
||||
consumed_old_ids: set[str] = set()
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
routes = (marker.get("vacuum") or {}).get("map_routes")
|
||||
old_routes = None if validate_all else ((old_marker or {}).get("vacuum") or {}).get("map_routes")
|
||||
if not validate_all and routes == old_routes:
|
||||
continue
|
||||
problems = validate_marker_routes(marker_id, routes, space_ids)
|
||||
if problems:
|
||||
reasons = ", ".join(sorted({problem["reason"] for problem in problems}))
|
||||
# Literal, not the constant: the #42 scanner proves the emitted
|
||||
# code against ERROR_CODES by reading the source, and it cannot
|
||||
# follow a name. The pair is pinned by a test below.
|
||||
raise MarkerControlError(
|
||||
"invalid_vacuum_map_route",
|
||||
f"Invalid vacuum map routes on {marker_id}: {reasons}",
|
||||
)
|
||||
|
||||
|
||||
def validate_marker_light_entities(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
@@ -1729,6 +1774,22 @@ MARKER_SCHEMA = vol.Schema(
|
||||
{str: vol.All([_finite], vol.Length(min=6, max=6))}
|
||||
),
|
||||
vol.Optional("segment_map"): vol.Schema({str: str}),
|
||||
# #162: canonical map -> space routing. The schema checks the
|
||||
# shape only; identity uniqueness and the referential space
|
||||
# check live in validate_marker_vacuum_routes, which is
|
||||
# change-aware and must not reject untouched legacy data.
|
||||
vol.Optional("map_routes"): vol.Any(
|
||||
None,
|
||||
vol.All([vol.Schema({
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=128)),
|
||||
vol.Required("source"): vol.All(str, vol.Length(min=1, max=255)),
|
||||
vol.Required("map_id"): vol.All(str, vol.Length(max=255)),
|
||||
vol.Required("space"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Optional("calibration"): vol.Any(
|
||||
None, vol.All([_finite], vol.Length(min=6, max=6)),
|
||||
),
|
||||
}, extra=vol.ALLOW_EXTRA)], vol.Length(max=32)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
@@ -1935,6 +1996,7 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("show_room_tooltip"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .asset_integrity import get_asset_integrity_verifier
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
@@ -50,10 +51,12 @@ from .coordinate_canonicalization import (
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
from .decor_assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_ID_RE,
|
||||
asset_meta_path,
|
||||
asset_refs,
|
||||
public_asset,
|
||||
read_asset,
|
||||
read_catalog,
|
||||
)
|
||||
from .import_export import (
|
||||
@@ -110,6 +113,7 @@ from .validation import (
|
||||
valid_space_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_vacuum_routes,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
@@ -1135,22 +1139,29 @@ async def ws_assets_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -
|
||||
@websocket_api.async_response
|
||||
async def ws_assets_resolve(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Resolve each unique id once; absent/corrupt content is reported missing."""
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
requested = set(msg["asset_ids"])
|
||||
allowed = requested
|
||||
if not _check_write(hass, connection):
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
referenced = set(asset_refs(stored.get("config") or {}))
|
||||
allowed = requested & referenced
|
||||
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
verifier = get_asset_integrity_verifier(hass)
|
||||
|
||||
def _resolve() -> tuple[list[dict], list[str]]:
|
||||
rows: list[dict] = []
|
||||
found: set[str] = set()
|
||||
for row in read_catalog(root):
|
||||
aid = row["asset_id"]
|
||||
if aid not in requested:
|
||||
for aid in sorted(allowed):
|
||||
row = read_asset(root, aid)
|
||||
if row is None:
|
||||
continue
|
||||
path = root / f"{aid}{row['ext']}"
|
||||
try:
|
||||
import hashlib
|
||||
if hashlib.sha256(path.read_bytes()).hexdigest() != aid:
|
||||
continue
|
||||
except OSError:
|
||||
if not verifier.verify(path, aid):
|
||||
continue
|
||||
rows.append(public_asset(row))
|
||||
found.add(aid)
|
||||
@@ -1188,15 +1199,23 @@ async def ws_assets_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
return
|
||||
|
||||
def _delete() -> bool:
|
||||
row = next((item for item in read_catalog(root) if item["asset_id"] == aid), None)
|
||||
removed = False
|
||||
if row:
|
||||
for extension in ASSET_EXTENSIONS:
|
||||
path = root / f"{aid}{extension}"
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
(root / f"{aid}{row['ext']}").unlink()
|
||||
path.unlink()
|
||||
removed = True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
meta = asset_meta_path(root, aid)
|
||||
if meta.is_file():
|
||||
try:
|
||||
meta.unlink()
|
||||
removed = True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
asset_meta_path(root, aid).unlink(missing_ok=True)
|
||||
return removed
|
||||
|
||||
removed = await hass.async_add_executor_job(_delete)
|
||||
@@ -1560,6 +1579,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
validate_marker_vacuum_routes(msg["config"], data.get("config"))
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
return validate_junction_limits(
|
||||
@@ -1918,6 +1938,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
validate_marker_vacuum_routes(msg["config"], config_data.get("config"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(
|
||||
msg["config"], config_data.get("config"),
|
||||
|
||||
@@ -15,13 +15,15 @@ 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', '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 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 requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
@@ -71,7 +73,7 @@ try {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace,
|
||||
requiresWallFace, interaction, requiresInteraction,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -135,11 +137,11 @@ try {
|
||||
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 +153,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 +180,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
|
||||
@@ -206,9 +210,33 @@ 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 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');
|
||||
@@ -236,6 +264,406 @@ try {
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
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 () => {
|
||||
card._setMode('plan');
|
||||
@@ -432,6 +860,11 @@ try {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
...(interactionSeries ? {
|
||||
interactionSeriesMs: interactionSeries.ms,
|
||||
...interactionTimings,
|
||||
interactionDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -442,6 +875,7 @@ try {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
...(interactionSeries ? interactionLongTasks || {} : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -461,6 +895,7 @@ try {
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -474,6 +909,9 @@ const metricNames = [
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
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,
|
||||
|
||||
@@ -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-02T05:20:33.824Z",
|
||||
"sourceFingerprint": "baf59953cfd6eb3543f7da5af21787b20dc76f136b74782ae917f811d2fc1ab5",
|
||||
"matrixVersion": 55,
|
||||
"acceptedAt": "2026-09-05T15:05:00.040Z",
|
||||
"sourceFingerprint": "450863830ad6931b7698085deecbd39f6686a3e84f1b777008be5347426f4b21",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "linux",
|
||||
"witnesses": {
|
||||
"count": 90,
|
||||
"count": 117,
|
||||
"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",
|
||||
@@ -66,9 +70,9 @@
|
||||
"opening-symbol-flip-pairs-light": "c2d9102921483da1d0216ed9bc55fc9112d67762f159df8af64a277f0db0acf4",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"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 +137,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": "762c20c3d0499c1ea444e5d980eec4e78c7112455589b5202010f53a8b51099c",
|
||||
"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: 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()
|
||||
@@ -56,7 +57,7 @@ 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.evaluate(({ variables, theme, attemptedTarget, attemptKey }) => {
|
||||
let style = document.getElementById('hp-golden-stability');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
@@ -82,7 +83,15 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
@@ -736,7 +745,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) => {
|
||||
@@ -760,10 +769,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
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({
|
||||
@@ -788,8 +796,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 +829,16 @@ 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}`);
|
||||
}
|
||||
// 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 +865,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.
|
||||
@@ -1066,6 +1087,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (card._effectiveProjection?.() !== 'iso') {
|
||||
throw new Error(`isometric golden rendered Flat: ${scenario.id}`);
|
||||
}
|
||||
await until(() => card._renderProjection === 'iso');
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
@@ -1809,7 +1833,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 = 55;
|
||||
|
||||
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: [
|
||||
@@ -165,6 +160,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,23 +208,23 @@ 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,
|
||||
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 },
|
||||
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -348,7 +356,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 +383,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
|
||||
|
||||
@@ -12,7 +12,18 @@
|
||||
(`scripts/source-fingerprint.mjs`), то есть каждый новый `.mjs` там объявляет
|
||||
устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс.
|
||||
Пробы гарда ни одного пикселя не касаются, платить пересъёмкой за них нечем.
|
||||
3. **Каталог, а не файл** — проб три, и они читаются как набор.
|
||||
3. **Каталог, а не файл** — проб несколько, и они читаются как набор.
|
||||
|
||||
Запускает их `verify-guard.mjs`; он же вызывается из job «Смоки в браузере»
|
||||
и служит guard'ом двух мутантов в `scripts/mutation-gate.mjs`.
|
||||
и служит guard'ом мутантов в `scripts/mutation-gate.mjs`.
|
||||
|
||||
Одна проба живёт не здесь: `--guard-probe` у `demo/benchmark_backdrop_decode.mjs`
|
||||
(#430). Benchmark нельзя переселить в этот каталог — его запускают руками при
|
||||
рекалибровке порогов, — поэтому `verify-guard.mjs` умеет запускать файл выше
|
||||
каталога и с аргументами. Режим `--guard-probe` пропускает все замеры, бросает
|
||||
одно исключение внутри страницы и обязан выйти с кодом 1.
|
||||
|
||||
Новый benchmark, открывающий страницу Playwright, обязан получить такой же
|
||||
режим и запись здесь: этого требует `test/smoke-harness-contract.test.mjs`, и
|
||||
требует статически — потому что динамическое доказательство возможно только
|
||||
там, где есть браузер.
|
||||
|
||||
@@ -18,7 +18,13 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Каждая проба: чего ждём от кода возврата и что обязано быть в выводе. */
|
||||
/**
|
||||
* Каждая проба: чего ждём от кода возврата и что обязано быть в выводе.
|
||||
*
|
||||
* `args` — необязательные аргументы запуска; `file` может указывать и выше
|
||||
* этого каталога (`../benchmark_*.mjs`), потому что benchmark в `demo/guard/`
|
||||
* не переселить: его гоняют руками при рекалибровке порогов (#430).
|
||||
*/
|
||||
const PROBES = [
|
||||
{
|
||||
file: 'guard_tail_exception.mjs',
|
||||
@@ -38,6 +44,19 @@ const PROBES = [
|
||||
expectOutput: /OK/,
|
||||
because: 'round-trip к закрытой странице не имеет права ронять вердикт',
|
||||
},
|
||||
{
|
||||
// #430: до этой задачи гард benchmark доказывался тестом, который искал
|
||||
// в тексте те самые подстроки, которые сам же и вырезал у мутанта, —
|
||||
// доказано было, что регулярка не пуста. Режим `--guard-probe` в
|
||||
// benchmark существовал с #423 и не вызывался ни одним прогоном; теперь
|
||||
// вызывается здесь, в единственной job с настоящим браузером.
|
||||
file: '../benchmark_backdrop_decode.mjs',
|
||||
args: ['--guard-probe'],
|
||||
expectExit: 1,
|
||||
expectOutput: /uncaught exception\(s\) inside the card/,
|
||||
because: 'benchmark открывает страницу Playwright и обязан выносить тот же вердикт,'
|
||||
+ ' что и смоки: исключение внутри карточки во время замера иначе не увидит никто',
|
||||
},
|
||||
{
|
||||
file: 'guard_report_page_errors.mjs',
|
||||
expectExit: 1,
|
||||
@@ -48,14 +67,14 @@ const PROBES = [
|
||||
|
||||
let failed = 0;
|
||||
for (const probe of PROBES) {
|
||||
const run = spawnSync(process.execPath, [resolve(HERE, probe.file)], {
|
||||
const run = spawnSync(process.execPath, [resolve(HERE, probe.file), ...probe.args || []], {
|
||||
encoding: 'utf8', cwd: resolve(HERE, '../..'), timeout: 90_000,
|
||||
});
|
||||
const output = `${run.stdout || ''}${run.stderr || ''}`;
|
||||
const exitOk = run.status === probe.expectExit;
|
||||
const textOk = probe.expectOutput.test(output);
|
||||
if (exitOk && textOk) {
|
||||
console.log(`ok ${probe.file} → exit ${run.status}`);
|
||||
console.log(`ok ${[probe.file, ...probe.args || []].join(' ')} → exit ${run.status}`);
|
||||
continue;
|
||||
}
|
||||
failed += 1;
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
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.
|
||||
@@ -23,6 +25,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
|
||||
@@ -119,11 +139,33 @@ 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-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,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,22 +17,41 @@ 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',
|
||||
'_settingsDialog',
|
||||
@@ -42,7 +61,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
// checks these members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze([
|
||||
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
|
||||
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_liveEditorPaintCount', '_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_wallFaceBatch', '_wallFaceGraphCache',
|
||||
]),
|
||||
// #380: v1.68.1 owns the same resize session directly on the card; newer
|
||||
@@ -54,12 +73,19 @@ 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',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_hassSequence: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_liveEditorPaintCount: 'number',
|
||||
_model: 'array',
|
||||
_onLabsSnapshot: 'function',
|
||||
_offerWallFaces: 'function',
|
||||
|
||||
@@ -143,6 +143,20 @@ export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absolu
|
||||
},
|
||||
));
|
||||
|
||||
for (const [windowName, budget] of Object.entries(budgets.longTaskWindows ?? {})) {
|
||||
const windows = candidate.rows.map((row) => row.longTasks?.[windowName]);
|
||||
const available = windows.every((window) => window?.supported === true);
|
||||
checks.push({
|
||||
id: `longTask.${windowName}.available`, actual: available ? 1 : 0, limit: 1, pass: available,
|
||||
});
|
||||
const maxSingle = available ? Math.max(...windows.map((window) => window.maxMs)) : NaN;
|
||||
const countP95 = available ? percentile(windows.map((window) => window.count), 0.95) : NaN;
|
||||
const totalP95 = available ? percentile(windows.map((window) => window.totalMs), 0.95) : NaN;
|
||||
checks.push(makeCheck(`longTask.${windowName}.maxSingleMs`, maxSingle, budget.maxSingleMs));
|
||||
checks.push(makeCheck(`longTask.${windowName}.countP95`, countP95, budget.maxCountP95));
|
||||
checks.push(makeCheck(`longTask.${windowName}.totalP95Ms`, totalP95, budget.maxTotalP95Ms));
|
||||
}
|
||||
|
||||
const candidateHeap = candidate.rows
|
||||
.map((row) => row.heapGrowthBytes)
|
||||
.filter(finite)
|
||||
|
||||
@@ -250,15 +250,18 @@ const res = await page.evaluate(async () => {
|
||||
await wait(100);
|
||||
const coldCalls = [];
|
||||
let coldRev = 40;
|
||||
let releaseColdConfig;
|
||||
const coldConfigGate = new Promise((resolve) => { releaseColdConfig = resolve; });
|
||||
const coldHass = window.__mkHass();
|
||||
const coldBaseCallWS = coldHass.callWS;
|
||||
coldHass.callWS = async (message) => {
|
||||
coldCalls.push(structuredClone(message));
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
await coldConfigGate;
|
||||
return { config: coldConfig, rev: coldRev, can_write: true };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') {
|
||||
return { layout: coldLayout, rev: coldRev };
|
||||
return { layout: structuredClone(coldLayout), rev: coldRev };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/delete') {
|
||||
delete coldLayout[message.device_id];
|
||||
@@ -274,6 +277,12 @@ const res = await page.evaluate(async () => {
|
||||
coldCard.setConfig({ type: 'custom:houseplan-card', title: 'Cold Area backfill' });
|
||||
document.body.append(coldCard);
|
||||
coldCard.hass = coldHass;
|
||||
await wait(100);
|
||||
const coldCacheStayedReadOnly = !coldCalls.some((message) =>
|
||||
message.type === 'houseplan/config/set'
|
||||
|| ['houseplan/layout/set', 'houseplan/layout/update', 'houseplan/layout/delete']
|
||||
.includes(message.type));
|
||||
releaseColdConfig();
|
||||
for (let attempt = 0; attempt < 20 && !coldCard._loadedOnce; attempt += 1) await wait(100);
|
||||
await wait(450);
|
||||
await paint(coldCard);
|
||||
@@ -525,6 +534,7 @@ const res = await page.evaluate(async () => {
|
||||
&& compositeDeletesAfter === compositeDeletesBefore
|
||||
&& c._newIds.has(compositeId) === compositeAttentionBefore
|
||||
&& !c._areaRelocationIds.has(compositeId),
|
||||
coldCacheStayedReadOnly,
|
||||
coldStartBackfilled,
|
||||
staticReadOnlyProjection: staticCard._areaRelocationIds.has('d_leak')
|
||||
&& staticLeft > 50 && !!staticEl?.querySelector('.newdot'),
|
||||
|
||||
@@ -110,6 +110,51 @@ const out = await page.evaluate(async () => {
|
||||
out.reducedTo4096 = reducedDims[0] === 4096 && reducedDims[1] === 4096;
|
||||
card._spaceDialog = { ...card._spaceDialog, planFile: null };
|
||||
|
||||
// ── #427: decor source >2 MiB can still choose a reduced copy ───────────
|
||||
// The canonical asset limit forbids only the original. Trailing bytes make
|
||||
// this valid JPEG exceed the source limit without another huge allocation;
|
||||
// Chromium deliberately ignores data after JPEG EOI while decoding.
|
||||
const decorBigFile = new File([
|
||||
bigBlob, new Uint8Array(2 * 1024 * 1024 + 1),
|
||||
], 'decor-large.jpg', { type: 'image/jpeg' });
|
||||
const realUploadDecorImage = card._editorRuntime._uploadDecorImage;
|
||||
let decorUpload = null;
|
||||
card._editorRuntime._uploadDecorImage = async (blob, name, replace) => {
|
||||
decorUpload = { blob, name, replace };
|
||||
};
|
||||
await card._editorRuntime._decorImageUpload({
|
||||
target: { files: [decorBigFile], value: 'x' },
|
||||
});
|
||||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||||
const decorButtons = guardButtons();
|
||||
out.decorOversizeOffersReducedWithoutOriginal = decorButtons.length === 2
|
||||
&& decorButtons.some((button) => button.textContent.includes(card._t('btn.cancel')))
|
||||
&& decorButtons.some((button) => button.textContent.includes(card._t('backdrop.use_downscaled')))
|
||||
&& !decorButtons.some((button) => button.textContent.includes(card._t('backdrop.keep_original')));
|
||||
decorButtons.find((button) => button.textContent.includes(
|
||||
card._t('backdrop.use_downscaled'),
|
||||
))?.click();
|
||||
out.decorOversizeUploadsReducedCopy = await waitFor(() => !!decorUpload
|
||||
&& !card._backdropGuard, 15000)
|
||||
&& decorUpload.blob !== decorBigFile
|
||||
&& decorUpload.blob.size < decorBigFile.size
|
||||
&& /-reduced\.jpg$/.test(decorUpload.name)
|
||||
&& decorUpload.replace === false;
|
||||
card._editorRuntime._uploadDecorImage = realUploadDecorImage;
|
||||
|
||||
await card._editorRuntime._decorImageUpload({
|
||||
target: {
|
||||
files: [fileOf(pngHeader(20000, 20000), 'decor-hard.png', 'image/png')],
|
||||
value: 'x',
|
||||
},
|
||||
});
|
||||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||||
const decorHardButtons = guardButtons();
|
||||
out.decorHardStillHasOnlyCancel = decorHardButtons.length === 1
|
||||
&& decorHardButtons[0].textContent.includes(card._t('btn.cancel'));
|
||||
decorHardButtons[0]?.click();
|
||||
await waitFor(() => !card._backdropGuard);
|
||||
|
||||
// ── alpha-ветка: PNG с alpha остаётся PNG ────────────────────────────────
|
||||
const alphaSrc = new OffscreenCanvas(6200, 6200);
|
||||
const alphaCtx = alphaSrc.getContext('2d');
|
||||
|
||||
@@ -325,6 +325,7 @@ const drawn = await page.evaluate(async () => {
|
||||
},
|
||||
options: { minDim: 1, eps: 0.01 }, rooms: resizeRooms, openings: [],
|
||||
snapshotIdentity: snapshot, before: JSON.parse(snapshot), wallUnionBefore: null,
|
||||
epochBefore: c._cfgEpoch,
|
||||
});
|
||||
c._resize.move({
|
||||
pointerId: 912, point: [687.5, 250], step: 0.5, snap: (point) => point,
|
||||
|
||||
@@ -41,37 +41,74 @@ const warmGate = await page.evaluate(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const originalLanguage = card._config?.language;
|
||||
const committedBody = root().querySelector('ha-card')?.outerHTML;
|
||||
const openDecision = card._confirmDanger({
|
||||
key: 'ready-before-warm',
|
||||
kind: 'destructive',
|
||||
title: 'Delete?',
|
||||
message: 'The plan and all its rooms will be deleted.',
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
});
|
||||
await settle();
|
||||
const openedWhileReady = !!root().querySelector('hp-confirm');
|
||||
card._config = { ...card._config, language: 'de' };
|
||||
card.requestUpdate();
|
||||
for (let attempt = 0; attempt < 50 && card._dangerConfirmLocaleGate !== 'warm'; attempt++) {
|
||||
for (let attempt = 0; attempt < 50 && !card.inert; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await settle();
|
||||
}
|
||||
|
||||
const entered = card._dangerConfirmLocaleGate === 'warm' && card.inert;
|
||||
const before = root().innerHTML;
|
||||
const entered = card.inert && card.getAttribute('aria-busy') === 'true';
|
||||
const cancelledOnTransition = await Promise.race([
|
||||
openDecision,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
await settle();
|
||||
const warmDecision = card._confirmDanger({
|
||||
key: 'warm-language-gate',
|
||||
kind: 'destructive',
|
||||
title: 'Delete?',
|
||||
message: 'The plan and all its rooms will be deleted.',
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
});
|
||||
// Refusal is a synchronous registration barrier, not eventual cleanup by
|
||||
// the next warm render. A briefly pending request has no decision surface.
|
||||
const warmRequestWasNeverRegistered = card._dangerConfirm === null
|
||||
&& card._dangerConfirmController.state === null;
|
||||
const decision = await Promise.race([
|
||||
card._confirmDanger({
|
||||
key: 'warm-language-gate',
|
||||
kind: 'destructive',
|
||||
title: 'Delete?',
|
||||
message: 'The plan and all its rooms will be deleted.',
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
}),
|
||||
warmDecision,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
const result = {
|
||||
warmLanguageGateActuallyEntered: entered,
|
||||
readyConfirmationOpenedBeforeWarm: openedWhileReady,
|
||||
readyToWarmCancelsOpenConfirmation: cancelledOnTransition === false,
|
||||
readyToWarmKeepsCommittedBody: root().querySelector('ha-card')?.outerHTML === committedBody,
|
||||
warmLanguageGateNeverRegisters: warmRequestWasNeverRegistered,
|
||||
warmLanguageGateRefusesImmediately: decision === false,
|
||||
warmLanguageGateKeepsControllerEmpty: card._dangerConfirm === null
|
||||
&& card._dangerConfirmController.state === null,
|
||||
warmLanguageGateKeepsNoChangeDom: root().innerHTML === before
|
||||
&& !root().querySelector('hp-confirm'),
|
||||
warmLanguageGateRemovesDecisionSurface: !root().querySelector('hp-confirm'),
|
||||
};
|
||||
|
||||
// Change the requested language back without waiting for render. A decision
|
||||
// made in this warm -> ready window must use current config/runtime state,
|
||||
// not the warm branch painted by the previous update.
|
||||
card._config = { ...card._config, language: originalLanguage };
|
||||
card.requestUpdate();
|
||||
const readyAgain = card._confirmDanger({
|
||||
key: 'ready-before-render',
|
||||
kind: 'warning',
|
||||
title: 'Continue?',
|
||||
message: 'The language is ready again.',
|
||||
confirmLabel: 'Continue',
|
||||
cancelLabel: 'Cancel',
|
||||
});
|
||||
result.warmToReadyAllowsBeforeRender = card._dangerConfirmController.state?.request.key
|
||||
=== 'ready-before-render' && !card.inert;
|
||||
card._cancelDangerConfirm();
|
||||
result.warmToReadyDecisionSettles = await readyAgain === false;
|
||||
await settle();
|
||||
return result;
|
||||
});
|
||||
@@ -81,7 +118,10 @@ await Promise.race([
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('German locale request not seen')), 1000)),
|
||||
]);
|
||||
releaseGerman();
|
||||
await germanCompleted;
|
||||
await Promise.race([
|
||||
germanCompleted,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('German locale request did not complete')), 1000)),
|
||||
]);
|
||||
await page.unroute(germanAsset, holdGerman);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
@@ -160,6 +200,28 @@ const out = await page.evaluate(async () => {
|
||||
}));
|
||||
await settle();
|
||||
};
|
||||
const boundedDangerDecision = async (decision, timeoutMs = 250) => {
|
||||
let timeoutId;
|
||||
const outcome = await Promise.race([
|
||||
decision.then((value) => ({ settled: true, value })),
|
||||
new Promise((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve({ settled: false, value: 'timeout' }), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timeoutId);
|
||||
if (!outcome.settled) {
|
||||
// A missing or dead decision surface must fail the smoke quickly instead
|
||||
// of consuming the whole mutation shard. Cancelling also keeps later
|
||||
// branches independent from the timed-out controller state.
|
||||
card._cancelDangerConfirm();
|
||||
await Promise.race([
|
||||
decision,
|
||||
new Promise((resolve) => setTimeout(resolve, 100)),
|
||||
]);
|
||||
await settle();
|
||||
}
|
||||
return outcome;
|
||||
};
|
||||
|
||||
await settle();
|
||||
|
||||
@@ -179,7 +241,9 @@ const out = await page.evaluate(async () => {
|
||||
await settle();
|
||||
result.mainBranchRendersExactlyOneConfirm = dialogs() === 1;
|
||||
card._cancelDangerConfirm();
|
||||
result.mainBranchCancelResolvesFalse = (await single) === false;
|
||||
const singleDecision = await boundedDangerDecision(single);
|
||||
result.mainBranchCancelResolvesFalse = singleDecision.settled
|
||||
&& singleDecision.value === false;
|
||||
|
||||
const savedCfg = card._serverCfg;
|
||||
const savedModel = card._model;
|
||||
@@ -206,7 +270,9 @@ const out = await page.evaluate(async () => {
|
||||
await settle();
|
||||
result.onboardingBranchShowsConfirm = dialogs() === 1;
|
||||
await decide(false);
|
||||
result.onboardingBranchResolves = (await onboarding) === false;
|
||||
const onboardingDecision = await boundedDangerDecision(onboarding);
|
||||
result.onboardingBranchResolves = onboardingDecision.settled
|
||||
&& onboardingDecision.value === false;
|
||||
await leaveBranch();
|
||||
|
||||
// Открытое подтверждение переживает смену ветки: раньше оно исчезало вместе
|
||||
@@ -217,8 +283,9 @@ const out = await page.evaluate(async () => {
|
||||
await enterBranch([]);
|
||||
const stillOpenInOnboarding = dialogs() === 1;
|
||||
await decide(true);
|
||||
const survivorDecision = await boundedDangerDecision(survivor);
|
||||
result.openConfirmSurvivesBranchChange = openedInMainBranch && stillOpenInOnboarding
|
||||
&& (await survivor) === true;
|
||||
&& survivorDecision.settled && survivorDecision.value === true;
|
||||
await leaveBranch();
|
||||
|
||||
// The exact missed branch from #417: a non-empty model whose active space
|
||||
@@ -231,22 +298,17 @@ const out = await page.evaluate(async () => {
|
||||
card._spaceModel = () => undefined;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
const spaceLossDecision = await Promise.race([
|
||||
pendingAtSpaceLoss,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
const spaceLossDecision = await boundedDangerDecision(pendingAtSpaceLoss);
|
||||
result.lostSpaceBranchIsActuallyEntered = root().childElementCount === 0;
|
||||
result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision === false
|
||||
result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision.settled
|
||||
&& spaceLossDecision.value === false
|
||||
&& card._dangerConfirm === null && card._dangerConfirmController.state === null;
|
||||
const refusedWithoutSpacePromise = card._confirmDanger(request('space-already-lost'));
|
||||
const refusedWithoutSpaceWasNeverRegistered = card._dangerConfirm === null
|
||||
&& card._dangerConfirmController.state === null;
|
||||
const refusedWithoutSpace = await Promise.race([
|
||||
refusedWithoutSpacePromise,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
const refusedWithoutSpace = await boundedDangerDecision(refusedWithoutSpacePromise);
|
||||
result.lostSpaceRequestRefusesImmediately = refusedWithoutSpaceWasNeverRegistered
|
||||
&& refusedWithoutSpace === false
|
||||
&& refusedWithoutSpace.settled && refusedWithoutSpace.value === false
|
||||
&& card._dangerConfirm === null && card._dangerConfirmController.state === null
|
||||
&& dialogs() === 0;
|
||||
card._spaceModel = originalSpaceModel;
|
||||
@@ -268,7 +330,8 @@ const out = await page.evaluate(async () => {
|
||||
await settle();
|
||||
const shown = dialogs() === 1;
|
||||
await decide(false);
|
||||
return branchEntered && shown && (await pending) === false;
|
||||
const decision = await boundedDangerDecision(pending);
|
||||
return branchEntered && shown && decision.settled && decision.value === false;
|
||||
};
|
||||
result.fixedFloorPendingStillShowsConfirm = await confirmInFixedBranch(false, 'pending');
|
||||
result.fixedFloorInvalidStillShowsConfirm = await confirmInFixedBranch(true, 'invalid');
|
||||
@@ -300,7 +363,9 @@ const out = await page.evaluate(async () => {
|
||||
}
|
||||
}
|
||||
await settle();
|
||||
result.touchTapOnCancelResolvesFalse = !!cancelButton && (await byTap) === false
|
||||
const tapDecision = await boundedDangerDecision(byTap);
|
||||
result.touchTapOnCancelResolvesFalse = !!cancelButton
|
||||
&& tapDecision.settled && tapDecision.value === false
|
||||
&& dialogs() === 0;
|
||||
await leaveBranch();
|
||||
|
||||
@@ -316,7 +381,9 @@ const out = await page.evaluate(async () => {
|
||||
card.hass = savedHass;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
result.notReadyCardRefusesInsteadOfHanging = (await refused) === false;
|
||||
const refusedDecision = await boundedDangerDecision(refused);
|
||||
result.notReadyCardRefusesInsteadOfHanging = refusedDecision.settled
|
||||
&& refusedDecision.value === false;
|
||||
|
||||
// Match the public surface of HA's pinned ha-dialog closely enough to prove
|
||||
// branch selection and ARIA forwarding without depending on private shadow
|
||||
|
||||
@@ -10,6 +10,7 @@ const res = await page.evaluate(async () => {
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
@@ -272,10 +273,10 @@ const res = await page.evaluate(async () => {
|
||||
const vw = () => c._viewOr(c._baseVb());
|
||||
c._decorTool = 'line';
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.lineBadgeShown = !!label();
|
||||
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
|
||||
out.lineBadgeText = txt();
|
||||
@@ -287,12 +288,12 @@ const res = await page.evaluate(async () => {
|
||||
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
|
||||
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
|
||||
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
|
||||
// прямоугольник показывает габарит и чистую площадь
|
||||
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.rectBadgeHasSizeAndArea = txt().startsWith(fmt(g * 8) + ' × ' + fmt(g * 6) + ' · ')
|
||||
&& /(?:m²|ft²)$/.test(txt());
|
||||
// отпустили — плашки нет
|
||||
@@ -493,6 +494,127 @@ const res = await page.evaluate(async () => {
|
||||
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
|
||||
c._decorTool = 'select'; await c.updateComplete;
|
||||
out.decorRestored = c._decorList.length === decorBefore.length;
|
||||
// #447: Arrow is a separate rigid transaction, not a synthetic pointer
|
||||
// move. It preserves off-grid phase, bypasses both magnets and keeps one
|
||||
// history/save boundary per browser keydown.
|
||||
const keyboardOriginal = JSON.parse(JSON.stringify(c._decorList));
|
||||
const keyboardShapes = [
|
||||
{ id: 'key-rect', kind: 'rect', x: 0.1013, y: 0.2017, w: 0.08, h: 0.05,
|
||||
angle: 17, color: '#123456', fill: true },
|
||||
{ id: 'key-furniture', kind: 'furniture', x: 0.3013, y: 0.4017, w: 0.08, h: 0.05,
|
||||
symbol: 'chair', angle: 90, flip_h: true },
|
||||
{ id: 'key-image', kind: 'image', x: 0.5013, y: 0.6017, w: 0.08, h: 0.05,
|
||||
asset_id: 'smoke-missing-asset', opacity: 0.6 },
|
||||
];
|
||||
c._curSpaceCfg.decor = keyboardShapes;
|
||||
c._decorTool = 'select'; c._decorSel = 'key-furniture';
|
||||
c._decorDraft = null; c._decorMove = null; c._dtDrag = null; c._bdDrag = null;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._geometryHistory.clear();
|
||||
c._saveConfigDebounced.cancel();
|
||||
const realDebouncedSave = c._saveConfigDebounced;
|
||||
let keyboardSaves = 0, decorSnapCalls = 0, furnitureMoveCalls = 0;
|
||||
const countedSave = () => { keyboardSaves += 1; };
|
||||
countedSave.pending = () => false; countedSave.cancel = () => {}; countedSave.flush = () => {};
|
||||
c._saveConfigDebounced = countedSave;
|
||||
const realDecorSnap = c._decorSnap;
|
||||
const realFurnitureMove = c._editorRuntime._furnMoveUpdate;
|
||||
c._decorSnap = (...args) => { decorSnapCalls += 1; return realDecorSnap.apply(c, args); };
|
||||
c._editorRuntime._furnMoveUpdate = (...args) => {
|
||||
furnitureMoveCalls += 1;
|
||||
return realFurnitureMove.apply(c._editorRuntime, args);
|
||||
};
|
||||
const arrow = (key, target = window, init = {}) => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key, bubbles: true, composed: true, cancelable: true, ...init,
|
||||
});
|
||||
return target.dispatchEvent(event) === false;
|
||||
};
|
||||
const keyed = (id) => c._decorList.find((shape) => shape.id === id);
|
||||
const keyStep = g / 1000;
|
||||
const furnitureBeforeKey = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
const epochBeforeKey = c._cfgEpoch;
|
||||
const firstArrowPrevented = arrow('ArrowRight');
|
||||
const furnitureAfterRight = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
out.arrowMovesFurnitureOneCellWithoutMagnet = firstArrowPrevented
|
||||
&& Math.abs(furnitureAfterRight.x - furnitureBeforeKey.x - keyStep) < 1e-12
|
||||
&& furnitureAfterRight.y === furnitureBeforeKey.y
|
||||
&& furnitureAfterRight.angle === furnitureBeforeKey.angle
|
||||
&& furnitureAfterRight.flip_h === furnitureBeforeKey.flip_h
|
||||
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
|
||||
out.arrowIsOneHistoryAndSaveTransaction = c._geometryHistory.size === 1
|
||||
&& c._geometryHistory.undoName === c._t('history.decor_move')
|
||||
&& keyboardSaves === 1 && c._cfgEpoch === epochBeforeKey + 1
|
||||
&& c._decorSel === 'key-furniture';
|
||||
const shiftArrowPrevented = arrow('ArrowDown', window, { shiftKey: true });
|
||||
const furnitureAfterDown = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
out.shiftArrowKeepsTheSameOneCellStep = shiftArrowPrevented
|
||||
&& Math.abs(furnitureAfterDown.y - furnitureAfterRight.y - keyStep) < 1e-12
|
||||
&& c._geometryHistory.size === 2 && keyboardSaves === 2;
|
||||
arrow('z', window, { ctrlKey: true });
|
||||
const furnitureAfterUndo = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
arrow('y', window, { ctrlKey: true });
|
||||
const furnitureAfterRedo = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
out.arrowUndoRedoIsExactlyOneStep = Math.abs(furnitureAfterUndo.y - furnitureAfterRight.y) < 1e-8
|
||||
&& Math.abs(furnitureAfterRedo.y - furnitureAfterDown.y) < 1e-8
|
||||
&& Math.abs(furnitureAfterRedo.x - furnitureAfterRight.x) < 1e-8
|
||||
&& c._decorSel === 'key-furniture';
|
||||
|
||||
c._decorSel = 'key-image';
|
||||
const imageX = keyed('key-image').x;
|
||||
arrow('ArrowLeft');
|
||||
c._decorSel = 'key-rect';
|
||||
const rectY = keyed('key-rect').y;
|
||||
arrow('ArrowUp');
|
||||
out.imageAndOrdinaryDecorUseTheSameArrowPath = Math.abs(keyed('key-image').x - imageX + keyStep) < 1e-12
|
||||
&& Math.abs(keyed('key-rect').y - rectY + keyStep) < 1e-12
|
||||
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
|
||||
|
||||
const guardX = () => keyed('key-rect').x;
|
||||
const guardStart = guardX();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const controls = ['input', 'textarea', 'select'].map((tag) => document.createElement(tag));
|
||||
const editable = document.createElement('div'); editable.contentEditable = 'true';
|
||||
controls.push(editable);
|
||||
const fieldResults = [];
|
||||
for (const control of controls) {
|
||||
document.body.append(control); control.focus();
|
||||
fieldResults.push(!arrow('ArrowRight', control) && guardX() === guardStart);
|
||||
control.remove();
|
||||
}
|
||||
out.arrowLeavesFormControlsAlone = fieldResults.every(Boolean);
|
||||
const toolbarControl = sr().querySelector('.editor-secondary button');
|
||||
toolbarControl?.focus();
|
||||
const toolbarGuarded = !!toolbarControl && !arrow('ArrowRight', toolbarControl) && guardX() === guardStart;
|
||||
toolbarControl?.blur();
|
||||
c._decorEraseConfirm = { id: 'key-rect', kind: 'rect' };
|
||||
const dialogGuarded = !arrow('ArrowRight') && guardX() === guardStart;
|
||||
c._decorEraseConfirm = null;
|
||||
c._decorMove = { id: 'key-rect', start: [0, 0], orig: JSON.parse(JSON.stringify(keyed('key-rect'))),
|
||||
pid: 447, moved: false, before: c._geometrySnapshot() };
|
||||
const gestureGuarded = !arrow('ArrowRight') && guardX() === guardStart;
|
||||
c._decorMove = null;
|
||||
out.arrowRespectsToolbarDialogAndGestureGuards = toolbarGuarded && dialogGuarded && gestureGuarded;
|
||||
const modifierResults = [
|
||||
{ ctrlKey: true }, { metaKey: true }, { altKey: true },
|
||||
].map((init) => !arrow('ArrowRight', window, init) && guardX() === guardStart);
|
||||
out.modifiedArrowsRemainBrowserCommands = modifierResults.every(Boolean);
|
||||
|
||||
c._curSpaceCfg.decor = [{ id: 'key-limit', kind: 'rect', x: 4999.9, y: 0, w: 0.1, h: 0.1 }];
|
||||
c._decorSel = 'key-limit'; c._geometryHistory.clear();
|
||||
const savesBeforeNoop = keyboardSaves, epochBeforeNoop = c._cfgEpoch;
|
||||
const noopPrevented = arrow('ArrowRight');
|
||||
out.boundaryNoopIsConsumedButNotSaved = noopPrevented
|
||||
&& c._curSpaceCfg.decor[0].x === 4999.9
|
||||
&& c._geometryHistory.size === 0 && keyboardSaves === savesBeforeNoop
|
||||
&& c._cfgEpoch === epochBeforeNoop;
|
||||
|
||||
c._decorSnap = realDecorSnap;
|
||||
c._editorRuntime._furnMoveUpdate = realFurnitureMove;
|
||||
c._saveConfigDebounced = realDebouncedSave;
|
||||
c._curSpaceCfg.decor = keyboardOriginal;
|
||||
c._decorSel = null; c._geometryHistory.clear(); c._cfgEpoch++;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// Switching modes during a live move cancels the transaction and restores
|
||||
// its start instead of leaving mutated geometry with a forgotten pointer.
|
||||
const liveShape = c._decorList[0];
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
* Backend upload/parser/storage security is covered by tests_backend; this
|
||||
* smoke uses deterministic in-memory raster data so it never depends on a
|
||||
* writable demo server or leaves user files behind.
|
||||
*
|
||||
* С #433 сюда входит и путь загрузки: последний блок проходит настоящие
|
||||
* `uploadFromInput` → `upload` → `delete`, подменяя только транспорт
|
||||
* (`fetchWithAuth` и одну ветку `callWS`). До этого смок подменял `callWS`
|
||||
* целиком и до этих методов не доходил — именно поэтому #427 (гасли обе
|
||||
* кнопки диалога для файла тяжелее 2 МиБ) прожил четыре круга ревью.
|
||||
* Детерминированная часть того же контракта — `test/decor-image-upload.test.mjs`.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
@@ -199,6 +206,82 @@ const result = await page.evaluate(async () => {
|
||||
out.hideDecorHidesImages = !root().querySelector('.decorlayer image[data-kind="image"]');
|
||||
sp.settings = { ...(sp.settings || {}), hide_decor: false };
|
||||
await settle();
|
||||
|
||||
// ── #433: настоящий uploadFromInput, а не подмена callWS ──────────────────
|
||||
// Шапка этого файла честно признавалась, что путь загрузки не проверяется
|
||||
// ничем: смок подменял `callWS` и до `uploadFromInput`/`upload`/`delete` не
|
||||
// доходил. Цена — #427, четыре круга ревью. Здесь подменяется только
|
||||
// транспорт (`fetchWithAuth`), а весь путь от change-события до каталога
|
||||
// проходит продуктовый код собранного бандла.
|
||||
const id3 = '7'.repeat(64);
|
||||
const rawUrl3 = `/api/houseplan/content/assets/_/${id3}.png`;
|
||||
const asset3 = {
|
||||
asset_id: id3, name: 'small.png', mime: 'image/png',
|
||||
width: 100, height: 100, bytes: 33, url: rawUrl3, used_by: [],
|
||||
};
|
||||
// PNG до конца IHDR: на проходном пути ничего не декодирует ни карточка, ни
|
||||
// браузер — заголовок читает probeBackdrop, байты уходят как есть.
|
||||
const pngHead = new Uint8Array(33);
|
||||
pngHead.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
|
||||
pngHead.set([0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52], 8);
|
||||
new DataView(pngHead.buffer).setUint32(16, 100);
|
||||
new DataView(pngHead.buffer).setUint32(20, 100);
|
||||
pngHead[24] = 8; pngHead[25] = 2;
|
||||
const uploads = [];
|
||||
const toasts = [];
|
||||
const realToast = c._showToast.bind(c);
|
||||
const realConfirm = c._confirmDanger.bind(c);
|
||||
c._showToast = (text) => { toasts.push(text); };
|
||||
c.hass = { ...c.hass, fetchWithAuth: async (url, init) => {
|
||||
uploads.push({ url, method: init?.method, name: init?.body?.get('file')?.name });
|
||||
return { ok: true, json: async () => ({ asset: asset3 }) };
|
||||
} };
|
||||
c._decorAssetCatalog = [asset];
|
||||
c._decorImagePalette = null;
|
||||
await c._editorRuntime._decorImageUpload({
|
||||
target: { files: [new File([pngHead], 'small.png', { type: 'image/png' })], value: 'x' },
|
||||
});
|
||||
await settle();
|
||||
out.uploadPassesThroughRealInputHandler = uploads.length === 1
|
||||
&& uploads[0].url === '/api/houseplan/assets/upload'
|
||||
&& uploads[0].method === 'POST'
|
||||
&& uploads[0].name === 'small.png'
|
||||
&& !c._backdropGuard;
|
||||
out.uploadedAssetArmsPaletteAndCatalog = c._decorAssetCatalog[0]?.asset_id === id3
|
||||
&& c._decorAssets.get(id3)?.url === rawUrl3
|
||||
&& c._decorImagePalette?.asset_id === id3
|
||||
&& c._decorAssetBusy === false;
|
||||
|
||||
// Неподдерживаемый формат: тост и ни одного запроса.
|
||||
toasts.length = 0;
|
||||
await c._editorRuntime._decorImageUpload({
|
||||
target: { files: [new File([pngHead], 'plan.gif', { type: 'image/gif' })], value: 'x' },
|
||||
});
|
||||
out.rejectedFormatNeverReachesTransport = uploads.length === 1
|
||||
&& toasts.length === 1 && toasts[0] === c._t('toast.plan_formats');
|
||||
|
||||
// Удаление: подтверждение спрашивается, WS уходит, каталог редеет.
|
||||
const deletes = [];
|
||||
const beforeDelete = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/assets/delete') { deletes.push(message.asset_id); return {}; }
|
||||
return beforeDelete(message);
|
||||
} };
|
||||
let confirmAsked = 0;
|
||||
c._confirmDanger = async () => { confirmAsked++; return true; };
|
||||
await c._editorRuntime._decorImageDelete(asset3);
|
||||
await settle();
|
||||
out.deleteAsksConfirmationAndPrunesCatalog = confirmAsked === 1
|
||||
&& deletes.length === 1 && deletes[0] === id3
|
||||
&& !c._decorAssetCatalog.some((row) => row.asset_id === id3)
|
||||
&& !c._decorAssets.has(id3)
|
||||
&& c._decorImagePalette === null;
|
||||
c._showToast = realToast;
|
||||
c._confirmDanger = realConfirm;
|
||||
c._decorAssets = new Map([[id, asset], [id2, asset2]]);
|
||||
c._decorAssetCatalog = [asset, asset2];
|
||||
await settle();
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const config = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
let staticResolveCalls = 0;
|
||||
|
||||
@@ -27,9 +27,11 @@ const res = await page.evaluate(async () => {
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;
|
||||
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
|
||||
// смок должен показать список провалов, а не упасть с исключением
|
||||
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
const el = (id) => sr().querySelector(`[data-hp-live-editor] .decorlayer text.dtext[data-id="${id}"]`)
|
||||
|| sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
|
||||
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
@@ -101,7 +103,7 @@ const res = await page.evaluate(async () => {
|
||||
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.fourCornersAndRotate = !!frame()
|
||||
&& frame().querySelectorAll('.dthandle').length === 5
|
||||
@@ -147,7 +149,7 @@ const res = await page.evaluate(async () => {
|
||||
ev('pointerdown', corner, ax + 40, ay2);
|
||||
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
|
||||
ev('pointermove', stageEl(), ax + 80, ay2);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const scaled = c._decorList.find((x) => x.id === 'dtm');
|
||||
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
|
||||
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
|
||||
|
||||