Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce07db748e | ||
|
|
5ff97858f8 | ||
|
|
855b62729d | ||
|
|
0a955f5df5 | ||
|
|
198484f762 | ||
|
|
2558d68f06 | ||
|
|
7ab12294fc | ||
|
|
23c65e449e | ||
|
|
1856302cd3 | ||
|
|
523bd4569d | ||
|
|
f6956fa472 | ||
|
|
fc0c616698 | ||
|
|
daccaf7ea5 | ||
|
|
12e5728a17 | ||
|
|
65d38b2403 | ||
|
|
21a1993015 | ||
|
|
dc032dcb3e | ||
|
|
152a6ca5cc | ||
|
|
fcf0ce80eb | ||
|
|
a4fe939f63 | ||
|
|
248c926a17 | ||
|
|
0f5c3d8967 | ||
|
|
ea2ec19b71 | ||
|
|
282e52cc73 | ||
|
|
46d43a29be | ||
|
|
0466a02dfd | ||
|
|
69207f5757 | ||
|
|
6b77d598fd | ||
|
|
fcc4125c6e | ||
|
|
41e6eef941 | ||
|
|
13ab4b12b7 | ||
|
|
34d53bf2d1 | ||
|
|
04a1de091c | ||
|
|
9bdf531c1a | ||
|
|
1ef21037a8 | ||
|
|
16b4c2d6d3 | ||
|
|
9ee60993ff | ||
|
|
dfb4efaa1c | ||
|
|
5d09e722e4 | ||
|
|
5763c6aec0 | ||
|
|
570da3dd1a | ||
|
|
ca2a00231b | ||
|
|
833c9a2a33 | ||
|
|
50bc1d6955 | ||
|
|
ba47395337 | ||
|
|
33f16dd3cc | ||
|
|
ef5bfa4bcd | ||
|
|
b4898583d7 | ||
|
|
7320937f62 | ||
|
|
d80805acec | ||
|
|
896ceecf3a | ||
|
|
7cb1eee386 | ||
|
|
da22b87fe1 | ||
|
|
41b8e71248 | ||
|
|
58b1651570 | ||
|
|
793d06c75e | ||
|
|
d13ecf1237 | ||
|
|
a63aef0239 | ||
|
|
b04e4aacce | ||
|
|
c81d1d9775 | ||
|
|
6af6d7b388 | ||
|
|
7dd5b4798a | ||
|
|
5108202419 | ||
|
|
2c7879131f | ||
|
|
6dd9fb03c3 | ||
|
|
b286d7ba43 | ||
|
|
fe0c2e0c83 | ||
|
|
aac9232bbc | ||
|
|
de30066be7 | ||
|
|
6ef0804bc4 | ||
|
|
356b260035 | ||
|
|
3d0fe7d756 | ||
|
|
5adada070f | ||
|
|
5bd0c4a033 | ||
|
|
588f1188c3 | ||
|
|
17bfdaaed0 | ||
|
|
07ba2ffbd1 | ||
|
|
5692a2880f | ||
|
|
d7e97687ce | ||
|
|
444562e47c | ||
|
|
046efe9613 | ||
|
|
d513f13afa | ||
|
|
a7bee0f5fb | ||
|
|
4b6b4abc70 | ||
|
|
8d787530b1 | ||
|
|
fe03eae213 | ||
|
|
cb68492c99 | ||
|
|
f8b3445dc3 | ||
|
|
1850eb18b5 | ||
|
|
5254fb9ef5 | ||
|
|
67c000f17b | ||
|
|
8ef0b6a1fa | ||
|
|
5d594e70c8 | ||
|
|
c0d61ca3d2 | ||
|
|
137e160f1a | ||
|
|
66c2eec1f9 | ||
|
|
338700bdcb | ||
|
|
029aba6b88 | ||
|
|
0cd5145df0 | ||
|
|
5784a0e271 | ||
|
|
2a883217c1 | ||
|
|
d5e114524c | ||
|
|
9e7ae520a8 | ||
|
|
4b443d8cf5 | ||
|
|
dc76c5b2b8 | ||
|
|
8d8263202f | ||
|
|
cc9387163e | ||
|
|
f70a973834 | ||
|
|
36b4e1cf31 | ||
|
|
5016aa01cc | ||
|
|
28a4cb5eec | ||
|
|
9e56842bc1 | ||
|
|
1d8edf4a01 | ||
|
|
aa51ed2bf5 | ||
|
|
a158d75712 | ||
|
|
05ef318133 | ||
|
|
b39f99b3a4 | ||
|
|
85a064430b | ||
|
|
070c276e26 | ||
|
|
1a7488d695 | ||
|
|
1ce62613ed | ||
|
|
b8a31e90ca | ||
|
|
5da19f1793 | ||
|
|
a8ccb2b90f | ||
|
|
8935a736cb | ||
|
|
411a0a20a9 | ||
|
|
89647c10bd | ||
|
|
ed4ac972b3 | ||
|
|
ee9b28ed98 | ||
|
|
658f284b44 | ||
|
|
169fed01f0 | ||
|
|
7230e4248a | ||
|
|
0ec0785dfd | ||
|
|
e846833ed6 | ||
|
|
ed0fdd4859 | ||
|
|
112ec0eb66 | ||
|
|
775e55f0ed | ||
|
|
3f9ef807d8 | ||
|
|
f0ed526a59 | ||
|
|
4203a01b04 | ||
|
|
aab5c3ca95 | ||
|
|
a2867df9a2 | ||
|
|
2202b20730 | ||
|
|
85702afc0c | ||
|
|
e5587aed7c | ||
|
|
aed0b5b65a | ||
|
|
c53702cdfa | ||
|
|
a6a3df9310 | ||
|
|
ec9824f227 | ||
|
|
260767e2f8 | ||
|
|
3a1f2b2d86 | ||
|
|
6eea4d1c7a | ||
|
|
a54addb694 | ||
|
|
6a7ef48ae8 | ||
|
|
6190234c51 | ||
|
|
39ba4e2b88 | ||
|
|
15cfa96553 | ||
|
|
4aad9facdf | ||
|
|
81026539d3 | ||
|
|
f672de674f | ||
|
|
68992d5582 | ||
|
|
f29c4c5b79 | ||
|
|
d5bfa0e3e1 | ||
|
|
f62dd6b632 | ||
|
|
6b07b70d7c | ||
|
|
62ecb8566e | ||
|
|
818ad2d2bb | ||
|
|
6c668d4bfe | ||
|
|
a1fbea07d2 |
@@ -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, а не только в логе прогона.
|
||||
|
||||
@@ -761,6 +761,8 @@ jobs:
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
run: npm run benchmark:junction-limits
|
||||
- name: Быстрый commit точки стены (#461)
|
||||
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -344,9 +344,27 @@ whole review round that a local run would have caught immediately.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
|
||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
||||
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||
the beta gate is CI at the exact SHA.
|
||||
|
||||
**Verifying and capturing are different things (#455).** `golden:verify` is
|
||||
advisory and legal anywhere, Windows included: it reports differences and
|
||||
accepts nothing. **Capturing** frames is refused outside Linux
|
||||
before the browser even starts — `golden:capture` through
|
||||
`demo/golden/policy.mjs`, documentation screenshots through
|
||||
`npm run docs:capture` (use that script, not a bare `node
|
||||
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
|
||||
capture script invalidates the committed screenshot index). The refusal prints
|
||||
the WSL command. The reason is not policy but physics: Windows
|
||||
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
|
||||
so no frame ever matches an accepted baseline byte for byte, no environment
|
||||
witness can exist, and acceptance would refuse anyway (#401 accepts any
|
||||
environment that proves itself with byte-identical undeclared frames — Linux is
|
||||
simply the only one we have). The deliberate override is
|
||||
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
|
||||
manifest. Baselines are still accepted only via
|
||||
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
|
||||
index records the platform next to the Chromium build.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
@@ -399,21 +417,20 @@ Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
|
||||
reproduces against the pristine committed bundle, so treat it as
|
||||
pre-existing/pixel-precision, not a regression you introduced.
|
||||
|
||||
## Labs flags
|
||||
## Alpha experiments
|
||||
|
||||
`src/labs.ts` is the single registry and resolver for hidden presentation
|
||||
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
|
||||
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
|
||||
YAML/config switch for a Labs-only experiment. A new entry needs a unique
|
||||
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
|
||||
unit/browser coverage. Invalid or duplicate registry entries fail closed.
|
||||
experiments. One browser-local switch controls the complete set known to the
|
||||
installed build: `hp_alpha=1` in query or the shared hash grammar enables and
|
||||
persists it, while `hp_alpha=0` disables and persists it. Do not add per-feature
|
||||
URL/storage keys or a YAML/config switch. The legacy `hp-labs` grammar and
|
||||
`houseplan_card_labs_v1` storage are ignored and never migrated.
|
||||
|
||||
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
|
||||
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
|
||||
experiment or graduate it through its own reviewed issue; never extend expiry as
|
||||
an incidental change. Labs may alter presentation only and must not gate data,
|
||||
migrations, stores, HA actions or network calls. Current renderer details are in
|
||||
`docs/ISOMETRIC.md`.
|
||||
A new registry entry needs a unique lowercase id, issue, summary and
|
||||
unit/browser coverage; it does not get `since` or `expires`. Invalid or duplicate
|
||||
entries fail closed. Alpha capabilities may alter presentation only and must not
|
||||
gate data, migrations, stores, HA actions or network calls. Current renderer
|
||||
details are in `docs/ISOMETRIC.md`.
|
||||
|
||||
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
|
||||
page directly in a browser renders the floor plan but **device icons only appear
|
||||
|
||||
@@ -94,17 +94,46 @@ House Plan is in the HACS default catalog — no custom repository needed.
|
||||
2. Restart Home Assistant.
|
||||
3. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
The card is registered automatically. After installing or updating House Plan,
|
||||
restart Home Assistant and fully reload the page: `Ctrl+F5` on Windows/Linux or
|
||||
`Cmd+Shift+R` on macOS.
|
||||
|
||||
#### Storage mode (Home Assistant default)
|
||||
|
||||
No YAML is normally needed. If automatic registration did not make the card
|
||||
available, open **Settings → Dashboards → menu ⋮ → Resources → Add
|
||||
resource**, enter `/houseplan_files/houseplan-card.js`, and select **JavaScript
|
||||
module**.
|
||||
|
||||
#### YAML resources mode (Home Assistant 2026.2+)
|
||||
|
||||
To manage resources in `configuration.yaml` independently of the dashboard
|
||||
mode, use:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Do not use the on-disk path inside `custom_components`; Home Assistant does not
|
||||
serve that path as a JavaScript module.
|
||||
#### Legacy Home Assistant 2024.6–2026.1
|
||||
|
||||
Only for a full-YAML dashboard that is already managed in YAML, use:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` changes the dashboard itself to YAML mode. Do not switch a storage
|
||||
dashboard to legacy YAML just for House Plan; use the Storage mode instructions
|
||||
above instead. Do not use the on-disk path inside `custom_components`; Home
|
||||
Assistant does not serve that path as a JavaScript module.
|
||||
|
||||
### Manual installation
|
||||
|
||||
|
||||
@@ -98,17 +98,46 @@ House Plan входит в основной каталог HACS — пользо
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
Карточка регистрируется автоматически. После установки или обновления
|
||||
House Plan перезапустите Home Assistant и полностью перезагрузите
|
||||
страницу: `Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS.
|
||||
|
||||
#### Режим Storage (по умолчанию в Home Assistant)
|
||||
|
||||
Обычно YAML не нужен. Если авторегистрация не сделала карточку доступной,
|
||||
откройте **Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить
|
||||
ресурс**, укажите `/houseplan_files/houseplan-card.js` и выберите тип
|
||||
**JavaScript-модуль**.
|
||||
|
||||
#### YAML-ресурсы в Home Assistant 2026.2+
|
||||
|
||||
Чтобы управлять ресурсами в `configuration.yaml` независимо от режима
|
||||
самой панели, используйте:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
|
||||
публикует его как JavaScript-модуль.
|
||||
#### Home Assistant 2024.6–2026.1: устаревший режим
|
||||
|
||||
Только для панели, которая уже полностью управляется через YAML:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` переводит в YAML-режим саму панель. Не переключайте
|
||||
storage-панель в устаревший YAML только ради House Plan; используйте
|
||||
инструкцию для Storage выше. Не используйте путь к файлу внутри
|
||||
`custom_components`: Home Assistant не публикует его как JavaScript-модуль.
|
||||
|
||||
### Вручную
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -16,9 +15,11 @@ from .const import (
|
||||
ASSETS_DIR,
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
VERSION,
|
||||
)
|
||||
from .frontend_registration import (
|
||||
async_remove_frontend_registration,
|
||||
async_setup_frontend_registration,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
@@ -99,49 +100,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
)
|
||||
)
|
||||
|
||||
# Static paths cannot be unregistered — register once per HA run.
|
||||
if not hass.data[DOMAIN].get("static_registered"):
|
||||
hass.data[DOMAIN]["static_registered"] = True
|
||||
static_paths = []
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the entry and manifest-gated JS chunks stay public —
|
||||
# Lovelace modules must be loadable without an auth header.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
return True
|
||||
|
||||
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
|
||||
# resources before rendering dashboards, so the card is available even on a cold
|
||||
# start of the mobile app). If the resource registry is unavailable (YAML-mode
|
||||
# Lovelace, old versions) — fall back to extra_module_url.
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}"
|
||||
registered = await _register_lovelace_resource(hass, module_url)
|
||||
if not registered:
|
||||
add_extra_js_url(hass, module_url)
|
||||
# Tell the user exactly where the card lives — the #1 support issue is people adding a
|
||||
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
|
||||
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
|
||||
if registered:
|
||||
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
|
||||
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
|
||||
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
|
||||
module_url, module_url,
|
||||
)
|
||||
# NOTE (audit B1): plans and marker files are no longer static. They are
|
||||
# served by HouseplanContentView, which requires auth. Only the manifest-
|
||||
# gated frontend bundle stays public so Lovelace can load it. Registration
|
||||
# failures are observable and recoverable, but never skip storage migration,
|
||||
# repairs or housekeeping below.
|
||||
await async_setup_frontend_registration(hass, entry, card_path)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
@@ -319,61 +283,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
|
||||
"""Clean up on integration removal: drop our Lovelace resource entry."""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
|
||||
await resources.async_delete_item(item["id"])
|
||||
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
|
||||
except Exception as err: # noqa: BLE001 — best-effort cleanup
|
||||
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
|
||||
"""Register (or update) the card in the Lovelace resource registry.
|
||||
|
||||
Returns True on success. Idempotent: if a resource with our path exists —
|
||||
update the URL on version change; otherwise create it. Any exception → False
|
||||
(fall back to extra_module_url).
|
||||
"""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return False
|
||||
# the resource registry must be loaded
|
||||
if hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
elif hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
# only storage mode allows creating items
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return False
|
||||
base = FRONTEND_URL
|
||||
existing = [
|
||||
item for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == base
|
||||
]
|
||||
if existing:
|
||||
item = existing[0]
|
||||
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
|
||||
await resources.async_update_item(item["id"], {"url": module_url})
|
||||
return True
|
||||
await resources.async_create_item({"res_type": "module", "url": module_url})
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 — any failure → fallback
|
||||
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
|
||||
return False
|
||||
async def async_remove_entry(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Clean up frontend registration and notice on integration removal."""
|
||||
await async_remove_frontend_registration(hass, entry)
|
||||
|
||||
@@ -51,7 +51,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.71.0"
|
||||
VERSION = "1.72.0"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
|
||||
@@ -1,125 +1,182 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "b58e7cdef9cffc5c835789c5b3ebc59d270d8170d1928c8a2b224b40569c939e",
|
||||
"fingerprint": "0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js",
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 292741,
|
||||
"initialViewGzipBytes": 300154,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/de-B00cun6t.js",
|
||||
"houseplan-assets/editor-Cv6TOit6.js",
|
||||
"houseplan-assets/fr-BMO7ooc0.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-xreJqGLn.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/de-BRjMWzox.js",
|
||||
"houseplan-assets/editor-DIlG_iy6.js",
|
||||
"houseplan-assets/fr-CB9Rh5BY.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
|
||||
"houseplan-assets/live-interaction-runtime-BuROtNwW.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
|
||||
],
|
||||
"lazyGzipBytes": 223395,
|
||||
"lazyGzipBytes": 250869,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/editor-Cv6TOit6.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-xreJqGLn.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/editor-DIlG_iy6.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 167358,
|
||||
"lazyEditorGzipBytes": 186626,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14239,
|
||||
"lazyOnboardingGzipBytes": 14325,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-B00cun6t.js",
|
||||
"houseplan-assets/fr-BMO7ooc0.js"
|
||||
"houseplan-assets/de-BRjMWzox.js",
|
||||
"houseplan-assets/fr-CB9Rh5BY.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49079,
|
||||
"lazyLocaleGzipBytes": 50148,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"sha256": "e7e88a03ea2fbbdb148c38de69b4796464e72708025193793c86fc143508aab2",
|
||||
"rawBytes": 21418,
|
||||
"gzipBytes": 7281,
|
||||
"path": "houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"sha256": "acd02f075bc18ca361cb543749461208b9f2dea2433aa3950a1296c31759de6b",
|
||||
"rawBytes": 21592,
|
||||
"gzipBytes": 7357,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-B00cun6t.js",
|
||||
"sha256": "63d4a5f686bdf72fb90a1265fbf13ca4044fe8f9c044f027e0f6829dc70f8c1a",
|
||||
"rawBytes": 82140,
|
||||
"gzipBytes": 24809,
|
||||
"path": "houseplan-assets/de-BRjMWzox.js",
|
||||
"sha256": "fdba581311125cadbec7b827e8a4b87f3aba4eab7ff12d4dbf8af58517e9e9a8",
|
||||
"rawBytes": 84232,
|
||||
"gzipBytes": 25351,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-Cv6TOit6.js",
|
||||
"sha256": "04a98bcb607339183d4c441f81c2d9f67bb174da26cd23ca8279df5ce48dd133",
|
||||
"rawBytes": 3826,
|
||||
"path": "houseplan-assets/editor-DIlG_iy6.js",
|
||||
"sha256": "8c28088bcc9df5581948eeda9518f43eb73ca8ed9918ab0961f3eea114e2f3c9",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-BMO7ooc0.js",
|
||||
"sha256": "90d0488056c3c2be3c7f231a6e97686f0e54e5713528e062984bc6d36087822d",
|
||||
"rawBytes": 84267,
|
||||
"gzipBytes": 24270,
|
||||
"path": "houseplan-assets/fr-CB9Rh5BY.js",
|
||||
"sha256": "95cf4e535042c78eb00bedc08c8c2a9e779236847f3e12dca4757fca435629b7",
|
||||
"rawBytes": 86273,
|
||||
"gzipBytes": 24797,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-BzvSZLQf.js",
|
||||
"sha256": "73ff73117f8baa0c1125bfdf84dd0042fdf6b3d1f2b149e3963430cedbcc71ea",
|
||||
"rawBytes": 1039623,
|
||||
"gzipBytes": 291943,
|
||||
"path": "houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"sha256": "03a04aa491c26e42f8adea0126c8bb970b54ec7a12bbd9475f9279a91a2b7fc0",
|
||||
"rawBytes": 1069135,
|
||||
"gzipBytes": 299358,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-B00cun6t.js",
|
||||
"houseplan-assets/editor-Cv6TOit6.js",
|
||||
"houseplan-assets/fr-BMO7ooc0.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-xreJqGLn.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js"
|
||||
"houseplan-assets/de-BRjMWzox.js",
|
||||
"houseplan-assets/editor-DIlG_iy6.js",
|
||||
"houseplan-assets/fr-CB9Rh5BY.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
|
||||
"houseplan-assets/live-interaction-runtime-BuROtNwW.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-xreJqGLn.js",
|
||||
"sha256": "b2ae41efa27b191388b09eaf02e9e4b302c2031238c6e90037d71c9422179792",
|
||||
"rawBytes": 601522,
|
||||
"gzipBytes": 158494,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"sha256": "fc089a1e4a1df49c3e1c8e78620a3bd23f3ba7270213db663cd018854c22fe55",
|
||||
"rawBytes": 639580,
|
||||
"gzipBytes": 170261,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
|
||||
"sha256": "87d24dae20c92a060e34d1e4ce80d300c947b7ac6b3a86f6025b39750d286d46",
|
||||
"rawBytes": 28071,
|
||||
"gzipBytes": 6968,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js",
|
||||
"sha256": "f78f4fdbc5e390829ef57e8e09d2ad856639138d83f3c62c997c2cbb71115f6c",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6958,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
|
||||
"sha256": "b5cbcfffe86b10e56c078dbf139123a6c16625f8c2396570215f06def12e08ba",
|
||||
"rawBytes": 8626,
|
||||
"gzipBytes": 3236,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-BuROtNwW.js",
|
||||
"sha256": "b0d405033a72cab412d7475170818f00729dfedd049f2dcfc3387beefa43ea42",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2126,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DjEvSVKB.js",
|
||||
"sha256": "531006824ca5197709f62fc3bae854d4a7349d3c7f3c3ab85c223d7e31261b9a",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7425,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js",
|
||||
"sha256": "09566891268f0fd002283422cd1b83151520240025fb9e2899cee758e6147e8c",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1765,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "a4f11751d4f28af28debedad8298a810cd999c7bc6ed66f22d6fec1b8ebaae3f",
|
||||
"sha256": "12e80545e7ee775e0859c8113f922c25f15bef46c5c1866db3cff6d7d3dccdce",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 798,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BzvSZLQf.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b58e7cdef9cffc5c835789c5b3ebc59d270d8170d1928c8a2b224b40569c939e";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-BzvSZLQf.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-C0qjaV_3.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";import{n as t,b as n}from"./zigbee-topology-DjEvSVKB.js";import{I as e}from"./houseplan-card-C0qjaV_3.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b58e7cdef9cffc5c835789c5b3ebc59d270d8170d1928c8a2b224b40569c939e";try{await import("./houseplan-assets/houseplan-card-BzvSZLQf.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";try{await import("./houseplan-assets/houseplan-card-C0qjaV_3.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Register the House Plan card with the Home Assistant frontend."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import frontend, persistent_notification
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
from .const import DOMAIN, FRONTEND_URL, VERSION
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_REGISTRATION_KEY = "frontend_registration"
|
||||
FRONTEND_STATIC_REGISTERED_KEY = "static_registered"
|
||||
FRONTEND_FALLBACK_URLS_KEY = "frontend_fallback_urls"
|
||||
FRONTEND_RELOAD_NOTICE_DATA_KEY = "frontend_reload_notice_created"
|
||||
FRONTEND_RELOAD_NOTICE_ID = "houseplan_frontend_reload_notice"
|
||||
FRONTEND_RETRY_DELAY_SECONDS = 1.0
|
||||
|
||||
_NOTICE_TITLE_KEY = "component.houseplan.issues.frontend_reload_notice.title"
|
||||
_NOTICE_DESCRIPTION_KEY = (
|
||||
"component.houseplan.issues.frontend_reload_notice.description"
|
||||
)
|
||||
_NOTICE_TITLE_FALLBACK = "House Plan card connected"
|
||||
_NOTICE_DESCRIPTION_FALLBACK = (
|
||||
"The House Plan card is connected. Fully reload this page to use the latest "
|
||||
"frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you "
|
||||
"manage dashboard resources manually in storage mode, open Settings → "
|
||||
"Dashboards → Resources."
|
||||
)
|
||||
|
||||
RegistrationStatus = Literal[
|
||||
"not_attempted",
|
||||
"created",
|
||||
"updated",
|
||||
"existing",
|
||||
"registry_pending",
|
||||
"yaml_fallback",
|
||||
"transient_error",
|
||||
"error_fallback",
|
||||
]
|
||||
ResourceLoader = Literal[
|
||||
"none",
|
||||
"lovelace_resource",
|
||||
"extra_module_url",
|
||||
"lovelace_resource_with_session_fallback",
|
||||
]
|
||||
ReloadNoticeStatus = Literal[
|
||||
"created",
|
||||
"already_created",
|
||||
"pending_frontend",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegistrationOutcome:
|
||||
"""Result of one Lovelace resource registry attempt."""
|
||||
|
||||
status: RegistrationStatus
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FrontendRegistrationState:
|
||||
"""Observable state and lifecycle handles for the current config-entry run."""
|
||||
|
||||
card_file_present: bool
|
||||
static_path_registered: bool = False
|
||||
resource_status: RegistrationStatus = "not_attempted"
|
||||
loader: ResourceLoader = "none"
|
||||
module_url: str | None = None
|
||||
retry_pending: bool = False
|
||||
retry_attempted: bool = False
|
||||
last_error: str | None = None
|
||||
first_reload_notice: ReloadNoticeStatus = "pending_frontend"
|
||||
active: bool = True
|
||||
fallback_added: bool = False
|
||||
_cancel_started: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_cancel_timer: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_retry_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
||||
_notice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
|
||||
|
||||
@callback
|
||||
def cancel(self) -> None:
|
||||
"""Prevent all delayed work for this setup generation."""
|
||||
self.active = False
|
||||
self.retry_pending = False
|
||||
if self._cancel_started is not None:
|
||||
self._cancel_started()
|
||||
self._cancel_started = None
|
||||
if self._cancel_timer is not None:
|
||||
self._cancel_timer()
|
||||
self._cancel_timer = None
|
||||
if self._retry_task is not None and not self._retry_task.done():
|
||||
self._retry_task.cancel()
|
||||
self._retry_task = None
|
||||
|
||||
|
||||
def _safe_error(phase: str, err: BaseException) -> str:
|
||||
"""Return a support-safe failure fingerprint without message or path data."""
|
||||
return f"{phase}:{type(err).__name__}"
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
"""Return the Lovelace resource collection across supported HA versions."""
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
def _is_current(hass: HomeAssistant, state: FrontendRegistrationState) -> bool:
|
||||
"""Return whether a delayed operation still belongs to the loaded entry."""
|
||||
return (
|
||||
state.active
|
||||
and hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY) is state
|
||||
)
|
||||
|
||||
|
||||
def _owned_fallback_urls(hass: HomeAssistant) -> set[str]:
|
||||
"""Return exact extra-module URLs owned for this Home Assistant run."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
urls = domain_data.get(FRONTEND_FALLBACK_URLS_KEY)
|
||||
if not isinstance(urls, set):
|
||||
urls = set()
|
||||
domain_data[FRONTEND_FALLBACK_URLS_KEY] = urls
|
||||
return urls
|
||||
|
||||
|
||||
def _active_extra_module_urls(hass: HomeAssistant) -> frozenset[str] | None:
|
||||
"""Return HA's current module URLs, or None when the API is unknowable.
|
||||
|
||||
``add_extra_js_url`` is intentionally idempotent and has no ownership
|
||||
return value. Looking before adding is therefore the only way to avoid
|
||||
claiming (and later removing) an identical URL configured by the user.
|
||||
Both the oldest supported HA and current HA expose ``UrlManager.urls``;
|
||||
unknown future/legacy shapes fail closed and are never claimed.
|
||||
"""
|
||||
key = getattr(frontend, "DATA_EXTRA_MODULE_URL", None)
|
||||
if key is None:
|
||||
return None
|
||||
manager = hass.data.get(key)
|
||||
if manager is None:
|
||||
# Frontend setup normally creates the manager before this integration.
|
||||
# If it is genuinely absent, no configured URL can predate our add;
|
||||
# add_extra_js_url will either create/use its supported path or fail.
|
||||
return frozenset()
|
||||
urls = getattr(manager, "urls", None)
|
||||
if not isinstance(urls, (set, frozenset, list, tuple)):
|
||||
return None
|
||||
return frozenset(url for url in urls if isinstance(url, str))
|
||||
|
||||
|
||||
async def _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing: list[dict],
|
||||
canonical_id: str,
|
||||
*,
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
"""Best-effort remove legacy duplicates after choosing one authority."""
|
||||
remover = getattr(resources, "async_delete_item", None)
|
||||
if remover is None:
|
||||
return
|
||||
for item in existing:
|
||||
if item.get("id") == canonical_id:
|
||||
continue
|
||||
if not is_active():
|
||||
return
|
||||
try:
|
||||
await remover(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - canonical registration still succeeded
|
||||
_LOGGER.debug(
|
||||
"Could not remove a duplicate House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def async_register_lovelace_resource(
|
||||
hass: HomeAssistant,
|
||||
module_url: str,
|
||||
*,
|
||||
is_active: Callable[[], bool] | None = None,
|
||||
) -> RegistrationOutcome:
|
||||
"""Create or update the canonical Lovelace resource entry."""
|
||||
active = is_active or (lambda: True)
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return RegistrationOutcome("yaml_fallback")
|
||||
|
||||
try:
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
elif hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
|
||||
existing = [
|
||||
item
|
||||
for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL
|
||||
]
|
||||
if existing:
|
||||
# Prefer an already-current entry when legacy duplicates exist. This
|
||||
# preserves its stable id while the remaining same-base entries are
|
||||
# removed best effort below.
|
||||
item = next(
|
||||
(candidate for candidate in existing if candidate.get("url") == module_url),
|
||||
existing[0],
|
||||
)
|
||||
if item.get("url") == module_url and item.get("type") == "module":
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("existing")
|
||||
if not hasattr(resources, "async_update_item"):
|
||||
return RegistrationOutcome(
|
||||
"transient_error", "registry_update:UnsupportedOperation"
|
||||
)
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_update_item(
|
||||
item["id"], {"url": module_url, "res_type": "module"}
|
||||
)
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("updated")
|
||||
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_create_item(
|
||||
{"url": module_url, "res_type": "module"}
|
||||
)
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource")
|
||||
return RegistrationOutcome("created")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - typed fallback is intentional
|
||||
_LOGGER.debug("Could not register the House Plan Lovelace resource", exc_info=True)
|
||||
return RegistrationOutcome("transient_error", _safe_error("registry", err))
|
||||
|
||||
|
||||
def _add_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> None:
|
||||
"""Add this setup's exact versioned extra-module URL, best effort."""
|
||||
if state.module_url is None or not _is_current(hass, state):
|
||||
return
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
active_before = _active_extra_module_urls(hass)
|
||||
if active_before is not None and state.module_url in active_before:
|
||||
# The exact URL predates this setup. It is a working loader, but it is
|
||||
# not ours to remove after registry recovery or on uninstall.
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
try:
|
||||
frontend.add_extra_js_url(hass, state.module_url)
|
||||
except Exception as err: # noqa: BLE001 - frontend setup must remain available
|
||||
state.loader = "none"
|
||||
state.last_error = _safe_error("fallback_add", err)
|
||||
_LOGGER.warning("Could not add the House Plan frontend fallback", exc_info=True)
|
||||
return
|
||||
if active_before is not None:
|
||||
owned_urls.add(state.module_url)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"House Plan could not prove ownership of the frontend fallback; "
|
||||
"it will remain until Home Assistant restarts"
|
||||
)
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
|
||||
|
||||
def _remove_owned_fallback_url(
|
||||
hass: HomeAssistant,
|
||||
state: FrontendRegistrationState,
|
||||
module_url: str,
|
||||
) -> bool:
|
||||
"""Remove one exact integration-owned fallback when HA supports it."""
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if module_url not in owned_urls:
|
||||
return True
|
||||
remover = getattr(frontend, "remove_extra_js_url", None)
|
||||
if remover is None:
|
||||
return False
|
||||
try:
|
||||
remover(hass, module_url)
|
||||
except KeyError:
|
||||
# Another lifecycle path already removed the exact URL.
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 - registry remains authoritative
|
||||
state.last_error = _safe_error("fallback_remove", err)
|
||||
_LOGGER.debug("Could not remove the House Plan frontend fallback", exc_info=True)
|
||||
return False
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
|
||||
|
||||
def _remove_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> bool:
|
||||
"""Remove this setup's exact fallback when the running HA supports it."""
|
||||
if state.module_url is None:
|
||||
return True
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
removed = _remove_owned_fallback_url(hass, state, state.module_url)
|
||||
state.fallback_added = not removed
|
||||
return removed
|
||||
|
||||
# A pre-existing/user-owned or uninspectable exact URL must never be
|
||||
# removed. Report the combined loader while it is known (or conservatively
|
||||
# assumed) to remain active in this HA document lifecycle.
|
||||
active = _active_extra_module_urls(hass)
|
||||
state.fallback_added = active is None or state.module_url in active
|
||||
return not state.fallback_added
|
||||
|
||||
|
||||
async def _async_create_reload_notice(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Create and persist the one-time localized hard-reload instruction."""
|
||||
# Initial fallback setup and the one-second retry may overlap when loading a
|
||||
# translation is slow. Serialize the persisted one-shot contract rather
|
||||
# than relying only on the stable notification ID (which prevents two UI
|
||||
# rows but would still perform and persist two creates).
|
||||
async with state._notice_lock:
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True:
|
||||
if state.first_reload_notice != "created":
|
||||
state.first_reload_notice = "already_created"
|
||||
return
|
||||
if (
|
||||
not _is_current(hass, state)
|
||||
or not state.card_file_present
|
||||
or not state.static_path_registered
|
||||
or state.loader == "none"
|
||||
):
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
return
|
||||
|
||||
title = _NOTICE_TITLE_FALLBACK
|
||||
description = _NOTICE_DESCRIPTION_FALLBACK
|
||||
try:
|
||||
translations = await async_get_translations(
|
||||
hass,
|
||||
hass.config.language,
|
||||
"issues",
|
||||
integrations={DOMAIN},
|
||||
)
|
||||
title = translations.get(_NOTICE_TITLE_KEY, title)
|
||||
description = translations.get(_NOTICE_DESCRIPTION_KEY, description)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - built-in English fallback is intentional
|
||||
_LOGGER.debug(
|
||||
"Could not load the House Plan reload notice translation",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
try:
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
description,
|
||||
title=title,
|
||||
notification_id=FRONTEND_RELOAD_NOTICE_ID,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - do not consume flag on failure
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not create the House Plan reload notice", exc_info=True
|
||||
)
|
||||
return
|
||||
try:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, FRONTEND_RELOAD_NOTICE_DATA_KEY: True},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - roll back the unpersisted notice
|
||||
# A stable ID prevents duplicate rows, but dismissing the notice is
|
||||
# still important: without a durable flag, displaying it would make
|
||||
# System Health claim that the one-shot contract is pending while a
|
||||
# user-visible notice already exists. A later setup/retry can now
|
||||
# attempt the complete create+persist operation honestly.
|
||||
try:
|
||||
persistent_notification.async_dismiss(
|
||||
hass, FRONTEND_RELOAD_NOTICE_ID
|
||||
)
|
||||
except Exception: # noqa: BLE001 - stable ID remains a safe fallback
|
||||
_LOGGER.debug(
|
||||
"Could not roll back an unpersisted House Plan reload notice",
|
||||
exc_info=True,
|
||||
)
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not persist the House Plan reload notice state",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
state.first_reload_notice = "created"
|
||||
|
||||
|
||||
async def _async_retry_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Perform the single delayed resource-registry retry."""
|
||||
if not _is_current(hass, state) or state.module_url is None:
|
||||
return
|
||||
state.retry_pending = False
|
||||
state.retry_attempted = True
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
elif outcome.status == "yaml_fallback":
|
||||
state.resource_status = "yaml_fallback"
|
||||
state.last_error = outcome.last_error
|
||||
_add_fallback(hass, state)
|
||||
else:
|
||||
state.resource_status = "error_fallback"
|
||||
state.last_error = outcome.last_error or "registry_retry:Unavailable"
|
||||
_add_fallback(hass, state)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
|
||||
|
||||
def _schedule_retry(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Schedule exactly one lifecycle-bound retry after HA start plus one second."""
|
||||
state.retry_pending = True
|
||||
|
||||
@callback
|
||||
def _after_started(_event=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_started = None
|
||||
|
||||
@callback
|
||||
def _after_delay(_now=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_timer = None
|
||||
state._retry_task = entry.async_create_background_task(
|
||||
hass,
|
||||
_async_retry_registration(hass, entry, state),
|
||||
"houseplan frontend resource retry",
|
||||
)
|
||||
|
||||
state._cancel_timer = async_call_later(
|
||||
hass, FRONTEND_RETRY_DELAY_SECONDS, _after_delay
|
||||
)
|
||||
|
||||
state._cancel_started = async_at_started(hass, _after_started)
|
||||
|
||||
|
||||
async def async_setup_frontend_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
card_path: Path,
|
||||
) -> FrontendRegistrationState:
|
||||
"""Register static/card resources without blocking the rest of entry setup."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
previous = domain_data.get(FRONTEND_REGISTRATION_KEY)
|
||||
if isinstance(previous, FrontendRegistrationState):
|
||||
previous.cancel()
|
||||
|
||||
card_file_present = card_path.is_file()
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}" if card_file_present else None
|
||||
active_fallbacks = _active_extra_module_urls(hass)
|
||||
fallback_added = module_url is not None and (
|
||||
module_url in _owned_fallback_urls(hass)
|
||||
or (active_fallbacks is not None and module_url in active_fallbacks)
|
||||
)
|
||||
state = FrontendRegistrationState(
|
||||
card_file_present=card_file_present,
|
||||
loader="extra_module_url" if fallback_added else "none",
|
||||
module_url=module_url,
|
||||
first_reload_notice=(
|
||||
"already_created"
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True
|
||||
else "pending_frontend"
|
||||
),
|
||||
fallback_added=fallback_added,
|
||||
)
|
||||
domain_data[FRONTEND_REGISTRATION_KEY] = state
|
||||
entry.async_on_unload(state.cancel)
|
||||
|
||||
if domain_data.get(FRONTEND_STATIC_REGISTERED_KEY):
|
||||
state.static_path_registered = True
|
||||
elif card_file_present:
|
||||
try:
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
except ImportError: # Home Assistant versions before the async API
|
||||
hass.http.register_static_path(
|
||||
FRONTEND_URL, str(card_path), cache_headers=False
|
||||
)
|
||||
else:
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)]
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - setup continues without frontend
|
||||
state.last_error = _safe_error("static_path", err)
|
||||
_LOGGER.warning("Could not register the House Plan static path", exc_info=True)
|
||||
else:
|
||||
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = True
|
||||
state.static_path_registered = True
|
||||
|
||||
if not card_file_present:
|
||||
_LOGGER.warning("houseplan-card.js was not found next to the integration")
|
||||
return state
|
||||
if not state.static_path_registered or state.module_url is None:
|
||||
return state
|
||||
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return state
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan card auto-registered as a Lovelace resource: %s",
|
||||
state.module_url,
|
||||
)
|
||||
else:
|
||||
_add_fallback(hass, state)
|
||||
if outcome.status in {"registry_pending", "transient_error"}:
|
||||
_schedule_retry(hass, entry, state)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card uses the frontend extra-module fallback: %s",
|
||||
state.module_url,
|
||||
)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
return state
|
||||
|
||||
|
||||
@callback
|
||||
def get_frontend_registration_state(
|
||||
hass: HomeAssistant,
|
||||
) -> FrontendRegistrationState | None:
|
||||
"""Return current frontend registration state for System Health."""
|
||||
state = hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY)
|
||||
return state if isinstance(state, FrontendRegistrationState) else None
|
||||
|
||||
|
||||
async def async_remove_frontend_registration(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Remove all House Plan resource entries and its one-time notice."""
|
||||
state = get_frontend_registration_state(hass)
|
||||
if state is not None:
|
||||
state.cancel()
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
if domain_data.get(FRONTEND_REGISTRATION_KEY) is state:
|
||||
domain_data.pop(FRONTEND_REGISTRATION_KEY, None)
|
||||
|
||||
# Fallback ownership belongs to this HA run, not to the latest config-entry
|
||||
# generation. A bundle-missing reload can therefore have no current URL
|
||||
# while an earlier exact URL is still registered. Uninstall cleans every
|
||||
# URL that this integration itself added, and never pattern-matches foreign
|
||||
# extra-module entries.
|
||||
cleanup_state = state or FrontendRegistrationState(card_file_present=False)
|
||||
for module_url in tuple(_owned_fallback_urls(hass)):
|
||||
_remove_owned_fallback_url(hass, cleanup_state, module_url)
|
||||
|
||||
try:
|
||||
persistent_notification.async_dismiss(hass, FRONTEND_RELOAD_NOTICE_ID)
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not dismiss the House Plan reload notice", exc_info=True)
|
||||
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] != FRONTEND_URL:
|
||||
continue
|
||||
try:
|
||||
await resources.async_delete_item(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - continue best-effort cleanup
|
||||
_LOGGER.debug(
|
||||
"Could not remove one House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
_LOGGER.debug("House Plan Lovelace resource removed")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not remove the House Plan Lovelace resource", exc_info=True)
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"domain": "houseplan",
|
||||
"name": "House Plan",
|
||||
"after_dependencies": [
|
||||
"lovelace"
|
||||
],
|
||||
"codeowners": [
|
||||
"@Matysh"
|
||||
],
|
||||
@@ -16,5 +19,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.71.0"
|
||||
"version": "1.72.0"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .frontend_registration import get_frontend_registration_state
|
||||
from .store import get_data
|
||||
|
||||
|
||||
@@ -23,7 +24,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
cfg_raw = await data.config_store.async_load() or {}
|
||||
layout_raw = await data.store.async_load() or {}
|
||||
config = cfg_raw.get("config", {})
|
||||
return {
|
||||
result = {
|
||||
"config_rev": cfg_raw.get("rev", 0),
|
||||
"spaces": len(config.get("spaces", [])),
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
@@ -33,3 +34,42 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
frontend = get_frontend_registration_state(hass)
|
||||
if frontend is None:
|
||||
result.update(
|
||||
{
|
||||
"card_file": "missing",
|
||||
"static_path": "not_registered",
|
||||
"resource_status": "not_attempted",
|
||||
"resource_loader": "none",
|
||||
"resource_url": "unavailable",
|
||||
"resource_retry": "not_needed",
|
||||
"resource_error": "none",
|
||||
"first_reload_notice": "pending_frontend",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
result.update(
|
||||
{
|
||||
"card_file": "present" if frontend.card_file_present else "missing",
|
||||
"static_path": (
|
||||
"registered"
|
||||
if frontend.static_path_registered
|
||||
else "not_registered"
|
||||
),
|
||||
"resource_status": frontend.resource_status,
|
||||
"resource_loader": frontend.loader,
|
||||
"resource_url": frontend.module_url or "unavailable",
|
||||
"resource_retry": (
|
||||
"pending"
|
||||
if frontend.retry_pending
|
||||
else "attempted"
|
||||
if frontend.retry_attempted
|
||||
else "not_needed"
|
||||
),
|
||||
"resource_error": frontend.last_error or "none",
|
||||
"first_reload_notice": frontend.first_reload_notice,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Grundrissbild fehlt",
|
||||
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House-Plan-Karte verbunden",
|
||||
"description": "Die House-Plan-Karte ist verbunden. Laden Sie diese Seite vollständig neu, um die aktuelle Benutzeroberfläche zu verwenden (`Ctrl+F5` unter Windows/Linux oder `Cmd+Shift+R` unter macOS). Wenn Sie Dashboard-Ressourcen im Speichermodus manuell verwalten, öffnen Sie Einstellungen → Dashboards → Ressourcen."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Datei der House-Plan-Karte",
|
||||
"static_path": "Statischer Frontend-Pfad",
|
||||
"resource_status": "Status der Ressourcenregistrierung",
|
||||
"resource_loader": "Lademethode der Frontend-Ressource",
|
||||
"resource_url": "URL der Frontend-Ressource",
|
||||
"resource_retry": "Wiederholung der Ressourcenregistrierung",
|
||||
"resource_error": "Fehler der Frontend-Registrierung",
|
||||
"first_reload_notice": "Erster Hinweis zum Neuladen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "L’image du plan est introuvable",
|
||||
"description": "Le fichier de plan `{file}` pour l’espace `{space}` est introuvable dans `config/houseplan/plans/`. Ouvrez les paramètres de l’espace dans la carte House Plan et téléversez de nouveau le plan."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Carte House Plan connectée",
|
||||
"description": "La carte House Plan est connectée. Rechargez complètement cette page pour utiliser l’interface la plus récente (`Ctrl+F5` sous Windows/Linux ou `Cmd+Shift+R` sous macOS). Si vous gérez manuellement les ressources du tableau de bord en mode stockage, ouvrez Paramètres → Tableaux de bord → Ressources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Fichier de la carte House Plan",
|
||||
"static_path": "Chemin statique de l’interface",
|
||||
"resource_status": "État de l’enregistrement de la ressource",
|
||||
"resource_loader": "Mode de chargement de la ressource",
|
||||
"resource_url": "URL de la ressource de l’interface",
|
||||
"resource_retry": "Nouvelle tentative d’enregistrement",
|
||||
"resource_error": "Erreur d’enregistrement de l’interface",
|
||||
"first_reload_notice": "Premier avis de rechargement"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Файл плана этажа не найден",
|
||||
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Карточка House Plan подключена",
|
||||
"description": "Карточка House Plan подключена. Полностью перезагрузите эту страницу, чтобы использовать актуальный интерфейс (`Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS). Если вы вручную управляете ресурсами панели в режиме хранения, откройте Настройки → Панели → Ресурсы."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Файл карточки House Plan",
|
||||
"static_path": "Статический путь интерфейса",
|
||||
"resource_status": "Статус регистрации ресурса",
|
||||
"resource_loader": "Способ загрузки ресурса",
|
||||
"resource_url": "URL ресурса интерфейса",
|
||||
"resource_retry": "Повтор регистрации ресурса",
|
||||
"resource_error": "Ошибка регистрации интерфейса",
|
||||
"first_reload_notice": "Первое уведомление о перезагрузке"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,15 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1', 'large-house-interaction-v1'].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const interaction = profile === 'large-house-interaction-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
@@ -71,7 +73,7 @@ try {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace,
|
||||
requiresWallFace, interaction, requiresInteraction,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -135,11 +137,11 @@ try {
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
if (isometric) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
|
||||
localStorage.setItem('houseplan_card_alpha_v1', '1');
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
|
||||
fixture.config.spaces.map((space) => [space.id, 'iso']),
|
||||
)));
|
||||
history.replaceState(null, '', '?hp-labs=iso');
|
||||
history.replaceState(null, '', '?hp_alpha=1');
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
@@ -151,10 +153,10 @@ try {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
};
|
||||
const hassFor = (states) => ({
|
||||
const hassBase = {
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
@@ -178,17 +180,19 @@ try {
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
};
|
||||
const hassFor = (states) => ({ ...hassBase, states });
|
||||
|
||||
const loadLongTasks = startLongTaskWindow();
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
if (requiresIsometric) {
|
||||
if (requiresIsometric && window.__hpAlpha !== true) {
|
||||
if (typeof card._onLabsSnapshot !== 'function')
|
||||
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
|
||||
// The product flag expires at 1.65.0. Performance keeps exercising
|
||||
// the dormant renderer without changing the public registry contract.
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
// Comparison bundles before #448 do not understand hp_alpha. Preserve
|
||||
// the cross-version benchmark only for that base; current candidates
|
||||
// must activate through the real URL/storage contract above.
|
||||
card._onLabsSnapshot({ alpha: true, active: Object.freeze(['iso']), space: '' });
|
||||
}
|
||||
card.hass = hassFor(fixture.states);
|
||||
// launch() warms its own bootstrap card, but each measured sample creates
|
||||
@@ -206,9 +210,33 @@ try {
|
||||
await frame();
|
||||
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
await until(() => card._booting === false);
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const loadLongTaskResult = await loadLongTasks.stop();
|
||||
let fullRenderCount = 0;
|
||||
let diagnosticsScanCount = 0;
|
||||
let physicalPreflightCount = 0;
|
||||
let physicalPreflightMs = 0;
|
||||
const fullRenderReasons = [];
|
||||
if (interaction) {
|
||||
const renderBody = card._renderBody.bind(card);
|
||||
card._renderBody = (...args) => { fullRenderCount++; return renderBody(...args); };
|
||||
const bindingStatus = card._bindingStatus.bind(card);
|
||||
card._bindingStatus = (...args) => { diagnosticsScanCount++; return bindingStatus(...args); };
|
||||
const checkSpacePhysicalGeometry = card._checkSpacePhysicalGeometry.bind(card);
|
||||
card._checkSpacePhysicalGeometry = (...args) => {
|
||||
physicalPreflightCount++;
|
||||
const started = performance.now();
|
||||
try { return checkSpacePhysicalGeometry(...args); }
|
||||
finally { physicalPreflightMs += performance.now() - started; }
|
||||
};
|
||||
const willUpdate = card.willUpdate.bind(card);
|
||||
card.willUpdate = (changed) => {
|
||||
fullRenderReasons.push([...changed.keys()].map(String));
|
||||
return willUpdate(changed);
|
||||
};
|
||||
}
|
||||
const viewToggle = isometric ? await duration(async () => {
|
||||
if (typeof card._setProjection === 'function') {
|
||||
card._setProjection('flat');
|
||||
@@ -236,6 +264,406 @@ try {
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
let interactionDiagnostics = null;
|
||||
let interactionTimings = null;
|
||||
let interactionLongTasks = null;
|
||||
const interactionSeries = interaction ? await duration(async () => {
|
||||
const deltas = {};
|
||||
const timings = {};
|
||||
interactionLongTasks = {};
|
||||
const diagnosticsBefore = diagnosticsScanCount;
|
||||
const reasonsBefore = fullRenderReasons.length;
|
||||
const beforeIrrelevant = fullRenderCount;
|
||||
const intakeBefore = card._hassSequence;
|
||||
const irrelevantStarted = performance.now();
|
||||
let currentStates = nextStates;
|
||||
for (let index = 0; index < 30; index++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
'sensor.performance_unrelated': {
|
||||
entity_id: 'sensor.performance_unrelated', state: `${sample}:${index}`,
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.irrelevantHaTicksMs = performance.now() - irrelevantStarted;
|
||||
deltas.irrelevantFullRenders = fullRenderCount - beforeIrrelevant;
|
||||
deltas.irrelevantHassIntakes = card._hassSequence - intakeBefore;
|
||||
|
||||
const relevantBefore = fullRenderCount;
|
||||
const relevantStarted = performance.now();
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: {
|
||||
...currentStates[firstEntity],
|
||||
state: currentStates[firstEntity].state === 'on' ? 'off' : 'on',
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.relevantHaTickMs = performance.now() - relevantStarted;
|
||||
deltas.relevantOutsideGestureFullRenders = fullRenderCount - relevantBefore;
|
||||
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const device = card.renderRoot.querySelector('[data-hp="device"]');
|
||||
const room = card.renderRoot.querySelector('[data-hp="room"]');
|
||||
const heavy = card.renderRoot.querySelector('.wallbodies');
|
||||
if (!stage || !device || !room || !heavy)
|
||||
throw new Error('interaction profile has no stage/device/room/heavy scene');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const hoverBefore = fullRenderCount;
|
||||
const hoverLongTasks = startLongTaskWindow();
|
||||
const hoverStarted = performance.now();
|
||||
device.dispatchEvent(new PointerEvent('pointerover', {
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
device.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 100 + index, clientY: rect.top + 100 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
deltas.tooltipVisible = !!liveTip && !liveTip.hidden && !!liveTip.textContent?.trim();
|
||||
device.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
room.dispatchEvent(new PointerEvent('pointerenter', {
|
||||
clientX: rect.left + 300, clientY: rect.top + 300,
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
room.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 300 + index, clientY: rect.top + 300 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
room.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 20 + index, clientY: rect.top + 20,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
await card.updateComplete;
|
||||
timings.hoverSeriesMs = performance.now() - hoverStarted;
|
||||
deltas.hoverFullRenders = fullRenderCount - hoverBefore;
|
||||
deltas.hoverTargets = ['device', 'room', 'miss'];
|
||||
interactionLongTasks.hoverSeries = await hoverLongTasks.stop();
|
||||
|
||||
const cameraSvg = card.renderRoot.querySelector('[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg');
|
||||
const initialViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const panLongTasks = startLongTaskWindow();
|
||||
const panStarted = performance.now();
|
||||
const panMoveRenders = [];
|
||||
const panTerminalRenders = [];
|
||||
let relevantDuringGestureFullRenders = 0;
|
||||
let relevantDuringGestureIntakes = 0;
|
||||
for (let dragIndex = 0; dragIndex < 4; dragIndex++) {
|
||||
const pointerId = 992 + dragIndex;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 200, clientY: rect.top + 200, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const panMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 205 + index * 3, clientY: rect.top + 202 + index,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if (dragIndex === 0 && index === 10) {
|
||||
const deferredBefore = fullRenderCount;
|
||||
const deferredIntakeBefore = card._hassSequence;
|
||||
for (let tick = 0; tick < 3; tick++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: { ...currentStates[firstEntity], state: `gesture-${tick}` },
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
relevantDuringGestureFullRenders += fullRenderCount - deferredBefore;
|
||||
relevantDuringGestureIntakes += card._hassSequence - deferredIntakeBefore;
|
||||
}
|
||||
if ((index + 1) % 10 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const beforeTerminal = fullRenderCount;
|
||||
panMoveRenders.push(beforeTerminal - panMoveBefore);
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 265, clientY: rect.top + 222, button: 0, buttons: 0,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
panTerminalRenders.push(fullRenderCount - beforeTerminal);
|
||||
}
|
||||
timings.panSeriesMs = performance.now() - panStarted;
|
||||
interactionLongTasks.panSeries = await panLongTasks.stop();
|
||||
const movedViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const currentView = card._viewOr(card._baseVb());
|
||||
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
|
||||
const position = currentDevice ? card._pos(currentDevice) : null;
|
||||
const scene = position ? card._scenePoint([position.x, position.y]) : null;
|
||||
const deviceRect = device.getBoundingClientRect();
|
||||
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
|
||||
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
|
||||
const overlayErrorPx = scene ? Math.hypot(
|
||||
deviceRect.left + deviceRect.width / 2 - expectedX,
|
||||
deviceRect.top + deviceRect.height / 2 - expectedY,
|
||||
) : Infinity;
|
||||
|
||||
const cameraLongTasks = startLongTaskWindow();
|
||||
const cameraStarted = performance.now();
|
||||
const cameraBefore = fullRenderCount;
|
||||
const pinchPointerA = 1001, pinchPointerB = 1002;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 360, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerA,
|
||||
pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 460, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerB,
|
||||
pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const pinchMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 360 - index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerA, pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 460 + index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerB, pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
if ((index + 1) % 5 === 0) await frame();
|
||||
}
|
||||
const pinchTerminalBefore = fullRenderCount;
|
||||
for (const pointerId of [pinchPointerA, pinchPointerB]) {
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 410, clientY: rect.top + 300,
|
||||
button: 0, buttons: 0, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'touch', isPrimary: pointerId === pinchPointerA,
|
||||
}));
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const pinchTerminalRenders = fullRenderCount - pinchTerminalBefore;
|
||||
for (let index = 0; index < 4; index++) {
|
||||
stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY: index % 2 ? 120 : -120,
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
await until(() => !card._cameraTransition.active);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.cameraSeriesMs = performance.now() - cameraStarted;
|
||||
interactionLongTasks.cameraSeries = await cameraLongTasks.stop();
|
||||
deltas.cameraMoveFullRenders = pinchTerminalBefore - pinchMoveBefore;
|
||||
deltas.cameraTerminalFullRenders = pinchTerminalRenders;
|
||||
deltas.cameraFullRenders = fullRenderCount - cameraBefore;
|
||||
|
||||
const editorLongTaskWindows = [];
|
||||
const editorConfigBefore = JSON.stringify(card._serverCfg);
|
||||
const editorCallsBefore = wsCalls;
|
||||
const editorPaintBefore = card._liveEditorPaintCount || 0;
|
||||
let editorElapsed = 0;
|
||||
const editorMoveRenders = [];
|
||||
const editorTerminalRenders = [];
|
||||
|
||||
card._setMode('plan', false);
|
||||
card._tool = 'draw';
|
||||
card._path = [[100, 100]];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
let editorStage = card.renderRoot.querySelector('.stage');
|
||||
let editorRect = editorStage.getBoundingClientRect();
|
||||
let editorView = card._viewOr(card._baseVb());
|
||||
const fromScene = (x, y) => ({
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
let editorPartLongTasks = startLongTaskWindow();
|
||||
let partStarted = performance.now();
|
||||
let editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
editorStage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
...fromScene(110 + index * 2, 110 + index),
|
||||
bubbles: true, composed: true, pointerId: 1101,
|
||||
pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
let editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const resizeRoom = card._rszRooms()[0];
|
||||
const resizeEvent = {
|
||||
pointerId: 1102, stopPropagation: () => undefined,
|
||||
preventDefault: () => undefined, target: null,
|
||||
};
|
||||
card._rszEdgeDown(resizeEvent, resizeRoom.id, 1);
|
||||
const resizePlan = card._resize?.plan || card._rszDrag?.plan;
|
||||
if (!resizePlan) throw new Error('interaction resize plan was not created');
|
||||
const resizePreflightBefore = physicalPreflightCount;
|
||||
const resizePreflightMsBefore = physicalPreflightMs;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
// A browser delivers pointerdown and pointermove as separate tasks.
|
||||
// Keep the synthetic harness from attributing both handlers to one
|
||||
// impossible long task while still timing every move-side preflight.
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
const target = [
|
||||
resizePlan.a[0] + resizePlan.n[0] * card._gridPitch * (1 + index / 40),
|
||||
resizePlan.a[1] + resizePlan.n[1] * card._gridPitch * (1 + index / 40),
|
||||
];
|
||||
card._rszMove({
|
||||
...resizeEvent,
|
||||
clientX: editorRect.left + ((target[0] - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((target[1] - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
deltas.resizeLivePreflightChecks = physicalPreflightCount - resizePreflightBefore;
|
||||
deltas.resizeLivePreflightMs = physicalPreflightMs - resizePreflightMsBefore;
|
||||
card._rszCancelDrag();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._setMode('decor', false);
|
||||
card._decorTool = 'select';
|
||||
const decorShape = card._decorList.find((shape) =>
|
||||
shape.kind === 'furniture' || shape.kind === 'rect' || shape.kind === 'ellipse');
|
||||
if (!decorShape) throw new Error('interaction profile has no transformable decor');
|
||||
card._decorSel = decorShape.id;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
card._dtMeasure();
|
||||
await card.updateComplete;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
const box = card._decorBoxOf(decorShape);
|
||||
const cornerX = box.x + box.w, cornerY = box.y + box.h;
|
||||
const decorPointer = (x, y) => ({
|
||||
pointerId: 1103, pointerType: 'mouse', shiftKey: false,
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
stopPropagation: () => undefined, preventDefault: () => undefined,
|
||||
currentTarget: null, target: null,
|
||||
});
|
||||
card._dtStart(decorPointer(cornerX, cornerY), 'scale', [1, 1]);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
card._dtMove(decorPointer(cornerX + index, cornerY + index / 2));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cancelDecorGesture();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
timings.editorSeriesMs = editorElapsed;
|
||||
interactionLongTasks.editorSeries = {
|
||||
supported: editorLongTaskWindows.every((window) => window.supported),
|
||||
count: editorLongTaskWindows.reduce((sum, window) => sum + window.count, 0),
|
||||
maxMs: Math.max(0, ...editorLongTaskWindows.map((window) => window.maxMs)),
|
||||
totalMs: editorLongTaskWindows.reduce((sum, window) => sum + window.totalMs, 0),
|
||||
};
|
||||
deltas.editorMoveFullRenders = editorMoveRenders;
|
||||
deltas.editorTerminalFullRenders = editorTerminalRenders;
|
||||
deltas.editorLivePaints = (card._liveEditorPaintCount || 0) - editorPaintBefore;
|
||||
deltas.editorConfigStable = JSON.stringify(card._serverCfg) === editorConfigBefore;
|
||||
deltas.editorWsWrites = wsCalls - editorCallsBefore;
|
||||
deltas.editorLongTaskParts = editorLongTaskWindows;
|
||||
|
||||
card._setMode('view', false);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
deltas.panMoveFullRenders = panMoveRenders;
|
||||
deltas.terminalFullRenders = panTerminalRenders;
|
||||
deltas.relevantDuringGestureFullRenders = relevantDuringGestureFullRenders;
|
||||
deltas.relevantDuringGestureIntakes = relevantDuringGestureIntakes;
|
||||
deltas.viewBoxMoved = !!initialViewBox && initialViewBox !== movedViewBox;
|
||||
deltas.overlayErrorPx = Number(overlayErrorPx.toFixed(2));
|
||||
deltas.heavyNodeStable = card.renderRoot.querySelector('.wallbodies') === heavy;
|
||||
deltas.diagnosticsScans = diagnosticsScanCount - diagnosticsBefore;
|
||||
deltas.fullRenderReasons = fullRenderReasons.slice(reasonsBefore);
|
||||
interactionTimings = timings;
|
||||
interactionDiagnostics = { supported: requiresInteraction, ...deltas };
|
||||
if (requiresInteraction && (
|
||||
deltas.irrelevantFullRenders !== 0 || deltas.irrelevantHassIntakes !== 30
|
||||
|| deltas.relevantOutsideGestureFullRenders !== 1 || deltas.hoverFullRenders !== 0
|
||||
|| deltas.hoverTargets.join(',') !== 'device,room,miss'
|
||||
|| deltas.panMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.terminalFullRenders.some((count) => count !== 1)
|
||||
|| deltas.relevantDuringGestureFullRenders !== 0 || !deltas.tooltipVisible
|
||||
|| deltas.relevantDuringGestureIntakes !== 3
|
||||
|| deltas.cameraMoveFullRenders !== 0 || deltas.cameraTerminalFullRenders !== 1
|
||||
|| deltas.editorMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.editorTerminalFullRenders.some((count) => count > 1)
|
||||
|| deltas.resizeLivePreflightChecks < 1
|
||||
|| deltas.editorLivePaints < 3 || !deltas.editorConfigStable || deltas.editorWsWrites !== 0
|
||||
|| !deltas.viewBoxMoved || deltas.overlayErrorPx > 1 || !deltas.heavyNodeStable
|
||||
|| deltas.diagnosticsScans !== 0
|
||||
)) throw new Error(`interaction structural contract failed: ${JSON.stringify(interactionDiagnostics)}`);
|
||||
}) : null;
|
||||
|
||||
let planSnapDiagnostics = null;
|
||||
const planSnapPointer = planSnap ? await duration(async () => {
|
||||
card._setMode('plan');
|
||||
@@ -432,6 +860,11 @@ try {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
...(interactionSeries ? {
|
||||
interactionSeriesMs: interactionSeries.ms,
|
||||
...interactionTimings,
|
||||
interactionDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -442,6 +875,7 @@ try {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
...(interactionSeries ? interactionLongTasks || {} : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -461,6 +895,7 @@ try {
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -474,6 +909,9 @@ const metricNames = [
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
if (interaction) metricNames.splice(2, 0,
|
||||
'interactionSeriesMs', 'hoverSeriesMs', 'panSeriesMs', 'cameraSeriesMs',
|
||||
'editorSeriesMs', 'irrelevantHaTicksMs', 'relevantHaTickMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/** #461 production-bundle wall-draw terminal-click performance profile. */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch, finish } from './serve.mjs';
|
||||
import {
|
||||
installWallDrawClickHarness, resetWallDrawClickHarness, runWallDrawClickChain,
|
||||
} from './wall-draw-click-harness.mjs';
|
||||
|
||||
const outputArg = process.argv.find((arg) => arg.startsWith('--output='));
|
||||
const output = outputArg ? resolve(outputArg.slice('--output='.length)) : null;
|
||||
const { page, browser } = await launch({ width: 1200, height: 900 }, 1);
|
||||
await installWallDrawClickHarness(page);
|
||||
|
||||
const run = async (remote) => {
|
||||
const fixture = await resetWallDrawClickHarness(page, remote);
|
||||
// One complete chain warms the browser/JIT; reset removes all of its state.
|
||||
await runWallDrawClickChain(page);
|
||||
await resetWallDrawClickHarness(page, remote);
|
||||
return { fixture, result: await runWallDrawClickChain(page) };
|
||||
};
|
||||
const base = await run(false);
|
||||
const remote = await run(true);
|
||||
const sorted = [...base.result.times].sort((a, b) => a - b);
|
||||
const medianMs = sorted[Math.floor(sorted.length / 2)];
|
||||
const maxMs = Math.max(...base.result.times);
|
||||
const remoteSorted = [...remote.result.times].sort((a, b) => a - b);
|
||||
const remoteMedianMs = remoteSorted[Math.floor(remoteSorted.length / 2)];
|
||||
|
||||
const clickShape = (click) => click.fullSpacePhysicalChecks === 0
|
||||
&& click.localPhysicalChecks === 1
|
||||
&& click.junctionArtifactPasses === 1
|
||||
&& click.configWrites === 1 && click.history === 1 && click.pathPoints === 1;
|
||||
const fixtureShape = base.fixture.spaces === 5 && base.fixture.rooms === 12
|
||||
&& base.fixture.positiveSegments >= 36 && base.fixture.savedDrafts === 4;
|
||||
const remoteFixtureShape = remote.fixture.positiveSegments === base.fixture.positiveSegments * 2;
|
||||
const structural = fixtureShape && remoteFixtureShape
|
||||
&& base.result.first.fullSpacePhysicalChecks === 0
|
||||
&& base.result.first.localPhysicalChecks === 0
|
||||
&& base.result.first.configWrites === 0 && base.result.first.history === 0
|
||||
&& base.result.clicks.length === 7 && base.result.clicks.every(clickShape)
|
||||
&& remote.result.clicks.every(clickShape)
|
||||
&& base.result.metrics.localProofMaxObjects < base.fixture.positiveSegments
|
||||
&& remote.result.metrics.localProofMaxObjects === base.result.metrics.localProofMaxObjects
|
||||
&& base.result.ids.length === 7 && new Set(base.result.ids).size === 7
|
||||
&& base.result.terminal.fullSpacePhysicalChecks >= 1
|
||||
&& base.result.terminalPartitionCount >= 1 && base.result.activeCleared;
|
||||
const budgets = {
|
||||
medianMs: 150,
|
||||
maxMs: 250,
|
||||
remoteMedianMs: medianMs * 1.5 + 20,
|
||||
};
|
||||
const timing = medianMs <= budgets.medianMs && maxMs <= budgets.maxMs
|
||||
&& remoteMedianMs <= budgets.remoteMedianMs;
|
||||
const report = {
|
||||
issue: 461, profile: 'wall-draw-click-v1',
|
||||
fixture: base.fixture, remoteFixture: remote.fixture,
|
||||
timing: {
|
||||
samplesMs: base.result.times.map((value) => Math.round(value * 10) / 10),
|
||||
medianMs: Math.round(medianMs * 10) / 10,
|
||||
maxMs: Math.round(maxMs * 10) / 10,
|
||||
remoteMedianMs: Math.round(remoteMedianMs * 10) / 10,
|
||||
},
|
||||
budgets: Object.fromEntries(Object.entries(budgets)
|
||||
.map(([key, value]) => [key, Math.round(value * 10) / 10])),
|
||||
counters: base.result.metrics,
|
||||
structural,
|
||||
timingPass: timing,
|
||||
pass: structural && timing,
|
||||
};
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,47 @@
|
||||
/** #54: bounded provider normalization and incident-only hover performance. */
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js';
|
||||
|
||||
const NODES = 500;
|
||||
const SAMPLES = 9;
|
||||
const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 };
|
||||
const hex = (index) => index.toString(16).padStart(16, '0');
|
||||
const rows = Array.from({ length: NODES }, (_, index) => ({
|
||||
ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
|
||||
neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => {
|
||||
const other = index + offset;
|
||||
return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : [];
|
||||
}),
|
||||
}));
|
||||
const registry = {
|
||||
revision: 1, authoritative: true, access: 'full', lastSuccess: Date.now(), entities: {},
|
||||
devices: Object.fromEntries(rows.map((_, index) => [`d${index}`, { id: `d${index}` }])),
|
||||
};
|
||||
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
|
||||
const devices = rows.map((_, index) => ({
|
||||
id: `m${index}`, name: '', model: '', area: '', space: index % 5 ? 'main' : 'remote',
|
||||
icon: '', entities: [], bindingKind: 'device', bindingRef: `d${index}`, bindingStatus: active,
|
||||
}));
|
||||
|
||||
const measured = (fn) => {
|
||||
const samples = [];
|
||||
for (let i = 0; i < SAMPLES; i++) {
|
||||
const started = performance.now(); fn(); samples.push(performance.now() - started);
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length * .9)];
|
||||
};
|
||||
|
||||
let topology; let mapped;
|
||||
const normalizeMs = measured(() => { topology = normalizeZhaTopology(rows); });
|
||||
const mapMs = measured(() => { mapped = mapTopologies([topology], devices, registry); });
|
||||
const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', 'm1'));
|
||||
const repeatedHoverMs = measured(() => {
|
||||
for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`);
|
||||
});
|
||||
const result = { nodes: topology.nodes.length, links: topology.links.length,
|
||||
normalizeMs, mapMs, firstHoverMs, repeatedHoverMs };
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
for (const [name, budget] of Object.entries(BUDGETS)) {
|
||||
if (result[name] > budget) throw new Error(`${name} ${result[name].toFixed(2)} ms > ${budget} ms`);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const CELL_CM = 5;
|
||||
const point = (x, y) => [x / 240, y / 240];
|
||||
|
||||
const roomAt = (id, x, y) => {
|
||||
const poly = [point(x, y), point(x + 24, y), point(x + 24, y + 24), point(x, y + 24)];
|
||||
const wallIds = poly.map((_, index) => `${id}-wall-${index}`);
|
||||
return {
|
||||
room: { id, name: id, area: null, poly, wall_ids: wallIds },
|
||||
segments: poly.map((a, index) => ({
|
||||
id: wallIds[index], a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
walls: poly.map((a, index) => ({
|
||||
key: `${id}-legacy-${index}`, a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const roomGrid = (prefix, xOffset = 0) => Array.from({ length: 12 }, (_, index) =>
|
||||
roomAt(`${prefix}-${index}`, xOffset + 8 + (index % 4) * 32, 8 + Math.floor(index / 4) * 32));
|
||||
|
||||
export const WALL_DRAW_CLICK_POINTS = [
|
||||
point(152, 144), point(176, 144), point(176, 168), point(200, 168),
|
||||
point(200, 144), point(224, 144), point(224, 168), point(236, 168),
|
||||
];
|
||||
|
||||
export function makeWallDrawClickFixture(remoteVariant = false) {
|
||||
const primary = roomGrid('base');
|
||||
const remote = remoteVariant ? roomGrid('remote', 300) : [];
|
||||
const all = [...primary, ...remote];
|
||||
const roomDrafts = Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `saved-draft-${index}`,
|
||||
points: [point(8 + index * 28, 112), point(20 + index * 28, 112), point(20 + index * 28, 124)],
|
||||
segments: [
|
||||
{ id: `saved-draft-${index}-0`, cm: 15 },
|
||||
{ id: `saved-draft-${index}-1`, cm: 15 },
|
||||
],
|
||||
}));
|
||||
const edited = {
|
||||
id: 'edited', title: 'Wall draw click', cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1],
|
||||
rooms: all.map((item) => item.room),
|
||||
wall_segments: all.flatMap((item) => item.segments),
|
||||
walls: all.flatMap((item) => item.walls),
|
||||
room_drafts: roomDrafts,
|
||||
partitions: [], wall_columns: [], openings: [], open_spans: [],
|
||||
};
|
||||
return {
|
||||
model_version: 9,
|
||||
spaces: [edited, ...Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `other-${index}`, title: `Other ${index}`, cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [], walls: [],
|
||||
room_drafts: [], partitions: [], wall_columns: [], openings: [], open_spans: [],
|
||||
}))],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
||||
goldenWitnessRefusal,
|
||||
} from '../../scripts/golden-acceptance.mjs';
|
||||
import {
|
||||
assertCaptureEnvironment, captureEnvironment, environmentNote,
|
||||
} from '../../scripts/capture-environment.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const reviewed = process.argv.includes('--reviewed');
|
||||
@@ -38,6 +41,16 @@ if (typeof report.chromium !== 'string' || !report.chromium)
|
||||
throw new Error('candidate report does not identify its Chromium build');
|
||||
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
|
||||
|
||||
// Среда приёмки (#455). Отчёт съёмки платформу не несёт — `run.mjs` входит в
|
||||
// корпус sourceFingerprint, и его правка объявила бы устаревшими бандл,
|
||||
// скриншоты документации и сам индекс эталонов. Но приёмка идёт там же, где
|
||||
// лежат артефакты, поэтому её платформа — честный признак среды кадров.
|
||||
const environment = captureEnvironment();
|
||||
const foreignAllowed = assertCaptureEnvironment({ kind: 'golden', stage: 'accept' });
|
||||
if (foreignAllowed) {
|
||||
console.log(`Чужая среда приёмки разрешена осознанно: ${foreignAllowed}`);
|
||||
}
|
||||
|
||||
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
|
||||
if (refusal) throw new Error(refusal);
|
||||
|
||||
@@ -72,7 +85,18 @@ const witnessCheck = goldenWitnessRefusal({
|
||||
skipWitnesses,
|
||||
skipReason,
|
||||
});
|
||||
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
|
||||
if (witnessCheck.refusal) {
|
||||
// Приписка про среду — то, чего не хватало отказу: «свидетелей 0 из 10» без
|
||||
// неё читается как «объяви больше сцен», и обход в одну команду выглядит
|
||||
// решением (#455).
|
||||
const note = environmentNote({
|
||||
capturedOn: environment.platform,
|
||||
acceptedOn: existsSync(manifestPath)
|
||||
? JSON.parse(readFileSync(manifestPath, 'utf8')).platform || null
|
||||
: null,
|
||||
});
|
||||
throw new Error(note ? `${witnessCheck.refusal}\n${note}` : witnessCheck.refusal);
|
||||
}
|
||||
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
|
||||
// не имеет права оставить каталог эталонов половинным.
|
||||
for (const scenario of GOLDEN_SCENARIOS) {
|
||||
@@ -102,6 +126,9 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
|
||||
acceptedAt: new Date().toISOString(),
|
||||
sourceFingerprint: report.buildFingerprint,
|
||||
chromium: report.chromium,
|
||||
// Платформа рядом с версией браузера: провенанс среды, по которому следующая
|
||||
// приёмка отличит «объявили не то» от «сняли не там» (#455).
|
||||
platform: environment.platform,
|
||||
// #355: след приёмки в артефакте, не только в истории shell.
|
||||
witnesses: skipWitnesses
|
||||
? { skipped: true, reason: skipReason }
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 54,
|
||||
"acceptedAt": "2026-09-03T06:25:02.855Z",
|
||||
"sourceFingerprint": "b69bdfe3df225cf1b96fea0159a03a620e38cb94ec6106f9ee3b0e57cdefd183",
|
||||
"matrixVersion": 55,
|
||||
"acceptedAt": "2026-09-05T15:05:00.040Z",
|
||||
"sourceFingerprint": "450863830ad6931b7698085deecbd39f6686a3e84f1b777008be5347426f4b21",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "linux",
|
||||
"witnesses": {
|
||||
"count": 132,
|
||||
"count": 117,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"version-mismatch-desktop-dark-en": "8efc4365de5e5f69dc864a36d87144c3a16a2a52f89ec6e5556fc7755f538fb6",
|
||||
"version-mismatch-touch-light-ru": "6fee87758c8373472b57542b8705595113ef07b92014ef6f5b977d4d1aa19808",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "d3d0863fe814d20f5b081de6ac14f8bbf7d559be019808e62cd6dcdc7abcaa70",
|
||||
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
|
||||
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
|
||||
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
|
||||
@@ -66,9 +70,9 @@
|
||||
"opening-symbol-flip-pairs-light": "c2d9102921483da1d0216ed9bc55fc9112d67762f159df8af64a277f0db0acf4",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "7c4072fce11f91b2c894bdb5f4f9113e72089306d629c995d38dfd17f2b65cc8",
|
||||
"device-inbox-desktop-en-light": "89587944da06872f610da33e8e39441d108cb34849d8b09d4925bff4d0977565",
|
||||
"device-inbox-desktop-ru-dark": "fe9506408fc198c167f2d4b9cfe91bd9fdfb722b311772457ae44c9d06248680",
|
||||
"device-inbox-narrow-ru-dark": "3ddb3b66a613c6406d3d9ce2d4d479c8d84eff04acd9b5b2dce02c4c777fc738",
|
||||
"device-inbox-desktop-en-light": "a73761da0594ba2138f098d971d74566f9915f59a563fd55295add241145ba36",
|
||||
"device-inbox-desktop-ru-dark": "5a9eb32ffb7f408c6bf8adca9d76b0d01d7b0b514f8b33a01e500f9230541f1a",
|
||||
"device-inbox-narrow-ru-dark": "65c46b7311f850f305147d75a24ddeded67c9e8b18ea362c88b9964c50a5b727",
|
||||
"geometry-decor-editor-dark": "4023e36ff4521011153983538e4a43ad80efd92bf07d523c333982bbd2f80ad6",
|
||||
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
|
||||
"tray-wide-tool-ru": "56c09bb397b76b7e5c6f4d7eaf7ab59e5d96faa409e553c45c6ce7605e9a9418",
|
||||
@@ -133,33 +137,33 @@
|
||||
"large-house-zoom-040-dark": "5601d5ce3b1fe79365984301872a851d6c2e81878ec6f6cc3b88aab811d8609a",
|
||||
"large-house-zoom-250-dark": "7abb72baba00ed035d9f7519b99e1d9f872a6cef81861ed096a511fe9320471f",
|
||||
"large-house-warm-remount-dark": "441c32f130b8f291582482fa6445f9c85f6f13c61b956020d4fa1d6153094efa",
|
||||
"device-dialog-desktop-en": "465175fc9960c7ceb5208cf62f919381d56179ef12a93bee20e5c7af63757e7a",
|
||||
"device-dialog-mobile-ru": "8128783f4e9f9ff301f15db9fa7388b51aa7e8a4a74ac0aaf962b21ecda52c78",
|
||||
"device-dialog-desktop-de": "8440d4e980fac5df53142b6b118e527d9c0272e7515b65faaba6a58bc9441075",
|
||||
"device-dialog-desktop-en": "f3a3a45dad2cc22e692366114a4f87272b39ba261c1509e8a501dbfb82b6584e",
|
||||
"device-dialog-mobile-ru": "7ac796286ab4e65275838b4095ea3f16491311f99fd98cad3bbb475c6061e31c",
|
||||
"device-dialog-desktop-de": "9d39d3e73a3f36171e8cc7c930bcb43bfbfd788e31db0f584962300a2b00ca93",
|
||||
"german-view-mobile-light": "55112787d9a26be66dfd8b724115962c4b891d1f8276b7b5b36b0ff25da48257",
|
||||
"toggle-entity-dialog-desktop-en": "7a8ac3071d8bb3f79f2bfe724d0138e2b309baab3104ef8afb0b2b081c8b82b1",
|
||||
"toggle-entity-dialog-mobile-ru": "d3062e76da27218cac45ca4c76f34b358d9cb98e85288f24e64b7de1bcca949d",
|
||||
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
|
||||
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
|
||||
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
|
||||
"general-color-popover-desktop-en": "5b47ab921f889c9ad41cab195e6efeed283f014d37f5377714b54ef30282ebf6",
|
||||
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
|
||||
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
|
||||
"support-desktop-empty-light-en": "fecabb63521f258acc66ecdf7a438db10c73d0eac39618c55fb51287080f01f5",
|
||||
"support-desktop-preview-dark-en": "a866aa74e3385ac45e837b328d64ed65bc4b108eb9dc4149fcccf393975e72b6",
|
||||
"support-phone-validation-light-ru": "7a461c00631a05872b9aab1f4c0b0b65d1c918273ff06bba25c7bd2e87fba1ca",
|
||||
"support-phone-success-dark-en": "81f83402933a573002581646d0b322f3770f3df41d5334373315ed5ad4974883",
|
||||
"support-relay-error-light-en": "e9a9ad906e106eedfa6e84c592325f1604eb64c89c51135c076ac87a6a0766b2",
|
||||
"support-tablet-preview-dark-ru": "1264e005cd764408edb9b285a5dfbe92ceb90cb925f624b8f65cd47edb96c0c9",
|
||||
"device-ripple-color-popover-mobile-ru": "d9ebf83ad0d2995b46f6174daf6e21ca5404952afa308ddd3a66ecefcee565d0",
|
||||
"space-room-color-popover-desktop-ru": "9bbbcf9c5ea6ff5490b500be8cb3086af9d368d14ca50c304f42a697e296ece1",
|
||||
"backup-full-preview-desktop-en": "0fbd23a92121668001faf5c9ed555a31107704f3074d1791709c8436517d3d1f",
|
||||
"backup-plan-only-export-desktop-en": "ce66fb57edbc87d56355b0d73a2d4203a67c275d5927d400ef1758ac0b607b34",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "101e1a97c6229c693a4edfb4878c6a4564132d6fbcaddd6276cb25aefa429260",
|
||||
"optimize-preflight-dialog-light-ru": "4f34a59baa95b7ad0ad3dbb47f23996537878f6b666dc9111236713f513de97e",
|
||||
"optimize-orphan-references-dark-en": "c8122eda61a52ead0f3609b9872b513004ffbc44c1d4928ec11e5d3b5978af78",
|
||||
"optimize-orphan-references-light-ru": "3cc0b16af015cf695f8bd278dee4d76b160639021a33bc3a28441fb72a5e8e5c",
|
||||
"toggle-entity-dialog-desktop-en": "3ea2bb828b554b6fb544b77fdef2af947e45af632769f4deab03c6adf195fa68",
|
||||
"toggle-entity-dialog-mobile-ru": "2d433f3f1fab9e9d4c3d413c302ac0d3b44a2d46ec9ecbf098c3063da6f1df04",
|
||||
"device-help-popover-light-ru": "b8cb39c77690bb2a2a150a2a77af55411b3f2053741eee86cd5fd19b41b4307f",
|
||||
"decor-color-popover-mobile-ru": "c63b72493040fefdbcf55ce5325630a321b171f62962a345caa89ec5fa837f55",
|
||||
"decor-color-popover-desktop-en": "49bc8317d452fbc9e7e95f7c139423838a5ecde763b1fe328c7b1be6ca20ed98",
|
||||
"general-color-popover-desktop-en": "4b3cd5b7fb405b912cb3506680151b0237b803499bbfbad597c20d3fabc869a7",
|
||||
"settings-help-zoom-200-en-light": "a6f49b4d0a171dc5f74d409c6e7df0e1c57bc52086b3a66dd794efde1236eef8",
|
||||
"settings-help-zoom-200-ru-dark": "0b1f995abec3b14cd5b25e0c0fb0a8d04d08567b0b3e5717463059efa244296d",
|
||||
"support-desktop-empty-light-en": "04d968e28c85096f6eb637f9ae01c2bae86c6d1540a9b3bfb83f0f4992127b4e",
|
||||
"support-desktop-preview-dark-en": "dd3502ce1584109a837ba80f5683ddd5a1ea2063150217121f6e0b8832a0a659",
|
||||
"support-phone-validation-light-ru": "37059c99431d6890d1f1a66444c7e853e51387655cd1ee6474063299281f2b1f",
|
||||
"support-phone-success-dark-en": "024e72048558561b66f13d635fc22e1e3d289d0b39499e6595dc986761daa31f",
|
||||
"support-relay-error-light-en": "ab769c512f94a1dea22f36f57a36796bc3b5bc3ec788f4ca52bfccbc7fc25ab0",
|
||||
"support-tablet-preview-dark-ru": "416604355b3785aecda02f1ad21d031ce4c977180ed8606937b6d189b0afb7b1",
|
||||
"device-ripple-color-popover-mobile-ru": "5b7ff2f212a1731d0d25f57a385fe594b32af8ba3ae0183c40fb285addf9f832",
|
||||
"space-room-color-popover-desktop-ru": "137deea402c850207bd6e217524359757ca13d9783c7728419e89260b81bcc75",
|
||||
"backup-full-preview-desktop-en": "fc3ac9ec9eae728f2524131f3f9e3a55fa4afce43f440ee6f7b04bd3b78e66df",
|
||||
"backup-plan-only-export-desktop-en": "b000db624416d90a8d9e658ec602d278f2bc8df63af2e8eb9562bc2fbff24ed8",
|
||||
"backup-space-preview-mobile-ru": "4575ba8783647518abbbe9903fd915ec0d3caceaa08a70fa70cf8bf8e7b08356",
|
||||
"optimize-preflight-dialog-dark-en": "0e140653af81cf8a965fee00d6f5ce7d0b49c22d329f5237e96e6ba1829252b4",
|
||||
"optimize-preflight-dialog-light-ru": "1fc545b7017c6ae3b0a2b0e1330b037dc0e565113c5892d48be64ca30f34abf6",
|
||||
"optimize-orphan-references-dark-en": "9611469c4d4e542b77abcc52c7c9484237aa18ff58994926be66514a377564f4",
|
||||
"optimize-orphan-references-light-ru": "7ea732b04dfd7aca25107935576326975b3cc7cf5757d844d809ceb4cef0a245",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -35,6 +35,7 @@ const wallUnionIsolationFixture = JSON.parse(readFileSync(
|
||||
const cardVersion = JSON.parse(readFileSync(
|
||||
new URL('../../package.json', import.meta.url), 'utf8',
|
||||
)).version;
|
||||
const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
? makeLargeHouseFixture()
|
||||
@@ -56,7 +57,7 @@ const themeVars = {
|
||||
async function stableEnvironment(page, scenario) {
|
||||
await page.setViewportSize(scenario.viewport);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: scenario.theme });
|
||||
await page.evaluate(({ variables, theme }) => {
|
||||
await page.evaluate(({ variables, theme, attemptedTarget, attemptKey }) => {
|
||||
let style = document.getElementById('hp-golden-stability');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
@@ -82,7 +83,15 @@ async function stableEnvironment(page, scenario) {
|
||||
'--hp-golden-page-bg', theme === 'light' ? '#eef1f4' : '#11151b',
|
||||
);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
|
||||
sessionStorage.removeItem(attemptKey);
|
||||
if (attemptedTarget) sessionStorage.setItem(attemptKey, attemptedTarget);
|
||||
}, {
|
||||
variables: themeVars[scenario.theme] || themeVars.dark,
|
||||
theme: scenario.theme,
|
||||
attemptedTarget: scenario.versionRecoveryAttempted
|
||||
? String(scenario.integrationVersion || '').trim() : null,
|
||||
attemptKey: VERSION_RELOAD_ATTEMPT_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
@@ -736,7 +745,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await page.mouse.move(0, 0);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
|
||||
const result = await page.evaluate(async ({ fixture, scenario, cardVersion }) => {
|
||||
const result = await page.evaluate(async ({ fixture, scenario, cardVersion, attemptKey }) => {
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -760,10 +769,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
window.__goldenEditor?.remove?.();
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
history.replaceState(null, '', scenario.labs?.length
|
||||
? `?hp-labs=${encodeURIComponent(scenario.labs.join(','))}` : location.pathname);
|
||||
if (scenario.labs?.length) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(scenario.labs));
|
||||
history.replaceState(null, '', scenario.alpha ? '?hp_alpha=1' : location.pathname);
|
||||
if (scenario.alpha) {
|
||||
localStorage.setItem('houseplan_card_alpha_v1', '1');
|
||||
}
|
||||
if (scenario.projection && scenario.space) {
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
|
||||
@@ -788,8 +796,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/config/get') return {
|
||||
config: structuredClone(fixture.config), rev: 1, can_write: true,
|
||||
...(Object.prototype.hasOwnProperty.call(scenario, 'integrationVersion')
|
||||
? { integration_version: scenario.integrationVersion } : {}),
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout || {}), rev: 1 };
|
||||
if (message.type === 'houseplan/trail/get') return scenario.vacuumTrail ? {
|
||||
@@ -818,21 +829,16 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig(cardConfig);
|
||||
host.replaceChildren(card);
|
||||
if (scenario.testOnlyLabsSnapshot) {
|
||||
if (!scenario.labs?.length || typeof card._onLabsSnapshot !== 'function') {
|
||||
throw new Error(`invalid test-only Labs contract: ${scenario.id}`);
|
||||
}
|
||||
// connectedCallback has already received the real (expired) registry
|
||||
// snapshot. Inject only the renderer fixture before hass/model boot so
|
||||
// fit and warm-remount follow the former live-Labs lifecycle exactly.
|
||||
card._onLabsSnapshot({ active: Object.freeze([...scenario.labs]), space: '' });
|
||||
}
|
||||
card.hass = hassFor();
|
||||
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
|
||||
await card.updateComplete;
|
||||
const expectedDevices = Object.keys(fixture.devices || {}).length;
|
||||
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
|
||||
await until(() => card._booting === false);
|
||||
if (scenario.alpha
|
||||
&& (window.__hpAlpha !== true || JSON.stringify(window.__hpLabs) !== '["iso"]')) {
|
||||
throw new Error(`alpha contract did not activate isometric capability: ${scenario.id}`);
|
||||
}
|
||||
// Golden scenarios intentionally call internal editor commands directly.
|
||||
// Preload the lazy runtime for that legacy harness contract; cold-View
|
||||
// loading and retry semantics are covered by smoke_lazy_editor_chunk.
|
||||
@@ -859,6 +865,21 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(scenario, 'integrationVersion')) {
|
||||
const target = String(scenario.integrationVersion || '').trim();
|
||||
await until(() => card._versionRecovery?.relation?.kind === 'mismatch'
|
||||
&& card.renderRoot.querySelector('.version-recovery'));
|
||||
const notice = card._versionRecovery.banner;
|
||||
const rendered = card.renderRoot.querySelector('.version-recovery');
|
||||
if (!target || notice?.backend !== target
|
||||
|| rendered?.getAttribute('data-version-recovery-target') !== target) {
|
||||
throw new Error(`golden version notice target mismatch: ${scenario.id}`);
|
||||
}
|
||||
if (scenario.versionRecoveryAttempted
|
||||
&& sessionStorage.getItem(attemptKey) !== target) {
|
||||
throw new Error(`golden kiosk attempted target missing: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
// #304: the two existing hidden-wall golden scenes also guard the static
|
||||
// architectural overlay in every Plan tool. Finish in draw so the reviewed
|
||||
// pixels stay stable; a missing/changed tool projection fails before capture.
|
||||
@@ -1066,6 +1087,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (card._effectiveProjection?.() !== 'iso') {
|
||||
throw new Error(`isometric golden rendered Flat: ${scenario.id}`);
|
||||
}
|
||||
await until(() => card._renderProjection === 'iso');
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
@@ -1809,7 +1833,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
cachedRays: card._sunRaysCache?.rays?.length || 0,
|
||||
} } : {}),
|
||||
};
|
||||
}, { fixture, scenario, cardVersion });
|
||||
}, { fixture, scenario, cardVersion, attemptKey: VERSION_RELOAD_ATTEMPT_KEY });
|
||||
if (scenario.tabDrag) {
|
||||
const drag = await page.evaluate((placement) => {
|
||||
const card = window.__goldenCard;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 54;
|
||||
export const GOLDEN_MATRIX_VERSION = 55;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
|
||||
// The product flag expires at 1.65.0 by contract. Golden keeps the dormant
|
||||
// renderer covered through an explicit card-only snapshot; this never changes
|
||||
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
|
||||
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
|
||||
|
||||
const openingRoomContract = {
|
||||
kind: 'room', wallCm: 30, surface: 'flat',
|
||||
openings: [
|
||||
@@ -165,6 +160,19 @@ const decorLayerProbes = {
|
||||
};
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
// #462 AC12: the card-level version recovery notice must stay compact,
|
||||
// readable and outside layout/fit flow on desktop, narrow touch and the
|
||||
// manual-only kiosk state after this tab already attempted the target.
|
||||
{ id: 'version-mismatch-desktop-dark-en', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', integrationVersion: '0.0.0-golden-backend',
|
||||
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'version-mismatch-touch-light-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', integrationVersion: '0.0.0-golden-backend', touchViewport: true,
|
||||
language: 'ru', theme: 'light', viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'version-mismatch-kiosk-attempted-dark-de', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', integrationVersion: '0.0.0-golden-backend',
|
||||
versionRecoveryAttempted: true, kiosk: true,
|
||||
language: 'de', theme: 'dark', viewport: { width: 820, height: 760 }, ...page },
|
||||
{ id: 'hidden-wall-diagnostics-plan-light', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'before',
|
||||
hiddenWallDiagnostics: true, mode: 'plan', theme: 'light',
|
||||
@@ -200,23 +208,23 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
cornerSplitWall: 'zero-taper', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, showBorders: false, fillMode: 'custom',
|
||||
alpha: true, projection: 'iso', showBorders: false, fillMode: 'custom',
|
||||
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, kiosk: true,
|
||||
alpha: true, projection: 'iso', kiosk: true,
|
||||
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
|
||||
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, warmRemount: true,
|
||||
alpha: true, projection: 'iso', warmRemount: true,
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -348,7 +356,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
@@ -375,7 +383,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
mode: 'view', openingSymbolContract: openingFlipContract,
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-opening-symbol-parity-dark', fixture: 'visual', space: 'golden-opening-symbols',
|
||||
mode: 'view', labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
mode: 'view', alpha: true, projection: 'iso',
|
||||
openingSymbolContract: openingIsoContract,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import {
|
||||
captureEnvironment, foreignCaptureAllowance, foreignCaptureRefusal,
|
||||
} from '../../scripts/capture-environment.mjs';
|
||||
|
||||
// The name must NOT end with `manifest.json`: the HACS submission check globs
|
||||
// `*manifest.json` over the whole clone of the default branch and refuses a
|
||||
// repository with more than one match (test/repo-hygiene.test.mjs).
|
||||
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
|
||||
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
|
||||
/**
|
||||
* Проверка вызова — и заодно проверка среды съёмки (#455).
|
||||
*
|
||||
* Почему здесь, а не в `run.mjs`: `run.mjs` входит в корпус
|
||||
* `sourceFingerprint`, и его правка объявляет устаревшими закоммиченный бандл,
|
||||
* манифест скриншотов документации и индекс эталонов. Этот файл из корпуса
|
||||
* исключён именно как «предикаты, вызываемые до и после съёмки», и уже
|
||||
* вызывается первой строкой `run.mjs` — гейт встаёт без единой правки
|
||||
* фингерпринтуемого файла.
|
||||
*
|
||||
* Платформа — ПАРАМЕТР, а не `process.platform` внутри: иначе юнит стал бы
|
||||
* зелёным на Linux и красным на машине владельца, то есть тестом про хост, а
|
||||
* не про правило.
|
||||
*/
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '', options = {}) => {
|
||||
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
|
||||
if (mode === 'verify' && scenarioFilter)
|
||||
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
|
||||
// Диагностический прогон verify в чужой среде законен: он ничего не принимает.
|
||||
if (mode !== 'capture') return;
|
||||
const platform = options.platform ?? captureEnvironment().platform;
|
||||
const allowance = options.allowance ?? foreignCaptureAllowance();
|
||||
const { refusal, allowance: accepted } = foreignCaptureRefusal({
|
||||
platform, kind: 'golden', stage: 'capture', allowance,
|
||||
});
|
||||
if (refusal) throw new Error(refusal);
|
||||
if (accepted) console.log(`Чужая среда съёмки разрешена осознанно: ${accepted}`);
|
||||
};
|
||||
|
||||
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
|
||||
40 columns and 500 decor objects on three floors.
|
||||
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. Because the
|
||||
product flag expires at 1.65.0, the runner injects an explicit test-only Labs
|
||||
snapshot, selects iso per fixture space, measures the View toggle and records
|
||||
the capped `isoGeometry` cache. A comparison SHA predating #89 remains flat
|
||||
while reporting the same profile. The dedicated
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. The runner
|
||||
enables the current candidate through the real `hp_alpha=1` URL/storage
|
||||
contract, selects iso per fixture space, measures the View toggle and records
|
||||
the capped `isoGeometry` cache. A compatibility-only snapshot keeps comparison
|
||||
bundles from before #448 measurable without restoring a legacy user-facing
|
||||
activation path; a comparison SHA predating #89 remains flat while reporting
|
||||
the same profile. The dedicated
|
||||
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
|
||||
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
|
||||
evidence; a local report is diagnostic.
|
||||
@@ -23,6 +25,24 @@ websocket traffic change. Its dedicated budget retains every original timing,
|
||||
heap and cache ceiling and adds the measured pointer series plus a one-entry
|
||||
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
|
||||
|
||||
Issue #451 adds `large-house-interaction-v1`. It dispatches 120 real
|
||||
device/room/free-background hover moves, four 20-move pans, pinch/wheel camera input, three editor
|
||||
drag series and 30 unrelated HA snapshots. It also injects relevant snapshots
|
||||
inside and outside a gesture and counts heavy `_renderBody()` passes plus full
|
||||
marker-binding diagnostic scans. Current bundles fail inside the runner unless
|
||||
hover, editor moves and unrelated ticks perform zero full renders, a relevant
|
||||
tick is deferred during pan, each terminal event reconciles at most one full
|
||||
frame, the heavy wall DOM remains identical, and the projected HTML device
|
||||
layer stays within one CSS pixel of its SVG scene position. Older comparison
|
||||
bundles still produce timing baselines; structural assertions become mandatory
|
||||
only when the target source contains the live viewport implementation. Its
|
||||
dedicated budget preserves every existing large-house ceiling and adds the
|
||||
individual interaction timing gates.
|
||||
|
||||
Structural assertions are evaluated independently of timing budgets: a fast
|
||||
run still fails when an interaction performs an unexpected full render or
|
||||
changes the heavy scene DOM.
|
||||
|
||||
The runner records seven measured samples after one discarded warm-up. With
|
||||
this intentionally small CI sample, the nearest-rank `p95` is the observed
|
||||
maximum; reports keep the conventional field name but should be read as a
|
||||
@@ -119,11 +139,33 @@ it would make the same runner incompatible with its base bundle.
|
||||
|
||||
## Local diagnostics
|
||||
|
||||
### Wall draw terminal clicks
|
||||
|
||||
`wall-draw-click-v1` (#461) loads the production bundle with five spaces. The
|
||||
edited space contains 12 rooms, 48 positive-thickness wall atoms and four saved
|
||||
open drafts, then places seven consecutive Walls segments after warm-up. A
|
||||
second variant doubles remote, non-interacting room geometry. The runner fails
|
||||
structurally unless every intermediate click performs zero full-space physical
|
||||
checks, exactly one local physical check, reuses its wall artifact in the
|
||||
junction proof, adds one history entry and queues one full-config write. The
|
||||
chain finish must independently return to the full-space barrier.
|
||||
|
||||
The canonical Linux ceilings are 150 ms median and 250 ms maximum for the seven
|
||||
base clicks; the remote median may not exceed `base × 1.5 + 20 ms`. Counters are
|
||||
the primary verdict, so a fast machine cannot hide a route back through the
|
||||
generic barrier. Run against a freshly synchronized bundle:
|
||||
|
||||
```bash
|
||||
npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
|
||||
node demo/smoke_wall_draw_click.mjs
|
||||
```
|
||||
|
||||
Build and copy a fresh demo bundle first, then run:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
|
||||
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json
|
||||
```
|
||||
|
||||
A local report is diagnostic only; it cannot replace the CI comparison.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-interaction-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 200,
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"interactionSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"hoverSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"panSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"cameraSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"editorSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 750
|
||||
},
|
||||
"irrelevantHaTicksMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 40,
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"relevantHaTickMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 40,
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.4,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 2000
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.35,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 7000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxSingleRegressionRatio": 0.3,
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.35,
|
||||
"countNoiseAllowance": 3,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 150
|
||||
},
|
||||
"longTaskWindows": {
|
||||
"hoverSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"panSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"cameraSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"editorSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 }
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.75,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1,
|
||||
"wallFaceGraph": 4
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0,
|
||||
"wallFaceGraph": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -17,22 +17,41 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
label: 'large-house-v1',
|
||||
methods: Object.freeze([
|
||||
'_baseVb',
|
||||
'_bindingStatus',
|
||||
'_cancelDecorGesture',
|
||||
'_checkSpacePhysicalGeometry',
|
||||
'_decorBoxOf',
|
||||
'_dtMeasure',
|
||||
'_dtMove',
|
||||
'_dtStart',
|
||||
'_openSettingsDialog',
|
||||
'_pickSpace',
|
||||
'_pos',
|
||||
'_renderBody',
|
||||
'_rszCancelDrag',
|
||||
'_rszEdgeDown',
|
||||
'_rszMove',
|
||||
'_rszRooms',
|
||||
'_setMode',
|
||||
'_scenePoint',
|
||||
'_viewOr',
|
||||
]),
|
||||
fields: Object.freeze([
|
||||
'_booting',
|
||||
'_bootSoft',
|
||||
'_cameraTransition',
|
||||
...CACHE_FIELDS,
|
||||
'_continuity',
|
||||
'_cursorPt',
|
||||
'_decorList',
|
||||
'_decorSel',
|
||||
'_decorTool',
|
||||
'_devices',
|
||||
'_gridPitch',
|
||||
'_hassSequence',
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_modeTransitionBusy',
|
||||
'_path',
|
||||
'_serverCfg',
|
||||
'_settingsDialog',
|
||||
@@ -42,7 +61,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
// checks these members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze([
|
||||
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
|
||||
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_liveEditorPaintCount', '_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_wallFaceBatch', '_wallFaceGraphCache',
|
||||
]),
|
||||
// #380: v1.68.1 owns the same resize session directly on the card; newer
|
||||
@@ -54,12 +73,19 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
]),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_bootSoft: 'boolean',
|
||||
_cameraTransition: 'object',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_continuity: 'object',
|
||||
_decorList: 'array',
|
||||
_decorTool: 'string',
|
||||
_draftSegmentCms: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_hassSequence: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_liveEditorPaintCount: 'number',
|
||||
_model: 'array',
|
||||
_onLabsSnapshot: 'function',
|
||||
_offerWallFaces: 'function',
|
||||
|
||||
@@ -143,6 +143,20 @@ export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absolu
|
||||
},
|
||||
));
|
||||
|
||||
for (const [windowName, budget] of Object.entries(budgets.longTaskWindows ?? {})) {
|
||||
const windows = candidate.rows.map((row) => row.longTasks?.[windowName]);
|
||||
const available = windows.every((window) => window?.supported === true);
|
||||
checks.push({
|
||||
id: `longTask.${windowName}.available`, actual: available ? 1 : 0, limit: 1, pass: available,
|
||||
});
|
||||
const maxSingle = available ? Math.max(...windows.map((window) => window.maxMs)) : NaN;
|
||||
const countP95 = available ? percentile(windows.map((window) => window.count), 0.95) : NaN;
|
||||
const totalP95 = available ? percentile(windows.map((window) => window.totalMs), 0.95) : NaN;
|
||||
checks.push(makeCheck(`longTask.${windowName}.maxSingleMs`, maxSingle, budget.maxSingleMs));
|
||||
checks.push(makeCheck(`longTask.${windowName}.countP95`, countP95, budget.maxCountP95));
|
||||
checks.push(makeCheck(`longTask.${windowName}.totalP95Ms`, totalP95, budget.maxTotalP95Ms));
|
||||
}
|
||||
|
||||
const candidateHeap = candidate.rows
|
||||
.map((row) => row.heapGrowthBytes)
|
||||
.filter(finite)
|
||||
|
||||
@@ -250,15 +250,18 @@ const res = await page.evaluate(async () => {
|
||||
await wait(100);
|
||||
const coldCalls = [];
|
||||
let coldRev = 40;
|
||||
let releaseColdConfig;
|
||||
const coldConfigGate = new Promise((resolve) => { releaseColdConfig = resolve; });
|
||||
const coldHass = window.__mkHass();
|
||||
const coldBaseCallWS = coldHass.callWS;
|
||||
coldHass.callWS = async (message) => {
|
||||
coldCalls.push(structuredClone(message));
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
await coldConfigGate;
|
||||
return { config: coldConfig, rev: coldRev, can_write: true };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') {
|
||||
return { layout: coldLayout, rev: coldRev };
|
||||
return { layout: structuredClone(coldLayout), rev: coldRev };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/delete') {
|
||||
delete coldLayout[message.device_id];
|
||||
@@ -274,6 +277,12 @@ const res = await page.evaluate(async () => {
|
||||
coldCard.setConfig({ type: 'custom:houseplan-card', title: 'Cold Area backfill' });
|
||||
document.body.append(coldCard);
|
||||
coldCard.hass = coldHass;
|
||||
await wait(100);
|
||||
const coldCacheStayedReadOnly = !coldCalls.some((message) =>
|
||||
message.type === 'houseplan/config/set'
|
||||
|| ['houseplan/layout/set', 'houseplan/layout/update', 'houseplan/layout/delete']
|
||||
.includes(message.type));
|
||||
releaseColdConfig();
|
||||
for (let attempt = 0; attempt < 20 && !coldCard._loadedOnce; attempt += 1) await wait(100);
|
||||
await wait(450);
|
||||
await paint(coldCard);
|
||||
@@ -525,6 +534,7 @@ const res = await page.evaluate(async () => {
|
||||
&& compositeDeletesAfter === compositeDeletesBefore
|
||||
&& c._newIds.has(compositeId) === compositeAttentionBefore
|
||||
&& !c._areaRelocationIds.has(compositeId),
|
||||
coldCacheStayedReadOnly,
|
||||
coldStartBackfilled,
|
||||
staticReadOnlyProjection: staticCard._areaRelocationIds.has('d_leak')
|
||||
&& staticLeft > 50 && !!staticEl?.querySelector('.newdot'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,15 +65,20 @@ const warmGate = await page.evaluate(async () => {
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
await settle();
|
||||
const warmDecision = card._confirmDanger({
|
||||
key: 'warm-language-gate',
|
||||
kind: 'destructive',
|
||||
title: 'Delete?',
|
||||
message: 'The plan and all its rooms will be deleted.',
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
});
|
||||
// Refusal is a synchronous registration barrier, not eventual cleanup by
|
||||
// the next warm render. A briefly pending request has no decision surface.
|
||||
const warmRequestWasNeverRegistered = card._dangerConfirm === null
|
||||
&& card._dangerConfirmController.state === null;
|
||||
const decision = await Promise.race([
|
||||
card._confirmDanger({
|
||||
key: 'warm-language-gate',
|
||||
kind: 'destructive',
|
||||
title: 'Delete?',
|
||||
message: 'The plan and all its rooms will be deleted.',
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
}),
|
||||
warmDecision,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
const result = {
|
||||
@@ -81,6 +86,7 @@ const warmGate = await page.evaluate(async () => {
|
||||
readyConfirmationOpenedBeforeWarm: openedWhileReady,
|
||||
readyToWarmCancelsOpenConfirmation: cancelledOnTransition === false,
|
||||
readyToWarmKeepsCommittedBody: root().querySelector('ha-card')?.outerHTML === committedBody,
|
||||
warmLanguageGateNeverRegisters: warmRequestWasNeverRegistered,
|
||||
warmLanguageGateRefusesImmediately: decision === false,
|
||||
warmLanguageGateKeepsControllerEmpty: card._dangerConfirm === null
|
||||
&& card._dangerConfirmController.state === null,
|
||||
@@ -194,6 +200,28 @@ const out = await page.evaluate(async () => {
|
||||
}));
|
||||
await settle();
|
||||
};
|
||||
const boundedDangerDecision = async (decision, timeoutMs = 250) => {
|
||||
let timeoutId;
|
||||
const outcome = await Promise.race([
|
||||
decision.then((value) => ({ settled: true, value })),
|
||||
new Promise((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve({ settled: false, value: 'timeout' }), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timeoutId);
|
||||
if (!outcome.settled) {
|
||||
// A missing or dead decision surface must fail the smoke quickly instead
|
||||
// of consuming the whole mutation shard. Cancelling also keeps later
|
||||
// branches independent from the timed-out controller state.
|
||||
card._cancelDangerConfirm();
|
||||
await Promise.race([
|
||||
decision,
|
||||
new Promise((resolve) => setTimeout(resolve, 100)),
|
||||
]);
|
||||
await settle();
|
||||
}
|
||||
return outcome;
|
||||
};
|
||||
|
||||
await settle();
|
||||
|
||||
@@ -213,7 +241,9 @@ const out = await page.evaluate(async () => {
|
||||
await settle();
|
||||
result.mainBranchRendersExactlyOneConfirm = dialogs() === 1;
|
||||
card._cancelDangerConfirm();
|
||||
result.mainBranchCancelResolvesFalse = (await single) === false;
|
||||
const singleDecision = await boundedDangerDecision(single);
|
||||
result.mainBranchCancelResolvesFalse = singleDecision.settled
|
||||
&& singleDecision.value === false;
|
||||
|
||||
const savedCfg = card._serverCfg;
|
||||
const savedModel = card._model;
|
||||
@@ -240,7 +270,9 @@ const out = await page.evaluate(async () => {
|
||||
await settle();
|
||||
result.onboardingBranchShowsConfirm = dialogs() === 1;
|
||||
await decide(false);
|
||||
result.onboardingBranchResolves = (await onboarding) === false;
|
||||
const onboardingDecision = await boundedDangerDecision(onboarding);
|
||||
result.onboardingBranchResolves = onboardingDecision.settled
|
||||
&& onboardingDecision.value === false;
|
||||
await leaveBranch();
|
||||
|
||||
// Открытое подтверждение переживает смену ветки: раньше оно исчезало вместе
|
||||
@@ -251,8 +283,9 @@ const out = await page.evaluate(async () => {
|
||||
await enterBranch([]);
|
||||
const stillOpenInOnboarding = dialogs() === 1;
|
||||
await decide(true);
|
||||
const survivorDecision = await boundedDangerDecision(survivor);
|
||||
result.openConfirmSurvivesBranchChange = openedInMainBranch && stillOpenInOnboarding
|
||||
&& (await survivor) === true;
|
||||
&& survivorDecision.settled && survivorDecision.value === true;
|
||||
await leaveBranch();
|
||||
|
||||
// The exact missed branch from #417: a non-empty model whose active space
|
||||
@@ -265,22 +298,17 @@ const out = await page.evaluate(async () => {
|
||||
card._spaceModel = () => undefined;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
const spaceLossDecision = await Promise.race([
|
||||
pendingAtSpaceLoss,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
const spaceLossDecision = await boundedDangerDecision(pendingAtSpaceLoss);
|
||||
result.lostSpaceBranchIsActuallyEntered = root().childElementCount === 0;
|
||||
result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision === false
|
||||
result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision.settled
|
||||
&& spaceLossDecision.value === false
|
||||
&& card._dangerConfirm === null && card._dangerConfirmController.state === null;
|
||||
const refusedWithoutSpacePromise = card._confirmDanger(request('space-already-lost'));
|
||||
const refusedWithoutSpaceWasNeverRegistered = card._dangerConfirm === null
|
||||
&& card._dangerConfirmController.state === null;
|
||||
const refusedWithoutSpace = await Promise.race([
|
||||
refusedWithoutSpacePromise,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
|
||||
]);
|
||||
const refusedWithoutSpace = await boundedDangerDecision(refusedWithoutSpacePromise);
|
||||
result.lostSpaceRequestRefusesImmediately = refusedWithoutSpaceWasNeverRegistered
|
||||
&& refusedWithoutSpace === false
|
||||
&& refusedWithoutSpace.settled && refusedWithoutSpace.value === false
|
||||
&& card._dangerConfirm === null && card._dangerConfirmController.state === null
|
||||
&& dialogs() === 0;
|
||||
card._spaceModel = originalSpaceModel;
|
||||
@@ -302,7 +330,8 @@ const out = await page.evaluate(async () => {
|
||||
await settle();
|
||||
const shown = dialogs() === 1;
|
||||
await decide(false);
|
||||
return branchEntered && shown && (await pending) === false;
|
||||
const decision = await boundedDangerDecision(pending);
|
||||
return branchEntered && shown && decision.settled && decision.value === false;
|
||||
};
|
||||
result.fixedFloorPendingStillShowsConfirm = await confirmInFixedBranch(false, 'pending');
|
||||
result.fixedFloorInvalidStillShowsConfirm = await confirmInFixedBranch(true, 'invalid');
|
||||
@@ -334,7 +363,9 @@ const out = await page.evaluate(async () => {
|
||||
}
|
||||
}
|
||||
await settle();
|
||||
result.touchTapOnCancelResolvesFalse = !!cancelButton && (await byTap) === false
|
||||
const tapDecision = await boundedDangerDecision(byTap);
|
||||
result.touchTapOnCancelResolvesFalse = !!cancelButton
|
||||
&& tapDecision.settled && tapDecision.value === false
|
||||
&& dialogs() === 0;
|
||||
await leaveBranch();
|
||||
|
||||
@@ -350,7 +381,9 @@ const out = await page.evaluate(async () => {
|
||||
card.hass = savedHass;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
result.notReadyCardRefusesInsteadOfHanging = (await refused) === false;
|
||||
const refusedDecision = await boundedDangerDecision(refused);
|
||||
result.notReadyCardRefusesInsteadOfHanging = refusedDecision.settled
|
||||
&& refusedDecision.value === false;
|
||||
|
||||
// Match the public surface of HA's pinned ha-dialog closely enough to prove
|
||||
// branch selection and ARIA forwarding without depending on private shadow
|
||||
|
||||
@@ -10,6 +10,7 @@ const res = await page.evaluate(async () => {
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
@@ -272,10 +273,10 @@ const res = await page.evaluate(async () => {
|
||||
const vw = () => c._viewOr(c._baseVb());
|
||||
c._decorTool = 'line';
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.lineBadgeShown = !!label();
|
||||
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
|
||||
out.lineBadgeText = txt();
|
||||
@@ -287,12 +288,12 @@ const res = await page.evaluate(async () => {
|
||||
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
|
||||
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
|
||||
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
|
||||
// прямоугольник показывает габарит и чистую площадь
|
||||
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.rectBadgeHasSizeAndArea = txt().startsWith(fmt(g * 8) + ' × ' + fmt(g * 6) + ' · ')
|
||||
&& /(?:m²|ft²)$/.test(txt());
|
||||
// отпустили — плашки нет
|
||||
@@ -493,6 +494,127 @@ const res = await page.evaluate(async () => {
|
||||
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
|
||||
c._decorTool = 'select'; await c.updateComplete;
|
||||
out.decorRestored = c._decorList.length === decorBefore.length;
|
||||
// #447: Arrow is a separate rigid transaction, not a synthetic pointer
|
||||
// move. It preserves off-grid phase, bypasses both magnets and keeps one
|
||||
// history/save boundary per browser keydown.
|
||||
const keyboardOriginal = JSON.parse(JSON.stringify(c._decorList));
|
||||
const keyboardShapes = [
|
||||
{ id: 'key-rect', kind: 'rect', x: 0.1013, y: 0.2017, w: 0.08, h: 0.05,
|
||||
angle: 17, color: '#123456', fill: true },
|
||||
{ id: 'key-furniture', kind: 'furniture', x: 0.3013, y: 0.4017, w: 0.08, h: 0.05,
|
||||
symbol: 'chair', angle: 90, flip_h: true },
|
||||
{ id: 'key-image', kind: 'image', x: 0.5013, y: 0.6017, w: 0.08, h: 0.05,
|
||||
asset_id: 'smoke-missing-asset', opacity: 0.6 },
|
||||
];
|
||||
c._curSpaceCfg.decor = keyboardShapes;
|
||||
c._decorTool = 'select'; c._decorSel = 'key-furniture';
|
||||
c._decorDraft = null; c._decorMove = null; c._dtDrag = null; c._bdDrag = null;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._geometryHistory.clear();
|
||||
c._saveConfigDebounced.cancel();
|
||||
const realDebouncedSave = c._saveConfigDebounced;
|
||||
let keyboardSaves = 0, decorSnapCalls = 0, furnitureMoveCalls = 0;
|
||||
const countedSave = () => { keyboardSaves += 1; };
|
||||
countedSave.pending = () => false; countedSave.cancel = () => {}; countedSave.flush = () => {};
|
||||
c._saveConfigDebounced = countedSave;
|
||||
const realDecorSnap = c._decorSnap;
|
||||
const realFurnitureMove = c._editorRuntime._furnMoveUpdate;
|
||||
c._decorSnap = (...args) => { decorSnapCalls += 1; return realDecorSnap.apply(c, args); };
|
||||
c._editorRuntime._furnMoveUpdate = (...args) => {
|
||||
furnitureMoveCalls += 1;
|
||||
return realFurnitureMove.apply(c._editorRuntime, args);
|
||||
};
|
||||
const arrow = (key, target = window, init = {}) => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key, bubbles: true, composed: true, cancelable: true, ...init,
|
||||
});
|
||||
return target.dispatchEvent(event) === false;
|
||||
};
|
||||
const keyed = (id) => c._decorList.find((shape) => shape.id === id);
|
||||
const keyStep = g / 1000;
|
||||
const furnitureBeforeKey = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
const epochBeforeKey = c._cfgEpoch;
|
||||
const firstArrowPrevented = arrow('ArrowRight');
|
||||
const furnitureAfterRight = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
out.arrowMovesFurnitureOneCellWithoutMagnet = firstArrowPrevented
|
||||
&& Math.abs(furnitureAfterRight.x - furnitureBeforeKey.x - keyStep) < 1e-12
|
||||
&& furnitureAfterRight.y === furnitureBeforeKey.y
|
||||
&& furnitureAfterRight.angle === furnitureBeforeKey.angle
|
||||
&& furnitureAfterRight.flip_h === furnitureBeforeKey.flip_h
|
||||
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
|
||||
out.arrowIsOneHistoryAndSaveTransaction = c._geometryHistory.size === 1
|
||||
&& c._geometryHistory.undoName === c._t('history.decor_move')
|
||||
&& keyboardSaves === 1 && c._cfgEpoch === epochBeforeKey + 1
|
||||
&& c._decorSel === 'key-furniture';
|
||||
const shiftArrowPrevented = arrow('ArrowDown', window, { shiftKey: true });
|
||||
const furnitureAfterDown = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
out.shiftArrowKeepsTheSameOneCellStep = shiftArrowPrevented
|
||||
&& Math.abs(furnitureAfterDown.y - furnitureAfterRight.y - keyStep) < 1e-12
|
||||
&& c._geometryHistory.size === 2 && keyboardSaves === 2;
|
||||
arrow('z', window, { ctrlKey: true });
|
||||
const furnitureAfterUndo = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
arrow('y', window, { ctrlKey: true });
|
||||
const furnitureAfterRedo = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
out.arrowUndoRedoIsExactlyOneStep = Math.abs(furnitureAfterUndo.y - furnitureAfterRight.y) < 1e-8
|
||||
&& Math.abs(furnitureAfterRedo.y - furnitureAfterDown.y) < 1e-8
|
||||
&& Math.abs(furnitureAfterRedo.x - furnitureAfterRight.x) < 1e-8
|
||||
&& c._decorSel === 'key-furniture';
|
||||
|
||||
c._decorSel = 'key-image';
|
||||
const imageX = keyed('key-image').x;
|
||||
arrow('ArrowLeft');
|
||||
c._decorSel = 'key-rect';
|
||||
const rectY = keyed('key-rect').y;
|
||||
arrow('ArrowUp');
|
||||
out.imageAndOrdinaryDecorUseTheSameArrowPath = Math.abs(keyed('key-image').x - imageX + keyStep) < 1e-12
|
||||
&& Math.abs(keyed('key-rect').y - rectY + keyStep) < 1e-12
|
||||
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
|
||||
|
||||
const guardX = () => keyed('key-rect').x;
|
||||
const guardStart = guardX();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const controls = ['input', 'textarea', 'select'].map((tag) => document.createElement(tag));
|
||||
const editable = document.createElement('div'); editable.contentEditable = 'true';
|
||||
controls.push(editable);
|
||||
const fieldResults = [];
|
||||
for (const control of controls) {
|
||||
document.body.append(control); control.focus();
|
||||
fieldResults.push(!arrow('ArrowRight', control) && guardX() === guardStart);
|
||||
control.remove();
|
||||
}
|
||||
out.arrowLeavesFormControlsAlone = fieldResults.every(Boolean);
|
||||
const toolbarControl = sr().querySelector('.editor-secondary button');
|
||||
toolbarControl?.focus();
|
||||
const toolbarGuarded = !!toolbarControl && !arrow('ArrowRight', toolbarControl) && guardX() === guardStart;
|
||||
toolbarControl?.blur();
|
||||
c._decorEraseConfirm = { id: 'key-rect', kind: 'rect' };
|
||||
const dialogGuarded = !arrow('ArrowRight') && guardX() === guardStart;
|
||||
c._decorEraseConfirm = null;
|
||||
c._decorMove = { id: 'key-rect', start: [0, 0], orig: JSON.parse(JSON.stringify(keyed('key-rect'))),
|
||||
pid: 447, moved: false, before: c._geometrySnapshot() };
|
||||
const gestureGuarded = !arrow('ArrowRight') && guardX() === guardStart;
|
||||
c._decorMove = null;
|
||||
out.arrowRespectsToolbarDialogAndGestureGuards = toolbarGuarded && dialogGuarded && gestureGuarded;
|
||||
const modifierResults = [
|
||||
{ ctrlKey: true }, { metaKey: true }, { altKey: true },
|
||||
].map((init) => !arrow('ArrowRight', window, init) && guardX() === guardStart);
|
||||
out.modifiedArrowsRemainBrowserCommands = modifierResults.every(Boolean);
|
||||
|
||||
c._curSpaceCfg.decor = [{ id: 'key-limit', kind: 'rect', x: 4999.9, y: 0, w: 0.1, h: 0.1 }];
|
||||
c._decorSel = 'key-limit'; c._geometryHistory.clear();
|
||||
const savesBeforeNoop = keyboardSaves, epochBeforeNoop = c._cfgEpoch;
|
||||
const noopPrevented = arrow('ArrowRight');
|
||||
out.boundaryNoopIsConsumedButNotSaved = noopPrevented
|
||||
&& c._curSpaceCfg.decor[0].x === 4999.9
|
||||
&& c._geometryHistory.size === 0 && keyboardSaves === savesBeforeNoop
|
||||
&& c._cfgEpoch === epochBeforeNoop;
|
||||
|
||||
c._decorSnap = realDecorSnap;
|
||||
c._editorRuntime._furnMoveUpdate = realFurnitureMove;
|
||||
c._saveConfigDebounced = realDebouncedSave;
|
||||
c._curSpaceCfg.decor = keyboardOriginal;
|
||||
c._decorSel = null; c._geometryHistory.clear(); c._cfgEpoch++;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// Switching modes during a live move cancels the transaction and restores
|
||||
// its start instead of leaving mutated geometry with a forgotten pointer.
|
||||
const liveShape = c._decorList[0];
|
||||
|
||||
@@ -27,9 +27,11 @@ const res = await page.evaluate(async () => {
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;
|
||||
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
|
||||
// смок должен показать список провалов, а не упасть с исключением
|
||||
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
const el = (id) => sr().querySelector(`[data-hp-live-editor] .decorlayer text.dtext[data-id="${id}"]`)
|
||||
|| sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
|
||||
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
@@ -101,7 +103,7 @@ const res = await page.evaluate(async () => {
|
||||
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.fourCornersAndRotate = !!frame()
|
||||
&& frame().querySelectorAll('.dthandle').length === 5
|
||||
@@ -147,7 +149,7 @@ const res = await page.evaluate(async () => {
|
||||
ev('pointerdown', corner, ax + 40, ay2);
|
||||
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
|
||||
ev('pointermove', stageEl(), ax + 80, ay2);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const scaled = c._decorList.find((x) => x.id === 'dtm');
|
||||
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
|
||||
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
|
||||
|
||||
@@ -205,7 +205,7 @@ for (const metric of desktop.metrics) {
|
||||
}
|
||||
checks.space_dialog_still_uses_medium_shell = desktop.space.widePreset
|
||||
&& desktop.space.surfaceWidth >= 499 && desktop.space.surfaceWidth <= 501;
|
||||
checks.space_dialog_footer_not_regressed = desktop.space.buttons === 3
|
||||
checks.space_dialog_footer_not_regressed = desktop.space.buttons === 4
|
||||
&& desktop.space.oneRow && desktop.space.noHorizontalOverflow;
|
||||
|
||||
for (const kind of ['opening', 'physical', 'opening_de', 'physical_de']) {
|
||||
@@ -217,10 +217,10 @@ for (const kind of ['opening', 'physical', 'opening_de', 'physical_de']) {
|
||||
checks.draft_four_actions_remain_contained = narrow.draft.buttons === 4
|
||||
&& narrow.draft.insideViewport && narrow.draft.noHorizontalOverflow
|
||||
&& narrow.draft.buttonsContained;
|
||||
checks.space_narrow_not_regressed = narrow.space.buttons === 3
|
||||
checks.space_narrow_not_regressed = narrow.space.buttons === 4
|
||||
&& narrow.space.insideViewport && narrow.space.noHorizontalOverflow
|
||||
&& narrow.space.buttonsContained;
|
||||
checks.space_de_narrow_not_regressed = narrow.space_de.buttons === 3
|
||||
checks.space_de_narrow_not_regressed = narrow.space_de.buttons === 4
|
||||
&& narrow.space_de.insideViewport && narrow.space_de.noHorizontalOverflow
|
||||
&& narrow.space_de.buttonsContained;
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// #463: native hp-dialog is a real, centred top-layer modal throughout its
|
||||
// lifecycle. This is deliberately a production-bundle smoke: Lit updates,
|
||||
// HTMLDialogElement top-layer state, ::backdrop and late custom-element
|
||||
// registration cannot be proved by a pure DOM shim.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const DESKTOP = { width: 800, height: 600 };
|
||||
const NARROW = { width: 320, height: 720 };
|
||||
const { page, browser } = await launch(DESKTOP);
|
||||
|
||||
const desktop = await page.evaluate(async () => {
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const settle = async (element) => {
|
||||
await element.updateComplete;
|
||||
await element.querySelector('hp-dialog')?.updateComplete;
|
||||
await frame();
|
||||
await frame();
|
||||
};
|
||||
const deepActive = () => {
|
||||
let active = document.activeElement;
|
||||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||||
return active;
|
||||
};
|
||||
const backdropAlpha = (dialog) => {
|
||||
const color = getComputedStyle(dialog, '::backdrop').backgroundColor;
|
||||
if (!color || color === 'transparent') return 0;
|
||||
const values = color.match(/[\d.]+/g)?.map(Number) || [];
|
||||
return values.length >= 4 ? values.at(-1) : 1;
|
||||
};
|
||||
const geometry = (host) => {
|
||||
const native = host.shadowRoot?.querySelector('dialog');
|
||||
const surface = host.shadowRoot?.querySelector('.surface');
|
||||
const shellCount = host.shadowRoot?.querySelectorAll('dialog, ha-dialog').length || 0;
|
||||
const dialogRect = native?.getBoundingClientRect();
|
||||
const surfaceRect = surface?.getBoundingClientRect();
|
||||
const centred = !!surfaceRect
|
||||
&& Math.abs((surfaceRect.left + surfaceRect.right) / 2 - innerWidth / 2) <= 1
|
||||
&& Math.abs((surfaceRect.top + surfaceRect.bottom) / 2 - innerHeight / 2) <= 1;
|
||||
return {
|
||||
native,
|
||||
oneShell: shellCount === 1,
|
||||
openModal: native?.open === true && native.matches(':modal'),
|
||||
centred,
|
||||
shrinkWrapped: !!dialogRect && !!surfaceRect
|
||||
&& Math.abs(dialogRect.width - surfaceRect.width) <= 1
|
||||
&& Math.abs(dialogRect.height - surfaceRect.height) <= 1,
|
||||
backdropVisible: !!native && backdropAlpha(native) > 0,
|
||||
noOverflow: !!surface
|
||||
&& surface.scrollWidth <= surface.clientWidth + 1
|
||||
&& document.documentElement.scrollWidth <= innerWidth + 1,
|
||||
};
|
||||
};
|
||||
|
||||
// Exact user surface: hp-confirm forces the native alert branch even when
|
||||
// Home Assistant provides ha-dialog.
|
||||
const confirm = document.createElement('hp-confirm');
|
||||
confirm.token = 463001;
|
||||
confirm.request = {
|
||||
key: 'dialog-modal-contract',
|
||||
kind: 'destructive',
|
||||
title: 'Delete unfinished contour?',
|
||||
message: 'The unfinished contour will be removed.',
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
};
|
||||
const decision = new Promise((resolve) => confirm.addEventListener(
|
||||
'hp-confirm-decision', (event) => resolve(event.detail.accepted), { once: true },
|
||||
));
|
||||
document.body.append(confirm);
|
||||
await settle(confirm);
|
||||
const confirmShell = confirm.querySelector('hp-dialog');
|
||||
const confirmGeometry = geometry(confirmShell);
|
||||
const confirmCancel = confirm.querySelector('button[autofocus]');
|
||||
const confirmFocused = deepActive() === confirmCancel;
|
||||
confirmCancel?.click();
|
||||
const confirmCancelled = await decision === false;
|
||||
confirm.remove();
|
||||
await frame();
|
||||
|
||||
// An ordinary dialog starts on the native fallback because ha-dialog is
|
||||
// intentionally registered only in the second half of this smoke.
|
||||
const opener = document.createElement('button');
|
||||
opener.id = 'hp-463-opener';
|
||||
opener.textContent = 'Open dialog';
|
||||
document.body.append(opener);
|
||||
opener.focus();
|
||||
|
||||
const lifecycle = document.createElement('hp-dialog');
|
||||
lifecycle.id = 'hp-463-lifecycle';
|
||||
lifecycle.title = 'Lifecycle recovery';
|
||||
lifecycle.innerHTML = '<button autofocus>First action</button>';
|
||||
let lifecycleCloseCount = 0;
|
||||
lifecycle.addEventListener('hp-close', () => {
|
||||
lifecycleCloseCount += 1;
|
||||
lifecycle.remove();
|
||||
});
|
||||
document.body.append(lifecycle);
|
||||
await settle(lifecycle);
|
||||
const initialGeometry = geometry(lifecycle);
|
||||
const native = initialGeometry.native;
|
||||
|
||||
// Reproduce the independently confirmed open-but-nonmodal state. update()
|
||||
// must recover the same physical shell without reporting a user close.
|
||||
let showModalCalls = 0;
|
||||
const realShowModal = native.showModal.bind(native);
|
||||
native.showModal = () => {
|
||||
showModalCalls += 1;
|
||||
return realShowModal();
|
||||
};
|
||||
native.close();
|
||||
native.show();
|
||||
const becameOpenNonmodal = native.open && !native.matches(':modal');
|
||||
lifecycle.requestUpdate();
|
||||
await settle(lifecycle);
|
||||
const afterUpdate = geometry(lifecycle);
|
||||
const updateRecoveredSameShell = afterUpdate.native === native
|
||||
&& afterUpdate.openModal && afterUpdate.centred && afterUpdate.oneShell;
|
||||
const updateRecoveryWasBounded = showModalCalls === 1;
|
||||
lifecycle.requestUpdate();
|
||||
await settle(lifecycle);
|
||||
const stableUpdateDoesNotRetry = showModalCalls === 1;
|
||||
|
||||
// Removing a modal from the document drops it from the top layer while the
|
||||
// open attribute can survive. Reconnecting the same host must repair that
|
||||
// exact native node and must not synthesize hp-close.
|
||||
lifecycle.remove();
|
||||
const detachedLostTopLayer = !native.matches(':modal');
|
||||
document.body.append(lifecycle);
|
||||
await settle(lifecycle);
|
||||
const afterReconnect = geometry(lifecycle);
|
||||
const reconnectRecoveredSameShell = afterReconnect.native === native
|
||||
&& afterReconnect.openModal && afterReconnect.centred && afterReconnect.oneShell;
|
||||
const reconnectRestoresInitialFocus = deepActive() === lifecycle.querySelector('[autofocus]');
|
||||
const recoveryDidNotClose = lifecycleCloseCount === 0;
|
||||
|
||||
// A shrink-wrapped native shell still owns the entire viewport scrim hit
|
||||
// target through the top layer. A content click stays inside; a click on the
|
||||
// outer dialog follows dismissOnScrim and preserves the close/focus contract.
|
||||
lifecycle.dismissOnScrim = true;
|
||||
lifecycle.requestUpdate();
|
||||
await settle(lifecycle);
|
||||
lifecycle.shadowRoot.querySelector('.surface')?.click();
|
||||
const surfaceClickDoesNotDismiss = lifecycleCloseCount === 0;
|
||||
native.click();
|
||||
await frame();
|
||||
await frame();
|
||||
const closeContractSurvives = lifecycleCloseCount === 1
|
||||
&& !lifecycle.isConnected && deepActive() === opener;
|
||||
|
||||
// Top-layer ordering must remain intact when a native parent opens an alert
|
||||
// confirmation. Escape closes only the top alert, then the parent.
|
||||
opener.focus();
|
||||
const parent = document.createElement('hp-dialog');
|
||||
parent.title = 'Native parent';
|
||||
parent.innerHTML = '<button autofocus>Parent action</button>';
|
||||
let parentCloseCount = 0;
|
||||
parent.addEventListener('hp-close', () => {
|
||||
parentCloseCount += 1;
|
||||
parent.remove();
|
||||
});
|
||||
document.body.append(parent);
|
||||
await settle(parent);
|
||||
const child = document.createElement('hp-confirm');
|
||||
child.token = 463002;
|
||||
child.request = {
|
||||
key: 'nested-dialog-order', kind: 'warning', title: 'Nested alert',
|
||||
message: 'Close only this alert first.', confirmLabel: 'Continue', cancelLabel: 'Cancel',
|
||||
};
|
||||
const childDecision = new Promise((resolve) => child.addEventListener(
|
||||
'hp-confirm-decision', (event) => resolve(event.detail.accepted), { once: true },
|
||||
));
|
||||
document.body.append(child);
|
||||
await settle(child);
|
||||
const childNative = child.querySelector('hp-dialog')?.shadowRoot?.querySelector('dialog');
|
||||
const nestedStartIsTwoModals = parent.shadowRoot.querySelector('dialog')?.matches(':modal')
|
||||
&& childNative?.matches(':modal');
|
||||
deepActive()?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
const childCancelled = await childDecision === false;
|
||||
child.remove();
|
||||
await frame();
|
||||
await frame();
|
||||
const firstEscapeClosedOnlyAlert = childCancelled && parentCloseCount === 0
|
||||
&& parent.shadowRoot.querySelector('dialog')?.matches(':modal')
|
||||
&& deepActive() === parent.querySelector('[autofocus]');
|
||||
deepActive()?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await frame();
|
||||
await frame();
|
||||
const secondEscapeClosedParent = parentCloseCount === 1 && !parent.isConnected;
|
||||
|
||||
// Keep a second pre-registration ordinary dialog open. It must not switch
|
||||
// branch when ha-dialog is defined later.
|
||||
opener.focus();
|
||||
const early = document.createElement('hp-dialog');
|
||||
early.id = 'hp-463-early-native';
|
||||
early.title = 'Opened before HA';
|
||||
early.innerHTML = '<button autofocus>Keep open</button>';
|
||||
document.body.append(early);
|
||||
await settle(early);
|
||||
const earlyNative = early.shadowRoot.querySelector('dialog');
|
||||
earlyNative.__hp463Identity = true;
|
||||
|
||||
return {
|
||||
confirmHasOneNativeShell: confirmGeometry.oneShell,
|
||||
confirmIsTopLayerModal: confirmGeometry.openModal,
|
||||
confirmSurfaceIsCentred: confirmGeometry.centred && confirmGeometry.shrinkWrapped,
|
||||
confirmBackdropIsVisible: confirmGeometry.backdropVisible,
|
||||
confirmFocusAndCancelWork: confirmFocused && confirmCancelled,
|
||||
standardFallbackStartsCentred: initialGeometry.oneShell && initialGeometry.openModal
|
||||
&& initialGeometry.centred && initialGeometry.shrinkWrapped && initialGeometry.noOverflow,
|
||||
artificialOpenNonmodalWasCreated: becameOpenNonmodal,
|
||||
updateRecoversSameNativeShell: updateRecoveredSameShell,
|
||||
updateRecoveryRunsOnce: updateRecoveryWasBounded && stableUpdateDoesNotRetry,
|
||||
detachActuallyDropsTopLayer: detachedLostTopLayer,
|
||||
reconnectRecoversSameNativeShell: reconnectRecoveredSameShell,
|
||||
reconnectRestoresInitialFocus,
|
||||
recoveryNeverEmitsClose: recoveryDidNotClose,
|
||||
shrinkWrappedScrimKeepsDismissContract: surfaceClickDoesNotDismiss
|
||||
&& closeContractSurvives,
|
||||
nestedNativeEscapeKeepsTopLayerOrder: nestedStartIsTwoModals
|
||||
&& firstEscapeClosedOnlyAlert && secondEscapeClosedParent,
|
||||
earlyFallbackReadyForLateRegistration: !!earlyNative
|
||||
&& earlyNative.matches(':modal') && geometry(early).centred,
|
||||
};
|
||||
});
|
||||
|
||||
await page.setViewportSize(NARROW);
|
||||
|
||||
const narrowAndLateRegistration = await page.evaluate(async () => {
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const settle = async (element) => {
|
||||
await element.updateComplete;
|
||||
await element.querySelector('hp-dialog')?.updateComplete;
|
||||
await frame();
|
||||
await frame();
|
||||
};
|
||||
const centred = (host) => {
|
||||
const surface = host.shadowRoot?.querySelector('.surface');
|
||||
const rect = surface?.getBoundingClientRect();
|
||||
return !!rect
|
||||
&& Math.abs((rect.left + rect.right) / 2 - innerWidth / 2) <= 1
|
||||
&& Math.abs((rect.top + rect.bottom) / 2 - innerHeight / 2) <= 1;
|
||||
};
|
||||
|
||||
const early = document.querySelector('#hp-463-early-native');
|
||||
const originalNative = early.shadowRoot.querySelector('dialog');
|
||||
early.wide = true;
|
||||
early.requestUpdate();
|
||||
await settle(early);
|
||||
const surface = early.shadowRoot.querySelector('.surface');
|
||||
const surfaceRect = surface.getBoundingClientRect();
|
||||
const wideFitsNarrowViewport = early.wide && originalNative.matches(':modal')
|
||||
&& centred(early)
|
||||
&& surfaceRect.width <= innerWidth * 0.94 + 1
|
||||
&& surfaceRect.height <= innerHeight * 0.92 + 1
|
||||
&& surfaceRect.left >= -1 && surfaceRect.right <= innerWidth + 1
|
||||
&& surface.scrollWidth <= surface.clientWidth + 1
|
||||
&& originalNative.scrollWidth <= originalNative.clientWidth + 1;
|
||||
|
||||
class HaDialogStub extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' }).innerHTML = '<slot name="headerTitle"></slot>'
|
||||
+ '<slot></slot><slot name="footer"></slot>';
|
||||
}
|
||||
connectedCallback() {
|
||||
queueMicrotask(() => this.dispatchEvent(new Event('opened')));
|
||||
}
|
||||
}
|
||||
customElements.define('ha-dialog', HaDialogStub);
|
||||
await frame();
|
||||
await frame();
|
||||
|
||||
const earlyStillNative = early.shadowRoot.querySelector('dialog') === originalNative
|
||||
&& originalNative.__hp463Identity === true
|
||||
&& originalNative.matches(':modal')
|
||||
&& !early.shadowRoot.querySelector('ha-dialog')
|
||||
&& centred(early);
|
||||
|
||||
const lateOrdinary = document.createElement('hp-dialog');
|
||||
lateOrdinary.title = 'Opened after HA';
|
||||
lateOrdinary.innerHTML = '<button autofocus>HA action</button>';
|
||||
document.body.append(lateOrdinary);
|
||||
await settle(lateOrdinary);
|
||||
const newOrdinaryUsesOneHaShell = lateOrdinary.shadowRoot.querySelectorAll('ha-dialog').length === 1
|
||||
&& !lateOrdinary.shadowRoot.querySelector('dialog');
|
||||
|
||||
const lateAlert = document.createElement('hp-dialog');
|
||||
lateAlert.alert = true;
|
||||
lateAlert.title = 'Alert after HA';
|
||||
lateAlert.innerHTML = '<button autofocus>Native action</button>';
|
||||
document.body.append(lateAlert);
|
||||
await settle(lateAlert);
|
||||
const alertNative = lateAlert.shadowRoot.querySelector('dialog');
|
||||
const alertAfterHaStaysOneNativeModal = lateAlert.shadowRoot
|
||||
.querySelectorAll('dialog, ha-dialog').length === 1
|
||||
&& !!alertNative?.matches(':modal') && centred(lateAlert);
|
||||
|
||||
lateAlert.remove();
|
||||
lateOrdinary.remove();
|
||||
early.remove();
|
||||
await frame();
|
||||
await frame();
|
||||
|
||||
return {
|
||||
wideNativeFallbackFitsAndCentresOnNarrowViewport: wideFitsNarrowViewport,
|
||||
openFallbackDoesNotSwitchAfterLateHaRegistration: earlyStillNative,
|
||||
newOrdinaryUsesExactlyOneHaShell: newOrdinaryUsesOneHaShell,
|
||||
alertAfterHaStillUsesExactlyOneNativeModal: alertAfterHaStaysOneNativeModal,
|
||||
};
|
||||
});
|
||||
|
||||
const out = { ...desktop, ...narrowAndLateRegistration };
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ 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 = () => c._editorRuntime?._whenLiveEditorSettled() ?? 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,8 +48,25 @@ 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());
|
||||
|
||||
const reducedMotion = c._reducedMotion;
|
||||
c._reducedMotion = true;
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
await new Promise((resolve) => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
});
|
||||
observer.observe(stageEl());
|
||||
});
|
||||
while (c._modeTransitionBusy || c._refitRaf || c._pendingRefitSize) {
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._reducedMotion = reducedMotion;
|
||||
out.initialModeSettled = !c._modeTransitionBusy && !c._refitRaf && !c._pendingRefitSize;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'select'; c._decorSel = null; c._furnPalette = null; c._furnCategory = null;
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
@@ -140,7 +159,8 @@ const res = await page.evaluate(async () => {
|
||||
out.selectedItemIsMarked = !!item('sofa')?.classList.contains('on');
|
||||
|
||||
// ================= 3. точное превью без мутации ==========================
|
||||
const ghost = () => sr().querySelector('.furniture-placement-preview');
|
||||
const ghost = () => sr().querySelector('[data-hp-live-editor] .furniture-placement-preview')
|
||||
|| sr().querySelector('.furniture-placement-preview');
|
||||
const beforePreview = {
|
||||
decor: c._decorList.length,
|
||||
epoch: c._cfgEpoch,
|
||||
@@ -148,7 +168,7 @@ const res = await page.evaluate(async () => {
|
||||
redo: c._geometryHistory.redoName,
|
||||
};
|
||||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const firstPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
const firstTransform = ghost()?.getAttribute('transform');
|
||||
@@ -176,7 +196,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 +204,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,7 +216,7 @@ const res = await page.evaluate(async () => {
|
||||
// ================= 4. размещение в реальном размере ======================
|
||||
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
|
||||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const committedPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
const committedPreviewStroke = Number(ghost()?.getAttribute('stroke-width'));
|
||||
@@ -266,7 +286,7 @@ const res = await page.evaluate(async () => {
|
||||
category('bed')?.click(); await c.updateComplete;
|
||||
pick('bed_double'); await c.updateComplete;
|
||||
ev('pointermove', stageEl(), 300, 140 + wallHalf, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const thickWallPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
ev('pointerdown', stageEl(), 300, 140 + wallHalf);
|
||||
@@ -282,6 +302,39 @@ const res = await page.evaluate(async () => {
|
||||
&& ['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();
|
||||
@@ -289,10 +342,10 @@ const res = await page.evaluate(async () => {
|
||||
pick('chair'); await c.updateComplete;
|
||||
const sharedIntentX = 550 + wallHalf / 2;
|
||||
ev('pointermove', stageEl(), sharedIntentX, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
ev('pointerdown', stageEl(), sharedIntentX, 300);
|
||||
await c.updateComplete;
|
||||
const sharedChair = c._decorList.find((s) => s.symbol === 'chair');
|
||||
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
|
||||
@@ -304,7 +357,7 @@ const res = await page.evaluate(async () => {
|
||||
const sharedChairY = sharedChair ? (sharedChair.y + sharedChair.h / 2) * 1000 : NaN;
|
||||
ev('pointerdown', el(sharedChairId), rightSurfaceCentre, sharedChairY);
|
||||
ev('pointermove', stageEl(), 550, sharedChairY);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const axisDraggedChair = c._decorList.find((s) => s.id === sharedChairId);
|
||||
out.exactAxisDragPreservesWallSide = !!axisDraggedChair
|
||||
&& near((axisDraggedChair.x + axisDraggedChair.w / 2) * 1000, rightSurfaceCentre, 1e-6);
|
||||
@@ -352,14 +405,14 @@ const res = await page.evaluate(async () => {
|
||||
ev('pointerdown', el(sofaId), 300, 300);
|
||||
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
|
||||
ev('pointermove', stageEl(), 540, 300);
|
||||
await c.updateComplete;
|
||||
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,
|
||||
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 });
|
||||
@@ -377,7 +430,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')
|
||||
@@ -391,9 +444,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}"]`);
|
||||
@@ -406,7 +460,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. Индекс держался
|
||||
// на порядке отрисовки, а порядок — это решение о приоритете хита (углы
|
||||
@@ -418,7 +472,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
|
||||
@@ -433,7 +487,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);
|
||||
@@ -445,12 +499,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)
|
||||
@@ -461,13 +515,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);
|
||||
@@ -484,17 +538,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') || '');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||