mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Compare commits
230
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 | ||
|
|
a9fc2321a9 | ||
|
|
694db13760 | ||
|
|
ea01e3a683 | ||
|
|
44a38b3378 | ||
|
|
ff42d650ca | ||
|
|
3a6ceeea54 | ||
|
|
a265830e3b | ||
|
|
294a70b6be | ||
|
|
cadcbcc5f1 | ||
|
|
b3bcd3df8f | ||
|
|
e56f0f297b | ||
|
|
590d178e29 | ||
|
|
fe8970ee84 | ||
|
|
dc95563d96 | ||
|
|
09accce808 | ||
|
|
25f750378b | ||
|
|
52333c73d0 | ||
|
|
d4ecace64c | ||
|
|
f399ad547b | ||
|
|
84b5f63cbb | ||
|
|
3df0f1646b | ||
|
|
4eae3398a6 | ||
|
|
e438c25ebe | ||
|
|
a1e1748b2d | ||
|
|
0f5161164f | ||
|
|
74ad675a24 | ||
|
|
653b9a7632 | ||
|
|
d59ceee905 | ||
|
|
8ed8ecc3fd | ||
|
|
70805c9c6b | ||
|
|
9811674195 | ||
|
|
921d0f4c0d | ||
|
|
8de9ea008a | ||
|
|
09c410000a | ||
|
|
cfa5c7790f | ||
|
|
3576cc2ea7 | ||
|
|
4a7de3370a | ||
|
|
2038ab91b9 | ||
|
|
766bf33cec | ||
|
|
7102994dc6 | ||
|
|
c730b288ab | ||
|
|
f4b425f3c0 | ||
|
|
03cc606396 | ||
|
|
fac1f8840e | ||
|
|
7f9b919888 | ||
|
|
c409ec6a61 | ||
|
|
340c770b92 | ||
|
|
c5d9775c7a | ||
|
|
8215b61f4d | ||
|
|
567a45ab6b | ||
|
|
489e289f79 | ||
|
|
ce3e92b22b | ||
|
|
153dc3f794 | ||
|
|
1e479154f7 | ||
|
|
a3fb360ae1 | ||
|
|
080eb5ceff | ||
|
|
2b978d63ef | ||
|
|
e3073f6840 | ||
|
|
b71bc39b34 | ||
|
|
ad4cce0dda | ||
|
|
59ea93b4ba | ||
|
|
e46220f3eb | ||
|
|
1fd042d9e0 | ||
|
|
ef0d2be2be | ||
|
|
0e23ac2eee | ||
|
|
a79d4cd9d2 | ||
|
|
67935fa8c0 | ||
|
|
5e357a689a | ||
|
|
0e235047ef | ||
|
|
308aae1fc8 | ||
|
|
27044543a6 |
@@ -77,8 +77,17 @@ jobs:
|
||||
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/oxipng" --version
|
||||
- name: Capture
|
||||
run: node demo/docs/capture.mjs
|
||||
# Съёмка идёт дважды и сравнивается по хешам (#422). Прежний замер
|
||||
# (`--stability`) отвечает на вопрос «плавает ли кадр от времени внутри
|
||||
# страницы» и остаётся ниже; дефект #410 был по другой оси — обрезка
|
||||
# плавала МЕЖДУ прогонами, и три снимка внутри одного процесса совпали бы
|
||||
# всегда. Проверка, объявленная гарантией воспроизводимости, на
|
||||
# собственном инциденте промолчала бы.
|
||||
#
|
||||
# Второй прогон заодно оставляет в `docs/images` кадры, которые и уедут в
|
||||
# артефакт: сравнивать хеши и публиковать разные файлы было бы странно.
|
||||
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
|
||||
run: node scripts/capture-determinism.mjs
|
||||
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
|
||||
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
|
||||
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
|
||||
@@ -94,8 +103,9 @@ jobs:
|
||||
# правки состояния между ними, обязаны совпасть побайтово. Шаг падает,
|
||||
# если кадр снова начнёт зависеть от времени.
|
||||
#
|
||||
# Проверка стоит ПЕРЕД съёмкой набора: смысла публиковать артефакт,
|
||||
# снятый недетерминированной съёмкой, нет.
|
||||
# Шаг стоит ПОСЛЕ съёмки и до вердикта: публикацию артефакта он всё равно
|
||||
# блокирует падением job'а, а снимать набор третий раз ради порядка строк
|
||||
# в логе незачем.
|
||||
- name: "Кадр не плавает внутри одного состояния (#410)"
|
||||
run: node demo/docs/capture.mjs --stability=3
|
||||
- name: Хеши кадров
|
||||
|
||||
@@ -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), а в логе прогона осталось
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
@@ -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).
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FRONTEND_URL,
|
||||
@@ -39,11 +40,13 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
from .frontend_assets import HouseplanFrontendAssetView
|
||||
from .http_api import (
|
||||
HouseplanContentView,
|
||||
HouseplanDecorAssetUploadView,
|
||||
HouseplanImportPreviewView,
|
||||
HouseplanUploadView,
|
||||
)
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanDecorAssetUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
hass.http.register_view(HouseplanImportPreviewView())
|
||||
hass.http.register_view(HouseplanFrontendAssetView())
|
||||
@@ -87,8 +90,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||
files_path = Path(hass.config.path(FILES_DIR))
|
||||
assets_path = Path(hass.config.path(ASSETS_DIR))
|
||||
await hass.async_add_executor_job(
|
||||
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
|
||||
lambda: (
|
||||
plans_path.mkdir(parents=True, exist_ok=True),
|
||||
files_path.mkdir(parents=True, exist_ok=True),
|
||||
assets_path.mkdir(parents=True, exist_ok=True),
|
||||
)
|
||||
)
|
||||
|
||||
# Static paths cannot be unregistered — register once per HA run.
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +29,9 @@ MAX_PLANS_FILES = 200
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
MAX_DECOR_ASSET_BYTES = 2 * 1024 * 1024
|
||||
MAX_DECOR_ASSETS_BYTES = 256 * 1024 * 1024
|
||||
MAX_DECOR_ASSETS_COUNT = 200
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
@@ -46,8 +49,10 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.70.0-beta.4"
|
||||
VERSION = "1.72.0-beta.1"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
# URL would turn the integration into an SSRF proxy and make the privacy notice
|
||||
@@ -55,6 +60,7 @@ VERSION = "1.70.0-beta.4"
|
||||
# reviewed release change, not a Home Assistant option.
|
||||
SUPPORT_RELAY_URL = "https://support.houseplan.tech/v1/reports"
|
||||
SUPPORT_PREVIEW_TTL_S = 10 * 60
|
||||
SUPPORT_API_VERSION = 1
|
||||
MAX_SUPPORT_PREVIEWS_PER_USER = 3
|
||||
MAX_SUPPORT_PREVIEWS_TOTAL = 3
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES = 8 * 1024 * 1024
|
||||
@@ -65,7 +71,7 @@ MAX_SUPPORT_CONTACT_CODEPOINTS = 320
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
# user have different compatibility lifecycles.
|
||||
PLAN_MODEL_VERSION = 9
|
||||
EXPORT_VERSION = 1
|
||||
EXPORT_VERSION = 2
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
MAX_IMPORT_PREVIEWS_PER_USER = 3
|
||||
@@ -91,11 +97,12 @@ ERROR_CODES: frozenset[str] = frozenset({
|
||||
"capacity_exceeded", "commit_failed", "conflict",
|
||||
"content_confirmation_required", "duplicate_marker_control",
|
||||
"future_model", "in_use", "invalid_config", "invalid_content",
|
||||
"invalid_data", "invalid_format", "invalid_json", "invalid_layout",
|
||||
"invalid_data", "invalid_format", "invalid_image", "invalid_json", "invalid_layout",
|
||||
"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",
|
||||
@@ -106,7 +113,7 @@ ERROR_CODES: frozenset[str] = frozenset({
|
||||
"support_invalid_message", "support_package_too_large",
|
||||
"support_preview_expired", "support_rate_limited", "support_rejected",
|
||||
"support_unavailable",
|
||||
"too_large", "unauthorized", "unsupported_export_version",
|
||||
"too_large", "unauthorized", "unsupported_export_version", "unsupported_image",
|
||||
"value_badge_source_required", "wall_model_client_outdated",
|
||||
"wall_model_migration_blocked",
|
||||
})
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Content-addressed custom images for the decorative layer (#51).
|
||||
|
||||
Upload plumbing hands this module untrusted bytes. It either returns one fully
|
||||
validated canonical asset or rejects the whole file; SVG is never repaired or
|
||||
partially stripped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import stat
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.parsers import expat
|
||||
|
||||
from .const import CONTENT_URL, MAX_DECOR_ASSET_BYTES
|
||||
|
||||
ASSET_ID_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
ASSET_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".svg"})
|
||||
MIME_BY_EXT = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".svg": "image/svg+xml",
|
||||
}
|
||||
EXT_BY_MIME = {
|
||||
"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
MAX_RASTER_DIMENSION = 16_384
|
||||
MAX_RASTER_PIXELS = (128 * 1024 * 1024) // 4
|
||||
MAX_SVG_ELEMENTS = 5_000
|
||||
MAX_SVG_DEPTH = 64
|
||||
MAX_SVG_ATTR_CHARS = 512_000
|
||||
MAX_SVG_ATTR_VALUE_CHARS = 65_536
|
||||
_SVG_TAGS = frozenset({
|
||||
"svg", "g", "defs", "title", "desc", "path", "rect", "circle", "ellipse",
|
||||
"line", "polyline", "polygon", "clipPath", "mask", "linearGradient",
|
||||
"radialGradient", "stop",
|
||||
})
|
||||
_SVG_ATTRS = frozenset({
|
||||
"xmlns", "viewBox", "width", "height", "x", "y", "x1", "y1", "x2", "y2",
|
||||
"cx", "cy", "r", "rx", "ry", "d", "points", "transform", "fill", "fill-rule",
|
||||
"fill-opacity", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin",
|
||||
"stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "opacity", "offset",
|
||||
"stop-color", "stop-opacity", "gradientUnits", "gradientTransform", "id",
|
||||
"clip-path", "mask", "preserveAspectRatio", "href",
|
||||
})
|
||||
_LOCAL_REF = re.compile(r"^url\(#[A-Za-z_][A-Za-z0-9_.:-]*\)$")
|
||||
_LENGTH = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*(?:px)?\s*$", re.I)
|
||||
_SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
||||
_SVG_UNIT_INTERVAL_ATTRS = frozenset({
|
||||
"opacity", "fill-opacity", "stroke-opacity", "stop-opacity", "offset",
|
||||
})
|
||||
|
||||
|
||||
class DecorAssetError(ValueError):
|
||||
"""Stable validation failure suitable for an API error response."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidatedAsset:
|
||||
data: bytes
|
||||
mime: str
|
||||
ext: str
|
||||
width: int
|
||||
height: int
|
||||
|
||||
@property
|
||||
def asset_id(self) -> str:
|
||||
return hashlib.sha256(self.data).hexdigest()
|
||||
|
||||
|
||||
def _check_size(data: bytes) -> None:
|
||||
if not data:
|
||||
raise DecorAssetError("invalid_image", "The image is empty")
|
||||
if len(data) > MAX_DECOR_ASSET_BYTES:
|
||||
raise DecorAssetError("too_large", "The image exceeds the 2 MiB limit")
|
||||
|
||||
|
||||
def _raster_dimensions(data: bytes, ext: str) -> tuple[int, int]:
|
||||
try:
|
||||
if ext == ".png":
|
||||
if (len(data) < 45 or data[:8] != b"\x89PNG\r\n\x1a\n"
|
||||
or data[12:16] != b"IHDR" or b"IDAT" not in data
|
||||
or data[-8:-4] != b"IEND"):
|
||||
raise ValueError
|
||||
return struct.unpack(">II", data[16:24])
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
if len(data) < 4 or data[:2] != b"\xff\xd8":
|
||||
raise ValueError
|
||||
pos = 2
|
||||
while pos + 4 <= len(data):
|
||||
if data[pos] != 0xFF:
|
||||
pos += 1
|
||||
continue
|
||||
marker = data[pos + 1]
|
||||
pos += 2
|
||||
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
|
||||
continue
|
||||
length = int.from_bytes(data[pos:pos + 2], "big")
|
||||
if length < 2 or pos + length > len(data):
|
||||
raise ValueError
|
||||
if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
|
||||
if length < 7:
|
||||
raise ValueError
|
||||
return (
|
||||
int.from_bytes(data[pos + 5:pos + 7], "big"),
|
||||
int.from_bytes(data[pos + 3:pos + 5], "big"),
|
||||
)
|
||||
pos += length
|
||||
raise ValueError
|
||||
if ext == ".webp":
|
||||
if len(data) < 30 or data[:4] != b"RIFF" or data[8:12] != b"WEBP":
|
||||
raise ValueError
|
||||
kind = data[12:16]
|
||||
if kind == b"VP8X":
|
||||
return (
|
||||
1 + int.from_bytes(data[24:27], "little"),
|
||||
1 + int.from_bytes(data[27:30], "little"),
|
||||
)
|
||||
if kind == b"VP8 ":
|
||||
idx = data.find(b"\x9d\x01\x2a", 20)
|
||||
if idx < 0 or idx + 7 > len(data):
|
||||
raise ValueError
|
||||
return (
|
||||
int.from_bytes(data[idx + 3:idx + 5], "little") & 0x3FFF,
|
||||
int.from_bytes(data[idx + 5:idx + 7], "little") & 0x3FFF,
|
||||
)
|
||||
if kind == b"VP8L" and data[20] == 0x2F:
|
||||
bits = int.from_bytes(data[21:25], "little")
|
||||
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||
raise ValueError
|
||||
except (IndexError, struct.error, ValueError) as err:
|
||||
raise DecorAssetError(
|
||||
"invalid_image", "The raster image is corrupt or has the wrong type",
|
||||
) from err
|
||||
raise DecorAssetError("unsupported_image", "Unsupported image type")
|
||||
|
||||
|
||||
def _validate_dimensions(width: float, height: float) -> tuple[int, int]:
|
||||
if not math.isfinite(width) or not math.isfinite(height) or width <= 0 or height <= 0:
|
||||
raise DecorAssetError("invalid_image", "The image has no usable dimensions")
|
||||
w, h = int(round(width)), int(round(height))
|
||||
if w <= 0 or h <= 0:
|
||||
raise DecorAssetError("invalid_image", "The image has no usable dimensions")
|
||||
if w > MAX_RASTER_DIMENSION or h > MAX_RASTER_DIMENSION or w * h > MAX_RASTER_PIXELS:
|
||||
raise DecorAssetError("too_large", "Decoded image dimensions exceed the safety limit")
|
||||
return w, h
|
||||
|
||||
|
||||
def _svg_number(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
match = _LENGTH.fullmatch(value)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _validate_svg_unit_interval(name: str, value: str) -> None:
|
||||
"""Reject non-finite and out-of-range opacity/gradient values."""
|
||||
raw = value.strip()
|
||||
percent = raw.endswith("%")
|
||||
try:
|
||||
number = float(raw[:-1] if percent else raw)
|
||||
except ValueError as err:
|
||||
raise DecorAssetError("invalid_image", f"The SVG {name} value is invalid") from err
|
||||
limit = 100 if percent else 1
|
||||
if not math.isfinite(number) or number < 0 or number > limit:
|
||||
raise DecorAssetError("invalid_image", f"The SVG {name} value is out of range")
|
||||
|
||||
|
||||
def _reject_svg_declarations(data: bytes) -> None:
|
||||
"""Parse with encoding-aware expat guards before building an XML tree."""
|
||||
parser = expat.ParserCreate()
|
||||
|
||||
def reject(*_args: Any) -> None:
|
||||
raise DecorAssetError(
|
||||
"invalid_image", "DTD, entities and processing instructions are forbidden",
|
||||
)
|
||||
|
||||
parser.StartDoctypeDeclHandler = reject
|
||||
parser.EntityDeclHandler = reject
|
||||
parser.ExternalEntityRefHandler = reject
|
||||
parser.ProcessingInstructionHandler = reject
|
||||
try:
|
||||
parser.Parse(data, True)
|
||||
except DecorAssetError:
|
||||
raise
|
||||
except (expat.ExpatError, UnicodeError) as err:
|
||||
raise DecorAssetError("invalid_image", "The SVG is not valid XML") from err
|
||||
|
||||
|
||||
def _validate_svg(data: bytes) -> ValidatedAsset:
|
||||
# Byte-substring checks are bypassable with UTF-16/UTF-32. Expat detects
|
||||
# the declared/input encoding first and our handlers reject declarations
|
||||
# before entity expansion can allocate an ElementTree.
|
||||
_reject_svg_declarations(data)
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except (ET.ParseError, UnicodeError) as err:
|
||||
raise DecorAssetError("invalid_image", "The SVG is not valid XML") from err
|
||||
if root.tag.rsplit("}", 1)[-1] != "svg":
|
||||
raise DecorAssetError("invalid_image", "Only an SVG root is accepted")
|
||||
count = attr_chars = 0
|
||||
ids: set[str] = set()
|
||||
refs: list[str] = []
|
||||
ref_graph: dict[str, set[str]] = {}
|
||||
stack: list[tuple[ET.Element, int, str | None]] = [(root, 1, None)]
|
||||
while stack:
|
||||
node, depth, inherited_owner = stack.pop()
|
||||
count += 1
|
||||
if count > MAX_SVG_ELEMENTS or depth > MAX_SVG_DEPTH:
|
||||
raise DecorAssetError("too_large", "The SVG structure exceeds the safety limit")
|
||||
if node.tag.startswith("{"):
|
||||
namespace, tag = node.tag[1:].split("}", 1)
|
||||
if namespace != _SVG_NAMESPACE:
|
||||
raise DecorAssetError("unsupported_image", "Unknown SVG namespace")
|
||||
else:
|
||||
raise DecorAssetError("unsupported_image", "The SVG namespace is required")
|
||||
if tag not in _SVG_TAGS:
|
||||
raise DecorAssetError("unsupported_image", f"Unsupported SVG element: {tag}")
|
||||
node_id = node.attrib.get("id")
|
||||
owner = node_id or inherited_owner
|
||||
if node.text and node.text.strip():
|
||||
if tag not in {"title", "desc"} or len(node.text) > 4096:
|
||||
raise DecorAssetError("unsupported_image", "Unsupported or oversized SVG text")
|
||||
for raw_name, value in node.attrib.items():
|
||||
if len(value) > MAX_SVG_ATTR_VALUE_CHARS:
|
||||
raise DecorAssetError("too_large", "An SVG attribute exceeds the safety limit")
|
||||
if raw_name.startswith("{"):
|
||||
raise DecorAssetError("unsupported_image", "Namespaced SVG attributes are unsupported")
|
||||
name = raw_name
|
||||
attr_chars += len(raw_name) + len(value)
|
||||
if attr_chars > MAX_SVG_ATTR_CHARS:
|
||||
raise DecorAssetError("too_large", "The SVG attributes exceed the safety limit")
|
||||
if name.lower().startswith("on") or name not in _SVG_ATTRS:
|
||||
raise DecorAssetError("unsupported_image", f"Unsupported SVG attribute: {name}")
|
||||
if name in _SVG_UNIT_INTERVAL_ATTRS:
|
||||
_validate_svg_unit_interval(name, value)
|
||||
low = value.strip().lower()
|
||||
if any(token in low for token in ("javascript:", "data:", "http:", "https:", "//")):
|
||||
raise DecorAssetError("invalid_image", "External SVG resources are forbidden")
|
||||
if "url(" in low:
|
||||
if not _LOCAL_REF.fullmatch(value.strip()):
|
||||
raise DecorAssetError("invalid_image", "Only local SVG references are allowed")
|
||||
ref = value.strip()[5:-1]
|
||||
refs.append(ref)
|
||||
if owner:
|
||||
ref_graph.setdefault(owner, set()).add(ref)
|
||||
if name == "href":
|
||||
if not re.fullmatch(r"#[A-Za-z_][A-Za-z0-9_.:-]*", value.strip()):
|
||||
raise DecorAssetError("invalid_image", "Only local SVG references are allowed")
|
||||
ref = value.strip()[1:]
|
||||
refs.append(ref)
|
||||
if owner:
|
||||
ref_graph.setdefault(owner, set()).add(ref)
|
||||
if name == "id":
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.:-]*", value) or value in ids:
|
||||
raise DecorAssetError("invalid_image", "SVG ids must be unique and well formed")
|
||||
ids.add(value)
|
||||
stack.extend((child, depth + 1, owner) for child in node)
|
||||
if any(ref not in ids for ref in refs):
|
||||
raise DecorAssetError("invalid_image", "The SVG contains an unresolved local reference")
|
||||
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def _visit(node_id: str) -> None:
|
||||
if node_id in visiting:
|
||||
raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")
|
||||
if node_id in visited:
|
||||
return
|
||||
visiting.add(node_id)
|
||||
for ref in ref_graph.get(node_id, ()):
|
||||
_visit(ref)
|
||||
visiting.remove(node_id)
|
||||
visited.add(node_id)
|
||||
|
||||
for node_id in ids:
|
||||
_visit(node_id)
|
||||
view_box = root.attrib.get("viewBox")
|
||||
width = _svg_number(root.attrib.get("width"))
|
||||
height = _svg_number(root.attrib.get("height"))
|
||||
if view_box:
|
||||
try:
|
||||
parts = [float(part) for part in re.split(r"[\s,]+", view_box.strip())]
|
||||
if len(parts) != 4:
|
||||
raise ValueError
|
||||
width, height = parts[2], parts[3]
|
||||
except ValueError as err:
|
||||
raise DecorAssetError("invalid_image", "The SVG viewBox is invalid") from err
|
||||
w, h = _validate_dimensions(width or 0, height or 0)
|
||||
ET.register_namespace("", _SVG_NAMESPACE)
|
||||
canonical = ET.tostring(
|
||||
root, encoding="utf-8", xml_declaration=False, short_empty_elements=True,
|
||||
)
|
||||
_check_size(canonical)
|
||||
try:
|
||||
ET.fromstring(canonical)
|
||||
except ET.ParseError as err: # pragma: no cover - serializer invariant
|
||||
raise DecorAssetError("invalid_image", "Canonical SVG could not be reparsed") from err
|
||||
return ValidatedAsset(canonical, "image/svg+xml", ".svg", w, h)
|
||||
|
||||
|
||||
def validate_asset(
|
||||
data: bytes, filename: str, declared_mime: str | None = None,
|
||||
) -> ValidatedAsset:
|
||||
"""Validate and canonicalise one complete upload."""
|
||||
_check_size(data)
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in ASSET_EXTENSIONS:
|
||||
raise DecorAssetError("unsupported_image", "Use PNG, JPEG, WebP or SVG")
|
||||
expected_mime = MIME_BY_EXT[ext]
|
||||
claimed = str(declared_mime or "").split(";", 1)[0].strip().lower()
|
||||
if claimed and claimed != "application/octet-stream" and claimed != expected_mime:
|
||||
raise DecorAssetError("invalid_format", "The filename, MIME type and image bytes disagree")
|
||||
if ext == ".svg":
|
||||
return _validate_svg(data)
|
||||
width, height = _validate_dimensions(*_raster_dimensions(data, ext))
|
||||
# Home Assistant ships Pillow. Header parsing above keeps this helper
|
||||
# independently testable, while a full decode here catches valid-looking
|
||||
# but truncated/corrupt payloads before they enter the authenticated store.
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
image.load()
|
||||
if image.size != (width, height):
|
||||
raise DecorAssetError("invalid_image", "Image dimensions are inconsistent")
|
||||
if getattr(image, "is_animated", False):
|
||||
raise DecorAssetError("unsupported_image", "Animated images are unsupported")
|
||||
except ImportError:
|
||||
# The Home Assistant runtime includes Pillow. Keeping the pure parser
|
||||
# usable without the full HA dependency set lets repository-local
|
||||
# security tests still exercise signatures, dimensions and SVG.
|
||||
pass
|
||||
except DecorAssetError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - decoder failures are one public error
|
||||
raise DecorAssetError("invalid_image", "The raster image cannot be decoded") from err
|
||||
mime = expected_mime
|
||||
return ValidatedAsset(data, mime, EXT_BY_MIME[mime], width, height)
|
||||
|
||||
|
||||
def asset_refs(config: dict[str, Any] | None) -> dict[str, list[dict[str, str]]]:
|
||||
"""Return server-authoritative references grouped by asset id."""
|
||||
out: dict[str, list[dict[str, str]]] = {}
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
sid = str(space.get("id") or "")
|
||||
for shape in space.get("decor") or []:
|
||||
if not isinstance(shape, dict) or shape.get("kind") != "image":
|
||||
continue
|
||||
aid = shape.get("asset_id")
|
||||
if isinstance(aid, str) and ASSET_ID_RE.fullmatch(aid):
|
||||
out.setdefault(aid, []).append({
|
||||
"space_id": sid, "decor_id": str(shape.get("id") or ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
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"):
|
||||
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"])),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def public_asset(
|
||||
row: dict[str, Any], used_by: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
aid = str(row["asset_id"])
|
||||
ext = str(row["ext"])
|
||||
return {
|
||||
"asset_id": aid,
|
||||
"name": row.get("name", "image"),
|
||||
"mime": row.get("mime"),
|
||||
"width": row.get("width"),
|
||||
"height": row.get("height"),
|
||||
"bytes": row.get("bytes"),
|
||||
"url": f"{CONTENT_URL}/assets/_/{aid}{ext}",
|
||||
"used_by": used_by or [],
|
||||
}
|
||||
@@ -1,125 +1,182 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "cac53ed29e55322a0ae4656dadf180e8c1f79824bb04b52b4a805ce171576ce6",
|
||||
"fingerprint": "3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-DBOQmi4Z.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 290383,
|
||||
"initialViewGzipBytes": 296719,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-C8A4t-rp.js",
|
||||
"houseplan-assets/de-CSYAngJ6.js",
|
||||
"houseplan-assets/editor-Cn-epM4w.js",
|
||||
"houseplan-assets/fr-C2psJwjr.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-8SQ1wLpy.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-dQG99CBH.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
],
|
||||
"lazyGzipBytes": 205810,
|
||||
"lazyGzipBytes": 240651,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-C8A4t-rp.js",
|
||||
"houseplan-assets/editor-Cn-epM4w.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-8SQ1wLpy.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 148714,
|
||||
"lazyEditorGzipBytes": 178324,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-C8A4t-rp.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-dQG99CBH.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14026,
|
||||
"lazyOnboardingGzipBytes": 14239,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-CSYAngJ6.js",
|
||||
"houseplan-assets/fr-C2psJwjr.js"
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 50140,
|
||||
"lazyLocaleGzipBytes": 49104,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-C8A4t-rp.js",
|
||||
"sha256": "7b62d15f3898a79374871c4d21c7dc2f835a0f69c8c7a30ad3d621891ef1e100",
|
||||
"rawBytes": 20636,
|
||||
"gzipBytes": 7070,
|
||||
"path": "houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"sha256": "473ca1094a2f837fffc71026ba5f9c5787a6a1e18db7b37328f586bf7bdeb73a",
|
||||
"rawBytes": 21417,
|
||||
"gzipBytes": 7280,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DBOQmi4Z.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-CSYAngJ6.js",
|
||||
"sha256": "1990f02ad99110aedea6811e6e977d499f605436ec99874f4a7406e5b3237d20",
|
||||
"rawBytes": 83981,
|
||||
"gzipBytes": 25341,
|
||||
"path": "houseplan-assets/de-B9ewWWX2.js",
|
||||
"sha256": "3c291653c70e131993810e78b7ace06a10758b9fbdfa54636f890b36aa8cfc1a",
|
||||
"rawBytes": 82182,
|
||||
"gzipBytes": 24822,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-Cn-epM4w.js",
|
||||
"sha256": "9cadff5ce09e7208debeea78b7bcb3a2c8715afc23e6a273818682c17e619980",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1581,
|
||||
"path": "houseplan-assets/editor-CDcQqv3k.js",
|
||||
"sha256": "76a014106e36d05f727ecc2bfb654708df0d5d3db54d498bb58254eb9ebec15b",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DBOQmi4Z.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-C2psJwjr.js",
|
||||
"sha256": "31e68085a8f2b22c69d21a9e0cb921e2e648b70bd67d0e50e8e0c19ff8b41471",
|
||||
"rawBytes": 86212,
|
||||
"gzipBytes": 24799,
|
||||
"path": "houseplan-assets/fr-4rsWX0CK.js",
|
||||
"sha256": "b0508cefcba06761e14436fd8ab8bef916c0bdcb70a71e1d338ba7722057d6f1",
|
||||
"rawBytes": 84312,
|
||||
"gzipBytes": 24282,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-DBOQmi4Z.js",
|
||||
"sha256": "40c92954b4bcc9331051874fa89b986f82b8e1d4be827355fd1399eb80989bbe",
|
||||
"rawBytes": 1032340,
|
||||
"gzipBytes": 289587,
|
||||
"path": "houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"sha256": "24597a66c6c0b7ddb3859a5bc2e8b58e08ef57d4612d0cf9222895122edfe92b",
|
||||
"rawBytes": 1053904,
|
||||
"gzipBytes": 295923,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-CSYAngJ6.js",
|
||||
"houseplan-assets/editor-Cn-epM4w.js",
|
||||
"houseplan-assets/fr-C2psJwjr.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-8SQ1wLpy.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-dQG99CBH.js"
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-8SQ1wLpy.js",
|
||||
"sha256": "58beb4535bd283d95fe802450eb7b95fc7b6abd0b7334ad4002f2bb31858ddd8",
|
||||
"rawBytes": 543272,
|
||||
"gzipBytes": 140063,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"sha256": "fe6417012721db8c1124e8d1a1b9f037db6edd8edb1ccf328bbc1c9697186cbd",
|
||||
"rawBytes": 622491,
|
||||
"gzipBytes": 164615,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-C8A4t-rp.js",
|
||||
"houseplan-assets/houseplan-card-DBOQmi4Z.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"sha256": "69cf99a15ce74f74a309241f88f2cf0eade072507f2ec463560956367549d5d2",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6959,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-dQG99CBH.js",
|
||||
"sha256": "f46d15c6d708602c0e21b76f37fdc1fb1bc26ed1f40881602308d85c8986ae48",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6956,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"sha256": "e64d8d0dae12904eba4aa3f87fb7f046962091eb7284016e0442c838be3cdfb2",
|
||||
"rawBytes": 6006,
|
||||
"gzipBytes": 2376,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-C8A4t-rp.js",
|
||||
"houseplan-assets/houseplan-card-DBOQmi4Z.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
|
||||
"sha256": "05f378c2eef76f35ea98d05821b4680cae1bd599e410d27fbe69ad5ad76eb0d6",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2127,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"sha256": "c6f7dc8aad18071bd7766901c382201b0252725fae2c77993483030b3f73ca67",
|
||||
"rawBytes": 12382,
|
||||
"gzipBytes": 4846,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CVolox2I.js",
|
||||
"sha256": "e4dcb60eb07d78c23782ac134e498fff6b348f4f35a2a7820a13582ceb1887fa",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1761,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "84658b1841f464ab98e4b074a77293405b36c911ba021f4a11d90005fae25858",
|
||||
"sha256": "03b15da3629659eba3779885ccf8f0206455ba95378dd1fa246c8ea4bb372a8e",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DBOQmi4Z.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";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-CeQyO4TX.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__="cac53ed29e55322a0ae4656dadf180e8c1f79824bb04b52b4a805ce171576ce6";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-DBOQmi4Z.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);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2688
File diff suppressed because one or more lines are too long
+2939
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
+36
@@ -0,0 +1,36 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,A as t,dx as i,w as s,c as o,l as r,u as n}from"./houseplan-card-CeQyO4TX.js";import{a,r as h,t as d}from"./zigbee-topology-Q4h3gz_F.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-CVolox2I.js";const l={revision:0,topologies:[],states:{}};class v extends e{constructor(){super(...arguments),this.devices=[],this.currentSpace="",this._runtime=l,this._hovered="",this._pointerOver=e=>{if(!this._mouseAllowed(e))return void this._clear();const t=this._deviceFromEvent(e),i=t?.dataset.id||"";i&&i!==this._hovered&&(this._hovered=i,this.requestUpdate())},this._pointerOut=e=>{const t=this._deviceFromEvent(e);if(!t||t.dataset.id!==this._hovered)return;const i=e.relatedTarget instanceof Element?e.relatedTarget.closest('[data-hp="device"]'):null;i?.dataset.id!==this._hovered&&this._clear()},this._pointerDown=e=>{"mouse"!==e.pointerType&&this._clear()}}connectedCallback(){super.connectedCallback();const e=this.getRootNode();e.host&&"undefined"!=typeof MutationObserver&&(this._hoverGateObserver=new MutationObserver(()=>{e.host.hasAttribute("data-pointer-hover")||this._clear()}),this._hoverGateObserver.observe(e.host,{attributes:!0,attributeFilter:["data-pointer-hover"]})),queueMicrotask(()=>this._connectParent())}disconnectedCallback(){this._release?.(),this._release=void 0,this._disconnectParent(),this._hoverGateObserver?.disconnect(),this._hoverGateObserver=void 0,this._hovered="",super.disconnectedCallback()}updated(e){e.has("hass")&&(this._release?.(),this._runtime=p(this.hass),this._mappedMemo=void 0,this._release=c(this.hass,()=>{this._runtime=p(this.hass),this._mappedMemo=void 0,this.requestUpdate()})),(e.has("currentSpace")||e.has("devices")||e.has("registry"))&&(this._mappedMemo=void 0,this.devices.some(e=>e.id===this._hovered&&e.space===this.currentSpace)||(this._hovered=""))}_connectParent(){const e=this.parentElement;e&&e!==this._parent&&(this._disconnectParent(),this._parent=e,e.addEventListener("pointerover",this._pointerOver),e.addEventListener("pointerout",this._pointerOut),e.addEventListener("pointerdown",this._pointerDown,!0))}_disconnectParent(){this._parent?.removeEventListener("pointerover",this._pointerOver),this._parent?.removeEventListener("pointerout",this._pointerOut),this._parent?.removeEventListener("pointerdown",this._pointerDown,!0),this._parent=void 0}_deviceFromEvent(e){for(const t of e.composedPath())if(t instanceof HTMLElement&&"device"===t.dataset.hp)return t;return null}_mouseAllowed(e){const t=this.getRootNode();return"mouse"===e.pointerType&&t.host?.hasAttribute?.("data-pointer-hover")}_clear(){this._hovered&&(this._hovered="",this.requestUpdate())}_position(e){const t=[...this._parent?.querySelectorAll(".dev[data-id]")||[]].find(t=>t.dataset.id===e);if(!t)return null;const i=Number.parseFloat(t.style.left),s=Number.parseFloat(t.style.top),o=t.getBoundingClientRect();return Number.isFinite(i)&&Number.isFinite(s)?{x:i,y:s,width:o.width,height:o.height}:null}render(){if(!this._hovered||!this.registry||!this._runtime.topologies.length)return t;const e=this._mappedMemo,n=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:a(this._runtime.topologies,this.devices,this.registry);e&&n===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:n});const p=h(n,this.currentSpace,this._hovered),c=this._position(this._hovered);if(!c)return t;const l=p.lines.map(e=>({...e,point:this._position(e.neighborMarkerId)})).filter(e=>!!e.point);return o`
|
||||
${l.length?s`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
|
||||
aria-hidden="true" data-hp="zigbee-topology-lines">
|
||||
${l.map(e=>s`<line x1=${c.x} y1=${c.y}
|
||||
x2=${e.point.x} y2=${e.point.y}
|
||||
stroke=${void 0===e.lqi?"rgba(145,155,165,.85)":i(e.lqi)}
|
||||
stroke-width=${void 0===e.lqi?2:2.2}
|
||||
stroke-dasharray=${void 0===e.lqi?"5 5":t} opacity=".9"></line>`)}
|
||||
</svg>`:t}
|
||||
${l.map(e=>o`<div class="halo" data-hp="zigbee-topology-neighbor"
|
||||
data-id=${e.neighborMarkerId} style="left:${e.point.x}%;top:${e.point.y}%;width:${1.22*e.point.width}px;height:${1.22*e.point.height}px"></div>`)}
|
||||
${p.remoteCount?o`<div class="remote" data-hp="zigbee-topology-remote"
|
||||
style="left:${c.x}%;top:${c.y}%">${d(r(this.hass),"remote_count",{n:p.remoteCount})}</div>`:t}
|
||||
`}}v.properties={hass:{attribute:!1},devices:{attribute:!1},registry:{attribute:!1},currentSpace:{type:String,attribute:"current-space"},viewKey:{attribute:!1}},v.styles=n`
|
||||
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
|
||||
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
|
||||
.halo {
|
||||
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
|
||||
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
|
||||
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
|
||||
}
|
||||
.remote {
|
||||
position: absolute; transform: translate(12px, calc(-100% - 12px));
|
||||
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
|
||||
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
|
||||
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
|
||||
font: 600 11px/1.2 system-ui, sans-serif;
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
line { stroke: Highlight !important; }
|
||||
.halo { border-color: Highlight; box-shadow: none; }
|
||||
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
|
||||
}
|
||||
`,customElements.get("hp-zigbee-topology-overlay")||customElements.define("hp-zigbee-topology-overlay",v);export{v as HpZigbeeTopologyOverlay};
|
||||
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{n as t,b as n}from"./zigbee-topology-Q4h3gz_F.js";import{I as e}from"./houseplan-card-CeQyO4TX.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="cac53ed29e55322a0ae4656dadf180e8c1f79824bb04b52b4a805ce171576ce6";try{await import("./houseplan-assets/houseplan-card-DBOQmi4Z.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__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";try{await import("./houseplan-assets/houseplan-card-CeQyO4TX.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)}
|
||||
|
||||
@@ -5,30 +5,50 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
try: # KEY_HASS — the modern way to access hass from the aiohttp application
|
||||
from homeassistant.components.http import KEY_HASS
|
||||
except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .asset_integrity import get_asset_integrity_verifier
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
CONTENT_URL,
|
||||
FILES_DIR,
|
||||
MAX_DECOR_ASSET_BYTES,
|
||||
MAX_DECOR_ASSETS_BYTES,
|
||||
MAX_DECOR_ASSETS_COUNT,
|
||||
MAX_EXPORT_BYTES,
|
||||
MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT,
|
||||
MIN_FREE_BYTES,
|
||||
PLANS_DIR,
|
||||
)
|
||||
from .decor_assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_ID_RE,
|
||||
DecorAssetError,
|
||||
asset_meta_path,
|
||||
physical_asset_usage,
|
||||
public_asset,
|
||||
read_asset,
|
||||
validate_asset,
|
||||
)
|
||||
from .import_export import ImportFailure, create_preview
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
@@ -138,25 +158,38 @@ class HouseplanContentView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if kind not in ("plans", "files"):
|
||||
if kind not in ("plans", "files", "assets"):
|
||||
return web.Response(status=404)
|
||||
safe_sub = sanitize_marker_id(sub)
|
||||
safe_name = sanitize_filename(name)
|
||||
if not safe_sub or not safe_name:
|
||||
return web.Response(status=404)
|
||||
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
|
||||
root_dir = PLANS_DIR if kind == "plans" else ASSETS_DIR if kind == "assets" else FILES_DIR
|
||||
base = Path(hass.config.path(root_dir)).resolve()
|
||||
# plans live flat in one directory: the sub segment is a placeholder ("_")
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
if kind == "assets":
|
||||
stem, suffix = Path(safe_name).stem, Path(safe_name).suffix.lower()
|
||||
if safe_sub != "_" or not ASSET_ID_RE.fullmatch(stem) or suffix not in ASSET_EXTENSIONS:
|
||||
return web.Response(status=404)
|
||||
path = (base / safe_name).resolve()
|
||||
else:
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
# defence in depth: the sanitizers already strip separators
|
||||
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":
|
||||
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=3600",
|
||||
"Cache-Control": "private, max-age=31536000, immutable"
|
||||
if kind == "assets" else "private, max-age=3600",
|
||||
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
if suffix == ".svg":
|
||||
# An uploaded SVG is user content served from Home Assistant's own
|
||||
@@ -182,6 +215,140 @@ class HouseplanContentView(HomeAssistantView):
|
||||
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||
|
||||
|
||||
class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
"""POST one bounded custom decor image and return its catalog row."""
|
||||
|
||||
url = "/api/houseplan/assets/upload"
|
||||
name = "api:houseplan:decor-asset-upload"
|
||||
requires_auth = True
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
runtime = get_data(hass)
|
||||
if runtime is None:
|
||||
return web.json_response({"error": "not_ready"}, status=503)
|
||||
if request.content_length is not None and request.content_length > MAX_DECOR_ASSET_BYTES + _FLUSH_AT:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
filename: str | None = None
|
||||
declared_mime: str | None = None
|
||||
blocks: list[bytes] = []
|
||||
size = 0
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
if filename is not None:
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
filename = part.filename or "image"
|
||||
declared_mime = part.headers.get("Content-Type")
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_DECOR_ASSET_BYTES:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
blocks.append(chunk)
|
||||
except Exception as err: # noqa: BLE001 - malformed multipart boundary
|
||||
_LOGGER.warning("House Plan decor asset upload: multipart read error: %s", err)
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
if not filename:
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
|
||||
try:
|
||||
validated = await hass.async_add_executor_job(
|
||||
validate_asset, b"".join(blocks), filename, declared_mime,
|
||||
)
|
||||
except DecorAssetError as err:
|
||||
return web.json_response({"error": err.code, "message": str(err)}, status=413 if err.code == "too_large" else 400)
|
||||
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
|
||||
def _store() -> tuple[dict, bool]:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
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
|
||||
aid = validated.asset_id
|
||||
blob = root / f"{aid}{validated.ext}"
|
||||
meta = asset_meta_path(root, aid)
|
||||
safe_display = sanitize_filename(Path(filename).name) or "image"
|
||||
row = {
|
||||
"asset_id": aid, "name": safe_display, "mime": validated.mime,
|
||||
"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)
|
||||
meta_temp = temp.with_suffix(".json")
|
||||
promoted_blob = False
|
||||
try:
|
||||
with temp.open("wb") as stream:
|
||||
stream.write(validated.data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
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(temp, blob)
|
||||
promoted_blob = True
|
||||
os.replace(meta_temp, meta)
|
||||
return row, False
|
||||
except Exception:
|
||||
# A normal I/O failure is fully transactional. A process hard
|
||||
# stop between the two renames is healed by the orphan branch
|
||||
# above on the next identical upload.
|
||||
if promoted_blob:
|
||||
blob.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
meta_temp.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
async with runtime.upload_lock:
|
||||
row, reused = await hass.async_add_executor_job(_store)
|
||||
except DecorAssetError as err:
|
||||
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)
|
||||
return web.json_response({"ok": True, "reused": reused, "asset": public_asset(row)})
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
"""POST /api/houseplan/upload — save a marker file, return its URL."""
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from urllib.parse import urlsplit
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
CONTENT_URL,
|
||||
EXPORT_VERSION,
|
||||
FILES_DIR,
|
||||
@@ -51,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,
|
||||
@@ -73,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",
|
||||
@@ -97,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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -389,6 +399,12 @@ def _internal_path(root: Path, url: str) -> tuple[str, Path] | None:
|
||||
if name != raw_name:
|
||||
return None
|
||||
return "plan", root / PLANS_DIR / name
|
||||
asset_prefix = CONTENT_URL + "/assets/_/"
|
||||
if url.startswith(asset_prefix):
|
||||
raw_name = url[len(asset_prefix):]
|
||||
if not re.fullmatch(r"[0-9a-f]{64}\.(?:png|jpg|webp|svg)", raw_name):
|
||||
return None
|
||||
return "decor_asset", root / ASSETS_DIR / raw_name
|
||||
for prefix in (CONTENT_URL + "/files/", FILES_URL + "/"):
|
||||
if url.startswith(prefix):
|
||||
tail = url[len(prefix):].split("/")
|
||||
@@ -433,6 +449,41 @@ def content_manifest(config: dict[str, Any], config_root: Path) -> list[dict[str
|
||||
"attachment", "marker", str(marker.get("id")),
|
||||
f"pdfs[{index}].url", pdf.get("url") if isinstance(pdf, dict) else None,
|
||||
)
|
||||
# Asset bytes are deliberately not embedded. The manifest records identity
|
||||
# and availability so a cross-instance import can preserve the image box as
|
||||
# a repairable editor placeholder instead of silently deleting geometry.
|
||||
assets_root = config_root / ASSETS_DIR
|
||||
for space in config.get("spaces") or []:
|
||||
for shape in space.get("decor") or []:
|
||||
if not isinstance(shape, dict) or shape.get("kind") != "image":
|
||||
continue
|
||||
aid = shape.get("asset_id")
|
||||
if not isinstance(aid, str) or not re.fullmatch(r"[0-9a-f]{64}", aid):
|
||||
continue
|
||||
matches = sorted(assets_root.glob(f"{aid}.*")) if assets_root.is_dir() else []
|
||||
blob = next((path for path in matches if path.suffix in {".png", ".jpg", ".webp", ".svg"}), None)
|
||||
metadata: dict[str, Any] = {}
|
||||
try:
|
||||
metadata = json.loads((assets_root / f"{aid}.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
exists = False
|
||||
if blob and blob.is_file():
|
||||
try:
|
||||
exists = hashlib.sha256(blob.read_bytes()).hexdigest() == aid
|
||||
except OSError:
|
||||
pass
|
||||
out.append({
|
||||
"kind": "decor_asset", "owner": "decor",
|
||||
"owner_id": f"{space.get('id', '')}:{shape.get('id', '')}", "field": "asset_id",
|
||||
# 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 _DECOR_ASSET_MIME_BY_SUFFIX.get(
|
||||
blob.suffix if blob else ""
|
||||
),
|
||||
"hash": aid, "exists_at_export": exists,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -525,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),
|
||||
@@ -593,7 +661,7 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
raise ImportFailure("invalid_json", str(err)) from err
|
||||
if not isinstance(document, dict) or document.get("format") != FORMAT:
|
||||
raise ImportFailure("invalid_format", "Not a House Plan export")
|
||||
if document.get("export_version") != EXPORT_VERSION:
|
||||
if document.get("export_version") not in (1, EXPORT_VERSION):
|
||||
raise ImportFailure("unsupported_export_version", "Unsupported export version")
|
||||
if document.get("kind") not in ("full", "space"):
|
||||
raise ImportFailure("invalid_format", "Export kind must be full or space")
|
||||
@@ -759,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")
|
||||
@@ -1526,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 (
|
||||
@@ -1596,7 +1669,37 @@ 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 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
|
||||
if blob:
|
||||
try:
|
||||
exists = hashlib.sha256(blob.read_bytes()).hexdigest() == aid
|
||||
except OSError:
|
||||
pass
|
||||
row["exists_on_target"] = exists
|
||||
row["state"] = "available" if exists else "missing_preserved"
|
||||
confirmation = confirmation or not exists
|
||||
rows.append(row)
|
||||
continue
|
||||
internal = _internal_path(config_root, item["url"])
|
||||
if _looks_internal(item["url"]) and internal is None:
|
||||
raise ImportFailure("invalid_content", "Non-canonical internal content URL")
|
||||
@@ -1791,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)
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.70.0-beta.4"
|
||||
"version": "1.72.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ _LANGUAGES = frozenset({"en", "ru", "de", "fr"})
|
||||
_BROWSERS = frozenset({"chromium", "firefox", "webkit", "unknown"})
|
||||
_REGISTRY_ACCESS = frozenset({"full", "partial", "unavailable"})
|
||||
_REGISTRY_AGE = frozenset({"fresh", "stale", "unknown"})
|
||||
_VALUE_BADGE_POSITIONS = frozenset({"right", "bottom", "left", "top"})
|
||||
|
||||
|
||||
class SupportPackageError(ValueError):
|
||||
@@ -139,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"] = {
|
||||
@@ -232,6 +236,23 @@ def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | N
|
||||
return None
|
||||
|
||||
|
||||
def _project_value_badge(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
|
||||
"""Project the compatibility-tolerant badge config into safe scalar fields."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
enabled = value.get("enabled")
|
||||
if isinstance(enabled, bool):
|
||||
out["enabled"] = enabled
|
||||
position = value.get("position")
|
||||
if isinstance(position, str) and position in _VALUE_BADGE_POSITIONS:
|
||||
out["position"] = position
|
||||
source = _project_value_source(ids, value.get("source"))
|
||||
if source is not None:
|
||||
out["source"] = source
|
||||
return out or None
|
||||
|
||||
|
||||
def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
binding_kind, binding = _binding(ids, marker.get("binding"))
|
||||
out: dict[str, Any] = {
|
||||
@@ -264,12 +285,8 @@ def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
projected = _project_value_source(ids, marker.get(key))
|
||||
if projected:
|
||||
out[key] = projected
|
||||
badge = marker.get("value_badge")
|
||||
if isinstance(badge, dict):
|
||||
safe_badge = _copy_keys(badge, ("enabled", "position"))
|
||||
source = _project_value_source(ids, badge.get("source"))
|
||||
if source:
|
||||
safe_badge["source"] = source
|
||||
safe_badge = _project_value_badge(ids, marker.get("value_badge"))
|
||||
if safe_badge is not None:
|
||||
out["value_badge"] = safe_badge
|
||||
return out
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ async def async_submit_report(
|
||||
idempotency_key: str,
|
||||
attachment: bytes | None,
|
||||
attachment_sha256: str | None,
|
||||
filename_token: str,
|
||||
) -> str:
|
||||
"""Send once, without redirects, proxies, arbitrary hosts or reflected text."""
|
||||
target = urlsplit(SUPPORT_RELAY_URL)
|
||||
@@ -56,9 +55,10 @@ async def async_submit_report(
|
||||
content_type="application/json",
|
||||
)
|
||||
if attachment is not None:
|
||||
filename_sha256 = str(attachment_sha256 or "")
|
||||
form.add_field(
|
||||
"attachment", attachment,
|
||||
filename=f"houseplan-support-{filename_token[:32]}.json",
|
||||
filename=f"houseplan-support-{filename_sha256[:12]}.json",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
@@ -1319,6 +1364,7 @@ _FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
|
||||
# …and its size: strictly positive, capped by the same canvas insurance limit
|
||||
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
|
||||
_FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
|
||||
_DECOR_ASSET_ID = vol.All(str, vol.Match(r"^[0-9a-f]{64}$"))
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
@@ -1384,6 +1430,13 @@ DECOR_SCHEMA = vol.Any(
|
||||
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "image",
|
||||
vol.Required("asset_id"): _DECOR_ASSET_ID,
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
|
||||
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
|
||||
@@ -1721,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))),
|
||||
@@ -1927,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.
|
||||
|
||||
@@ -6,6 +6,7 @@ import binascii
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import Counter
|
||||
@@ -20,9 +21,12 @@ 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,
|
||||
CONTENT_URL,
|
||||
DECOR_ASSETS_API_VERSION,
|
||||
DEFAULT_CONFIG,
|
||||
DOMAIN,
|
||||
EXPORT_VERSION,
|
||||
@@ -38,6 +42,7 @@ from .const import (
|
||||
PLAN_MODEL_VERSION,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
SUPPORT_API_VERSION,
|
||||
SUPPORT_PREVIEW_TTL_S,
|
||||
VERSION,
|
||||
)
|
||||
@@ -45,6 +50,15 @@ from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
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 (
|
||||
ImportFailure,
|
||||
content_manifest,
|
||||
@@ -99,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,
|
||||
@@ -194,6 +209,9 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_assets_list)
|
||||
websocket_api.async_register_command(hass, ws_assets_resolve)
|
||||
websocket_api.async_register_command(hass, ws_assets_delete)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
websocket_api.async_register_command(hass, ws_export_create)
|
||||
websocket_api.async_register_command(hass, ws_import_revalidate)
|
||||
@@ -234,25 +252,51 @@ def _send_support_error(connection, msg_id: int, code: str) -> None:
|
||||
connection.send_error(msg_id, code, code)
|
||||
|
||||
|
||||
def _support_monotonic() -> float:
|
||||
"""Clock seam for deterministic support-preview lifecycle tests."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _prune_support_previews(rt: HouseplanData, now: float | None = None) -> None:
|
||||
current = time.monotonic() if now is None else now
|
||||
current = _support_monotonic() if now is None else now
|
||||
for token, preview in list(rt.support_previews.items()):
|
||||
if float(preview.get("expires", 0)) <= current:
|
||||
rt.support_previews.pop(token, None)
|
||||
|
||||
|
||||
_SUPPORT_REPAIR_FAMILY = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
||||
|
||||
|
||||
def _support_repairs(hass: HomeAssistant) -> list[dict[str, Any]]:
|
||||
"""Expose stable House Plan repair families, never raw issue ids/placeholders."""
|
||||
counts: Counter[str] = Counter()
|
||||
registry = ir.async_get(hass)
|
||||
for domain, issue_id in list(registry.issues):
|
||||
for (domain, _issue_id), issue in list(registry.issues.items()):
|
||||
if domain != DOMAIN:
|
||||
continue
|
||||
if str(issue_id).startswith("broken_plan_"):
|
||||
counts["broken_plan"] += 1
|
||||
family = getattr(issue, "translation_key", None)
|
||||
if isinstance(family, str) and _SUPPORT_REPAIR_FAMILY.fullmatch(family):
|
||||
counts[family] += 1
|
||||
return [{"code": code, "count": count} for code, count in sorted(counts.items())]
|
||||
|
||||
|
||||
def _support_preview_has_capacity(
|
||||
rt: HouseplanData,
|
||||
owner: str,
|
||||
draft_id: str,
|
||||
) -> bool:
|
||||
"""Check quota while treating this owner's current draft as replaceable."""
|
||||
retained = [
|
||||
item for item in rt.support_previews.values()
|
||||
if not (item.get("owner") == owner and item.get("draft_id") == draft_id)
|
||||
]
|
||||
owned = sum(1 for item in retained if item.get("owner") == owner)
|
||||
return (
|
||||
owned < MAX_SUPPORT_PREVIEWS_PER_USER
|
||||
and len(retained) < MAX_SUPPORT_PREVIEWS_TOTAL
|
||||
)
|
||||
|
||||
|
||||
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the exact non-layout portion of a layout-store document."""
|
||||
return {
|
||||
@@ -1064,6 +1108,120 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"urls": out})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/assets/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_assets_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""List reusable custom images with authoritative reference counts."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only writers may list image assets")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock, rt.upload_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = asset_refs(stored.get("config") or {})
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
rows = await hass.async_add_executor_job(read_catalog, root)
|
||||
connection.send_result(
|
||||
msg["id"], {"assets": [public_asset(row, refs.get(row["asset_id"])) for row in rows]},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/assets/resolve",
|
||||
vol.Required("asset_ids"): vol.All(
|
||||
[vol.All(str, vol.Match(r"^[0-9a-f]{64}$"))], vol.Length(max=200),
|
||||
),
|
||||
}
|
||||
)
|
||||
@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."""
|
||||
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 aid in sorted(allowed):
|
||||
row = read_asset(root, aid)
|
||||
if row is None:
|
||||
continue
|
||||
path = root / f"{aid}{row['ext']}"
|
||||
if not verifier.verify(path, aid):
|
||||
continue
|
||||
rows.append(public_asset(row))
|
||||
found.add(aid)
|
||||
return rows, sorted(requested - found)
|
||||
|
||||
assets, missing = await hass.async_add_executor_job(_resolve)
|
||||
connection.send_result(msg["id"], {"assets": assets, "missing": missing})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/assets/delete",
|
||||
vol.Required("asset_id"): vol.All(str, vol.Match(r"^[0-9a-f]{64}$")),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_assets_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete only an explicitly requested, currently unreferenced asset."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only writers may delete images")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
aid = msg["asset_id"]
|
||||
if not ASSET_ID_RE.fullmatch(aid):
|
||||
connection.send_error(msg["id"], "invalid_format", "Invalid asset id")
|
||||
return
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
async with rt.write_lock, rt.upload_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = asset_refs(stored.get("config") or {}).get(aid, [])
|
||||
if refs:
|
||||
connection.send_error(msg["id"], "in_use", "A decor object still uses this image")
|
||||
return
|
||||
|
||||
def _delete() -> bool:
|
||||
removed = False
|
||||
for extension in ASSET_EXTENSIONS:
|
||||
path = root / f"{aid}{extension}"
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
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
|
||||
return removed
|
||||
|
||||
removed = await hass.async_add_executor_job(_delete)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/cleanup",
|
||||
@@ -1233,6 +1391,9 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# #295: the card compares this against its own version to decide
|
||||
# whether the «update House Plan» preflight hint can actually help.
|
||||
"integration_version": VERSION,
|
||||
# #423: protocol capability is independent from release skew.
|
||||
"support_api": SUPPORT_API_VERSION,
|
||||
"decor_assets_api": DECOR_ASSETS_API_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1418,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(
|
||||
@@ -1776,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"),
|
||||
@@ -2134,6 +2297,12 @@ async def ws_support_preview(
|
||||
if rt is None:
|
||||
return
|
||||
|
||||
owner = _connection_user_id(connection)
|
||||
_prune_support_previews(rt)
|
||||
if not _support_preview_has_capacity(rt, owner, msg["draft_id"]):
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
|
||||
# The write lock guarantees that config/layout and both revisions describe
|
||||
# one accepted pair. Projection happens after the deep copy, so the lock is
|
||||
# not held while JSON is built and a large plan cannot stall an editor save.
|
||||
@@ -2183,18 +2352,19 @@ async def ws_support_preview(
|
||||
_send_support_error(connection, msg["id"], code)
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
owner = _connection_user_id(connection)
|
||||
now = _support_monotonic()
|
||||
_prune_support_previews(rt, now)
|
||||
# The executor yields to other requests. Re-check after it returns so two
|
||||
# concurrent builds cannot both consume the final slot. The old preview is
|
||||
# retained until the replacement is ready and this second check succeeds.
|
||||
if not _support_preview_has_capacity(rt, owner, msg["draft_id"]):
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
# A refresh replaces only this card instance's draft. Other cards keep
|
||||
# their token and exact bytes.
|
||||
for old_token, record in list(rt.support_previews.items()):
|
||||
if record.get("owner") == owner and record.get("draft_id") == msg["draft_id"]:
|
||||
rt.support_previews.pop(old_token, None)
|
||||
owned = sum(1 for item in rt.support_previews.values() if item.get("owner") == owner)
|
||||
if owned >= MAX_SUPPORT_PREVIEWS_PER_USER or len(rt.support_previews) >= MAX_SUPPORT_PREVIEWS_TOTAL:
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
token = secrets.token_hex(24)
|
||||
expires = now + SUPPORT_PREVIEW_TTL_S
|
||||
rt.support_previews[token] = {
|
||||
@@ -2299,7 +2469,6 @@ async def ws_support_submit(
|
||||
idempotency_key=msg["idempotency_key"],
|
||||
attachment=attachment,
|
||||
attachment_sha256=preview.get("sha256") if preview else None,
|
||||
filename_token=token or msg["idempotency_key"].lower().replace("_", "-"),
|
||||
)
|
||||
except SupportTransportError as error:
|
||||
_send_support_error(connection, msg["id"], error.code)
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
// rerun by hand when recalibrating WARN_DECODED_BYTES / HARD_DIMENSION.
|
||||
// PLAYWRIGHT_BROWSERS_PATH=... node demo/benchmark_backdrop_decode.mjs
|
||||
import { chromium } from 'playwright';
|
||||
import { reportPageErrors, watchPage } from './serve.mjs';
|
||||
|
||||
const guardProbe = process.argv.includes('--guard-probe');
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext()).newPage();
|
||||
const page = watchPage(await (await browser.newContext()).newPage());
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await page.goto('about:blank');
|
||||
const metrics = async () => {
|
||||
@@ -17,7 +20,7 @@ const cases = [
|
||||
{ mp: 32, alpha: false }, { mp: 32, alpha: true }, { mp: 64, alpha: false },
|
||||
{ mp: 100, alpha: false }, { mp: 165, alpha: false },
|
||||
];
|
||||
for (const c of cases) {
|
||||
for (const c of guardProbe ? [] : cases) {
|
||||
try {
|
||||
const r = await page.evaluate(async ({ mp, alpha }) => {
|
||||
const side = Math.round(Math.sqrt(mp * 1e6));
|
||||
@@ -53,4 +56,13 @@ for (const c of cases) {
|
||||
console.log(JSON.stringify({ mp: c.mp, alpha: c.alpha, FAIL: String(e).slice(0, 120) }));
|
||||
}
|
||||
}
|
||||
if (guardProbe) {
|
||||
await page.evaluate(() => {
|
||||
setTimeout(() => { throw new Error('houseplan backdrop guard probe'); }, 0);
|
||||
});
|
||||
}
|
||||
if (await reportPageErrors()) {
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
+449
-11
@@ -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,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,32 @@
|
||||
/**
|
||||
* Аргументы запуска Chromium для съёмки документации (#410, #424).
|
||||
*
|
||||
* Модуль отдельный и чистый намеренно: `capture.mjs` — исполняемый скрипт,
|
||||
* который при импорте поднимает браузер и снимает набор. Тест, желающий
|
||||
* прочитать эти константы, не должен запускать съёмку внутри `npm test` — тот
|
||||
* же приём и та же причина, что у `clip.mjs`.
|
||||
*
|
||||
* Первые три флага фиксируют растеризацию текста и цветовой профиль: именно они
|
||||
* убрали основную часть дрейфа в #410.
|
||||
*
|
||||
* Последние два добавлены в #424 и обязаны стоять вместе — по отдельности ни
|
||||
* один дрейф не убирает (проверено перебором, восемь прогонов на конфигурацию):
|
||||
*
|
||||
* - `--disable-partial-raster` запрещает переиспользовать ранее нарисованные
|
||||
* куски тайла. С включённой частичной растеризацией кадр зависит от того,
|
||||
* что композитор рисовал ДО него, то есть от истории прогона;
|
||||
* - `--run-all-compositor-stages-before-draw` заставляет пройти все стадии
|
||||
* композитора до отрисовки, иначе снимок берётся на полпути и «докрутка»
|
||||
* каждый раз происходит по-своему.
|
||||
*
|
||||
* Подпись дефекта, ради которого это сделано: один-два случайных кадра из
|
||||
* десяти расходятся между прогонами на единицы пикселей и два уровня, при этом
|
||||
* внутри одного процесса всё стабильно.
|
||||
*/
|
||||
export const DETERMINISTIC_ARGS = [
|
||||
'--force-color-profile=srgb',
|
||||
'--font-render-hinting=none',
|
||||
'--disable-lcd-text',
|
||||
'--disable-partial-raster',
|
||||
'--run-all-compositor-stages-before-draw',
|
||||
];
|
||||
+13
-29
@@ -8,11 +8,13 @@ import { fileURLToPath } from 'node:url';
|
||||
// manifest-owned tree before launching Chromium; copying only the stable entry
|
||||
// leaves every content-hashed import at 404.
|
||||
import '../../scripts/bundle-sync.mjs';
|
||||
import { wholePixelClip } from './clip.mjs';
|
||||
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
|
||||
import { launch } from '../serve.mjs';
|
||||
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
|
||||
import { DETERMINISTIC_ARGS } from './browser-args.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUTPUT = resolve(ROOT, 'docs/images');
|
||||
@@ -167,14 +169,11 @@ mkdirSync(OUTPUT, { recursive: true });
|
||||
// 09-device-info). Дельта — единицы уровней в RGB на сглаженных границах, alpha
|
||||
// не менялась: подпись субпиксельного сглаживания, а не изменения продукта.
|
||||
//
|
||||
// `--disable-lcd-text` убирает субпиксельное сглаживание (оно и плавало),
|
||||
// `--font-render-hinting=none` снимает зависимость от хинтинга,
|
||||
// `--force-color-profile=srgb` фиксирует профиль. `reducedMotion: 'reduce'`
|
||||
// добавлен к `animations: 'disabled'` у самого скриншота: первое гасит анимации
|
||||
// в CSS, второе — уже начатые переходы на момент съёмки.
|
||||
const DETERMINISTIC_ARGS = [
|
||||
'--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text',
|
||||
];
|
||||
// Аргументы запуска и объяснение каждого — в `browser-args.mjs`: константы
|
||||
// живут отдельно, чтобы тест мог их прочитать, не поднимая браузер (#424).
|
||||
// `reducedMotion: 'reduce'` добавлен к `animations: 'disabled'` у самого
|
||||
// скриншота: первое гасит анимации в CSS, второе — уже начатые переходы на
|
||||
// момент съёмки.
|
||||
/**
|
||||
* Режим замера стабильности (#410): `node demo/docs/capture.mjs --stability=3`.
|
||||
*
|
||||
@@ -266,27 +265,12 @@ try {
|
||||
const rawClip = scenario.capture === 'room-card'
|
||||
? await roomCardClip(page)
|
||||
: await goldenClip(page, scenario.capture);
|
||||
// Обрезка выравнивается по целым пикселям (#410).
|
||||
//
|
||||
// Прямоугольник считается из живого DOM через getBoundingClientRect, то есть
|
||||
// приходит дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и
|
||||
// тогда сдвиг раскладки на десятую пикселя — от метрики шрифта, от
|
||||
// появившегося скроллбара — переписывает границы всех элементов на единицы
|
||||
// уровней. Ровно эта подпись и была в #410: 76 пикселей, максимум 2 уровня,
|
||||
// alpha не тронута, всё на сглаженных границах полей.
|
||||
//
|
||||
// Замер показал, где искать: три снимка подряд в одном состоянии страницы
|
||||
// совпадают побайтово у всех десяти сценариев, а между прогонами плавают
|
||||
// два. Значит дело не в рендере, а в том, что приходит на вход съёмке.
|
||||
//
|
||||
// Внешняя рамка расширяется, а не округляется к ближайшему: обрезка обязана
|
||||
// содержать цель целиком, потерять полпикселя по краю нельзя.
|
||||
const clip = rawClip && {
|
||||
x: Math.floor(rawClip.x),
|
||||
y: Math.floor(rawClip.y),
|
||||
width: Math.ceil(rawClip.x + rawClip.width) - Math.floor(rawClip.x),
|
||||
height: Math.ceil(rawClip.y + rawClip.height) - Math.floor(rawClip.y),
|
||||
};
|
||||
// Обрезка выравнивается по целым пикселям (#410); почему именно так —
|
||||
// в `wholePixelClip`. Замер тогда показал, где искать: три снимка подряд в
|
||||
// одном состоянии совпадают побайтово у всех десяти сценариев, а между
|
||||
// прогонами плавают два. Значит дело не в рендере, а в том, что приходит на
|
||||
// вход съёмке — и проверяется это между прогонами (#422).
|
||||
const clip = wholePixelClip(rawClip);
|
||||
if (STABILITY_SHOTS && rawClip) {
|
||||
console.log(`${scenario.id} обрезка: сырая`
|
||||
+ ` ${rawClip.x},${rawClip.y} ${rawClip.width}x${rawClip.height}`
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Выравнивание области съёмки по целым пикселям (#410, гейт — #422).
|
||||
*
|
||||
* Прямоугольник приходит из живого DOM через `getBoundingClientRect`, то есть
|
||||
* дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и тогда сдвиг
|
||||
* раскладки на десятую пикселя — от метрики шрифта, от появившегося
|
||||
* скроллбара — переписывает границы всех элементов на единицы уровней. Ровно
|
||||
* эта подпись и была в #410: 76 пикселей, максимум 2 уровня, alpha не тронута,
|
||||
* всё на сглаженных границах полей.
|
||||
*
|
||||
* Внешняя рамка РАСШИРЯЕТСЯ, а не округляется к ближайшему: обрезка обязана
|
||||
* содержать цель целиком, потерять полпикселя по краю нельзя. Именно поэтому
|
||||
* здесь `floor` для начала и `ceil` для конца, а не `Math.round` — округление к
|
||||
* ближайшему срезало бы край на половине входов.
|
||||
*
|
||||
* Функция живёт отдельным модулем, потому что она чистая, а `capture.mjs` —
|
||||
* исполняемый скрипт: импортировать его в тест значит запустить съёмку.
|
||||
*/
|
||||
export function wholePixelClip(rect) {
|
||||
if (!rect) return rect;
|
||||
const x = Math.floor(rect.x);
|
||||
const y = Math.floor(rect.y);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.ceil(rect.x + rect.width) - x,
|
||||
height: Math.ceil(rect.y + rect.height) - y,
|
||||
};
|
||||
}
|
||||
+28
-1
@@ -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 }
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 54,
|
||||
"acceptedAt": "2026-09-02T05:20:33.824Z",
|
||||
"sourceFingerprint": "baf59953cfd6eb3543f7da5af21787b20dc76f136b74782ae917f811d2fc1ab5",
|
||||
"acceptedAt": "2026-09-04T13:10:56.492Z",
|
||||
"sourceFingerprint": "7acb1b9d3bb384bc1a0c70fb01347386c157ae88b12b00ccd876b7658fc84a1c",
|
||||
"chromium": "151.0.7922.34",
|
||||
"witnesses": {
|
||||
"count": 90,
|
||||
"count": 127,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
@@ -142,9 +142,9 @@
|
||||
"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",
|
||||
"general-color-popover-desktop-en": "ba147ee0a21d0ad0986fbdf933b4b36b8c8ae7dee005efb8befe7fc59a9dc28d",
|
||||
"settings-help-zoom-200-en-light": "9f5e2ae5ac30c99c52870cebd5114096b413a6e4a5ee060b87f3a0f327065ee6",
|
||||
"settings-help-zoom-200-ru-dark": "07b3a635617d9b592c7b51e3a34bd1f2b88447814b60d694746ba85bd178520d",
|
||||
"support-desktop-empty-light-en": "fecabb63521f258acc66ecdf7a438db10c73d0eac39618c55fb51287080f01f5",
|
||||
"support-desktop-preview-dark-en": "a866aa74e3385ac45e837b328d64ed65bc4b108eb9dc4149fcccf393975e72b6",
|
||||
"support-phone-validation-light-ru": "7a461c00631a05872b9aab1f4c0b0b65d1c918273ff06bba25c7bd2e87fba1ca",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
+11
-13
@@ -760,10 +760,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({
|
||||
@@ -818,21 +817,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.
|
||||
@@ -1066,6 +1060,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)) {
|
||||
@@ -1604,6 +1601,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
throw new Error(`golden general-settings help is clipped: ${scenario.openHelp}`);
|
||||
} else if (scenario.dialog === 'support') {
|
||||
card._haIntegrationVersion = cardVersion;
|
||||
card._haSupportApi = 1;
|
||||
card._openSupportDialog();
|
||||
await card.updateComplete;
|
||||
const opened = card._supportDialog;
|
||||
|
||||
+8
-13
@@ -6,11 +6,6 @@ export const GOLDEN_MATRIX_VERSION = 54;
|
||||
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: [
|
||||
@@ -200,23 +195,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 +343,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 +370,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',
|
||||
|
||||
+28
-1
@@ -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
|
||||
|
||||
+13
-2
@@ -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`, и
|
||||
требует статически — потому что динамическое доказательство возможно только
|
||||
там, где есть браузер.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Probe #421: reportPageErrors() owns a separate delivery round-trip from finish().
|
||||
//
|
||||
// The page error is scheduled at the tail of the browser turn and there is no
|
||||
// page interaction afterwards. The verdict must therefore flush the page by
|
||||
// itself. This probe deliberately never calls finish(): doing so would prove
|
||||
// the older branch while leaving reportPageErrors() blind.
|
||||
import { launch, reportPageErrors } from '../serve.mjs';
|
||||
|
||||
const { browser, page } = await launch();
|
||||
await page.evaluate(() => {
|
||||
setTimeout(() => { throw new Error('guard-report-page-errors'); }, 0);
|
||||
});
|
||||
await reportPageErrors();
|
||||
await browser.close();
|
||||
@@ -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,18 +44,37 @@ 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,
|
||||
expectOutput: /uncaught exception\(s\) inside the card/,
|
||||
because: 'reportPageErrors() обязан сам дождаться хвостового pageerror, не полагаясь на finish()',
|
||||
},
|
||||
];
|
||||
|
||||
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
|
||||
@@ -124,6 +144,7 @@ Build and copy a fresh demo bundle first, then run:
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
|
||||
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.
|
||||
@@ -144,9 +165,10 @@ rationale in the change. Do not loosen a threshold merely to make a single red
|
||||
run pass. A new fixture profile gets a new profile id instead of silently
|
||||
changing the meaning of `large-house-v1`.
|
||||
|
||||
The `cleanFloor` entry ceiling is 160: the reviewed fixture currently warms
|
||||
120 deterministic room/physical-body entries, and the extra 40 slots allow a
|
||||
legitimate fixture extension without weakening the separate zero-growth gate.
|
||||
The `cleanFloor` entry ceiling is 100: the reviewed fixture warms exactly 100
|
||||
deterministic room/physical-body entries. An extra 20 means that one complete
|
||||
floor was invalidated and rebuilt, so fixture extensions must recalibrate this
|
||||
ceiling explicitly instead of receiving silent cache headroom.
|
||||
|
||||
## Glow profiles
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 160,
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 160,
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 160,
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -367,6 +367,129 @@ const res = await page.evaluate(async () => {
|
||||
?.marker_area_snapshot?.[orphanId];
|
||||
limitedCard.remove();
|
||||
|
||||
// #419: destructive snapshot cleanup uses the raw registry roster and two
|
||||
// distinct non-empty revisions. Presentation filtering is never absence.
|
||||
const registryRow = (id) => ({
|
||||
id, name: id, area_id: 'area-without-houseplan-room', disabled_by: null,
|
||||
});
|
||||
const entityRow = {
|
||||
entity_id: 'sensor.registry_witness', device_id: null,
|
||||
platform: 'demo', disabled_by: null,
|
||||
};
|
||||
const runCleanupProbe = async ({
|
||||
snapshot, deviceFrames, liveDevices = {}, rejectFirstCleanup = false,
|
||||
}) => {
|
||||
const config = structuredClone(c._serverCfg);
|
||||
config.markers = [];
|
||||
config.settings = {
|
||||
...config.settings,
|
||||
filter_seeded: true,
|
||||
known_devices: [],
|
||||
new_device_ids: [],
|
||||
marker_area_snapshot: structuredClone(snapshot),
|
||||
};
|
||||
const probeCalls = [];
|
||||
let deviceRegistryCalls = 0;
|
||||
let entityRegistryCalls = 0;
|
||||
let cleanupWrites = 0;
|
||||
let rejected = false;
|
||||
let rev = 100;
|
||||
const base = window.__mkHass();
|
||||
const probeHass = {
|
||||
...base,
|
||||
connection: {
|
||||
subscribeEvents: async () => () => {},
|
||||
subscribeMessage: async () => () => {},
|
||||
},
|
||||
devices: liveDevices,
|
||||
entities: { [entityRow.entity_id]: entityRow },
|
||||
states: {},
|
||||
};
|
||||
probeHass.callWS = async (message) => {
|
||||
probeCalls.push(structuredClone(message));
|
||||
if (message.type === 'config/device_registry/list') {
|
||||
const frame = deviceFrames[Math.min(deviceRegistryCalls, deviceFrames.length - 1)];
|
||||
deviceRegistryCalls += 1;
|
||||
return structuredClone(frame);
|
||||
}
|
||||
if (message.type === 'config/entity_registry/list') {
|
||||
entityRegistryCalls += 1;
|
||||
return [structuredClone(entityRow)];
|
||||
}
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
return { config, rev, can_write: true };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') return { layout: {}, rev };
|
||||
if (message.type === 'houseplan/config/set') {
|
||||
const beforeCount = Object.keys(snapshot).length;
|
||||
const afterCount = Object.keys(
|
||||
message.config?.settings?.marker_area_snapshot || {},
|
||||
).length;
|
||||
if (afterCount < beforeCount) {
|
||||
cleanupWrites += 1;
|
||||
if (rejectFirstCleanup && !rejected) {
|
||||
rejected = true;
|
||||
throw new Error('synthetic cleanup config failure');
|
||||
}
|
||||
}
|
||||
Object.assign(config, structuredClone(message.config));
|
||||
return { ok: true, rev: ++rev };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: 'Area cleanup probe' });
|
||||
document.body.append(card);
|
||||
card.hass = probeHass;
|
||||
for (let attempt = 0; attempt < 20 && !card._loadedOnce; attempt += 1) await wait(100);
|
||||
await wait(550);
|
||||
if (rejectFirstCleanup) {
|
||||
card._regSignature = '';
|
||||
card._maybeRebuildDevices();
|
||||
await wait(450);
|
||||
}
|
||||
const result = {
|
||||
snapshot: structuredClone(card._serverCfg?.settings?.marker_area_snapshot || {}),
|
||||
deviceIds: card._devices.map((item) => item.id),
|
||||
deviceRegistryCalls,
|
||||
entityRegistryCalls,
|
||||
cleanupWrites,
|
||||
configWrites: probeCalls.filter((message) => message.type === 'houseplan/config/set').length,
|
||||
};
|
||||
card.remove();
|
||||
return result;
|
||||
};
|
||||
|
||||
const filteredRow = registryRow('filtered-but-live');
|
||||
const transientRow = registryRow('transient-device');
|
||||
const recoveredProbe = await runCleanupProbe({
|
||||
snapshot: {
|
||||
filtered: { binding: 'device:filtered-but-live', area: 'living_room' },
|
||||
transient: { binding: 'device:transient-device', area: 'living_room' },
|
||||
},
|
||||
deviceFrames: [[filteredRow], [filteredRow, transientRow]],
|
||||
liveDevices: { [filteredRow.id]: filteredRow },
|
||||
});
|
||||
|
||||
const keepRow = registryRow('still-live');
|
||||
const confirmedProbe = await runCleanupProbe({
|
||||
snapshot: {
|
||||
orphan: { binding: 'device:confirmed-orphan', area: 'living_room' },
|
||||
keep: { binding: 'device:still-live', area: 'living_room' },
|
||||
},
|
||||
deviceFrames: [[keepRow], [keepRow]],
|
||||
liveDevices: { [keepRow.id]: keepRow },
|
||||
rejectFirstCleanup: true,
|
||||
});
|
||||
|
||||
const emptyProbe = await runCleanupProbe({
|
||||
snapshot: {
|
||||
orphan: { binding: 'device:empty-frame-orphan', area: 'living_room' },
|
||||
},
|
||||
deviceFrames: [[]],
|
||||
liveDevices: {},
|
||||
});
|
||||
|
||||
return {
|
||||
movedToRegistryArea: moved?.area === 'kitchen' && moved?.space === 'f1',
|
||||
staleLayoutDeleted: !c._layout.d_light1,
|
||||
@@ -411,6 +534,19 @@ const res = await page.evaluate(async () => {
|
||||
.includes(message.type)),
|
||||
authoritativeOrphanRemoved,
|
||||
nonAuthoritativeOrphanPreserved,
|
||||
filteredRosterIsNotAbsence: !recoveredProbe.deviceIds.includes('filtered-but-live')
|
||||
&& !!recoveredProbe.snapshot.filtered,
|
||||
transientRosterPreserved: !!recoveredProbe.snapshot.transient,
|
||||
confirmationRefreshRunsOnce: recoveredProbe.deviceRegistryCalls === 2
|
||||
&& recoveredProbe.entityRegistryCalls === 2
|
||||
&& recoveredProbe.cleanupWrites === 0,
|
||||
confirmedOrphanRemovedAlone: !confirmedProbe.snapshot.orphan
|
||||
&& !!confirmedProbe.snapshot.keep,
|
||||
confirmedCleanupRetriesAfterWriteFailure: confirmedProbe.cleanupWrites >= 2,
|
||||
emptyRegistryPreservesSnapshot: !!emptyProbe.snapshot.orphan
|
||||
&& emptyProbe.cleanupWrites === 0,
|
||||
emptyRegistryDoesNotReloadLoop: emptyProbe.deviceRegistryCalls === 1
|
||||
&& emptyProbe.entityRegistryCalls === 1,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,109 @@ const { page, browser } = await launch(
|
||||
{ width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true },
|
||||
);
|
||||
|
||||
// Hold the first German chunk request so the card remains in the real `warm`
|
||||
// language gate long enough to exercise it. Aborting would immediately select
|
||||
// the English fallback and could turn this into a test that never enters the
|
||||
// branch it claims to cover.
|
||||
const germanAsset = /\/houseplan-assets\/de-[^/?]+\.js(?:\?.*)?$/;
|
||||
let releaseGerman;
|
||||
let markGermanStarted;
|
||||
let markGermanCompleted;
|
||||
const germanStarted = new Promise((resolve) => { markGermanStarted = resolve; });
|
||||
const germanCompleted = new Promise((resolve) => { markGermanCompleted = resolve; });
|
||||
const holdGerman = async (route) => {
|
||||
markGermanStarted();
|
||||
await new Promise((resolve) => { releaseGerman = resolve; });
|
||||
await route.continue();
|
||||
markGermanCompleted();
|
||||
};
|
||||
await page.route(germanAsset, holdGerman);
|
||||
|
||||
const warmGate = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
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.inert; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await settle();
|
||||
}
|
||||
|
||||
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 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',
|
||||
}),
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
const result = {
|
||||
warmLanguageGateActuallyEntered: entered,
|
||||
readyConfirmationOpenedBeforeWarm: openedWhileReady,
|
||||
readyToWarmCancelsOpenConfirmation: cancelledOnTransition === false,
|
||||
readyToWarmKeepsCommittedBody: root().querySelector('ha-card')?.outerHTML === committedBody,
|
||||
warmLanguageGateRefusesImmediately: decision === false,
|
||||
warmLanguageGateKeepsControllerEmpty: card._dangerConfirm === null
|
||||
&& card._dangerConfirmController.state === null,
|
||||
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 };
|
||||
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;
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
germanStarted,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('German locale request not seen')), 1000)),
|
||||
]);
|
||||
releaseGerman();
|
||||
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 () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
@@ -152,6 +255,62 @@ const out = await page.evaluate(async () => {
|
||||
&& (await survivor) === true;
|
||||
await leaveBranch();
|
||||
|
||||
// The exact missed branch from #417: a non-empty model whose active space
|
||||
// cannot be resolved returns `nothing`, unlike onboarding and fixed-floor
|
||||
// status cards. An already open request must be cancelled during the same
|
||||
// update, and a new request must never enter the controller.
|
||||
const originalSpaceModel = card._spaceModel;
|
||||
const pendingAtSpaceLoss = card._confirmDanger(request('space-lost-after-open'));
|
||||
await settle();
|
||||
card._spaceModel = () => undefined;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
const spaceLossDecision = await Promise.race([
|
||||
pendingAtSpaceLoss,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
result.lostSpaceBranchIsActuallyEntered = root().childElementCount === 0;
|
||||
result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision === 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)),
|
||||
]);
|
||||
result.lostSpaceRequestRefusesImmediately = refusedWithoutSpaceWasNeverRegistered
|
||||
&& refusedWithoutSpace === false
|
||||
&& card._dangerConfirm === null && card._dangerConfirmController.state === null
|
||||
&& dialogs() === 0;
|
||||
card._spaceModel = originalSpaceModel;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
|
||||
// The two status branches called out by #402 still paint a card, so they
|
||||
// must keep hosting the confirmation instead of being caught by the new
|
||||
// lost-space guard.
|
||||
const originalConfig = card._config;
|
||||
const originalLoadOk = card._loadOk;
|
||||
const confirmInFixedBranch = async (loadOk, state) => {
|
||||
card._loadOk = loadOk;
|
||||
card._config = { ...originalConfig, floor: 'missing-floor-for-confirm-smoke' };
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
const branchEntered = !!root().querySelector(`[data-fixed-floor-state="${state}"]`);
|
||||
const pending = card._confirmDanger(request(`fixed-${state}`));
|
||||
await settle();
|
||||
const shown = dialogs() === 1;
|
||||
await decide(false);
|
||||
return branchEntered && shown && (await pending) === false;
|
||||
};
|
||||
result.fixedFloorPendingStillShowsConfirm = await confirmInFixedBranch(false, 'pending');
|
||||
result.fixedFloorInvalidStillShowsConfirm = await confirmInFixedBranch(true, 'invalid');
|
||||
card._loadOk = originalLoadOk;
|
||||
card._config = originalConfig;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
|
||||
// Тап по «Отмена» — тот же путь, что и клик: на touch подтверждение обязано
|
||||
// спрашиваться, а не деградировать (TOUCH-SUPPORT § Safety floor).
|
||||
await enterBranch([]);
|
||||
@@ -236,6 +395,8 @@ const out = await page.evaluate(async () => {
|
||||
return result;
|
||||
});
|
||||
|
||||
Object.assign(out, warmGate);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const element = document.createElement('hp-confirm');
|
||||
element.token = 4_060_000;
|
||||
|
||||
+131
-5
@@ -10,6 +10,11 @@ const res = await page.evaluate(async () => {
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const settleLive = async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await c.updateComplete;
|
||||
};
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
@@ -189,7 +194,7 @@ const res = await page.evaluate(async () => {
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'Default probe' };
|
||||
c._decorSaveText();
|
||||
c._furnPalette = { symbol: 'chair', w: 45, h: 45 };
|
||||
c._furnPlace([gDefault * 24, gDefault * 12], true);
|
||||
c._editorRuntime._furnPlace([gDefault * 24, gDefault * 12], true);
|
||||
await c.updateComplete;
|
||||
const createdByDefault = c._decorList.filter((shape) => shape.id !== existingDefaultProbe.id);
|
||||
out.everyNewDecorKindUsesMainDefault = textInheritsDefault
|
||||
@@ -272,10 +277,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 +292,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 +498,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];
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* #51 targeted browser proof for the custom-image projection and editor flow.
|
||||
* 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';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 760 });
|
||||
const result = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const settle = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const stage = () => root().querySelector('.stage');
|
||||
const point = (x, y) => {
|
||||
const rect = stage().getBoundingClientRect();
|
||||
const view = c._viewOr(c._baseVb());
|
||||
return {
|
||||
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
|
||||
};
|
||||
};
|
||||
const pointer = (type, x, y, extra = {}) => {
|
||||
const screen = point(x, y);
|
||||
stage().dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, isPrimary: true,
|
||||
pointerId: 510, pointerType: 'mouse', button: 0, ...screen, ...extra,
|
||||
}));
|
||||
};
|
||||
|
||||
const sp = c._curSpaceCfg;
|
||||
const saved = {
|
||||
decor: JSON.parse(JSON.stringify(sp.decor || [])),
|
||||
settings: JSON.parse(JSON.stringify(sp.settings || {})),
|
||||
assets: c._decorAssets,
|
||||
catalog: c._decorAssetCatalog,
|
||||
capability: c._haDecorAssetsApi,
|
||||
hass: c.hass,
|
||||
};
|
||||
const id = '5'.repeat(64);
|
||||
const url = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 1"%3E%3Cpath fill="%23f80" d="M0 0h2v1H0z"/%3E%3C/svg%3E';
|
||||
const rawUrl = `/api/houseplan/content/assets/_/${id}.svg`;
|
||||
const asset = {
|
||||
asset_id: id, name: 'orange.svg', mime: 'image/svg+xml',
|
||||
width: 200, height: 100, bytes: 100, url: rawUrl, used_by: [],
|
||||
};
|
||||
const id2 = '6'.repeat(64);
|
||||
const url2 = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 2"%3E%3Cpath fill="%2308f" d="M0 0h1v2H0z"/%3E%3C/svg%3E';
|
||||
const rawUrl2 = `/api/houseplan/content/assets/_/${id2}.svg`;
|
||||
const asset2 = {
|
||||
asset_id: id2, name: 'blue.svg', mime: 'image/svg+xml',
|
||||
width: 100, height: 200, bytes: 100, url: rawUrl2, used_by: [],
|
||||
};
|
||||
|
||||
const originalCallWS = c.hass.callWS.bind(c.hass);
|
||||
c.hass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/assets/list') return { assets: [asset, asset2] };
|
||||
if (message.type === 'houseplan/content/sign') return { urls: Object.fromEntries(
|
||||
message.paths.map((path) => [path, path === rawUrl ? url : path === rawUrl2 ? url2 : path]),
|
||||
) };
|
||||
return originalCallWS(message);
|
||||
} };
|
||||
|
||||
c._haDecorAssetsApi = null;
|
||||
c._decorAssets = new Map();
|
||||
c._decorAssetCatalog = [asset];
|
||||
sp.decor = [];
|
||||
sp.settings = { ...(sp.settings || {}), hide_decor: false };
|
||||
c._setMode('decor');
|
||||
await settle();
|
||||
const capabilityFailsClosed = !root().querySelector('[data-editor-palette="image"]');
|
||||
c._haDecorAssetsApi = 1;
|
||||
await settle();
|
||||
|
||||
const imageTool = root().querySelector('[data-editor-palette="image"]');
|
||||
imageTool?.click();
|
||||
await settle();
|
||||
await sleep(60);
|
||||
await settle();
|
||||
const palette = root().querySelector('.imagepalette');
|
||||
const item = palette?.querySelector('.imageasset .furnitem');
|
||||
item?.click();
|
||||
await settle();
|
||||
pointer('pointermove', 300, 300);
|
||||
await settle();
|
||||
const preview = root().querySelector('.decor-image-placement-preview');
|
||||
const previewExists = !!preview;
|
||||
const previewUsesAsset = preview?.getAttribute('href') === url;
|
||||
const previewIsInert = !!preview && getComputedStyle(preview).pointerEvents === 'none';
|
||||
const beforePlace = sp.decor.length;
|
||||
pointer('pointerdown', 300, 300);
|
||||
await settle();
|
||||
const image = sp.decor.find((shape) => shape.kind === 'image');
|
||||
const rendered = image
|
||||
? root().querySelector(`.decorlayer [data-id="${image.id}"][data-kind="image"]`) : null;
|
||||
const near = (left, right, tolerance = 1e-6) => Math.abs(left - right) <= tolerance;
|
||||
const unitsPerCm = (1000 / 240) / c._cellCm;
|
||||
|
||||
const out = {
|
||||
capabilityFailsClosed,
|
||||
oneImageTool: root().querySelectorAll('[data-editor-palette="image"]').length === 1,
|
||||
paletteIsNamedDialog: palette?.getAttribute('role') === 'dialog'
|
||||
&& !!palette?.getAttribute('aria-label'),
|
||||
reusableFileIsListed: item?.textContent?.includes('orange.svg') === true,
|
||||
reusableFileWasResolvedForPainting: c._decorAssets.get(id)?.url === rawUrl,
|
||||
hoverPreviewExists: previewExists,
|
||||
hoverPreviewUsesAsset: previewUsesAsset,
|
||||
hoverPreviewIsInert: previewIsInert,
|
||||
hoverPreviewIsExactAndInert: previewExists && previewUsesAsset && previewIsInert,
|
||||
oneShotPlacement: sp.decor.length === beforePlace + 1
|
||||
&& c._decorTool === 'select' && c._decorImagePalette === null,
|
||||
initialPhysicalSize: !!image
|
||||
&& near(image.w * 1000, 100 * unitsPerCm)
|
||||
&& near(image.h * 1000, 50 * unitsPerCm),
|
||||
fullViewProjection: rendered?.tagName?.toLowerCase() === 'image'
|
||||
&& rendered.getAttribute('href') === url,
|
||||
};
|
||||
|
||||
// A point near a wall may use ordinary decor/room snap, but the object stays
|
||||
// centred on that point and is never offset/rotated parallel like furniture.
|
||||
c._decorTool = 'image';
|
||||
c._decorImagePalette = asset;
|
||||
const expectedImagePoint = c._editorRuntime._decorSnap([300, 150]);
|
||||
c._editorRuntime._decorImagePlace([300, 150]);
|
||||
await settle();
|
||||
const nearWall = sp.decor.filter((shape) => shape.kind === 'image').at(-1);
|
||||
const nearWallCenterY = nearWall ? (nearWall.y + nearWall.h / 2) * 1000 : NaN;
|
||||
out.nearWallImageWasPlaced = !!nearWall;
|
||||
out.nearWallKeepsAngle = nearWall?.angle === undefined;
|
||||
out.nearWallKeepsClickedCentre = near(nearWallCenterY, expectedImagePoint[1], 1e-3);
|
||||
out.noFurnitureWallMagnet = out.nearWallImageWasPlaced && out.nearWallKeepsAngle
|
||||
&& out.nearWallKeepsClickedCentre;
|
||||
|
||||
const countBeforeTouch = sp.decor.length;
|
||||
c._decorTool = 'image';
|
||||
c._decorImagePalette = asset;
|
||||
pointer('pointerdown', 360, 360, { pointerType: 'touch', pointerId: 511 });
|
||||
pointer('pointercancel', 360, 360, { pointerType: 'touch', pointerId: 511 });
|
||||
await settle();
|
||||
out.cancelledTouchDoesNotPlace = sp.decor.length === countBeforeTouch
|
||||
&& c._furnTouchPending === null;
|
||||
|
||||
let committedPointerType = '';
|
||||
const originalImagePlace = c._editorRuntime._decorImagePlace;
|
||||
c._editorRuntime._decorImagePlace = (_raw, pointerType) => { committedPointerType = pointerType; };
|
||||
c._decorTool = 'image';
|
||||
c._decorImagePalette = asset;
|
||||
pointer('pointerdown', 360, 360, { pointerType: 'touch', pointerId: 512 });
|
||||
pointer('pointerup', 360, 360, { pointerType: 'touch', pointerId: 512 });
|
||||
await settle();
|
||||
c._editorRuntime._decorImagePlace = originalImagePlace;
|
||||
out.touchCommitUsesTouchSnapTolerance = committedPointerType === 'touch';
|
||||
|
||||
c._decorTool = 'select';
|
||||
const imageBeforeReplace = { ...sp.decor.find((shape) => shape.id === image.id) };
|
||||
c._editorRuntime._openDecorProperties(imageBeforeReplace);
|
||||
await settle();
|
||||
const replaceInputExists = !!root().querySelector('.imagepropertypreview + .imageupload input[type="file"]');
|
||||
const d = c._decorShapeDialog;
|
||||
c._decorShapeDialog = {
|
||||
...d, assetId: id2, opacity: 0.4, angle: '45', sizeWField: `-${d.sizeWField}`,
|
||||
};
|
||||
c._editorRuntime._decorSaveShape();
|
||||
await settle();
|
||||
const replaced = sp.decor.find((shape) => shape.id === image.id);
|
||||
const replacedNode = root().querySelector(`.decorlayer image[data-id="${image.id}"]`);
|
||||
out.propertiesOfferNewUpload = replaceInputExists;
|
||||
out.replacePreservesGeometryAndChangesOnlySelectedRef = !!replaced
|
||||
&& replaced.asset_id === id2
|
||||
&& near(replaced.x + replaced.w / 2, imageBeforeReplace.x + imageBeforeReplace.w / 2)
|
||||
&& near(replaced.y + replaced.h / 2, imageBeforeReplace.y + imageBeforeReplace.h / 2)
|
||||
&& near(replaced.w, imageBeforeReplace.w) && near(replaced.h, imageBeforeReplace.h)
|
||||
&& nearWall?.asset_id === id;
|
||||
out.propertiesProjectOpacityRotationAndFlip = replacedNode?.getAttribute('href') === url2
|
||||
&& Number(replacedNode.getAttribute('opacity')) === 0.4
|
||||
&& replacedNode.getAttribute('transform')?.includes('rotate(45)')
|
||||
&& replacedNode.getAttribute('transform')?.includes('scale(-1 1)');
|
||||
|
||||
c._decorTool = 'select';
|
||||
c._decorAssets = new Map();
|
||||
await settle();
|
||||
const missing = root().querySelector(`.decorlayer .dimage-missing[data-id="${image.id}"]`);
|
||||
out.missingIsRepairableOnlyInEditor = !!missing
|
||||
&& /image unavailable|изображение недоступно/i.test(missing.textContent || '');
|
||||
c._setMode('view');
|
||||
await settle();
|
||||
out.missingFailsDarkInView = !root().querySelector(
|
||||
`.decorlayer [data-id="${image.id}"]`,
|
||||
);
|
||||
|
||||
c._decorAssets = new Map([[id, asset], [id2, asset2]]);
|
||||
sp.settings = { ...(sp.settings || {}), hide_decor: true };
|
||||
await settle();
|
||||
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;
|
||||
const staticHass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return {
|
||||
config, rev: 1, decor_assets_api: 1, virtual_lights: { rev: 0, config_rev: 1, off: [] },
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
if (message.type === 'houseplan/assets/resolve') {
|
||||
staticResolveCalls++;
|
||||
return { assets: [asset, asset2], missing: [] };
|
||||
}
|
||||
if (message.type === 'houseplan/content/sign') return { urls: Object.fromEntries(
|
||||
message.paths.map((path) => [path, path === rawUrl ? url : path === rawUrl2 ? url2 : path]),
|
||||
) };
|
||||
return { ok: true };
|
||||
} };
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
|
||||
staticCard.hass = staticHass;
|
||||
host.append(staticCard);
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!staticCard.renderRoot?.querySelector('.decorlayer image.dimage')
|
||||
&& Date.now() < deadline) await sleep(50);
|
||||
const staticImage = staticCard.renderRoot?.querySelector('.decorlayer image.dimage');
|
||||
const staticSvg = staticCard.renderRoot?.querySelector('.hp-static-stage svg');
|
||||
const viewBox = staticSvg?.viewBox?.baseVal;
|
||||
const x = Number(staticImage?.getAttribute('x'));
|
||||
const y = Number(staticImage?.getAttribute('y'));
|
||||
const w = Number(staticImage?.getAttribute('width'));
|
||||
const h = Number(staticImage?.getAttribute('height'));
|
||||
out.staticCardParity = staticImage?.getAttribute('href') === url2
|
||||
&& Number(staticImage?.getAttribute('opacity')) === 0.4
|
||||
&& staticImage?.getAttribute('transform')?.includes('rotate(45)')
|
||||
&& staticImage?.getAttribute('transform')?.includes('scale(-1 1)');
|
||||
out.staticFrameContainsImage = !!viewBox
|
||||
&& viewBox.x <= x && viewBox.y <= y
|
||||
&& viewBox.x + viewBox.width >= x + w
|
||||
&& viewBox.y + viewBox.height >= y + h;
|
||||
out.staticImageIsInert = !!staticImage
|
||||
&& getComputedStyle(staticImage).pointerEvents === 'none';
|
||||
const resolveCallsAfterFirstLoad = staticResolveCalls;
|
||||
await staticCard._load(true);
|
||||
out.staticResolveCachesCompleteAssetSet = resolveCallsAfterFirstLoad === 1
|
||||
&& staticResolveCalls === resolveCallsAfterFirstLoad;
|
||||
host.remove();
|
||||
|
||||
sp.decor = saved.decor;
|
||||
sp.settings = saved.settings;
|
||||
c._decorAssets = saved.assets;
|
||||
c._decorAssetCatalog = saved.catalog;
|
||||
c._haDecorAssetsApi = saved.capability;
|
||||
c.hass = saved.hass;
|
||||
c._decorImagePalette = null;
|
||||
c._decorTool = 'select';
|
||||
c._setMode('view');
|
||||
await settle();
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -34,6 +34,13 @@ const fixture = await page.evaluate(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const roomPoly = [[0.1, 0.15], [0.9, 0.15], [0.9, 0.85], [0.1, 0.85]];
|
||||
const imageAssetId = '7'.repeat(64);
|
||||
c._decorAssets = new Map([[imageAssetId, {
|
||||
asset_id: imageAssetId, name: 'layer-order.svg', mime: 'image/svg+xml',
|
||||
width: 2, height: 1, bytes: 100,
|
||||
url: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 1"%3E%3Cpath fill="%23ff0033" d="M0 0h2v1H0z"/%3E%3C/svg%3E',
|
||||
used_by: [],
|
||||
}]]);
|
||||
sp.plan_url = null;
|
||||
sp.view_box = [0, 0, 1, 1];
|
||||
sp.cell_cm = 5;
|
||||
@@ -58,6 +65,8 @@ const fixture = await page.evaluate(async () => {
|
||||
color: '#ff0033', opacity: 1, size_cm: 14 },
|
||||
{ id: 'decor-order-furniture', kind: 'furniture', symbol: 'sofa',
|
||||
x: 0.64, y: 0.61, w: 0.18, h: 0.13, color: '#ff0033', opacity: 1, width_cm: 2 },
|
||||
{ id: 'decor-order-image', kind: 'image', asset_id: imageAssetId,
|
||||
x: 0.76, y: 0.72, w: 0.08, h: 0.04, opacity: 1 },
|
||||
];
|
||||
sp.settings = {
|
||||
...(sp.settings || {}), fill_mode: 'custom', custom_fill: { c: '#2255cc', a: 1 },
|
||||
@@ -86,7 +95,7 @@ const fixture = await page.evaluate(async () => {
|
||||
.map((node) => node.dataset.kind));
|
||||
return {
|
||||
before: {
|
||||
allDecorKindsRender: ['line', 'rect', 'ellipse', 'text', 'furniture']
|
||||
allDecorKindsRender: ['line', 'rect', 'ellipse', 'text', 'furniture', 'image']
|
||||
.every((kind) => kinds.has(kind)),
|
||||
oneDecorLayer: root().querySelectorAll('.decorlayer').length === 1,
|
||||
dataFloorBeforeDecor: follows(room, decor) && follows(dataTunnel, decor),
|
||||
@@ -230,7 +239,7 @@ const parity = await page.evaluate(async (stored) => {
|
||||
out.hideDecorStillHidesView = !root.querySelector('.decorlayer');
|
||||
c._setMode('decor'); await update();
|
||||
out.ownEditorOverridesHide = !!root.querySelector('.decorlayer')
|
||||
&& root.querySelectorAll('.decorlayer [data-hp="decor"]').length >= 5;
|
||||
&& root.querySelectorAll('.decorlayer [data-hp="decor"]').length >= 6;
|
||||
return out;
|
||||
}, fixture.stored);
|
||||
|
||||
|
||||
@@ -27,9 +27,15 @@ const res = await page.evaluate(async () => {
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const settleLive = async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await 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 +107,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 +153,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;
|
||||
|
||||
@@ -131,6 +131,7 @@ const out = await page.evaluate(async () => {
|
||||
beginDeviceDrag(dev3, r0, 13);
|
||||
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y,
|
||||
400, 400, { shiftKey: true, pointerId: 13 }), dev3);
|
||||
await Promise.resolve();
|
||||
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
|
||||
c._cancelDeviceDrag();
|
||||
|
||||
|
||||
+124
-32
@@ -23,8 +23,14 @@ const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const settleLive = async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await c.updateComplete;
|
||||
};
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
const el = (id) => (id ? sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
|
||||
const el = (id) => (id ? sr().querySelector(`[data-hp-live-editor] .decorlayer [data-id="${id}"]`)
|
||||
|| sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
|
||||
const attr = (id, n) => { const e = el(id); return e ? e.getAttribute(n) : null; };
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
@@ -46,6 +52,7 @@ const res = await page.evaluate(async () => {
|
||||
const cmToUnits = (cm) => (cm / CELL) * PITCH;
|
||||
const near = (a, b, tol) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;
|
||||
const pe = (id) => { const e = el(id); return e ? getComputedStyle(e).pointerEvents : null; };
|
||||
const box = () => c._decorBoxOf(sofaNow());
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
@@ -148,9 +155,9 @@ const res = await page.evaluate(async () => {
|
||||
redo: c._geometryHistory.redoName,
|
||||
};
|
||||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
const firstPreview = c._furniturePreviewPlacement
|
||||
? JSON.parse(JSON.stringify(c._furniturePreviewPlacement)) : null;
|
||||
await settleLive();
|
||||
const firstPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
const firstTransform = ghost()?.getAttribute('transform');
|
||||
out.mouseHoverShowsRealSymbol = ghost()?.getAttribute('data-symbol') === 'sofa'
|
||||
&& (ghost()?.getAttribute('d') || '').length > 20;
|
||||
@@ -166,7 +173,7 @@ const res = await page.evaluate(async () => {
|
||||
wIn().dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
await c.updateComplete;
|
||||
out.sizeUpdatesPreviewWithoutPointerMove = c._furniturePreviewPlacement?.w > firstPreview?.w
|
||||
out.sizeUpdatesPreviewWithoutPointerMove = c._editorRuntime?._furniturePreviewPlacement()?.w > firstPreview?.w
|
||||
&& ghost()?.getAttribute('transform') !== firstTransform;
|
||||
if (wIn()) {
|
||||
wIn().value = '1.8';
|
||||
@@ -176,7 +183,7 @@ const res = await page.evaluate(async () => {
|
||||
stageEl().dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: true, composed: true, pointerId: 11, pointerType: 'mouse',
|
||||
}));
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.pointerLeaveClearsPreview = !ghost() && c._furnPreviewInput === null;
|
||||
|
||||
// Unknown/stale symbols fail dark: neither an invisible ghost nor a decor
|
||||
@@ -184,7 +191,7 @@ const res = await page.evaluate(async () => {
|
||||
c._furnPalette = { symbol: 'future_unknown_symbol', w: 180, h: 90 };
|
||||
await c.updateComplete;
|
||||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const beforeUnknown = c._decorList.length;
|
||||
ev('pointerdown', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
@@ -196,9 +203,9 @@ const res = await page.evaluate(async () => {
|
||||
// ================= 4. размещение в реальном размере ======================
|
||||
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
|
||||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
const committedPreview = c._furniturePreviewPlacement
|
||||
? JSON.parse(JSON.stringify(c._furniturePreviewPlacement)) : null;
|
||||
await settleLive();
|
||||
const committedPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
const committedPreviewStroke = Number(ghost()?.getAttribute('stroke-width'));
|
||||
ev('pointerdown', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
@@ -249,18 +256,100 @@ const res = await page.evaluate(async () => {
|
||||
&& near((wc?.h ?? NaN) * 1000, cmToUnits(70), 1e-6);
|
||||
|
||||
// ================= 6. магнит к стене =====================================
|
||||
// #445: make the living-room top and shared right wall physically thick.
|
||||
// Exact endpoints are persisted in normalised config coordinates; the
|
||||
// magnet must consume their room-facing render surfaces, not their axes.
|
||||
c._curSpaceCfg.walls = [
|
||||
{ key: '0.295833,0.141667@0.0000', cm: 20, a: [0.04, 0.14], b: [0.55, 0.14] },
|
||||
{ key: '0.550000,0.358333@1.5706', cm: 20, a: [0.55, 0.14], b: [0.55, 0.58] },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
const wallHalf = cmToUnits(20) / 2;
|
||||
const firstSurfaceSnapshot = c._editorRuntime?._furnWalls;
|
||||
out.wallSurfaceCandidatesAreCached = !!firstSurfaceSnapshot
|
||||
&& firstSurfaceSnapshot === c._editorRuntime?._furnWalls;
|
||||
|
||||
await openFurniture();
|
||||
category('bed')?.click(); await c.updateComplete;
|
||||
pick('bed_double'); await c.updateComplete;
|
||||
ev('pointerdown', stageEl(), 300, 150); // 10 единиц от стены y=140
|
||||
ev('pointermove', stageEl(), 300, 140 + wallHalf, { pointerType: 'mouse' });
|
||||
await settleLive();
|
||||
const thickWallPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
ev('pointerdown', stageEl(), 300, 140 + wallHalf);
|
||||
await c.updateComplete;
|
||||
const bed = c._decorList.find((s) => s.symbol === 'bed_double');
|
||||
const depth = cmToUnits(200);
|
||||
out.wallMagnetPullsItFlat = !!bed
|
||||
&& near((bed.y + bed.h / 2) * 1000, 140 + depth / 2, 1e-6);
|
||||
&& near((bed.y + bed.h / 2) * 1000, 140 + wallHalf + depth / 2, 1e-6);
|
||||
out.wallMagnetKeepsTheAngle = !!bed && (bed.angle || 0) === 0; // стена горизонтальна
|
||||
out.wallMagnetQuantisesAlongTheWall = !!bed
|
||||
&& Math.abs((bed.x + bed.w / 2) * 1000 - 300) <= PITCH + 1e-6;
|
||||
out.thickWallPreviewAndCommitAreIdentical = !!bed && !!thickWallPreview
|
||||
&& ['x', 'y', 'w', 'h'].every((key) => near(bed[key], thickWallPreview[key], 1e-12))
|
||||
&& (bed.angle || 0) === thickWallPreview.angle;
|
||||
|
||||
// #447: the same finite outer atom also has its exterior physical face.
|
||||
// Raw intent above the wall must never be pulled through to the room side;
|
||||
// preview, commit and exact-axis drag all share that decision.
|
||||
await openFurniture();
|
||||
c._furnPalette = { symbol: 'chair', w: 50, h: 50 };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const exteriorIntentY = 140 - wallHalf;
|
||||
ev('pointermove', stageEl(), 300, exteriorIntentY, { pointerType: 'mouse' });
|
||||
await settleLive();
|
||||
const exteriorPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
const exteriorDepth = cmToUnits(50);
|
||||
const exteriorCentreY = 140 - wallHalf - exteriorDepth / 2;
|
||||
out.exteriorPreviewStaysOutside = !!exteriorPreview
|
||||
&& near((exteriorPreview.y + exteriorPreview.h / 2) * 1000, exteriorCentreY, 1e-6)
|
||||
&& Math.abs(Math.abs(exteriorPreview.angle) - 180) < 1e-6;
|
||||
ev('pointerdown', stageEl(), 300, exteriorIntentY);
|
||||
await c.updateComplete;
|
||||
const exteriorChair = c._decorList.find((shape) => shape.symbol === 'chair');
|
||||
const exteriorChairId = exteriorChair?.id ?? null;
|
||||
out.exteriorCommitMatchesPreview = !!exteriorChair && !!exteriorPreview
|
||||
&& ['x', 'y', 'w', 'h'].every((key) => near(exteriorChair[key], exteriorPreview[key], 1e-12))
|
||||
&& (exteriorChair.angle || 0) === exteriorPreview.angle;
|
||||
ev('pointerdown', el(exteriorChairId), 300, exteriorCentreY);
|
||||
ev('pointermove', stageEl(), 300, 140);
|
||||
await settleLive();
|
||||
const exteriorAxisDrag = c._decorList.find((shape) => shape.id === exteriorChairId);
|
||||
out.exteriorExactAxisDragPreservesSide = !!exteriorAxisDrag
|
||||
&& near((exteriorAxisDrag.y + exteriorAxisDrag.h / 2) * 1000, exteriorCentreY, 1e-6)
|
||||
&& Math.abs(Math.abs(exteriorAxisDrag.angle || 0) - 180) < 1e-6;
|
||||
ev('pointerup', stageEl(), 300, 140);
|
||||
await c.updateComplete;
|
||||
|
||||
// The regular decor snap resolves this pointer to the shared wall axis.
|
||||
// Surface-side selection must still use the raw point on the r2 side.
|
||||
await openFurniture();
|
||||
category('chair')?.click(); await c.updateComplete;
|
||||
pick('chair'); await c.updateComplete;
|
||||
const sharedIntentX = 550 + wallHalf / 2;
|
||||
ev('pointermove', stageEl(), sharedIntentX, 300, { pointerType: 'mouse' });
|
||||
await settleLive();
|
||||
ev('pointerdown', stageEl(), sharedIntentX, 300);
|
||||
await c.updateComplete;
|
||||
const sharedChair = c._decorList.find((s) => s.symbol === 'chair' && s.id !== exteriorChairId);
|
||||
const chairDepth = (sharedChair?.h ?? NaN) * 1000;
|
||||
const rightSurfaceCentre = 550 + wallHalf + chairDepth / 2;
|
||||
out.sharedWallUsesRawPointerSide = !!sharedChair
|
||||
&& near((sharedChair.x + sharedChair.w / 2) * 1000, rightSurfaceCentre, 1e-6);
|
||||
|
||||
// Once a piece is on the r2 face, an exact-axis drag preserves that face
|
||||
// instead of resolving an arbitrary room/input-order tie.
|
||||
const sharedChairId = sharedChair?.id ?? null;
|
||||
const sharedChairY = sharedChair ? (sharedChair.y + sharedChair.h / 2) * 1000 : NaN;
|
||||
ev('pointerdown', el(sharedChairId), rightSurfaceCentre, sharedChairY);
|
||||
ev('pointermove', stageEl(), 550, sharedChairY);
|
||||
await settleLive();
|
||||
const axisDraggedChair = c._decorList.find((s) => s.id === sharedChairId);
|
||||
out.exactAxisDragPreservesWallSide = !!axisDraggedChair
|
||||
&& near((axisDraggedChair.x + axisDraggedChair.w / 2) * 1000, rightSurfaceCentre, 1e-6);
|
||||
ev('pointerup', stageEl(), 550, sharedChairY);
|
||||
await c.updateComplete;
|
||||
|
||||
// Shift обходит магнит, но оставляет обычную привязку декора к сетке.
|
||||
await openFurniture();
|
||||
@@ -294,21 +383,23 @@ const res = await page.evaluate(async () => {
|
||||
out.touchCancelMoveAndSecondContactDoNotSave = c._decorList.length === beforeTouch
|
||||
&& !ghost() && c._furnTouchPending === null;
|
||||
|
||||
// …магнит работает и при ПЕРЕТАСКИВАНИИ: тянем диван к левой стене (x=40)
|
||||
// …магнит работает и при ПЕРЕТАСКИВАНИИ: тянем диван к общей толстой стене
|
||||
// со стороны гостиной. BACK обязан лечь на x=550-half, а не на ось x=550.
|
||||
c._editorRuntime._clearFurniturePreview();
|
||||
c._decorTool = 'select'; c._furnPalette = null; c._furnCategory = null;
|
||||
c._decorSel = sofaId; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(40); await c.updateComplete;
|
||||
ev('pointerdown', el(sofaId), 300, 300);
|
||||
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
|
||||
ev('pointermove', stageEl(), 55, 300); // 15 единиц от стены x=40
|
||||
await c.updateComplete;
|
||||
ev('pointermove', stageEl(), 540, 300);
|
||||
await settleLive();
|
||||
const pulled = sofaNow();
|
||||
out.dragMagnetTurnsItToTheWall = Math.abs(Math.abs(pulled.angle || 0) - 90) < 1e-6;
|
||||
out.dragMagnetPressesTheBack =
|
||||
near((pulled.x + pulled.w / 2) * 1000, 40 + cmToUnits(90) / 2, 1e-6);
|
||||
near((pulled.x + pulled.w / 2) * 1000,
|
||||
550 - wallHalf - cmToUnits(90) / 2, 1e-6);
|
||||
ev('pointermove', stageEl(), 300, 300, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const shiftDragGridIndex = (sofaNow().x * 1000) / PITCH;
|
||||
out.shiftDragRemainsGridBound = near(shiftDragGridIndex, Math.round(shiftDragGridIndex), 1e-6);
|
||||
ev('pointerup', stageEl(), 300, 300, { shiftKey: true });
|
||||
@@ -326,7 +417,7 @@ const res = await page.evaluate(async () => {
|
||||
c._cfgEpoch++;
|
||||
c._decorSel = sofaId; 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.furnitureFrameHasFourSideHandles = !!frame()
|
||||
&& frame().classList.contains('dtfurnitureframe')
|
||||
@@ -340,9 +431,10 @@ const res = await page.evaluate(async () => {
|
||||
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
|
||||
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
|
||||
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
|
||||
out.frameBoxIsTheShapeBox = !!c._dtBox
|
||||
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
|
||||
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
|
||||
const frameBox = frame()?.querySelector('.dtbox');
|
||||
out.frameBoxIsTheShapeBox = !!frameBox
|
||||
&& near(+frameBox.getAttribute('width'), sofaNow().w * 1000, 1e-6)
|
||||
&& near(+frameBox.getAttribute('height'), sofaNow().h * 1000, 1e-6);
|
||||
|
||||
const visiblePath = el(sofaId);
|
||||
const hitPath = sr().querySelector(`.dfurniturehit[data-id="${sofaId}"]`);
|
||||
@@ -355,7 +447,7 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
// Shift: independent, continuous width/depth + live plates.
|
||||
const before = { w: sofaNow().w, h: sofaNow().h };
|
||||
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
|
||||
const b = box() || { x: 0, y: 0, w: 0, h: 0 };
|
||||
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
|
||||
// #400: угловая ручка ищется по РОЛИ, а не по индексу в DOM. Индекс держался
|
||||
// на порядке отрисовки, а порядок — это решение о приоритете хита (углы
|
||||
@@ -367,7 +459,7 @@ const res = await page.evaluate(async () => {
|
||||
ev('pointerdown', se, b.x + b.w, b.y + b.h);
|
||||
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
|
||||
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const grown = sofaNow();
|
||||
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
|
||||
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
|
||||
@@ -382,7 +474,7 @@ const res = await page.evaluate(async () => {
|
||||
&& plates.some((p) => /m|м|′/.test(p.textContent));
|
||||
// Without Shift the original ratio is preserved, still without grid snap.
|
||||
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const proportional = sofaNow();
|
||||
const wCells = (proportional.w * 1000) / PITCH;
|
||||
out.defaultResizePreservesRatio = near(proportional.w / proportional.h, before.w / before.h, 1e-5);
|
||||
@@ -394,12 +486,12 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
// A middle handle changes only one axis and keeps the opposite edge fixed.
|
||||
const edgeBefore = { ...sofaNow() };
|
||||
const eb = c._dtBox;
|
||||
const eb = box();
|
||||
const rightEdge = [...frame().querySelectorAll('.dthandle.dtedge')].find((handle) =>
|
||||
near(+handle.getAttribute('cx'), eb.x + eb.w, 1e-6));
|
||||
ev('pointerdown', rightEdge, eb.x + eb.w, eb.y + eb.h / 2);
|
||||
ev('pointermove', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const edgeAfter = sofaNow();
|
||||
out.edgeHandleChangesOneAxisContinuously = near(edgeAfter.h, edgeBefore.h, 1e-9)
|
||||
&& near(edgeAfter.w * 1000, edgeBefore.w * 1000 + 13.7, 1e-6)
|
||||
@@ -410,13 +502,13 @@ const res = await page.evaluate(async () => {
|
||||
// Crossing the fixed corner toggles only the crossed axis. pointercancel
|
||||
// must restore the exact pre-gesture object and create no persisted flip.
|
||||
const crossBefore = JSON.parse(JSON.stringify(sofaNow()));
|
||||
const cb = c._dtBox;
|
||||
const cb = box();
|
||||
const crossHandles = [...frame().querySelectorAll('.dthandle')]
|
||||
.filter((el) => !el.classList.contains('dtrot') && !el.classList.contains('dtedge'));
|
||||
const crossSe = crossHandles[2]; // #400: по роли, не по позиции в DOM
|
||||
ev('pointerdown', crossSe, cb.x + cb.w, cb.y + cb.h);
|
||||
ev('pointermove', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const crossed = sofaNow();
|
||||
out.crossingTogglesOnlyHorizontalFlip = crossed.flip_h === true && !crossed.flip_v
|
||||
&& crossed.w > 0 && crossed.h > 0 && near(crossed.x * 1000 + crossed.w * 1000, cb.x, 1e-6);
|
||||
@@ -433,17 +525,17 @@ const res = await page.evaluate(async () => {
|
||||
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot'), cx + 100, cy);
|
||||
out.rotateStarted = c._dtDrag?.kind === 'rotate';
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 100); // ровно 45°
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const fine = sofaNow().angle;
|
||||
out.rotationIsFreeWithoutShift = fine > 0.5 && fine < 5;
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.shiftSnapsRotationTo45 = (sofaNow().angle || 0) === 0;
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 100);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
ev('pointerup', stageEl(), cx + 100, cy + 100);
|
||||
await c.updateComplete;
|
||||
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
|
||||
|
||||
@@ -57,18 +57,26 @@ checkAll(res, {
|
||||
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
|
||||
// (display:none у .hdr не считается: скрытый в DOM узел всё ещё кликабелен
|
||||
// программно и всё ещё ловится автотестами как «есть»)
|
||||
const kio = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await page.evaluate(() => {
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
c.hass = window.__mkHass();
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
document.body.appendChild(c);
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
c.hass = { ...c.hass }; await c.updateComplete;
|
||||
window.__smokeKioskCard = c;
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const c = window.__smokeKioskCard;
|
||||
const sr = c && (c.shadowRoot || c.renderRoot);
|
||||
return !!sr?.querySelector('ha-card');
|
||||
});
|
||||
const kio = await page.evaluate(() => {
|
||||
const out = {};
|
||||
const c = window.__smokeKioskCard;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
out.kioskNoAddButton = !sr.querySelector('.tab.tabadd');
|
||||
c.remove();
|
||||
delete window.__smokeKioskCard;
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(kio)) check(k, v);
|
||||
|
||||
@@ -101,7 +101,11 @@ await page.evaluate(async () => {
|
||||
// raster pair and prove the precision ratio separately below.
|
||||
runtime._gridLevels = mode === 'view' ? originalGridLevels : () => null;
|
||||
card._gridLevels = () => runtime._gridLevels();
|
||||
card._labs = { ...card._labs, active: projection === 'iso' ? ['iso'] : [] };
|
||||
card._labs = {
|
||||
...card._labs,
|
||||
alpha: projection === 'iso',
|
||||
active: projection === 'iso' ? ['iso'] : [],
|
||||
};
|
||||
card._viewPreference = { ...card._viewPreference, f1: projection };
|
||||
card._isoFallback.clear();
|
||||
card._isoGeometryCache.clear();
|
||||
|
||||
@@ -271,9 +271,9 @@ const farIcon = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
const at1 = size();
|
||||
c._applyView(4); await c.updateComplete;
|
||||
c._applyView(4); await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const at4 = size();
|
||||
c._applyView(1 / 3); await c.updateComplete;
|
||||
c._applyView(1 / 3); await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const atOut = size();
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
|
||||
|
||||
@@ -11,18 +11,15 @@ const out = await page.evaluate(async () => {
|
||||
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
|
||||
};
|
||||
const enable = async () => {
|
||||
history.replaceState(null, '', '#space=f1&hp-labs=iso');
|
||||
history.replaceState(null, '', '#space=f1&hp_alpha=1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof card._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
const active = Object.freeze(['iso']);
|
||||
// The product flag expires at 1.65.0. Keep this renderer/lifecycle smoke
|
||||
// explicit without extending the public Labs registry contract.
|
||||
card._onLabsSnapshot({ active, space: '' });
|
||||
window.__hpLabs = active;
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
const result = {};
|
||||
result.cleanProfileStartsOff = window.__hpAlpha === false
|
||||
&& JSON.stringify(window.__hpLabs) === '[]'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]');
|
||||
const configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1');
|
||||
configSpace.settings = {
|
||||
...(configSpace.settings || {}), show_borders: true, show_names: true,
|
||||
@@ -66,8 +63,9 @@ const out = await page.evaluate(async () => {
|
||||
result.isoGateFlipReversesTurn = centredGate?.leaves[0]?.turnDeg
|
||||
=== -flippedGate?.leaves[0]?.turnDeg;
|
||||
const toggle = root().querySelector('[data-hp="projection-toggle"]');
|
||||
result.labsSnapshotFrozen = Object.isFrozen(window.__hpLabs)
|
||||
result.alphaSnapshotFrozen = window.__hpAlpha === true && Object.isFrozen(window.__hpLabs)
|
||||
&& JSON.stringify(window.__hpLabs) === '["iso"]';
|
||||
result.alphaStored = localStorage.getItem('houseplan_card_alpha_v1') === '1';
|
||||
result.toggleShown = !!toggle;
|
||||
result.toggleMinHitTarget = toggle && toggle.getBoundingClientRect().width >= 44
|
||||
&& toggle.getBoundingClientRect().height >= 44;
|
||||
@@ -144,14 +142,20 @@ const out = await page.evaluate(async () => {
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
history.replaceState(null, '', '#space=f1&hp-labs=-iso');
|
||||
history.replaceState(null, '', '#space=f1&hp_alpha=0');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
|
||||
result.removalIsImmediateFlat = window.__hpAlpha === false
|
||||
&& localStorage.getItem('houseplan_card_alpha_v1') === '0'
|
||||
&& JSON.stringify(window.__hpLabs) === '[]'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]')
|
||||
&& !root().querySelector('[data-hp="iso-walls"]')
|
||||
&& !root().querySelector('[id^="hp-iso-"]');
|
||||
await enable();
|
||||
result.reenableRestoresPreference = window.__hpAlpha === true
|
||||
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true'
|
||||
&& !!root().querySelector('[data-hp="iso-walls"]');
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
@@ -9,14 +9,8 @@ const out = await page.evaluate(async () => {
|
||||
const root = (card) => card.renderRoot;
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
history.replaceState(null, '', '?hp-labs=iso#space=f1');
|
||||
history.replaceState(null, '', '?hp_alpha=1#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof original._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
const testOnlyIso = Object.freeze(['iso']);
|
||||
// The product registry remains expired; this direct snapshot exercises the
|
||||
// dormant Stage 2 renderer and is removed by the real -iso path below.
|
||||
original._onLabsSnapshot({ active: testOnlyIso, space: '' });
|
||||
window.__hpLabs = testOnlyIso;
|
||||
await original.updateComplete;
|
||||
const configSpace = original._serverCfg.spaces.find((space) => space.id === 'f1');
|
||||
configSpace.settings = {
|
||||
@@ -301,7 +295,6 @@ const out = await page.evaluate(async () => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', kiosk });
|
||||
host.replaceChildren(card);
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
card.hass = window.__mkHass();
|
||||
const started = performance.now();
|
||||
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
|
||||
@@ -333,11 +326,13 @@ out.orientationResizeKeepsIso = await page.evaluate(async () => {
|
||||
});
|
||||
out.kioskEmergencyOffIsFlat = await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
history.replaceState(null, '', '?hp-labs=-iso#space=f1');
|
||||
history.replaceState(null, '', '?hp_alpha=0#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
return !card.renderRoot.querySelector('[data-hp="iso-walls"]')
|
||||
return window.__hpAlpha === false
|
||||
&& localStorage.getItem('houseplan_card_alpha_v1') === '0'
|
||||
&& !card.renderRoot.querySelector('[data-hp="iso-walls"]')
|
||||
&& !card.renderRoot.querySelector('[data-hp="projection-toggle"]');
|
||||
});
|
||||
|
||||
|
||||
@@ -210,7 +210,8 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
staticCard.remove();
|
||||
|
||||
const labs = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active: labs, space: '' });
|
||||
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
|
||||
window.__hpAlpha = true;
|
||||
window.__hpLabs = labs;
|
||||
card._setProjection('iso');
|
||||
await update(false);
|
||||
|
||||
@@ -22,21 +22,22 @@ const res = await page.evaluate(async () => {
|
||||
// 3) свайп при 1:1 переключает пространство (и точки показываются)
|
||||
const s0 = c._space;
|
||||
const stage = sr().querySelector('.stage');
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
|
||||
await c.updateComplete;
|
||||
out.swipeSwitches = c._space !== s0;
|
||||
out.dotsShown = !!sr().querySelector('.kioskdots');
|
||||
// 4) при зуме свайп не переключает
|
||||
c._zoom = 2; const s1 = c._space;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
|
||||
await c.updateComplete;
|
||||
out.noSwipeZoomed = c._space === s1;
|
||||
// 5) двойной тап сбрасывает зум
|
||||
c._lastTap = Date.now() - 100;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
|
||||
for (const pointerId of [3, 4]) {
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 500, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 501, clientY: 300 }));
|
||||
}
|
||||
const resetStarted = performance.now();
|
||||
while (c._cameraTransition?.active) {
|
||||
if (performance.now() - resetStarted > 1000) throw new Error('double-tap camera transition did not settle');
|
||||
|
||||
@@ -30,7 +30,8 @@ const out = await page.evaluate(async () => {
|
||||
const sr = k.shadowRoot || k.renderRoot;
|
||||
const stage = sr.querySelector('.stage');
|
||||
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id,
|
||||
pointerType: 'touch', isPrimary: true, button: 0, clientX: x, clientY: y,
|
||||
}));
|
||||
o.kioskHasSeveralSpaces = k._model.length > 1;
|
||||
|
||||
@@ -108,9 +109,10 @@ const out = await page.evaluate(async () => {
|
||||
await home();
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
k._lastTap = Date.now() - 100;
|
||||
fire('pointerdown', 58, 500, 300);
|
||||
fire('pointerup', 58, 501, 300);
|
||||
for (const id of [58, 60]) {
|
||||
fire('pointerdown', id, 500, 300);
|
||||
fire('pointerup', id, 501, 300);
|
||||
}
|
||||
const resetStarted = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (k._cameraTransition.active && performance.now() - resetStarted < 1000);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// #442: marker Save is an immutable config transaction. A rejected semantic
|
||||
// write restores the accepted View while keeping the dialog draft for Retry;
|
||||
// failures after config acceptance never roll the accepted marker back.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const result = {};
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await c._writeChain;
|
||||
await c._areaRelocationWrite;
|
||||
|
||||
c._setMode('devices');
|
||||
await c.updateComplete;
|
||||
const device = c._devices[0];
|
||||
const markerId = device.id;
|
||||
const acceptedName = c._serverCfg.markers.find((item) => item.id === markerId)?.name ?? null;
|
||||
let acceptedFingerprint = c._cfgContentFingerprint;
|
||||
c._openMarkerDialog(device);
|
||||
await c.updateComplete;
|
||||
const draftName = 'Rejected marker draft #442';
|
||||
c._markerDialog = { ...c._markerDialog, name: draftName };
|
||||
|
||||
const realWS = c.hass.callWS;
|
||||
let rejectConfig = false;
|
||||
let configWrites = 0;
|
||||
let configGate = null;
|
||||
let rejectLayout = false;
|
||||
c.hass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/set') {
|
||||
configWrites += 1;
|
||||
const rejectThis = rejectConfig;
|
||||
if (configGate) await configGate.promise;
|
||||
if (rejectThis) throw new Error('semantic marker reject');
|
||||
}
|
||||
if (message.type === 'houseplan/layout/update' && rejectLayout)
|
||||
throw new Error('layout unavailable');
|
||||
return realWS(message);
|
||||
} };
|
||||
await c.updateComplete;
|
||||
|
||||
// Queue the rejected marker attempt behind an older accepted write. Its
|
||||
// rollback guard must adopt the revision actually used after the queue wait.
|
||||
let releasePrior;
|
||||
configGate = { promise: new Promise((resolve) => { releasePrior = resolve; }) };
|
||||
const priorWrite = c._writeConfig();
|
||||
while (configWrites === 0) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
acceptedFingerprint = c._cfgContentFingerprint;
|
||||
rejectConfig = true;
|
||||
const rejectedWrite = c._saveMarker();
|
||||
releasePrior();
|
||||
await priorWrite;
|
||||
configGate = null;
|
||||
await rejectedWrite;
|
||||
await c.updateComplete;
|
||||
const rejectedMarker = c._serverCfg.markers.find((item) => item.id === markerId);
|
||||
result.rejectedConfigRestored = (rejectedMarker?.name ?? null) === acceptedName;
|
||||
result.rejectedViewRestored = c._devices.find((item) => item.id === markerId)?.name === device.name;
|
||||
result.acceptedFingerprintRestored = c._cfgContentFingerprint === acceptedFingerprint;
|
||||
result.dialogDraftPreserved = c._markerDialog?.name === draftName && c._markerDialog.busy === false;
|
||||
result.rejectHasNoSuccess = c._toast !== c._t('toast.marker_saved');
|
||||
result.oneRejectedWrite = configWrites === 2;
|
||||
|
||||
rejectConfig = false;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
result.retryAccepted = c._serverCfg.markers.find((item) => item.id === markerId)?.name === draftName;
|
||||
result.retryClosedAndToasted = c._markerDialog === null && c._toast === c._t('toast.marker_saved');
|
||||
result.retryWroteOnce = configWrites === 3;
|
||||
|
||||
// A new virtual marker has a layout side effect after config/set. Reject
|
||||
// that second phase: the already accepted config must remain authoritative.
|
||||
c._openMarkerDialog();
|
||||
const virtualName = 'Accepted before layout failure #442';
|
||||
c._markerDialog = { ...c._markerDialog, name: virtualName, binding: 'virtual' };
|
||||
rejectLayout = true;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
result.sideEffectFailureKeepsAcceptedConfig = c._serverCfg.markers
|
||||
.some((item) => item.name === virtualName);
|
||||
result.sideEffectFailureHasNoSuccess = c._toast !== c._t('toast.marker_saved');
|
||||
result.sideEffectFailureKeepsDraft = c._markerDialog?.name === virtualName
|
||||
&& c._markerDialog.busy === false;
|
||||
|
||||
c.hass = { ...c.hass, callWS: realWS };
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -201,7 +201,8 @@ const result = await page.evaluate(async (source) => {
|
||||
staticCard.remove();
|
||||
|
||||
const labs = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active: labs, space: '' });
|
||||
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
|
||||
window.__hpAlpha = true;
|
||||
window.__hpLabs = labs;
|
||||
card._setProjection('iso');
|
||||
await update(false);
|
||||
|
||||
@@ -211,7 +211,8 @@ const result = await page.evaluate(async (source) => {
|
||||
staticCard.remove();
|
||||
|
||||
const labs = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active: labs, space: '' });
|
||||
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
|
||||
window.__hpAlpha = true;
|
||||
window.__hpLabs = labs;
|
||||
card._setProjection('iso');
|
||||
await update(false);
|
||||
|
||||
@@ -42,7 +42,10 @@ await page.evaluate(() => {
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
await page.waitForTimeout(220); // editor chrome transition changes stage coordinates
|
||||
await page.waitForFunction(() => {
|
||||
const c = window.__card;
|
||||
return !!c._editorRuntime && !c._modeTransitionBusy && !c._cameraTransition?.active;
|
||||
});
|
||||
await settle();
|
||||
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
@@ -288,7 +291,7 @@ const previews = () => page.evaluate(() =>
|
||||
|
||||
// the click places it AT THE MAGNET and clears every hint
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
const [hx, hy] = await screenPt(295, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.click(hx, hy);
|
||||
|
||||
@@ -125,6 +125,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.resizeHandleTakesThePointer = c._resize?.dragging === true && c._panStart === null;
|
||||
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
await Promise.resolve();
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
o.resizeHandleResizes = c._resize?.dragging === true && c._resize.moved === true;
|
||||
@@ -143,6 +144,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.deviceTakesThePointer = !!c._deviceDrag && c._panStart === null;
|
||||
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
await Promise.resolve();
|
||||
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
|
||||
@@ -81,6 +81,7 @@ const out = await page.evaluate(async () => {
|
||||
card._opPointerMove(new PointerEvent('pointermove', {
|
||||
pointerId: 186, clientX: 20, clientY: 0, bubbles: true,
|
||||
}), saved);
|
||||
await Promise.resolve();
|
||||
runtime._svgPoint = originalSvgPoint;
|
||||
card._opPointerUp(new PointerEvent('pointerup', { pointerId: 186, bubbles: true }), saved);
|
||||
await update();
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// #451: production-bundle guard for the boundary between HA intake, full Lit
|
||||
// renders and RAF-owned interaction layers. The large benchmark owns timings;
|
||||
// this fast smoke runs on every selected-smoke pass and owns wiring semantics.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const result = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 5000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('render invalidation smoke timed out');
|
||||
await new Promise((done) => setTimeout(done, 10));
|
||||
}
|
||||
};
|
||||
const out = {};
|
||||
card._setMode('view', false);
|
||||
await card.updateComplete;
|
||||
if ('_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
|
||||
let renders = 0;
|
||||
let intakes = 0;
|
||||
let scans = 0;
|
||||
const originalRenderBody = card._renderBody.bind(card);
|
||||
const originalObserve = card._renderLife.observe.bind(card._renderLife);
|
||||
const originalBindingStatus = card._bindingStatus.bind(card);
|
||||
card._renderBody = (...args) => { renders++; return originalRenderBody(...args); };
|
||||
card._renderLife.observe = (...args) => { intakes++; return originalObserve(...args); };
|
||||
card._bindingStatus = (...args) => { scans++; return originalBindingStatus(...args); };
|
||||
|
||||
const dependency = [...(card._visibleDeviceSnapshot?.entityIds || [])]
|
||||
.find((id) => card.hass.states?.[id]);
|
||||
const originalHass = card.hass;
|
||||
const stableConfig = JSON.stringify(card._serverCfg);
|
||||
const stableLayout = JSON.stringify(card._layout);
|
||||
const withState = (id, state) => ({
|
||||
...card.hass,
|
||||
states: { ...card.hass.states, [id]: { ...card.hass.states[id], state } },
|
||||
});
|
||||
|
||||
const irrelevantBefore = renders;
|
||||
const intakeBefore = intakes;
|
||||
for (let index = 0; index < 5; index++) {
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: {
|
||||
...card.hass.states,
|
||||
[`sensor.hp_451_irrelevant_${index}`]: { entity_id: `sensor.hp_451_irrelevant_${index}`, state: String(index), attributes: {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.irrelevantHaHasNoFullRender = renders - irrelevantBefore === 0;
|
||||
out.irrelevantHaStillIntakes = intakes - intakeBefore === 5;
|
||||
|
||||
const relevantBefore = renders;
|
||||
if (dependency) card.hass = withState(dependency, '451-outside');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.relevantHaRendersOnce = !!dependency && renders - relevantBefore === 1;
|
||||
|
||||
scans = 0;
|
||||
const heavy = card.renderRoot.querySelector('.wallbodies');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const hoverBefore = renders;
|
||||
const device = card.renderRoot.querySelector('[data-hp="device"]');
|
||||
if (device) {
|
||||
device.dispatchEvent(new PointerEvent('pointerover', {
|
||||
bubbles: true, composed: true, pointerId: 450, pointerType: 'mouse', isPrimary: true,
|
||||
clientX: rect.left + 220, clientY: rect.top + 220,
|
||||
}));
|
||||
device.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerId: 450, pointerType: 'mouse', isPrimary: true,
|
||||
clientX: rect.left + 225, clientY: rect.top + 225,
|
||||
}));
|
||||
}
|
||||
await frame();
|
||||
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
out.hoverUsesLightweightPaint = !!device && renders === hoverBefore
|
||||
&& !!liveTip && !liveTip.hidden;
|
||||
device?.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 450, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await frame();
|
||||
|
||||
const pointer = (type, x, y, buttons) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, pointerId: 451, pointerType: 'mouse', isPrimary: true,
|
||||
button: 0, buttons, clientX: rect.left + x, clientY: rect.top + y,
|
||||
}));
|
||||
pointer('pointerdown', 300, 300, 1);
|
||||
await card.updateComplete;
|
||||
const panBefore = renders;
|
||||
const panIntakeBefore = intakes;
|
||||
for (let index = 0; index < 3; index++) {
|
||||
pointer('pointermove', 320 + index * 20, 315 + index * 10, 1);
|
||||
if (dependency) card.hass = withState(dependency, `451-drag-${index}`);
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.panAndDeferredHaSkipFullRender = renders === panBefore;
|
||||
out.deferredHaTicksStillIntake = intakes - panIntakeBefore === 3;
|
||||
const panTerminalBefore = renders;
|
||||
pointer('pointerup', 360, 335, 0);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.panTerminalRendersOnce = renders - panTerminalBefore === 1;
|
||||
out.deferredHaIsLastWins = !!dependency
|
||||
&& card._renderPlanHass.states?.[dependency]?.state === '451-drag-2';
|
||||
|
||||
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();
|
||||
await frame();
|
||||
const editorBefore = renders;
|
||||
const editorPaintBefore = card._liveEditorPaintCount || 0;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerId: 452, pointerType: 'mouse', isPrimary: true,
|
||||
clientX: rect.left + 110 + index, clientY: rect.top + 120 + index,
|
||||
}));
|
||||
}
|
||||
await frame();
|
||||
out.editorMovesSkipFullRender = renders === editorBefore;
|
||||
out.editorMovesUseRafPaint = (card._liveEditorPaintCount || 0) > editorPaintBefore;
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
const editorTerminalBefore = renders;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.editorTerminalHasAtMostOneRender = renders - editorTerminalBefore <= 1;
|
||||
|
||||
out.heavySceneIdentityStable = card.renderRoot.querySelector('.wallbodies') === heavy;
|
||||
out.pointerWindowsSkipDiagnosticScans = scans === 0;
|
||||
out.interactionsDoNotWriteConfig = JSON.stringify(card._serverCfg) === stableConfig;
|
||||
out.interactionsDoNotWriteLayout = JSON.stringify(card._layout) === stableLayout;
|
||||
card.hass = originalHass;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -114,10 +114,13 @@ const sharedX = () => page.evaluate(() => {
|
||||
))).map((point) => point[0]);
|
||||
});
|
||||
const domHasSharedX = (x) => page.evaluate((wanted) =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')]
|
||||
[...(window.__card.renderRoot.querySelector('[data-hp-live-editor] .hp-live-resize')
|
||||
|| window.__card.renderRoot).querySelectorAll('.rszhandle[aria-disabled="false"]')]
|
||||
.some((entry) => Math.abs(Number(entry.getAttribute('cx')) - wanted) < 1), x);
|
||||
const sharedHandleX = () => page.evaluate(() => {
|
||||
const candidates = [...window.__card.renderRoot.querySelectorAll(
|
||||
const activeRoot = window.__card.renderRoot.querySelector('[data-hp-live-editor] .hp-live-resize')
|
||||
|| window.__card.renderRoot;
|
||||
const candidates = [...activeRoot.querySelectorAll(
|
||||
'.rszhandle[aria-disabled="false"]',
|
||||
)].filter((entry) => Math.abs(Number(entry.getAttribute('cy')) - 529.166667) < 2);
|
||||
return candidates
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// #152: the browser-painted room owns a clean View tap and the shared camera
|
||||
// fits its exact rendered geometry. This smoke is part of the pre-beta matrix;
|
||||
// the implementation cycle itself remains typecheck + unit + build.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 800 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const result = {};
|
||||
const config = {
|
||||
spaces: [{
|
||||
id: 'fit-floor', title: 'Fit floor', view_box: [0, 0, 1, 1],
|
||||
settings: { show_borders: true, show_names: true },
|
||||
partitions: [{ id: 'fit-wall', a: [0.1, 0.1], b: [0.9, 0.1], cm: 15 }],
|
||||
rooms: [
|
||||
{ id: 'outer', name: 'Outer', area: 'fit_outer',
|
||||
poly: [[0.05, 0.05], [0.95, 0.05], [0.95, 0.95], [0.05, 0.95]] },
|
||||
{ id: 'room-a', name: 'Office', area: 'fit_office',
|
||||
poly: [[0.55, 0.15], [0.85, 0.15], [0.85, 0.45], [0.72, 0.45], [0.72, 0.35], [0.55, 0.35]] },
|
||||
],
|
||||
}],
|
||||
markers: [], settings: { filter_seeded: true },
|
||||
};
|
||||
const server = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
|
||||
for (const key of Object.keys(server)) delete server[key];
|
||||
Object.assign(server, JSON.parse(JSON.stringify(config)));
|
||||
c._serverCfg = server;
|
||||
c._space = 'fit-floor';
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._cfgEpoch++;
|
||||
c._mode = 'view';
|
||||
c._zoom = 1;
|
||||
c._view = null;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
const waitCamera = async () => {
|
||||
const started = performance.now();
|
||||
while (c._cameraTransition?.active) {
|
||||
if (performance.now() - started > 1200) throw new Error('room camera did not settle');
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
await c.updateComplete;
|
||||
};
|
||||
const pointer = (target, type, id, x = 500, y = 300, extra = {}) =>
|
||||
target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id,
|
||||
pointerType: 'mouse', isPrimary: true, button: 0,
|
||||
buttons: type === 'pointerdown' || type === 'pointermove' ? 1 : 0,
|
||||
clientX: x, clientY: y, ...extra,
|
||||
}));
|
||||
const roomNode = () => root.querySelector('[data-hp="room"][data-id="room-a"]');
|
||||
const room = () => c._spaceModel().rooms.find((item) => item.id === 'room-a');
|
||||
const margins = () => {
|
||||
const bounds = c._roomFitBounds(room(), c._spaceModel());
|
||||
const view = c._view;
|
||||
return {
|
||||
left: (bounds.x - view.x) / view.w,
|
||||
right: (view.x + view.w - bounds.x - bounds.w) / view.w,
|
||||
top: (bounds.y - view.y) / view.h,
|
||||
bottom: (view.y + view.h - bounds.y - bounds.h) / view.h,
|
||||
};
|
||||
};
|
||||
|
||||
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ 'fit-floor': 1 }));
|
||||
pointer(roomNode(), 'pointerdown', 15201);
|
||||
pointer(roomNode(), 'pointerup', 15201);
|
||||
const syncFrames = [];
|
||||
for (let frame = 0; frame < 3 && c._cameraTransition?.active; frame++) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await c.updateComplete;
|
||||
const activeView = c._view;
|
||||
const activeLabel = root.querySelector('.roomlabel[data-id="room-a"]');
|
||||
const activeSvg = root.querySelector('.plan-svg') || root.querySelector('.zoomwrap > svg');
|
||||
const position = c._scenePoint([c._labelPos(room(), 'fit-floor').x,
|
||||
c._labelPos(room(), 'fit-floor').y]);
|
||||
const expectedLeft = ((position[0] - activeView.x) / activeView.w) * 100;
|
||||
const expectedTop = ((position[1] - activeView.y) / activeView.h) * 100;
|
||||
const actualView = activeSvg?.getAttribute('viewBox')?.trim().split(/\s+/).map(Number) || [];
|
||||
const labelRect = activeLabel?.getBoundingClientRect();
|
||||
const stageRect = root.querySelector('.stage')?.getBoundingClientRect();
|
||||
syncFrames.push(actualView.length === 4
|
||||
&& actualView.every((value, index) => Math.abs(value
|
||||
- [activeView.x, activeView.y, activeView.w, activeView.h][index]) < 1e-6)
|
||||
&& !!labelRect && !!stageRect
|
||||
&& Math.abs(labelRect.left + labelRect.width / 2
|
||||
- (stageRect.left + stageRect.width * expectedLeft / 100)) < 1
|
||||
&& Math.abs(labelRect.top + labelRect.height / 2
|
||||
- (stageRect.top + stageRect.height * expectedTop / 100)) < 1);
|
||||
}
|
||||
result.svgAndHtmlShareTweenFrames = syncFrames.length > 0 && syncFrames.every(Boolean);
|
||||
await waitCamera();
|
||||
const flatMargins = margins();
|
||||
result.cleanRoomTapFits = Object.values(flatMargins).every((value) => value >= 0.099);
|
||||
result.oneFlatAxisUsesTenPercent = Math.min(...Object.values(flatMargins)) <= 0.102;
|
||||
result.roomIntentIsSessionOnly = c._roomFocus?.roomId === 'room-a'
|
||||
&& JSON.parse(localStorage.getItem('houseplan_card_zoom_v1'))['fit-floor'] === 1;
|
||||
|
||||
const settled = JSON.stringify(c._view);
|
||||
pointer(roomNode(), 'pointerdown', 15202);
|
||||
pointer(roomNode(), 'pointerup', 15202);
|
||||
await waitCamera();
|
||||
result.repeatedFitIsNoOp = !c._cameraTransition.active && JSON.stringify(c._view) === settled;
|
||||
|
||||
const label = root.querySelector('.roomlabel[data-id="room-a"]');
|
||||
result.labelIsKeyboardAction = label?.getAttribute('role') === 'button'
|
||||
&& label?.getAttribute('tabindex') === '0'
|
||||
&& /Office/.test(label?.getAttribute('aria-label') || '');
|
||||
c._resetZoom(); await waitCamera();
|
||||
label.focus();
|
||||
label.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
|
||||
await waitCamera();
|
||||
result.keyboardUsesSameCommand = c._roomFocus?.roomId === 'room-a'
|
||||
&& (root.activeElement === label || document.activeElement === label);
|
||||
|
||||
const editorLabelsArePassive = [];
|
||||
for (const mode of ['devices', 'decor']) {
|
||||
c._mode = mode;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const editorLabel = root.querySelector('.roomlabel[data-id="room-a"]');
|
||||
editorLabelsArePassive.push(editorLabel
|
||||
&& !editorLabel.hasAttribute('role')
|
||||
&& !editorLabel.hasAttribute('tabindex')
|
||||
&& !editorLabel.hasAttribute('aria-label'));
|
||||
}
|
||||
result.editorsDoNotExposeRoomAction = editorLabelsArePassive.every(Boolean);
|
||||
c._mode = 'view';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
c._resetZoom(); await waitCamera();
|
||||
c._clearRoomFocus(true);
|
||||
const currentLabel = root.querySelector('.roomlabel[data-id="room-a"]');
|
||||
const ownerProbe = document.createElement('span');
|
||||
ownerProbe.setAttribute('role', 'button');
|
||||
ownerProbe.dataset.roomFitBlock = 'smoke-action';
|
||||
currentLabel.append(ownerProbe);
|
||||
pointer(ownerProbe, 'pointerdown', 15203);
|
||||
pointer(ownerProbe, 'pointerup', 15203);
|
||||
await c.updateComplete;
|
||||
result.commonInteractiveOwnerSuppressesRoomFit = c._roomPointer === null
|
||||
&& c._roomFocus === null;
|
||||
const areaLink = currentLabel.querySelector('.rlgo');
|
||||
const beforeLink = JSON.stringify(c._view);
|
||||
pointer(areaLink, 'pointerdown', 15204);
|
||||
pointer(areaLink, 'pointerup', 15204);
|
||||
await c.updateComplete;
|
||||
result.areaLinkSuppressesRoomFit = JSON.stringify(c._view) === beforeLink
|
||||
&& c._roomPointer === null && c._roomFocus === null;
|
||||
|
||||
const stage = root.querySelector('.stage');
|
||||
const base = c._baseVb();
|
||||
const moveAway = async () => {
|
||||
c._doubleFit.clear();
|
||||
c._applyView(2, base[0] + base[2] * 0.65, base[1] + base[3] * 0.4);
|
||||
await c.updateComplete;
|
||||
};
|
||||
await moveAway();
|
||||
c._fitAll('fit');
|
||||
await waitCamera();
|
||||
const toolbarTarget = JSON.stringify({ zoom: c._zoom, view: c._view });
|
||||
|
||||
await moveAway();
|
||||
c._showFar = false;
|
||||
c._frame = null;
|
||||
c._roomFocus = { spaceId: 'fit-floor', roomId: 'outer' };
|
||||
const beforeFirstTap = JSON.stringify({ zoom: c._zoom, view: c._view });
|
||||
let firstTapUpdates = 0;
|
||||
const requestUpdate = c.requestUpdate;
|
||||
c.requestUpdate = (...args) => { firstTapUpdates++; return requestUpdate.apply(c, args); };
|
||||
pointer(stage, 'pointerdown', 44901, 400, 500);
|
||||
pointer(stage, 'pointerup', 44901, 401, 500);
|
||||
c.requestUpdate = requestUpdate;
|
||||
result.firstBackgroundTapIsPassive = firstTapUpdates === 0
|
||||
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === beforeFirstTap;
|
||||
pointer(stage, 'pointerdown', 44902, 650, 500);
|
||||
pointer(stage, 'pointerup', 44902, 651, 500);
|
||||
result.normalViewUsesDoubleTapReason = c._cameraTransition.state?.reason === 'double-tap';
|
||||
await waitCamera();
|
||||
result.normalViewDoubleClickMatchesFitAll = c._showFar === true && c._roomFocus === null
|
||||
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === toolbarTarget;
|
||||
|
||||
await moveAway();
|
||||
pointer(stage, 'pointerdown', 44903, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerup', 44903, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerdown', 44904, 500, 500, { pointerType: 'pen' });
|
||||
pointer(stage, 'pointerup', 44904, 500, 500, { pointerType: 'pen' });
|
||||
result.mixedModalitiesDoNotPair = c._zoom === 2 && !c._cameraTransition.active;
|
||||
pointer(stage, 'pointerdown', 44905, 550, 500, { pointerType: 'pen' });
|
||||
pointer(stage, 'pointerup', 44905, 550, 500, { pointerType: 'pen' });
|
||||
await waitCamera();
|
||||
result.penPairUsesSameFitAll = JSON.stringify({ zoom: c._zoom, view: c._view }) === toolbarTarget;
|
||||
|
||||
await moveAway();
|
||||
pointer(stage, 'pointerdown', 44906, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerup', 44906, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerdown', 44907, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointercancel', 44907, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerdown', 44908, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerup', 44908, 400, 500, { pointerType: 'touch' });
|
||||
result.cancelDisarmsThePreviousTap = c._zoom === 2 && !c._cameraTransition.active;
|
||||
|
||||
c._mode = 'decor';
|
||||
c._doubleFit.clear();
|
||||
pointer(stage, 'pointerdown', 44909, 400, 500);
|
||||
pointer(stage, 'pointerup', 44909, 400, 500);
|
||||
pointer(stage, 'pointerdown', 44910, 400, 500);
|
||||
pointer(stage, 'pointerup', 44910, 400, 500);
|
||||
result.editorBackgroundDoesNotFit = c._zoom === 2 && !c._cameraTransition.active;
|
||||
c._mode = 'view';
|
||||
c._doubleFit.clear();
|
||||
|
||||
pointer(roomNode(), 'pointerdown', 15205, 500, 300);
|
||||
pointer(roomNode(), 'pointermove', 15205, 520, 300);
|
||||
pointer(roomNode(), 'pointerup', 15205, 520, 300);
|
||||
await c.updateComplete;
|
||||
result.panCancelsRoomIntent = c._roomFocus === null && c._roomPointer === null;
|
||||
|
||||
pointer(roomNode(), 'pointerdown', 15206, 500, 300);
|
||||
pointer(roomNode(), 'pointerup', 15206, 500, 300);
|
||||
await waitCamera();
|
||||
c.style.width = '760px';
|
||||
c._lastValidStageSize = [1000, c._stageEl.clientHeight];
|
||||
c._refitView();
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
await c.updateComplete;
|
||||
const resizedMargins = margins();
|
||||
result.resizeRefitsAtomically = c._roomFocus?.roomId === 'room-a'
|
||||
&& Object.values(resizedMargins).every((value) => value >= 0.099);
|
||||
|
||||
c.style.width = '1000px';
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
c._clearRoomFocus(true);
|
||||
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await c.updateComplete;
|
||||
pointer(roomNode(), 'pointerdown', 15207);
|
||||
pointer(roomNode(), 'pointerup', 15207);
|
||||
await waitCamera();
|
||||
const isoMargins = margins();
|
||||
result.isoUsesProjectedBounds = c._effectiveProjection() === 'iso'
|
||||
&& Object.values(isoMargins).every((value) => value >= 0.099)
|
||||
&& (c._zoom === 8 || Math.min(...Object.values(isoMargins)) <= 0.102);
|
||||
|
||||
const kiosk = document.createElement('houseplan-card');
|
||||
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
kiosk.hass = c.hass;
|
||||
kiosk.style.cssText = 'position:fixed;inset:0;width:900px;height:700px;z-index:99';
|
||||
document.body.appendChild(kiosk);
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
kiosk.hass = { ...c.hass };
|
||||
await kiosk.updateComplete;
|
||||
const kioskRoot = kiosk.shadowRoot || kiosk.renderRoot;
|
||||
const kioskRoom = kioskRoot.querySelector('[data-hp="room"][data-id="room-a"]');
|
||||
const kioskPointer = (target, type, id) => target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id,
|
||||
pointerType: 'touch', isPrimary: true, button: 0,
|
||||
buttons: type === 'pointerdown' ? 1 : 0, clientX: 450, clientY: 300,
|
||||
}));
|
||||
for (const id of [15208, 15209]) {
|
||||
kioskPointer(kioskRoom, 'pointerdown', id);
|
||||
kioskPointer(kioskRoom, 'pointerup', id);
|
||||
const started = performance.now();
|
||||
while (kiosk._cameraTransition.active && performance.now() - started < 1200) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
const beforeKioskBackground = JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view });
|
||||
kioskPointer(kiosk._stageEl, 'pointerdown', 15210);
|
||||
kioskPointer(kiosk._stageEl, 'pointerup', 15210);
|
||||
result.kioskRoomTapDoesNotEnterDoubleTap = kiosk._roomFocus?.roomId === 'room-a'
|
||||
&& JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view }) === beforeKioskBackground;
|
||||
kiosk._clearRoomFocus(true);
|
||||
kiosk._applyView(2);
|
||||
for (const id of [15211, 15212]) {
|
||||
kioskPointer(kiosk._stageEl, 'pointerdown', id);
|
||||
kioskPointer(kiosk._stageEl, 'pointerup', id);
|
||||
}
|
||||
const resetStarted = performance.now();
|
||||
while (kiosk._cameraTransition.active && performance.now() - resetStarted < 1200) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
result.kioskBackgroundDoubleTapStillResets = Math.abs(kiosk._zoom - 1) < 1e-9;
|
||||
kiosk.remove();
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,174 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const result = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const room = () => root().querySelector('.room');
|
||||
const pointer = (type, pointerType, x, y) => new PointerEvent(type, {
|
||||
pointerType, bubbles: true, composed: true, clientX: x, clientY: y,
|
||||
});
|
||||
const mouse = (type, x, y) => pointer(type, 'mouse', x, y);
|
||||
const out = {};
|
||||
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
const settingRow = [...root().querySelectorAll('hp-dialog label.srcrow')]
|
||||
.find((label) => label.textContent.trim() === 'Show the room information window on hover');
|
||||
out.localizedControl = !!settingRow?.querySelector('ha-switch,input[type="checkbox"]');
|
||||
out.defaultOn = card._settingsDialog?.showRoomTooltip === true;
|
||||
|
||||
card._settingsDialog = { ...card._settingsDialog, showRoomTooltip: false };
|
||||
card._settingsDialog = null;
|
||||
out.cancelKeepsAbsentDefault = !Object.hasOwn(card._serverCfg.settings, 'show_room_tooltip');
|
||||
|
||||
room().dispatchEvent(mouse('pointerenter', 180, 180));
|
||||
room().dispatchEvent(mouse('pointermove', 180, 180));
|
||||
await card.updateComplete;
|
||||
out.defaultRoomTip = card._tip?.room === true && !!root().querySelector('.tip');
|
||||
|
||||
const originalCallWS = card.hass.callWS;
|
||||
const beforeRejectedSave = structuredClone(card._serverCfg);
|
||||
const beforeRejectedFingerprint = card._cfgContentFingerprint;
|
||||
card._openSettingsDialog();
|
||||
card._settingsDialog = {
|
||||
...card._settingsDialog,
|
||||
colors: {
|
||||
...card._settingsDialog.colors,
|
||||
light_on: { c: '#123456', a: 0.4 },
|
||||
},
|
||||
glowRadius: 4,
|
||||
bgColor: '#234567',
|
||||
northDeg: 45,
|
||||
bgMode: 'static',
|
||||
sunRays: true,
|
||||
showRoomTooltip: false,
|
||||
};
|
||||
card.hass = { ...card.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/set') throw new Error('offline');
|
||||
return originalCallWS(message);
|
||||
} };
|
||||
await card._saveSettingsDialog();
|
||||
await card.updateComplete;
|
||||
out.rejectedSettingsRolledBack = JSON.stringify(card._serverCfg)
|
||||
=== JSON.stringify(beforeRejectedSave);
|
||||
out.rejectedFingerprintRolledBack = card._cfgContentFingerprint
|
||||
=== beforeRejectedFingerprint;
|
||||
out.rejectedDraftKept = card._settingsDialog?.showRoomTooltip === false
|
||||
&& card._settingsDialog?.bgColor === '#234567'
|
||||
&& card._settingsDialog?.colors.light_on.c === '#123456';
|
||||
out.rejectedBusyCleared = card._settingsDialog?.busy === false;
|
||||
card._tip = null;
|
||||
room().dispatchEvent(mouse('pointermove', 185, 185));
|
||||
await card.updateComplete;
|
||||
out.rejectedRuntimeUsesServerSettings = card._tip?.room === true;
|
||||
|
||||
const authoritative = structuredClone(beforeRejectedSave);
|
||||
authoritative.settings = { ...authoritative.settings, bg_color: '#345678' };
|
||||
const authoritativeRev = card._cfgRev + 5;
|
||||
card._openSettingsDialog();
|
||||
card._settingsDialog = {
|
||||
...card._settingsDialog, bgColor: '#abcdef', showRoomTooltip: false,
|
||||
};
|
||||
card.hass = { ...card.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/set') {
|
||||
throw Object.assign(new Error('conflict'), { code: 'conflict' });
|
||||
}
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
return { config: structuredClone(authoritative), rev: authoritativeRev, can_write: true };
|
||||
}
|
||||
return originalCallWS(message);
|
||||
} };
|
||||
await card._saveSettingsDialog();
|
||||
await card.updateComplete;
|
||||
out.conflictKeepsAuthoritative = card._serverCfg.settings.bg_color === '#345678'
|
||||
&& !Object.hasOwn(card._serverCfg.settings, 'show_room_tooltip')
|
||||
&& card._cfgRev === authoritativeRev;
|
||||
out.conflictDraftKept = card._settingsDialog?.bgColor === '#abcdef'
|
||||
&& card._settingsDialog?.showRoomTooltip === false
|
||||
&& card._settingsDialog?.busy === false;
|
||||
card.hass = { ...card.hass, callWS: originalCallWS };
|
||||
|
||||
card._openSettingsDialog();
|
||||
card._settingsDialog = { ...card._settingsDialog, showRoomTooltip: false };
|
||||
await card._saveSettingsDialog();
|
||||
await card.updateComplete;
|
||||
out.falsePersists = card._serverCfg.settings.show_room_tooltip === false;
|
||||
out.visibleRoomTipCleared = card._tip === null && root().querySelector('.tip')?.hidden === true;
|
||||
|
||||
let roomAreaCalls = 0;
|
||||
const originalRoomArea = card._roomArea.bind(card);
|
||||
card._roomArea = (...args) => {
|
||||
roomAreaCalls += 1;
|
||||
return originalRoomArea(...args);
|
||||
};
|
||||
room().dispatchEvent(mouse('pointerenter', 190, 190));
|
||||
room().dispatchEvent(mouse('pointermove', 190, 190));
|
||||
await card.updateComplete;
|
||||
out.disabledRoomTip = card._tip === null && root().querySelector('.tip')?.hidden === true;
|
||||
out.disabledSkipsArea = roomAreaCalls === 0;
|
||||
out.roomHighlightSurvives = card._hoverRoom !== null
|
||||
&& !!root().querySelector('.room-hover-fill-layer,.room-hover-outline-layer');
|
||||
|
||||
// Call the room listener's preflight directly: the card also has a capture-
|
||||
// phase gesture safety net, and letting the event bubble would prove the
|
||||
// parent path rather than this tooltip-off early-return branch.
|
||||
card._roomTipEnabledForPointer(pointer('pointermove', 'pen', 190, 190));
|
||||
await card.updateComplete;
|
||||
out.disabledPenMoveUpdatesModality = card._pointerModality.modality === 'pen';
|
||||
out.disabledPenMoveClearsMouseHover = card._hoverRoom === null && card._tip === null;
|
||||
room().dispatchEvent(mouse('pointerenter', 195, 195));
|
||||
room().dispatchEvent(mouse('pointermove', 195, 195));
|
||||
card._roomTipEnabledForPointer(pointer('pointermove', 'touch', 195, 195));
|
||||
await card.updateComplete;
|
||||
out.disabledTouchMoveUpdatesModality = card._pointerModality.modality === 'touch';
|
||||
out.disabledTouchMoveClearsMouseHover = card._hoverRoom === null && card._tip === null;
|
||||
out.disabledNonMouseMovesStillSkipArea = roomAreaCalls === 0;
|
||||
|
||||
root().querySelector('.dev').dispatchEvent(mouse('pointermove', 200, 200));
|
||||
await card.updateComplete;
|
||||
out.deviceTipSurvives = card._tip?.room === false && !!root().querySelector('.tip');
|
||||
|
||||
card._openSettingsDialog();
|
||||
out.reopenShowsOff = card._settingsDialog?.showRoomTooltip === false;
|
||||
card._settingsDialog = { ...card._settingsDialog, showRoomTooltip: true };
|
||||
await card._saveSettingsDialog();
|
||||
await card.updateComplete;
|
||||
out.trueStoredAsAbsent = !Object.hasOwn(card._serverCfg.settings, 'show_room_tooltip');
|
||||
out.enableDoesNotRestoreStaleTip = card._tip?.room !== true;
|
||||
|
||||
room().dispatchEvent(mouse('pointermove', 210, 210));
|
||||
await card.updateComplete;
|
||||
out.roomTipRestoredOnMove = card._tip?.room === true && !!root().querySelector('.tip');
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result, {
|
||||
localizedControl: true,
|
||||
defaultOn: true,
|
||||
cancelKeepsAbsentDefault: true,
|
||||
defaultRoomTip: true,
|
||||
rejectedSettingsRolledBack: true,
|
||||
rejectedFingerprintRolledBack: true,
|
||||
rejectedDraftKept: true,
|
||||
rejectedBusyCleared: true,
|
||||
rejectedRuntimeUsesServerSettings: true,
|
||||
conflictKeepsAuthoritative: true,
|
||||
conflictDraftKept: true,
|
||||
falsePersists: true,
|
||||
visibleRoomTipCleared: true,
|
||||
disabledRoomTip: true,
|
||||
disabledSkipsArea: true,
|
||||
roomHighlightSurvives: true,
|
||||
disabledPenMoveUpdatesModality: true,
|
||||
disabledPenMoveClearsMouseHover: true,
|
||||
disabledTouchMoveUpdatesModality: true,
|
||||
disabledTouchMoveClearsMouseHover: true,
|
||||
disabledNonMouseMovesStillSkipArea: true,
|
||||
deviceTipSurvives: true,
|
||||
reopenShowsOff: true,
|
||||
trueStoredAsAbsent: true,
|
||||
enableDoesNotRestoreStaleTip: true,
|
||||
roomTipRestoredOnMove: true,
|
||||
});
|
||||
await finish(browser, result);
|
||||
@@ -112,7 +112,7 @@ const out = await page.evaluate(async () => {
|
||||
directBaseline(2);
|
||||
if (reason === 'home') card._fitAll('home');
|
||||
else if (reason === 'fit') card._fitAll('fit');
|
||||
else card._resetZoom('double-tap');
|
||||
else card._fitAll('double-tap');
|
||||
reasons[reason] = {
|
||||
liveReason: card._cameraTransition.state?.reason,
|
||||
samples: (await settle()).map((sample) => sample.zoom),
|
||||
@@ -141,7 +141,11 @@ const out = await page.evaluate(async () => {
|
||||
card._reducedMotion = false;
|
||||
|
||||
// Projection owns an atomic structural conversion and cancels the camera.
|
||||
card._labs = { ...card._labs, active: [...new Set([...card._labs.active, 'iso'])] };
|
||||
card._labs = {
|
||||
...card._labs,
|
||||
alpha: true,
|
||||
active: [...new Set([...card._labs.active, 'iso'])],
|
||||
};
|
||||
card._viewPreference = { ...card._viewPreference, [card._space]: 'flat' };
|
||||
directBaseline(1);
|
||||
card._stepZoom(1);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// #434: the embedded card treats decor_assets_api as fresh runtime authority.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 800, height: 700 });
|
||||
const out = await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const source = window.__card;
|
||||
const config = structuredClone(source._serverCfg);
|
||||
const space = config.spaces[0];
|
||||
const assetId = 'a'.repeat(64);
|
||||
space.decor = [...(space.decor || []), {
|
||||
id: 'capability-image', kind: 'image', asset_id: assetId,
|
||||
x: 0.1, y: 0.1, w: 0.2, h: 0.2,
|
||||
}];
|
||||
let capability;
|
||||
let resolveCalls = 0;
|
||||
const hass = {
|
||||
...source.hass,
|
||||
connection: {},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return {
|
||||
config,
|
||||
rev: 434,
|
||||
...(capability === undefined ? {} : { decor_assets_api: capability }),
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
if (message.type === 'houseplan/assets/resolve') {
|
||||
resolveCalls++;
|
||||
return { assets: [{
|
||||
asset_id: assetId, name: 'proof.png', mime: 'image/png',
|
||||
width: 1, height: 1, bytes: 1,
|
||||
url: `/api/houseplan/content/assets/_/${assetId}.png`,
|
||||
}], missing: [] };
|
||||
}
|
||||
return source.hass.callWS(message);
|
||||
},
|
||||
};
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: space.id });
|
||||
card.hass = hass;
|
||||
document.body.append(card);
|
||||
const settleLoad = async () => {
|
||||
for (let attempt = 0; attempt < 100 && card._loading; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
await card.updateComplete;
|
||||
};
|
||||
await card._load(true);
|
||||
await settleLoad();
|
||||
const oldBackendSkippedResolve = resolveCalls === 0 && card._decorAssets.size === 0;
|
||||
|
||||
capability = 1;
|
||||
await card._load(true);
|
||||
await settleLoad();
|
||||
const exactCapabilityResolves = resolveCalls === 1 && card._decorAssets.has(assetId);
|
||||
const capabilityOnlyUpgradeAdopted = card._snap.decorAssetsApi === 1;
|
||||
await card._load(true);
|
||||
await settleLoad();
|
||||
const sameEpochUsesCache = resolveCalls === 1;
|
||||
|
||||
capability = undefined;
|
||||
await card._load(true);
|
||||
await settleLoad();
|
||||
const downgradeRevokesWithoutResolve = resolveCalls === 1
|
||||
&& card._snap.decorAssetsApi === null && card._decorAssets.size === 0;
|
||||
card.remove();
|
||||
|
||||
return {
|
||||
oldBackendSkippedResolve,
|
||||
exactCapabilityResolves,
|
||||
capabilityOnlyUpgradeAdopted,
|
||||
sameEpochUsesCache,
|
||||
downgradeRevokesWithoutResolve,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -110,10 +110,8 @@ const res = await page.evaluate(async () => {
|
||||
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
c._config.kiosk = kioskBefore;
|
||||
|
||||
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
|
||||
history.replaceState(null, '', `?hp_alpha=1#space=${encodeURIComponent(c._space)}`);
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof c._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await update();
|
||||
|
||||
+177
-11
@@ -43,9 +43,23 @@ const result = await page.evaluate(async ({ version, previewText, previewSha })
|
||||
};
|
||||
|
||||
card._haIntegrationVersion = version;
|
||||
card._haSupportApi = 1;
|
||||
const originalCallWS = card.hass.callWS.bind(card.hass);
|
||||
const calls = [];
|
||||
let submitMode = 'success';
|
||||
let previewToken = 'a';
|
||||
let deferPreviews = false;
|
||||
const pendingPreviews = [];
|
||||
const previewPayload = (tokenChar = previewToken) => ({
|
||||
text: previewText,
|
||||
size: new TextEncoder().encode(previewText).byteLength,
|
||||
expires_in: 300,
|
||||
spaces: 2,
|
||||
token: tokenChar.repeat(48),
|
||||
sha256: previewSha,
|
||||
version: 1,
|
||||
format: 'houseplan-support-package',
|
||||
});
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
callWS: async (message) => {
|
||||
@@ -54,16 +68,10 @@ const result = await page.evaluate(async ({ version, previewText, previewSha })
|
||||
}
|
||||
calls.push(structuredClone(message));
|
||||
if (message.type === 'houseplan/support/preview') {
|
||||
return {
|
||||
text: previewText,
|
||||
size: new TextEncoder().encode(previewText).byteLength,
|
||||
expires_in: 300,
|
||||
spaces: 2,
|
||||
token: 'a'.repeat(48),
|
||||
sha256: previewSha,
|
||||
version: 1,
|
||||
format: 'houseplan-support-package',
|
||||
};
|
||||
if (deferPreviews) {
|
||||
return new Promise((resolve, reject) => pendingPreviews.push({ resolve, reject }));
|
||||
}
|
||||
return previewPayload();
|
||||
}
|
||||
if (message.type === 'houseplan/support/submit') {
|
||||
if (submitMode === 'rate') throw { code: 'support_rate_limited' };
|
||||
@@ -77,6 +85,11 @@ const result = await page.evaluate(async ({ version, previewText, previewSha })
|
||||
await update();
|
||||
|
||||
const out = {};
|
||||
card._adoptConfigCapabilities({ integration_version: 'mixed-release', support_api: 1 });
|
||||
const learnedCapability = card._haSupportApi === 1;
|
||||
card._adoptConfigCapabilities({ integration_version: version });
|
||||
out.capabilityAdoption = learnedCapability && card._haSupportApi === null;
|
||||
card._adoptConfigCapabilities({ integration_version: version, support_api: 1 });
|
||||
const modeResults = [];
|
||||
for (const mode of ['view', 'plan', 'devices', 'decor']) {
|
||||
card._setMode(mode);
|
||||
@@ -159,12 +172,18 @@ const result = await page.evaluate(async ({ version, previewText, previewSha })
|
||||
|
||||
card._haIntegrationVersion = '0.0.0';
|
||||
await open();
|
||||
out.mixedReleaseCompatible = !!root().querySelector('#support-dialog .supportform')
|
||||
&& !root().querySelector('#support-dialog .supportupdate');
|
||||
await close();
|
||||
card._haSupportApi = null;
|
||||
await open();
|
||||
out.oldBackendDegrades = !!root().querySelector('#support-dialog .supportupdate')
|
||||
&& !root().querySelector('#support-dialog .supportform')
|
||||
&& !!root().querySelector('#support-dialog .aboutver')
|
||||
&& root().querySelectorAll('#support-dialog a.aboutlink').length >= 3;
|
||||
await close();
|
||||
card._haIntegrationVersion = version;
|
||||
card._haSupportApi = 1;
|
||||
|
||||
await open();
|
||||
const submitCallsBeforeValidation = calls.filter((call) => call.type === 'houseplan/support/submit').length;
|
||||
@@ -202,7 +221,7 @@ const result = await page.evaluate(async ({ version, previewText, previewSha })
|
||||
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||
out.downloadExact = downloadedText === previewText
|
||||
&& downloadedName === `houseplan-support-${'a'.repeat(12)}.json`;
|
||||
&& downloadedName === `houseplan-support-${previewSha.slice(0, 12)}.json`;
|
||||
|
||||
submitMode = 'success';
|
||||
root().querySelector('#support-dialog .supportfooter .btn.on').click();
|
||||
@@ -244,6 +263,153 @@ const result = await page.evaluate(async ({ version, previewText, previewSha })
|
||||
&& retryRequests.every((call) => call.idempotency_key === retryKey)
|
||||
&& root().querySelector('#support-receipt')?.textContent.includes('HP-43-SMOKE') === true;
|
||||
|
||||
await close();
|
||||
await open();
|
||||
deferPreviews = true;
|
||||
const canceledBuild = card._editorRuntime._setSupportAttachment(true);
|
||||
await until(() => pendingPreviews.length === 1
|
||||
&& card._supportDialog?.status === 'building', 'deferred preview building');
|
||||
await update();
|
||||
const disabledWhileBuilding = root().querySelector('.supportattach input')?.disabled === true;
|
||||
const canceledRequest = pendingPreviews.shift();
|
||||
await card._editorRuntime._setSupportAttachment(false);
|
||||
await update();
|
||||
const hiddenImmediately = card._supportDialog?.attach === false
|
||||
&& card._supportDialog?.status === 'idle'
|
||||
&& card._supportDialog?.preview === null
|
||||
&& !root().querySelector('.supportpreview');
|
||||
canceledRequest.resolve(previewPayload('b'));
|
||||
await canceledBuild;
|
||||
await update();
|
||||
const discardedB = calls.filter((call) => call.type === 'houseplan/support/preview/discard'
|
||||
&& call.token === 'b'.repeat(48)).length;
|
||||
out.latePreviewConsentStaysRevoked = disabledWhileBuilding && hiddenImmediately
|
||||
&& card._supportDialog?.attach === false
|
||||
&& card._supportDialog?.status === 'idle'
|
||||
&& card._supportDialog?.preview === null
|
||||
&& !root().querySelector('.supportpreview')
|
||||
&& discardedB === 1;
|
||||
|
||||
const oldBuild = card._editorRuntime._setSupportAttachment(true);
|
||||
await until(() => pendingPreviews.length === 1, 'old preview queued');
|
||||
const oldRequest = pendingPreviews.shift();
|
||||
await card._editorRuntime._setSupportAttachment(false);
|
||||
const latestBuild = card._editorRuntime._setSupportAttachment(true);
|
||||
await until(() => pendingPreviews.length === 1, 'latest preview queued');
|
||||
const latestRequest = pendingPreviews.shift();
|
||||
latestRequest.resolve(previewPayload('c'));
|
||||
await latestBuild;
|
||||
oldRequest.resolve(previewPayload('d'));
|
||||
await oldBuild;
|
||||
await update();
|
||||
out.latestPreviewGenerationWins = card._supportDialog?.attach === true
|
||||
&& card._supportDialog?.status === 'ready'
|
||||
&& card._supportDialog?.preview?.token === 'c'.repeat(48)
|
||||
&& calls.filter((call) => call.type === 'houseplan/support/preview/discard'
|
||||
&& call.token === 'd'.repeat(48)).length === 1;
|
||||
|
||||
const rejectedBuild = card._editorRuntime._refreshSupportPreview();
|
||||
await until(() => pendingPreviews.length === 1
|
||||
&& card._supportDialog?.status === 'building', 'rejected stale preview queued');
|
||||
const rejectedRequest = pendingPreviews.shift();
|
||||
await card._editorRuntime._setSupportAttachment(false);
|
||||
rejectedRequest.reject({ code: 'support_rejected' });
|
||||
await rejectedBuild;
|
||||
await update();
|
||||
out.stalePreviewErrorIsIgnored = card._supportDialog?.attach === false
|
||||
&& card._supportDialog?.status === 'idle'
|
||||
&& card._supportDialog?.errorCode === ''
|
||||
&& card._supportDialog?.preview === null
|
||||
&& !root().querySelector('.supportpreview');
|
||||
|
||||
const invalidBuild = card._editorRuntime._setSupportAttachment(true);
|
||||
await until(() => pendingPreviews.length === 1, 'invalid preview queued');
|
||||
pendingPreviews.shift().resolve({ ...previewPayload('0'), sha256: 'invalid' });
|
||||
await invalidBuild;
|
||||
await update();
|
||||
const validInvalidDiscarded = calls.filter(
|
||||
(call) => call.type === 'houseplan/support/preview/discard'
|
||||
&& call.token === '0'.repeat(48),
|
||||
).length === 1;
|
||||
|
||||
const malformedBuild = card._editorRuntime._refreshSupportPreview();
|
||||
await until(() => pendingPreviews.length === 1, 'malformed-token preview queued');
|
||||
pendingPreviews.shift().resolve({ ...previewPayload('h'), token: 'not-a-token', sha256: 'invalid' });
|
||||
await malformedBuild;
|
||||
await update();
|
||||
const malformedNotDiscarded = calls.every(
|
||||
(call) => call.type !== 'houseplan/support/preview/discard' || call.token !== 'not-a-token',
|
||||
);
|
||||
|
||||
const runtime = card._editorRuntime;
|
||||
const realSupportPatch = runtime._supportPatch.bind(runtime);
|
||||
runtime._supportPatch = (candidateDraftId, patch) => patch?.status === 'ready'
|
||||
? false : realSupportPatch(candidateDraftId, patch);
|
||||
const unadoptedBuild = runtime._refreshSupportPreview();
|
||||
await until(() => pendingPreviews.length === 1, 'unadoptable preview queued');
|
||||
pendingPreviews.shift().resolve(previewPayload('1'));
|
||||
await unadoptedBuild;
|
||||
runtime._supportPatch = realSupportPatch;
|
||||
realSupportPatch(card._supportDialog.draftId, {
|
||||
status: 'error', errorCode: 'support_rejected',
|
||||
});
|
||||
await update();
|
||||
const unadoptedDiscarded = calls.filter(
|
||||
(call) => call.type === 'houseplan/support/preview/discard'
|
||||
&& call.token === '1'.repeat(48),
|
||||
).length === 1;
|
||||
out.invalidPreviewTokenIsDiscardedExactlyOnce = validInvalidDiscarded;
|
||||
out.malformedPreviewTokenIsNotEchoed = malformedNotDiscarded;
|
||||
out.locallyUnadoptedPreviewTokenIsDiscardedExactlyOnce = unadoptedDiscarded;
|
||||
deferPreviews = false;
|
||||
|
||||
await close();
|
||||
await open();
|
||||
const validationKey = card._supportDialog.idempotencyKey;
|
||||
const submitsBeforeEmptyValidation = calls.filter(
|
||||
(call) => call.type === 'houseplan/support/submit',
|
||||
).length;
|
||||
await card._editorRuntime._submitSupport();
|
||||
await update();
|
||||
out.validationDoesNotRotateKey = card._supportDialog?.idempotencyKey === validationKey
|
||||
&& card._supportDialog?.submissionFingerprint === ''
|
||||
&& calls.filter((call) => call.type === 'houseplan/support/submit').length
|
||||
=== submitsBeforeEmptyValidation;
|
||||
|
||||
const failSubmit = async () => {
|
||||
await card._editorRuntime._submitSupport();
|
||||
await until(() => card._supportDialog?.status === 'error', 'payload submit failure');
|
||||
await update();
|
||||
return calls.findLast((call) => call.type === 'houseplan/support/submit');
|
||||
};
|
||||
submitMode = 'rate';
|
||||
await setInput('#support-message', ' First payload ');
|
||||
const firstPayloadRequest = await failSubmit();
|
||||
await setInput('#support-message', 'First payload ');
|
||||
const trimOnlyRequest = await failSubmit();
|
||||
await setInput('#support-message', 'Second payload');
|
||||
const changedMessageRequest = await failSubmit();
|
||||
await setInput('#support-contact', 'owner@example.test');
|
||||
const changedContactRequest = await failSubmit();
|
||||
previewToken = 'e';
|
||||
await card._editorRuntime._setSupportAttachment(true);
|
||||
const attachedRequest = await failSubmit();
|
||||
await card._editorRuntime._setSupportAttachment(false);
|
||||
previewToken = 'f';
|
||||
await card._editorRuntime._setSupportAttachment(true);
|
||||
const changedPreviewRequest = await failSubmit();
|
||||
out.idempotencyKeyFollowsEffectivePayload = firstPayloadRequest?.idempotency_key === validationKey
|
||||
&& trimOnlyRequest?.idempotency_key === firstPayloadRequest?.idempotency_key
|
||||
&& changedMessageRequest?.idempotency_key !== trimOnlyRequest?.idempotency_key
|
||||
&& changedContactRequest?.idempotency_key !== changedMessageRequest?.idempotency_key
|
||||
&& attachedRequest?.idempotency_key !== changedContactRequest?.idempotency_key
|
||||
&& attachedRequest?.preview_token === 'e'.repeat(48)
|
||||
&& changedPreviewRequest?.idempotency_key !== attachedRequest?.idempotency_key
|
||||
&& changedPreviewRequest?.preview_token === 'f'.repeat(48)
|
||||
&& firstPayloadRequest?.message === 'First payload'
|
||||
&& firstPayloadRequest?.contact === '';
|
||||
await close();
|
||||
|
||||
return out;
|
||||
}, { version: VERSION, previewText: PREVIEW_TEXT, previewSha: PREVIEW_SHA });
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ const res = await page.evaluate(async () => {
|
||||
const humLine = metricLines.findIndex((line) => line.includes('средняя влажность'));
|
||||
const lqiLine = metricLines.findIndex((line) => line.includes(c._t('tip.lqi')));
|
||||
out.tipMetricOrder = tempLine >= 0 && humLine === tempLine + 1 && lqiLine === humLine + 1;
|
||||
out.tipPositionUnchanged = sr().querySelector('.tip')?.getAttribute('style') === 'left:212px;top:212px';
|
||||
out.tipPositionUnchanged = sr().querySelector('.tip')?.style.left === '212px'
|
||||
&& sr().querySelector('.tip')?.style.top === '212px';
|
||||
// _roomArea consumes the rendered room geometry (0..1000), not the stored
|
||||
// normalized config polygon (0..1).
|
||||
const expectedArea = c._roomArea(c._spaceModel().rooms[0]);
|
||||
|
||||
@@ -262,7 +262,7 @@ const out = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
|
||||
// save -> matrix stored for m2, panel closed, puck appears while cleaning
|
||||
c._vacFitSave(); await c.updateComplete;
|
||||
await c._vacFitSave(); await c.updateComplete;
|
||||
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
|
||||
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
|
||||
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
|
||||
|
||||
@@ -56,14 +56,23 @@ const out = await page.evaluate(async () => {
|
||||
// spy on config writes: a success toast is only honest after one of these
|
||||
const writes = [];
|
||||
const realWS = c.hass.callWS;
|
||||
c.hass.callWS = async (m) => { if (m.type === 'houseplan/config/set') writes.push(m); return realWS(m); };
|
||||
let configGate = null;
|
||||
let rejectConfig = false;
|
||||
c.hass.callWS = async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
writes.push(m);
|
||||
if (configGate) await configGate.promise;
|
||||
if (rejectConfig) throw new Error('calibration rejected');
|
||||
}
|
||||
return realWS(m);
|
||||
};
|
||||
|
||||
const dev = c._devices.find((x) => x.id === 'd_mower');
|
||||
o.devFound = !!dev;
|
||||
o.freshNoMarker = (c._serverCfg.markers || []).length === 0 && !dev.marker;
|
||||
|
||||
// ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ----
|
||||
c._vacAutoCalibrate(dev);
|
||||
await c._vacAutoCalibrate(dev);
|
||||
await c.updateComplete;
|
||||
const m1 = (c._serverCfg.markers || []).find((x) => x.id === 'd_mower');
|
||||
o.markerMaterialised = !!m1 && m1.binding === 'device:d_mower';
|
||||
@@ -79,25 +88,91 @@ const out = await page.evaluate(async () => {
|
||||
o.configWritePersisted = writes.length >= 1
|
||||
&& !!writes[writes.length - 1].config.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'];
|
||||
|
||||
// #442: a low-residual write stays busy until config/set answers. Rejecting
|
||||
// it restores the accepted matrix and leaves the Device editor usable.
|
||||
const acceptedBeforeReject = [...cal0];
|
||||
for (const room of Object.values(robotRooms)) { room.x0 += 200; room.x1 += 200; }
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
|
||||
let releaseGate;
|
||||
configGate = { promise: new Promise((resolve) => { releaseGate = resolve; }) };
|
||||
rejectConfig = true;
|
||||
c._toast = null;
|
||||
const beforeDeferredWrites = writes.length;
|
||||
const rejectedAuto = c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
|
||||
while (writes.length === beforeDeferredWrites) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await c.updateComplete;
|
||||
const autoButton = [...sr().querySelectorAll('.vacbtns button')]
|
||||
.find((button) => button.textContent.includes(c._t('vac.autocal')));
|
||||
o.autoWaitsBusyDisabled = c._markerDialog?.busy === true && autoButton?.disabled === true;
|
||||
o.autoNoEarlySuccess = c._toast === null;
|
||||
await c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
|
||||
o.autoDoubleClickSuppressed = writes.length === beforeDeferredWrites + 1;
|
||||
releaseGate();
|
||||
await rejectedAuto; await c.updateComplete;
|
||||
o.autoRejectRestoresAccepted = JSON.stringify(
|
||||
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
|
||||
) === JSON.stringify(acceptedBeforeReject);
|
||||
o.autoRejectKeepsDialog = c._markerDialog?.busy === false;
|
||||
o.autoRejectHasNoSuccess = !String(c._toast || '').startsWith('Done: bound via');
|
||||
configGate = null;
|
||||
rejectConfig = false;
|
||||
await c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
|
||||
await c.updateComplete;
|
||||
o.autoRetryAccepted = JSON.stringify(
|
||||
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
|
||||
) !== JSON.stringify(acceptedBeforeReject)
|
||||
&& String(c._toast || '').startsWith('Done: bound via');
|
||||
|
||||
// High residual is a proposal, never an implicit write. Cover Cancel,
|
||||
// explicit Apply and manual-fit Apply from the same deterministic fixture.
|
||||
const lowMatrix = [...cal0];
|
||||
const lowMatrix = [
|
||||
...c._serverCfg.markers.find((x) => x.id === 'd_mower').vacuum.calibration['0'],
|
||||
];
|
||||
robotRooms[4].x0 = 20600;
|
||||
robotRooms[4].x1 = 21400;
|
||||
c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
await c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
o.highResidualDialog = !!c._vacCalConfirm;
|
||||
o.highResidualNoImplicitSave = JSON.stringify(m1.vacuum.calibration['0']) === JSON.stringify(lowMatrix);
|
||||
o.highResidualNoImplicitSave = JSON.stringify(
|
||||
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
|
||||
) === JSON.stringify(lowMatrix);
|
||||
c._vacCalConfirm = null; await c.updateComplete;
|
||||
o.highResidualCancelUntouched = JSON.stringify(m1.vacuum.calibration['0']) === JSON.stringify(lowMatrix);
|
||||
c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
c._vacApplyCalibrationProposal(false); await c.updateComplete;
|
||||
const appliedProposal = [...m1.vacuum.calibration['0']];
|
||||
o.highResidualCancelUntouched = JSON.stringify(
|
||||
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
|
||||
) === JSON.stringify(lowMatrix);
|
||||
await c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
const rejectedProposalDraft = JSON.stringify(c._vacCalConfirm);
|
||||
rejectConfig = true;
|
||||
await c._vacApplyCalibrationProposal(false); await c.updateComplete;
|
||||
o.highResidualRejectKeepsProposal = !!c._vacCalConfirm
|
||||
&& c._vacCalConfirm.busy === false
|
||||
&& JSON.stringify({ ...c._vacCalConfirm, busy: undefined })
|
||||
=== JSON.stringify({ ...JSON.parse(rejectedProposalDraft), busy: undefined });
|
||||
o.highResidualRejectRestoresMatrix = JSON.stringify(
|
||||
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
|
||||
) === JSON.stringify(lowMatrix);
|
||||
rejectConfig = false;
|
||||
await c._vacApplyCalibrationProposal(false); await c.updateComplete;
|
||||
const appliedProposal = [
|
||||
...c._serverCfg.markers.find((x) => x.id === 'd_mower').vacuum.calibration['0'],
|
||||
];
|
||||
o.highResidualApplySaved = JSON.stringify(appliedProposal) !== JSON.stringify(lowMatrix);
|
||||
c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
c._vacApplyCalibrationProposal(true); await c.updateComplete;
|
||||
await c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
await c._vacApplyCalibrationProposal(true); await c.updateComplete;
|
||||
o.highResidualManualOpened = !!c._vacFit && c._vacFit.markerId === 'd_mower';
|
||||
c._vacFitSave(); await c.updateComplete;
|
||||
o.highResidualManualApplied = !c._vacFit && Array.isArray(m1.vacuum.calibration['0'])
|
||||
const manualDraft = JSON.stringify(c._vacFit);
|
||||
rejectConfig = true;
|
||||
await c._vacFitSave(); await c.updateComplete;
|
||||
o.manualRejectKeepsExactDraft = JSON.stringify({ ...c._vacFit, busy: undefined })
|
||||
=== JSON.stringify({ ...JSON.parse(manualDraft), busy: undefined });
|
||||
o.manualRejectRestoresMatrix = JSON.stringify(
|
||||
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
|
||||
) === JSON.stringify(appliedProposal);
|
||||
rejectConfig = false;
|
||||
await c._vacFitSave(); await c.updateComplete;
|
||||
o.highResidualManualApplied = !c._vacFit && Array.isArray(
|
||||
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
|
||||
)
|
||||
&& c._toast === c._t('vac.cal_done');
|
||||
robotRooms[4].x0 = 1700;
|
||||
robotRooms[4].x1 = 2500;
|
||||
@@ -108,7 +183,7 @@ const out = await page.evaluate(async () => {
|
||||
const dev2 = c._devices.find((x) => x.id === 'd_mower');
|
||||
o.fitFreshNoMarker = !dev2.marker;
|
||||
c._vacStartFit(dev2); await c.updateComplete;
|
||||
c._vacFitSave(); await c.updateComplete;
|
||||
await c._vacFitSave(); await c.updateComplete;
|
||||
const m2 = (c._serverCfg.markers || []).find((x) => x.id === 'd_mower');
|
||||
const calFit = m2?.vacuum?.calibration?.['0'];
|
||||
o.fitMaterialises = !!m2 && Array.isArray(calFit) && calFit.length === 6;
|
||||
@@ -167,7 +242,7 @@ const out = await page.evaluate(async () => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } };
|
||||
await c.updateComplete;
|
||||
c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
|
||||
await c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
|
||||
o.noRoomsToastReworded = c._toast === c._t('vac.autocal_no_rooms') && !/point|точк/i.test(c._toast);
|
||||
|
||||
c.hass.callWS = realWS;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// #162: карты робота живут на разных этажах, док — на своём.
|
||||
//
|
||||
// Смок проверяет ровно то, чего не мог проверить ни один прежний: раньше
|
||||
// маркер робота отбирался по пространству дока, поэтому на другом этаже
|
||||
// оверлея не существовало в принципе.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const M1 = [0.5, 0, 0, 0, 0.5, 0];
|
||||
const M2 = [0.25, 0, 0, 0, 0.25, 0];
|
||||
const attrs = (mapName) => ({ vacuum_position: { x: 600, y: 800, a: 45 }, map_name: mapName });
|
||||
|
||||
c.hass = { ...c.hass,
|
||||
entities: { ...c.hass.entities,
|
||||
'vacuum.robo': { entity_id: 'vacuum.robo', platform: 'demo', disabled_by: null },
|
||||
'camera.robo_map': { entity_id: 'camera.robo_map', platform: 'demo', disabled_by: null } },
|
||||
states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: attrs('m1') },
|
||||
} };
|
||||
const cfg = c._serverCfg;
|
||||
cfg.markers = cfg.markers || [];
|
||||
cfg.markers.push({
|
||||
id: 'e_vacuum_robo', binding: 'entity:vacuum.robo', space: 'f1',
|
||||
vacuum: {
|
||||
source: 'camera.robo_map',
|
||||
map_routes: [
|
||||
{ id: 'vr1', source: 'camera.robo_map', map_id: 'm1', space: 'f1', calibration: M1 },
|
||||
{ id: 'vr2', source: 'camera.robo_map', map_id: 'm2', space: 'garden', calibration: M2 },
|
||||
],
|
||||
},
|
||||
});
|
||||
c._layout['e_vacuum_robo'] = { s: 'f1', x: 0.1, y: 0.1 };
|
||||
c._regSignature = '';
|
||||
c._setMode('view');
|
||||
c._space = 'f1';
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 60));
|
||||
|
||||
const puck = () => sr().querySelector('.vacpuck');
|
||||
const dock = () => sr().querySelector('.dev[data-id="e_vacuum_robo"]');
|
||||
const warn = () => sr().querySelector('.vacwarn');
|
||||
|
||||
// Карта m1 → первый этаж: робот и док на одном этаже, как и раньше.
|
||||
o.floor1DockThere = !!dock();
|
||||
o.floor1PuckThere = !!puck();
|
||||
o.floor1NoWarning = !warn();
|
||||
|
||||
// Робот переехал на карту m2 → второй этаж.
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: attrs('m2') } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 60));
|
||||
o.floor1DockStays = !!dock();
|
||||
o.floor1PuckGone = !puck();
|
||||
|
||||
c._space = 'garden';
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 60));
|
||||
o.floor2PuckThere = !!puck();
|
||||
o.floor2NoDock = !dock();
|
||||
|
||||
// Возврат на m1 возвращает картину без единой правки конфигурации.
|
||||
const before = JSON.stringify(c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum);
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: attrs('m1') } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 60));
|
||||
o.floor2PuckGoneAfterReturn = !puck();
|
||||
c._space = 'f1';
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 60));
|
||||
o.floor1PuckBack = !!puck();
|
||||
o.routesUntouched = JSON.stringify(
|
||||
c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum) === before;
|
||||
|
||||
// Несопоставленная карта: нигде не рисуем, но у дока говорим почему.
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: attrs('m9') } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 60));
|
||||
o.unmappedNoPuck = !puck();
|
||||
o.unmappedWarns = !!warn();
|
||||
o.unmappedWarnLabelled = !!warn()?.getAttribute('aria-label');
|
||||
c._space = 'garden';
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 60));
|
||||
o.unmappedNoGhostOnOtherFloor = !puck();
|
||||
|
||||
// Донастройка матрицы с высоким residual обязана открыться на этаже
|
||||
// МАРШРУТА: она решалась против его геометрии, а не геометрии дока.
|
||||
c._space = 'f1';
|
||||
c._setMode('devices');
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 120));
|
||||
c._vacCalConfirm = {
|
||||
markerId: 'e_vacuum_robo', source: 'camera.robo_map', mapId: 'm2',
|
||||
routeId: 'vr2', space: 'garden', matrix: M2, rooms: 3, error: '80 см',
|
||||
};
|
||||
await c.updateComplete;
|
||||
await c._vacApplyCalibrationProposal(true);
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 80));
|
||||
o.manualFitSwitchedToRouteSpace = c._space === 'garden';
|
||||
o.manualFitKeepsRoute = c._vacFit?.routeId === 'vr2';
|
||||
c._vacFit = null;
|
||||
c._setMode('view');
|
||||
c._space = 'f1';
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 80));
|
||||
|
||||
// Робот встал — предупреждение снимается: тревожить нечем.
|
||||
c._space = 'f1';
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 60));
|
||||
o.dockedNoWarning = !warn();
|
||||
|
||||
return o;
|
||||
});
|
||||
|
||||
checkAll(out, {
|
||||
floor1DockThere: true, floor1PuckThere: true, floor1NoWarning: true,
|
||||
floor1DockStays: true, floor1PuckGone: true,
|
||||
floor2PuckThere: true, floor2NoDock: true,
|
||||
floor2PuckGoneAfterReturn: true, floor1PuckBack: true, routesUntouched: true,
|
||||
unmappedNoPuck: true, unmappedWarns: true, unmappedWarnLabelled: true,
|
||||
unmappedNoGhostOnOtherFloor: true,
|
||||
manualFitSwitchedToRouteSpace: true, manualFitKeepsRoute: true,
|
||||
dockedNoWarning: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,198 @@
|
||||
// #441: adding a second vacuum map is a UI draft, never an invalid route with
|
||||
// `space: ''`. The same smoke covers both add entry points and rejected saves.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const waitFor = async (read, timeout = 2000) => {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeout) {
|
||||
const value = read();
|
||||
if (value) return value;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await c._writeChain;
|
||||
|
||||
const originalCallWS = c.hass.callWS;
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
entities: {
|
||||
...c.hass.entities,
|
||||
'camera.mower_map': {
|
||||
entity_id: 'camera.mower_map', device_id: 'd_mower', platform: 'demo', disabled_by: null,
|
||||
},
|
||||
'camera.mower_upstairs': {
|
||||
entity_id: 'camera.mower_upstairs', device_id: 'd_mower', platform: 'demo', disabled_by: null,
|
||||
},
|
||||
},
|
||||
states: {
|
||||
...c.hass.states,
|
||||
'camera.mower_map': {
|
||||
state: 'idle', attributes: {
|
||||
friendly_name: 'Main map', map_name: 'm1', vacuum_position: { x: 1, y: 2 },
|
||||
},
|
||||
},
|
||||
'camera.mower_upstairs': {
|
||||
state: 'idle', attributes: {
|
||||
friendly_name: 'Upstairs map', map_name: 'm2', vacuum_position: { x: 1, y: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
c._serverCfg.markers = [{
|
||||
id: 'd_mower', binding: 'device:d_mower', space: 'garden', area: 'garden',
|
||||
vacuum: { source: 'camera.mower_map', map_routes: [] },
|
||||
}];
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices');
|
||||
await c.updateComplete;
|
||||
c._openMarkerDialog(c._devices.find((device) => device.id === 'd_mower'));
|
||||
await c.updateComplete;
|
||||
|
||||
const routes = () => c._serverCfg.markers.find((marker) => marker.id === 'd_mower')
|
||||
?.vacuum?.map_routes || [];
|
||||
const addCurrent = () => sr().querySelector('.vacroute-add-current');
|
||||
const pending = () => sr().querySelector('.vacroute.pending');
|
||||
|
||||
// The first route is a draft too, but its only safe default is the dock floor.
|
||||
addCurrent()?.click();
|
||||
await c.updateComplete;
|
||||
o.firstRoutePreselectsDock = pending()?.querySelector('.vacroute-draft-space')?.value === 'garden';
|
||||
o.firstDraftDoesNotWrite = routes().length === 0;
|
||||
pending()?.querySelector('button.ghostbtn')?.click();
|
||||
await c.updateComplete;
|
||||
|
||||
// Start with one accepted route, then exercise the separate-source entry.
|
||||
c._serverCfg.markers[0].vacuum.map_routes = [{
|
||||
id: 'r1', source: 'camera.mower_map', map_id: 'm1', space: 'garden', calibration: null,
|
||||
}];
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const sourceDetails = sr().querySelector('details.vacroute-sources');
|
||||
if (sourceDetails) sourceDetails.open = true;
|
||||
await c.updateComplete;
|
||||
[...(sourceDetails?.querySelectorAll('button.vacsource') || [])]
|
||||
.find((button) => button.textContent.includes('camera.mower_upstairs'))?.click();
|
||||
await c.updateComplete;
|
||||
o.separateSourceStartsBlank = pending()?.querySelector('.vacroute-draft-space')?.value === '';
|
||||
o.blankDraftCannotSave = pending()?.querySelector('.vacroute-draft-save')?.disabled === true;
|
||||
o.separateSourceDoesNotWrite = routes().length === 1;
|
||||
pending()?.querySelector('button.ghostbtn')?.click();
|
||||
await c.updateComplete;
|
||||
o.cancelLeavesAcceptedRoutes = !pending() && routes().length === 1;
|
||||
|
||||
// The same source reports another map. Confirm once against a rejecting
|
||||
// backend, then retry successfully without reopening the dialog.
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.mower_map': {
|
||||
state: 'idle', attributes: {
|
||||
friendly_name: 'Main map', map_name: 'm2', vacuum_position: { x: 1, y: 2 },
|
||||
},
|
||||
},
|
||||
} };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
addCurrent()?.click();
|
||||
await c.updateComplete;
|
||||
o.secondCurrentStartsBlank = pending()?.querySelector('.vacroute-draft-space')?.value === '';
|
||||
const select = pending()?.querySelector('.vacroute-draft-space');
|
||||
if (select) {
|
||||
select.value = 'f1';
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
await c.updateComplete;
|
||||
o.validFloorEnablesSave = pending()?.querySelector('.vacroute-draft-save')?.disabled === false;
|
||||
|
||||
const writes = [];
|
||||
let rejectNext = true;
|
||||
c.hass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/set') {
|
||||
writes.push(message);
|
||||
if (rejectNext) {
|
||||
rejectNext = false;
|
||||
throw new Error('synthetic route rejection');
|
||||
}
|
||||
}
|
||||
return originalCallWS(message);
|
||||
} };
|
||||
pending()?.querySelector('.vacroute-draft-save')?.click();
|
||||
await waitFor(() => writes.length === 1 && pending()?.querySelector('.vacroute-draft-save')?.disabled === false);
|
||||
o.rejectionRestoresAcceptedMarker = routes().length === 1 && routes()[0].id === 'r1';
|
||||
o.rejectionKeepsEditorUsable = !!pending()
|
||||
&& pending().querySelector('.vacroute-draft-space')?.value === 'f1';
|
||||
o.rejectedPayloadWasValid = writes[0]?.config?.markers
|
||||
?.find((marker) => marker.id === 'd_mower')?.vacuum?.map_routes
|
||||
?.every((route) => !!route.space) === true;
|
||||
|
||||
pending()?.querySelector('.vacroute-draft-save')?.click();
|
||||
await waitFor(() => routes().length === 2 && !pending());
|
||||
const added = routes().find((route) => route.map_id === 'm2');
|
||||
o.retryAddsExactlyOneRoute = routes().length === 2;
|
||||
o.retryKeepsExactIdentity = added?.source === 'camera.mower_map'
|
||||
&& added?.space === 'f1' && added?.calibration === null;
|
||||
o.noEmptySpaceEverSubmitted = writes.every((write) => write.config.markers
|
||||
.find((marker) => marker.id === 'd_mower').vacuum.map_routes
|
||||
.every((route) => !!route.space));
|
||||
|
||||
// #443: valid routes keep the space order, while every dead-space route is
|
||||
// collected under one explicit, operable and deterministic group.
|
||||
c._config = { ...c._config, language: 'en' };
|
||||
c._serverCfg.markers[0].vacuum.map_routes = [
|
||||
{ id: 'missing-z', source: 'camera.z', map_id: 'zeta', space: 'gone-z', calibration: null },
|
||||
{ id: 'valid-f1', source: 'camera.valid', map_id: 'valid', space: 'f1', calibration: null },
|
||||
{ id: 'missing-b', source: 'camera.b', map_id: 'alpha', space: 'gone-b', calibration: null },
|
||||
{ id: 'missing-a', source: 'camera.a', map_id: 'alpha', space: 'gone-a', calibration: null },
|
||||
];
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._openMarkerDialog(c._devices.find((device) => device.id === 'd_mower'));
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const missingGroups = [...sr().querySelectorAll('[data-hp="vacuum-route-missing-group"]')];
|
||||
const missingGroup = missingGroups[0];
|
||||
const missingIds = [...(missingGroup?.querySelectorAll('.vacroute') || [])]
|
||||
.map((row) => row.dataset.routeId);
|
||||
const validIds = [...sr().querySelectorAll('.vacroute')]
|
||||
.filter((row) => !row.closest('.vacroute-missing-group'))
|
||||
.map((row) => row.dataset.routeId);
|
||||
o.oneExplicitMissingSpaceGroup = missingGroups.length === 1;
|
||||
o.missingSpaceGroupIsLabelled = missingGroup?.querySelector('.vacroute-group-title')
|
||||
?.textContent.trim() === 'Deleted space';
|
||||
o.validRowsStayBeforeMissingGroup = validIds.join(',') === 'valid-f1';
|
||||
o.missingRowsHaveStableIdentityOrder = missingIds.join(',')
|
||||
=== 'missing-a,missing-b,missing-z';
|
||||
o.missingRowsRemainOperable = [...(missingGroup?.querySelectorAll('.vacroute') || [])]
|
||||
.every((row) => row.querySelector('select') && row.querySelectorAll('button').length === 2);
|
||||
o.missingSpaceWarningRemains = !!sr().querySelector('.warn');
|
||||
const groupThemeStates = [];
|
||||
for (const darkMode of [false, true]) {
|
||||
c.hass = { ...c.hass, themes: { ...(c.hass.themes || {}), darkMode } };
|
||||
await c.updateComplete;
|
||||
const themedGroup = sr().querySelector('[data-hp="vacuum-route-missing-group"]');
|
||||
const themedTitle = themedGroup?.querySelector('.vacroute-group-title');
|
||||
groupThemeStates.push(!!themedGroup && !!themedTitle
|
||||
&& getComputedStyle(themedGroup).display !== 'none'
|
||||
&& getComputedStyle(themedTitle).visibility === 'visible'
|
||||
&& themedTitle.textContent.trim() === 'Deleted space');
|
||||
}
|
||||
o.missingSpaceGroupReadableInLightAndDark = groupThemeStates.every(Boolean);
|
||||
|
||||
c.hass = { ...c.hass, callWS: originalCallWS };
|
||||
return o;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user