Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d6cea455d | ||
|
|
0e0ca3d4dc | ||
|
|
40d63080bd | ||
|
|
ffddc38376 | ||
|
|
1ba5363038 | ||
|
|
1e2e0fa61c | ||
|
|
7490575799 | ||
|
|
3ce5bc0ea2 | ||
|
|
321bd9691b | ||
|
|
cd8d01aced | ||
|
|
55b51a0270 | ||
|
|
c43bf84789 | ||
|
|
c7efcf1699 | ||
|
|
319b25c281 | ||
|
|
6b10cd52af | ||
|
|
0010281952 | ||
|
|
9d5c0357b0 | ||
|
|
2d143b570f | ||
|
|
cf7f47db27 | ||
|
|
e25aa302d4 | ||
|
|
4559b5cd74 | ||
|
|
00b6845058 | ||
|
|
4fa670e873 | ||
|
|
ca0b318e5e | ||
|
|
ed700c8c8e | ||
|
|
d945b03208 | ||
|
|
823fc316b2 | ||
|
|
184e009806 | ||
|
|
36e1b7f9c2 | ||
|
|
24af34ccba | ||
|
|
b4809a12ff | ||
|
|
e926ea453e | ||
|
|
d5c93d4e28 | ||
|
|
bf40e26ed0 | ||
|
|
2c6d937d46 | ||
|
|
9c9a22354b | ||
|
|
77a22efc78 | ||
|
|
269d828dd4 | ||
|
|
816b1476f0 | ||
|
|
372d838ec2 | ||
|
|
8273af59ca | ||
|
|
7806ab7182 | ||
|
|
da82ab9199 | ||
|
|
c3bb62a3b6 | ||
|
|
bd780c6013 | ||
|
|
b3f22b33e4 | ||
|
|
aad1eeaa10 | ||
|
|
15ff625964 | ||
|
|
8ac8f0105a | ||
|
|
2903374b72 | ||
|
|
b04387ce91 | ||
|
|
2274e6eb84 | ||
|
|
355b0516c7 | ||
|
|
09613ce322 | ||
|
|
98d6edb7b2 | ||
|
|
64f48e7ffd | ||
|
|
8e7c927264 | ||
|
|
8119c523fa | ||
|
|
cd0ecf4db2 | ||
|
|
601e68e52d | ||
|
|
2fae84af58 | ||
|
|
ccd870ddf7 | ||
|
|
00b6f41233 | ||
|
|
931e49e3d6 | ||
|
|
11959e4c85 | ||
|
|
9b29df28db | ||
|
|
d94db87ee7 | ||
|
|
4794183114 | ||
|
|
bc076d3f50 | ||
|
|
26cb00db24 | ||
|
|
d5268086c2 | ||
|
|
61dd7ccb89 | ||
|
|
b319fc9548 | ||
|
|
633cb20e59 | ||
|
|
c8ef4f19e4 | ||
|
|
cdd9588ca6 | ||
|
|
bce0ae97b2 | ||
|
|
59546662e6 | ||
|
|
84f9901ca0 | ||
|
|
5c8cb58e1f | ||
|
|
4683a493cc | ||
|
|
6a0bb985e7 | ||
|
|
bd6722ccd2 | ||
|
|
d153b20a1d | ||
|
|
f07e3c54ff | ||
|
|
73ecfbd1d7 | ||
|
|
fe3b85c06b | ||
|
|
532743b01c | ||
|
|
60c211c82a | ||
|
|
6bf39ee9a7 | ||
|
|
5c4d8cba9e | ||
|
|
8b2a146c6c | ||
|
|
98028a3093 | ||
|
|
cc44b28f3b | ||
|
|
bb635298c1 | ||
|
|
6befe4168f | ||
|
|
6d229c66f1 | ||
|
|
627a5359c3 | ||
|
|
a1d7e0da55 | ||
|
|
f7fb3369b7 | ||
|
|
d9b6766362 | ||
|
|
2b3cabe7e0 | ||
|
|
9fde407e4e | ||
|
|
692e3b72f4 | ||
|
|
480d202fa5 | ||
|
|
a4b686a7fe | ||
|
|
69dd09a7c9 | ||
|
|
037a19d093 | ||
|
|
f4c66bed3f | ||
|
|
b17f416740 | ||
|
|
08d5612265 | ||
|
|
d87cc29804 | ||
|
|
f4fcb1fac0 | ||
|
|
6edcde012a | ||
|
|
38b48ee0aa | ||
|
|
83692e7959 | ||
|
|
9fcbb64633 | ||
|
|
a25de7383f | ||
|
|
f3620f2b27 | ||
|
|
f8cf18a25a | ||
|
|
d6a2607692 | ||
|
|
8f485c0b00 | ||
|
|
3421065286 | ||
|
|
371f2e9869 | ||
|
|
334dcf2bf8 | ||
|
|
7cf22cb54f | ||
|
|
4132e03642 | ||
|
|
54f9efb798 | ||
|
|
cc6bbdf24c | ||
|
|
2f021046aa | ||
|
|
2b3d5613f2 | ||
|
|
f3ac0ac061 | ||
|
|
7b363caabc | ||
|
|
751769908a | ||
|
|
c91ac74e19 | ||
|
|
2782f3b1f3 | ||
|
|
4ba1b2730f | ||
|
|
a6b5161f07 | ||
|
|
206bcd1ae7 | ||
|
|
8955c02e3a | ||
|
|
7e27bc7922 | ||
|
|
dddbbe5522 | ||
|
|
01fbb77f3b | ||
|
|
972e708a71 | ||
|
|
569867487c | ||
|
|
9f50778df5 | ||
|
|
26d45cf405 | ||
|
|
fa1aa7a877 | ||
|
|
f7ae00019c | ||
|
|
778da930cb | ||
|
|
92cf0ab17c | ||
|
|
512ab7f279 | ||
|
|
6582a06e17 | ||
|
|
c9dd3401fe | ||
|
|
fa20e37330 | ||
|
|
09adfd73a0 | ||
|
|
d274e38d57 | ||
|
|
10cdba8435 | ||
|
|
323b7803e0 | ||
|
|
066cf44c2f | ||
|
|
e2a681b543 | ||
|
|
2518abd961 | ||
|
|
31cb2827bf | ||
|
|
a577fde799 | ||
|
|
8dfb0a9e36 | ||
|
|
a3dcee5241 | ||
|
|
eb5aa2a0f8 | ||
|
|
162c8a3bef | ||
|
|
a47ec97a04 | ||
|
|
b2074c103c | ||
|
|
a015f31376 | ||
|
|
f2d620b5d5 | ||
|
|
fd301b6b08 | ||
|
|
6379334b21 | ||
|
|
ac625833f2 | ||
|
|
511b1bb980 | ||
|
|
23b94b2e61 | ||
|
|
7d50628672 | ||
|
|
4a202a0379 | ||
|
|
e2464cbb48 | ||
|
|
ad10803a29 | ||
|
|
bb79ece0b3 | ||
|
|
2cf82d9156 | ||
|
|
e98f869826 | ||
|
|
e39cd3dfa1 | ||
|
|
4e69078f90 | ||
|
|
32c70ecd19 | ||
|
|
12165aae46 | ||
|
|
cbf5cc1baa | ||
|
|
66db451280 | ||
|
|
3ae3ec0f48 | ||
|
|
83d646c51e | ||
|
|
6d73d6e909 | ||
|
|
2e30daa399 | ||
|
|
4b5c9f2382 | ||
|
|
a6b0850a0c | ||
|
|
fc63f3bf36 | ||
|
|
bdf81fad58 | ||
|
|
857537c620 | ||
|
|
861cb0ec94 | ||
|
|
df92f8d448 | ||
|
|
f049d3b1eb | ||
|
|
f0e0542c64 | ||
|
|
84008a0a8f | ||
|
|
65339f63ad | ||
|
|
6542d190ea | ||
|
|
5bdde98b50 | ||
|
|
6ee53e6171 | ||
|
|
0ed83c36f0 | ||
|
|
e270bd7137 | ||
|
|
50851dc3f9 | ||
|
|
582d673a2d | ||
|
|
f7ac00f895 | ||
|
|
fa146fb133 | ||
|
|
04da7eb18f | ||
|
|
00a7d7d43d | ||
|
|
cd9bd54801 | ||
|
|
981b6b25e8 | ||
|
|
cd7c53cd97 | ||
|
|
74ec0199fa | ||
|
|
6dcf1e4345 | ||
|
|
8e4fa54477 | ||
|
|
244271835f | ||
|
|
e5f773ce76 | ||
|
|
26304c8ce2 | ||
|
|
4e0f84200a | ||
|
|
30c15a070b | ||
|
|
802b2dc71b | ||
|
|
22f22980fc | ||
|
|
2b0ac0c091 | ||
|
|
5c027a9f9b | ||
|
|
e4e1e3705f | ||
|
|
34778d81e4 | ||
|
|
8d431d6ddf | ||
|
|
d14cf769d5 | ||
|
|
44aa6968f7 | ||
|
|
6e6747dc09 | ||
|
|
ba56876361 | ||
|
|
df1dbccfdb | ||
|
|
3d4c509088 | ||
|
|
8819e390c9 | ||
|
|
15fa33b9d9 | ||
|
|
a7e531787e | ||
|
|
572cf92896 | ||
|
|
f43187218f | ||
|
|
867f476289 | ||
|
|
24681ee26f | ||
|
|
1e0e7ccf21 | ||
|
|
1c1f9d8ea4 | ||
|
|
3653bbf807 | ||
|
|
8335191bff | ||
|
|
e5cef16ad7 | ||
|
|
b1b1e7439b | ||
|
|
0d5d2896a6 | ||
|
|
686557c5cf | ||
|
|
4a83bd0540 | ||
|
|
2103b86c17 | ||
|
|
95115de265 | ||
|
|
e561e5fc5d | ||
|
|
c872c93516 | ||
|
|
2e77fe09a5 | ||
|
|
223f499e37 | ||
|
|
fbbea47545 | ||
|
|
75d11d7c2e | ||
|
|
4f28bf4f7f | ||
|
|
1613339463 | ||
|
|
3bb732c803 | ||
|
|
e442a7c8d3 | ||
|
|
c1f2d36267 | ||
|
|
ea7803230a | ||
|
|
20cd3241c0 | ||
|
|
212606c3e4 | ||
|
|
f7e607f867 | ||
|
|
cfb8ce401d | ||
|
|
083038f25f | ||
|
|
687b296639 | ||
|
|
1f95fba270 | ||
|
|
3aac9bd268 | ||
|
|
c28b2f2d41 | ||
|
|
9c162c0926 | ||
|
|
285688de47 | ||
|
|
d68d912f83 | ||
|
|
c4eded3d04 | ||
|
|
816bc77020 | ||
|
|
b2470df5a7 | ||
|
|
b5863be168 | ||
|
|
5f6ee65768 | ||
|
|
0f7b6f5550 | ||
|
|
e524f07dde | ||
|
|
dbbe94ae08 | ||
|
|
795325ee08 | ||
|
|
552b78a134 | ||
|
|
591f8f6ac9 | ||
|
|
ad9c634901 | ||
|
|
a851c8f958 | ||
|
|
6e1d93648a | ||
|
|
ae89f841c4 | ||
|
|
2775a7032a | ||
|
|
2e32cbcb38 | ||
|
|
8fda392734 | ||
|
|
82d710f160 | ||
|
|
40ba222ac3 | ||
|
|
1b1c1c1d99 | ||
|
|
10a1a26612 | ||
|
|
720200e3df | ||
|
|
41c09ea6a2 | ||
|
|
f56d9a76a8 | ||
|
|
7ebead5df8 | ||
|
|
0d33691fc4 | ||
|
|
a143a8e63c | ||
|
|
4a343814b7 | ||
|
|
51196c6576 | ||
|
|
05a81403bd | ||
|
|
187c90ba69 | ||
|
|
92f1d3cbbf | ||
|
|
6a21727177 | ||
|
|
97bb0eceea | ||
|
|
b300586f80 | ||
|
|
44929293bd | ||
|
|
e2e4cec133 | ||
|
|
9e60ade8df | ||
|
|
7722b3c8c6 | ||
|
|
4eede5bc59 | ||
|
|
67109efdd3 | ||
|
|
f5a49718a0 | ||
|
|
6e3d08eda7 | ||
|
|
aa3ad429e0 | ||
|
|
9e9e32847e | ||
|
|
6d838d7750 | ||
|
|
0dfc74244b | ||
|
|
8914190c56 | ||
|
|
c4f3798612 | ||
|
|
8a65e0bfcd | ||
|
|
1cd84dd07b | ||
|
|
4c4563cdbe | ||
|
|
c7dfd712b8 | ||
|
|
7f3e67bce0 | ||
|
|
89fa8ca588 | ||
|
|
fbf2c1bd41 | ||
|
|
2bb7dfca5f | ||
|
|
27a90dfd68 | ||
|
|
3c733c390c | ||
|
|
6392179013 | ||
|
|
d696541aba | ||
|
|
eadd69644c | ||
|
|
dd591a6c77 | ||
|
|
89950e9944 | ||
|
|
06fbfcbc90 | ||
|
|
e5ecf39977 | ||
|
|
5ba0d9e1b7 | ||
|
|
3068a97cff | ||
|
|
e8916c7131 | ||
|
|
914bb4ede7 | ||
|
|
0c74b2fc4f | ||
|
|
0935a4d099 | ||
|
|
88dc61dd40 | ||
|
|
d2f4f94159 | ||
|
|
22dc15c602 | ||
|
|
55155b0d4f | ||
|
|
2d1e69b0c1 | ||
|
|
7d29ca388b |
@@ -9,6 +9,12 @@
|
||||
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
|
||||
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
|
||||
# попадают в репозиторий через явное решение, а не через бота.
|
||||
#
|
||||
# Снимать здесь больше не обязанность, а удобство (#401). Приёмка проверяет не
|
||||
# место съёмки, а её воспроизводимость: каждый кадр, не объявленный изменённым,
|
||||
# должен совпасть с закоммиченным байт-в-байт. Эта джоба потому и удобна, что
|
||||
# среда у неё та же, в которой снят закоммиченный набор, — но принять получится
|
||||
# из любой, где кадры воспроизводятся, и не получится ни из одной, где нет.
|
||||
name: Скриншоты документации
|
||||
|
||||
on:
|
||||
@@ -79,6 +85,22 @@ jobs:
|
||||
# браузер: тот же Chromium и десять изменившихся картинок означают, что
|
||||
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
|
||||
# разницу рендеринга.
|
||||
# Хеши печатаются в лог, а не только уезжают в артефакт (#410): чтобы
|
||||
# сравнить два прогона одного и того же SHA, нужен текст, который видно с
|
||||
# экрана. Именно так измеряется недетерминированность съёмки — и её
|
||||
# отсутствие после починки.
|
||||
# Съёмка обязана быть воспроизводимой, и это проверяется, а не
|
||||
# предполагается (#410): три снимка на сценарий в одном процессе, без
|
||||
# правки состояния между ними, обязаны совпасть побайтово. Шаг падает,
|
||||
# если кадр снова начнёт зависеть от времени.
|
||||
#
|
||||
# Проверка стоит ПЕРЕД съёмкой набора: смысла публиковать артефакт,
|
||||
# снятый недетерминированной съёмкой, нет.
|
||||
- name: "Кадр не плавает внутри одного состояния (#410)"
|
||||
run: node demo/docs/capture.mjs --stability=3
|
||||
- name: Хеши кадров
|
||||
run: |
|
||||
for f in docs/images/*.png; do sha256sum "$f"; done
|
||||
- name: Вердикт
|
||||
run: |
|
||||
# Поле манифеста «до» — из закоммиченного состояния, «после» — из
|
||||
|
||||
@@ -61,10 +61,15 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Установить backend test dependencies
|
||||
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
run: |
|
||||
# Точные версии — в tests_backend/requirements.txt (#392): без них
|
||||
# резолвер выбирает набор сам, и «зелёный backend» значит разное
|
||||
# в разные дни.
|
||||
pip install -r tests_backend/requirements.txt
|
||||
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
name: Бенчмарки рендера и геометрии
|
||||
# Every profile keeps base and candidate sequential on one hosted runner.
|
||||
# Independent profile pairs run in parallel: cross-profile timing is never
|
||||
# compared, while serialising all five pairs cannot fit the job timeout.
|
||||
# compared, while serialising all profile pairs cannot fit the job timeout.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -39,6 +39,8 @@ jobs:
|
||||
- plan-snap
|
||||
- blend
|
||||
- overlay
|
||||
- space-default
|
||||
- space-glow
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v7
|
||||
@@ -200,6 +202,18 @@ jobs:
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
fi
|
||||
;;
|
||||
space-default)
|
||||
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-default-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-default-candidate.json
|
||||
;;
|
||||
space-glow)
|
||||
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-candidate.json
|
||||
if ! grep -q "light_pools" ../baseline/src/space-card.ts; then
|
||||
echo "Base predates opt-in static Glow; bootstrap relative baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown performance profile: $PROFILE"
|
||||
exit 1
|
||||
@@ -227,6 +241,12 @@ jobs:
|
||||
overlay)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
;;
|
||||
space-default)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-default.json --baseline=../artifacts/performance/space-default-baseline.json --candidate=../artifacts/performance/space-default-candidate.json --output=../artifacts/performance/space-default-comparison.json
|
||||
;;
|
||||
space-glow)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance report
|
||||
|
||||
@@ -32,6 +32,13 @@ jobs:
|
||||
preflight:
|
||||
name: "Предполётные проверки: документация, провенанс, процесс"
|
||||
runs-on: ubuntu-latest
|
||||
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
|
||||
# проверка 8 процессного гейта. Права перечислены явно, потому что job
|
||||
# обращается к API сверх содержимого репозитория.
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
steps:
|
||||
# `blob:none` при полной истории (#345): этой job нужны сообщения
|
||||
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
|
||||
@@ -69,12 +76,38 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||||
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
|
||||
# штатно отменяется следующим (concurrency), и тогда его коммиты не судит
|
||||
# никто: свой прогон отменён, а следующий пуш сравнивает уже с ними.
|
||||
#
|
||||
# База — последний предок с УСПЕШНО завершённым Validate. Фолбэк остаётся
|
||||
# прежним `before`: расширять диапазон здесь нельзя, иначе гейт, который
|
||||
# сам красит прогон, лишает следующий пуш зелёного предка и запирает dev
|
||||
# в красноте навсегда.
|
||||
- name: "База диапазона: последний доказанно зелёный предок"
|
||||
id: range
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
FALLBACK: ${{ github.event.before }}
|
||||
run: |
|
||||
# `status=completed`, а не `success`: гейтам диапазона нужен факт
|
||||
# «коммит судили», а не «вердикт был оправдательный». Упавший прогон
|
||||
# коммит судил; отменённый — нет, его отсеивает judgedShas (#388).
|
||||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||||
-f branch=dev -f status=completed -F per_page=100 \
|
||||
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
|
||||
--fallback="$FALLBACK" --runs=/tmp/validate-runs.json
|
||||
- name: "Провенанс коммитов: трейлеры и эталоны"
|
||||
id: provenance
|
||||
continue-on-error: true
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
@@ -92,7 +125,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
@@ -140,20 +173,65 @@ jobs:
|
||||
changes:
|
||||
name: Классификация изменённых файлов
|
||||
runs-on: ubuntu-latest
|
||||
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
|
||||
# содержимого репозитория (#387).
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
|
||||
# судить сам диапазон на dev (#388). Общее имя означало бы, что
|
||||
# потребитель однажды молча возьмёт чужую базу.
|
||||
range_base: ${{ steps.base.outputs.range_base }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
# блобы истории этой job не нужны ни на одном шаге (#345).
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
|
||||
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
|
||||
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
|
||||
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
|
||||
# без единого исполненного тяжёлого гейта.
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
FALLBACK: ${{ github.event.before }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
# Недоступность API — не отказ гейта: пустой ответ уводит базу в
|
||||
# сторону БОЛЬШЕГО объёма проверок, а не меньшего.
|
||||
# Один запрос на оба режима: `completed` — надмножество `success`,
|
||||
# а нужный предикат применяет скрипт (#387 — успех, #388 — судимость).
|
||||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||||
-f branch="$BRANCH" -f status=completed -F per_page=100 \
|
||||
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
# На dev классифицировать нечего (всё true), но база диапазона
|
||||
# нужна гейту «новый код не добавляет any» в job frontend (#388).
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
|
||||
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
|
||||
exit 0
|
||||
fi
|
||||
merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1")
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" \
|
||||
--merge-base="$merge_base" --runs=/tmp/validate-runs.json
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
GREEN_BASE: ${{ steps.base.outputs.base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
@@ -163,39 +241,45 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
zero=$(printf '%040d' 0)
|
||||
base="$BEFORE_SHA"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
# #347: непустой before, которого больше не существует, — это
|
||||
# force-push (ребейз/аменд переписал историю ветки). Диапазон в
|
||||
# этом случае НЕ угадывается: merge-base после переписанной истории
|
||||
# уже выдавал «изменились только два doc-файла» при реальном диффе
|
||||
# в custom_components/**, и тяжёлые job тихо скипались, а общий
|
||||
# статус рана оставался success — ровно класс тихого пропуска
|
||||
# #171/#207. Force-push редок, полный прогон дешевле ложного
|
||||
# «зелёного».
|
||||
if [ -n "$base" ] && [ "$base" != "$zero" ] \
|
||||
&& ! git cat-file -e "$base" 2>/dev/null; then
|
||||
echo "force-push: before=$base переписан — полный прогон без классификации"
|
||||
# force-push (ребейз/аменд переписал историю ветки). Диапазон в этом
|
||||
# случае НЕ угадывается: merge-base после переписанной истории уже
|
||||
# выдавал «изменились только два doc-файла» при реальном диффе в
|
||||
# custom_components/**, и тяжёлые job тихо скипались, а общий статус
|
||||
# рана оставался success — ровно класс тихого пропуска #171/#207.
|
||||
# Force-push редок, полный прогон дешевле ложного «зелёного».
|
||||
#
|
||||
# Проверка сохранена как есть и после #387: там база стала доказуемой
|
||||
# (зелёный предок), но механизм, из-за которого merge-base врал на
|
||||
# переписанной истории, не разобран до конца. Снимать защиту, не
|
||||
# объяснив её, — способ получить #347 второй раз.
|
||||
if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "$zero" ] \
|
||||
&& ! git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
|
||||
echo "force-push: before=$BEFORE_SHA переписан — полный прогон без классификации"
|
||||
{
|
||||
echo "### Классификация изменённых файлов"
|
||||
echo "\`before\` (=$base) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "все тяжёлые job запускаются без фильтра путей (#347)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
|
||||
# иначе классифицировалась бы вся история.
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ]; then
|
||||
git fetch -q origin dev
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
|
||||
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
|
||||
# а не от головы предыдущего пуша: его прогон штатно отменяется
|
||||
# следующим пушем. PR — исключение: там база задана событием.
|
||||
base="$GREEN_BASE"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "база не определена — полный прогон без классификации"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
|
||||
{
|
||||
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
|
||||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
|
||||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -339,17 +423,24 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch -q origin dev
|
||||
zero=$(printf '%040d' 0)
|
||||
base="$BEFORE_SHA"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
# Пуш прямо в dev: диапазон — то, что этот пуш добавил. Новая ветка или
|
||||
# перезаписанная история: точки отсчёта нет, берём merge-base с dev.
|
||||
if [ "$REF" != "refs/heads/dev" ] || [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
base=""
|
||||
# Пуш прямо в dev: диапазон — то, что добавлено с последнего
|
||||
# ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша:
|
||||
# его прогон штатно отменяется следующим, и добавленные им строки не
|
||||
# судил бы никто. Фолбэк — прежний `before`.
|
||||
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
|
||||
base="${PROVEN_BASE:-$BEFORE_SHA}"
|
||||
fi
|
||||
# Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev.
|
||||
# Там диапазон и так шире, поэтому #388 этих случаев не касается.
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
|
||||
fi
|
||||
@@ -441,6 +532,14 @@ jobs:
|
||||
path: test-build
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
# Гард исключений внутри карточки объявлен в demo/serve.mjs с 2026-07-27 и
|
||||
# до #404 не срабатывал ни разу: счётчик читался раньше, чем Playwright
|
||||
# доставлял pageerror. Такое ловится только запуском, поэтому пробы живут
|
||||
# здесь — в единственной job с настоящим браузером. Один шард из трёх:
|
||||
# проверка не зависит от разбиения, а платить за неё трижды незачем.
|
||||
- name: "Гард исключений умеет падать (#404)"
|
||||
if: matrix.shard == 1
|
||||
run: node demo/guard/verify-guard.mjs
|
||||
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
@@ -461,6 +560,12 @@ jobs:
|
||||
echo "ok $name"
|
||||
else
|
||||
echo "FAIL $name"
|
||||
# Диагностические строки смока печатаются раньше вердикта, и
|
||||
# `tail -20` их срезал: на #411 сам смок сообщал, НАСКОЛЬКО
|
||||
# разошлись кадры (введено в #302), а в логе прогона осталось
|
||||
# только «expected true, got false». Числа теперь достаются
|
||||
# адресно — они и есть разница между «чинить» и «гадать».
|
||||
grep -aE 'pixel-diffs|^EXC |diagnostic' "/tmp/smoke-logs/$name.log" | tail -5 || true
|
||||
tail -20 "/tmp/smoke-logs/$name.log"
|
||||
fail=1
|
||||
fi
|
||||
@@ -537,6 +642,14 @@ jobs:
|
||||
path: dist
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
# Маркер предыдущего падения с ТЕМ ЖЕ ключом (#386). Восстанавливается до
|
||||
# прогона, потому что после падения шаги кэша уже не помогут отличить
|
||||
# виновника от свидетеля.
|
||||
- name: Прежнее падение на тех же входах
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .fail-marker
|
||||
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
- name: Capture or verify golden matrix
|
||||
id: golden
|
||||
run: |
|
||||
@@ -553,6 +666,25 @@ jobs:
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
# Красная golden обязана сказать, ЧЕЙ это долг (#386). Без этого письмо
|
||||
# «Run failed» называет коммит, который пушнули следующим: 29 августа так
|
||||
# был назван документ ревью `0f7b6f5`, а сцену без эталона добавил
|
||||
# `dbbe94ae`. Вердикт job не меняется — меняется только формулировка.
|
||||
- name: Кто уронил golden
|
||||
id: fail_note
|
||||
if: failure()
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node scripts/gate-reuse.mjs --job=golden --note --marker=.fail-marker
|
||||
- name: Запомнить первопричину
|
||||
if: failure() && steps.fail_note.outputs.first == 'true'
|
||||
# Гонка двух прогонов даёт «Cache already exists»; для маркера падения
|
||||
# это тем более не отказ гейта — job и так красная по существу.
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: .fail-marker
|
||||
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
@@ -615,9 +747,11 @@ jobs:
|
||||
- name: Capture the heaviest Glow state
|
||||
run: |
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||||
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/space-candidate.json
|
||||
- name: Enforce absolute smoke ceilings
|
||||
run: |
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
|
||||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
@@ -657,10 +791,57 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/setup-python@v7
|
||||
with: { python-version: "3.13" }
|
||||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
- name: Backend unit tests (pure + HA harness)
|
||||
run: python -m pytest tests_backend/ -q
|
||||
with: { python-version: "3.14" }
|
||||
- run: |
|
||||
# Точные версии — в tests_backend/requirements.txt (#392): без них
|
||||
# резолвер выбирает набор сам, и «зелёный backend» значит разное
|
||||
# в разные дни. #42 ставит из того же файла ruff для шага линта.
|
||||
pip install -r tests_backend/requirements.txt
|
||||
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
|
||||
- name: Линт бэкенда (ruff, узкий набор)
|
||||
run: python -m ruff check custom_components/houseplan
|
||||
# #42: типизация была измеримой только локально — CI её не исполнял, и
|
||||
# регрессия в любом из allowlist-модулей проходила молча. Список модулей
|
||||
# берётся ИЗ pyproject.toml, а не дублируется здесь: разошедшийся дубль
|
||||
# означал бы зелёный шаг, проверяющий не то. Пустой список — отказ.
|
||||
- name: Типы бэкенда (mypy strict по allowlist)
|
||||
run: |
|
||||
modules=$(python -c "
|
||||
import tomllib
|
||||
cfg = tomllib.load(open('pyproject.toml', 'rb'))
|
||||
for o in cfg['tool']['mypy'].get('overrides', []):
|
||||
if o.get('strict'):
|
||||
print(' '.join('-p ' + m for m in o['module']))
|
||||
")
|
||||
test -n "$modules" || { echo '::error::strict-allowlist в pyproject.toml пуст — проверять нечего'; exit 1; }
|
||||
echo "mypy strict: $modules"
|
||||
python -m mypy $modules
|
||||
# #42: защита от тихого скипа HA-harness — импорт и порог collect
|
||||
- name: HA-harness присутствует
|
||||
run: |
|
||||
python -c "import homeassistant"
|
||||
collected=$(python -m pytest tests_backend/ -q --collect-only 2>/dev/null | grep -c "test_ha_" || true)
|
||||
echo "collected HA-harness tests: $collected"
|
||||
test "$collected" -ge 50
|
||||
- name: Backend unit tests (pure + HA harness, coverage)
|
||||
run: >
|
||||
python -m pytest tests_backend/ -q
|
||||
--cov=custom_components/houseplan --cov-branch
|
||||
--cov-report=xml --cov-report=term
|
||||
- name: Support relay unit tests
|
||||
run: python -m unittest discover -s scripts/support-relay/tests -q
|
||||
# #42: гейт «не ниже baseline» — пороги 90/95 поднимаются trivial-правкой числа
|
||||
- name: Порог покрытия не ниже baseline
|
||||
run: |
|
||||
baseline=$(head -1 scripts/backend-coverage-baseline.txt)
|
||||
actual=$(python -c "import xml.etree.ElementTree as ET; print(round(float(ET.parse('coverage.xml').getroot().get('line-rate'))*100, 1))")
|
||||
echo "coverage: $actual% (baseline $baseline%)"
|
||||
python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)"
|
||||
- name: Артефакт coverage.xml
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: coverage.xml
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
|
||||
@@ -15,10 +15,8 @@ from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FILES_URL,
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
@@ -38,8 +36,12 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
|
||||
from .frontend_assets import HouseplanFrontendAssetView
|
||||
from .http_api import (
|
||||
HouseplanContentView,
|
||||
HouseplanImportPreviewView,
|
||||
HouseplanUploadView,
|
||||
)
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, DOMAIN
|
||||
|
||||
@@ -47,7 +47,19 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.69.0-beta.3"
|
||||
VERSION = "1.70.0-beta.2"
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
# URL would turn the integration into an SSRF proxy and make the privacy notice
|
||||
# false. The relay is deployed and operated by the project; changing it is a
|
||||
# reviewed release change, not a Home Assistant option.
|
||||
SUPPORT_RELAY_URL = "https://support.houseplan.tech/v1/reports"
|
||||
SUPPORT_PREVIEW_TTL_S = 10 * 60
|
||||
MAX_SUPPORT_PREVIEWS_PER_USER = 3
|
||||
MAX_SUPPORT_PREVIEWS_TOTAL = 3
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES = 8 * 1024 * 1024
|
||||
MAX_SUPPORT_MESSAGE_CODEPOINTS = 10_000
|
||||
MAX_SUPPORT_CONTACT_CODEPOINTS = 320
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
@@ -63,8 +75,46 @@ MAX_IMPORT_PREVIEWS_PER_USER = 3
|
||||
# accounts.
|
||||
MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
DEFAULT_CONFIG: dict[str, object] = {
|
||||
"spaces": [],
|
||||
"markers": [],
|
||||
"settings": {"bg_mode": "daynight"},
|
||||
}
|
||||
|
||||
# #42: THE stable public error-code contract. Every code a user-facing
|
||||
# failure can carry — send_error literals, exception-class codes and the
|
||||
# literal MarkerControlError codes — lives here; the scanner in
|
||||
# tests_backend/test_backend_quality.py fails when a source emits a code
|
||||
# outside this set (fail-closed), and every fixed code has a localized
|
||||
# `backup.error.<code>` message on the frontend.
|
||||
ERROR_CODES: frozenset[str] = frozenset({
|
||||
"capacity_exceeded", "commit_failed", "conflict",
|
||||
"content_confirmation_required", "duplicate_marker_control",
|
||||
"future_model", "in_use", "invalid_config", "invalid_content",
|
||||
"invalid_data", "invalid_format", "invalid_json", "invalid_layout",
|
||||
"invalid_light_entity", "invalid_marker_control", "invalid_name",
|
||||
"invalid_partition_opening_host",
|
||||
"invalid_partition_opening_jamb_margin", "invalid_passage_fields",
|
||||
"invalid_space_id", "invalid_toggle_entity", "invalid_value_badge",
|
||||
"invalid_value_badge_attribute", "invalid_value_badge_position",
|
||||
"invalid_value_badge_source", "invalid_value_source",
|
||||
"invalid_value_source_attribute", "io_error", "marker_control_cycle",
|
||||
"marker_control_missing", "marker_control_not_light",
|
||||
"marker_control_self", "missing_content", "missing_plan", "no_backup",
|
||||
"not_ready", "not_toggleable", "nothing_to_repair", "preview_expired",
|
||||
"preview_owner_mismatch", "space_in_use", "space_not_found",
|
||||
"support_invalid_message", "support_package_too_large",
|
||||
"support_preview_expired", "support_rate_limited", "support_rejected",
|
||||
"support_unavailable",
|
||||
"too_large", "unauthorized", "unsupported_export_version",
|
||||
"value_badge_source_required", "wall_model_client_outdated",
|
||||
"wall_model_migration_blocked",
|
||||
})
|
||||
|
||||
# Template-code families: f-string codes carry one of these prefixes and are
|
||||
# served by the generic per-code fallback on the frontend.
|
||||
ERROR_CODE_FAMILIES: tuple[str, ...] = (
|
||||
"junction_limit_",
|
||||
"value_badge_",
|
||||
"value_source_",
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ import copy
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
LATTICE_GRID_N = 240
|
||||
|
||||
@@ -1,113 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "b76f28b2db2f8889fefaa8ea917e3afbad86383c2ea21d1fec3eba92af67c5b6",
|
||||
"fingerprint": "6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-DCggDxve.js",
|
||||
"houseplan-assets/houseplan-card-CZDS_78e.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 273697,
|
||||
"initialViewGzipBytes": 290378,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
|
||||
"houseplan-assets/de-CDaPitbg.js",
|
||||
"houseplan-assets/editor-CasKiVVQ.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js"
|
||||
"houseplan-assets/backdrop-pick-BOES8LdP.js",
|
||||
"houseplan-assets/de-CAtsunWz.js",
|
||||
"houseplan-assets/editor-DCuNBi0k.js",
|
||||
"houseplan-assets/fr-Bqax3ohv.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
|
||||
],
|
||||
"lazyGzipBytes": 168977,
|
||||
"lazyGzipBytes": 205828,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
|
||||
"houseplan-assets/editor-CasKiVVQ.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js"
|
||||
"houseplan-assets/backdrop-pick-BOES8LdP.js",
|
||||
"houseplan-assets/editor-DCuNBi0k.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 139700,
|
||||
"lazyEditorGzipBytes": 148719,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js"
|
||||
"houseplan-assets/backdrop-pick-BOES8LdP.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 9445,
|
||||
"lazyOnboardingGzipBytes": 14029,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-CDaPitbg.js"
|
||||
"houseplan-assets/de-CAtsunWz.js",
|
||||
"houseplan-assets/fr-Bqax3ohv.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 22783,
|
||||
"lazyLocaleGzipBytes": 50151,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-qoGkr9B3.js",
|
||||
"sha256": "e3bf2e1ca0f3352a31390d29596c901b60d3451bd65acb11045a4d2b9ab666a6",
|
||||
"rawBytes": 6967,
|
||||
"gzipBytes": 2951,
|
||||
"path": "houseplan-assets/backdrop-pick-BOES8LdP.js",
|
||||
"sha256": "aea5ea988e5aff3ffa8765511b0ed96acd7b4b3b5f3719242360716f3c6bd502",
|
||||
"rawBytes": 20636,
|
||||
"gzipBytes": 7071,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DCggDxve.js"
|
||||
"houseplan-assets/houseplan-card-CZDS_78e.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-CDaPitbg.js",
|
||||
"sha256": "968d659500403eee7ab7c8a0d6159621972df977b0b52a20c324921b6e309e09",
|
||||
"rawBytes": 74836,
|
||||
"gzipBytes": 22783,
|
||||
"path": "houseplan-assets/de-CAtsunWz.js",
|
||||
"sha256": "883dd5ab7905636373de30c2793fc5418d867087644dc198f93576eb4b87cd3a",
|
||||
"rawBytes": 83981,
|
||||
"gzipBytes": 25347,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-CasKiVVQ.js",
|
||||
"sha256": "c0ec3ab48e3d7a5bb767f02e7519ecdff932e4cb70a34f562a67b65f322c2350",
|
||||
"rawBytes": 3831,
|
||||
"path": "houseplan-assets/editor-DCuNBi0k.js",
|
||||
"sha256": "55ddef2226d3bc262b582216e556c667ad3dd8c2e793891afee33d1fb6687bdb",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1581,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DCggDxve.js"
|
||||
"houseplan-assets/houseplan-card-CZDS_78e.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-DCggDxve.js",
|
||||
"sha256": "57bd9d474cdccceed23e0acf6b309ea216a29debf5eed20657ba978307b80133",
|
||||
"rawBytes": 964180,
|
||||
"gzipBytes": 272947,
|
||||
"path": "houseplan-assets/fr-Bqax3ohv.js",
|
||||
"sha256": "5f75459b58f7bd380c21eb6998b417e743f65270425b2bc1dc46e9785d71ac56",
|
||||
"rawBytes": 86212,
|
||||
"gzipBytes": 24804,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CZDS_78e.js",
|
||||
"sha256": "7bac8a128b73c811e62484ce99a13a121b7bb25c1dfc8fa48a49715f1c4a3690",
|
||||
"rawBytes": 1032271,
|
||||
"gzipBytes": 289581,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-CDaPitbg.js",
|
||||
"houseplan-assets/editor-CasKiVVQ.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js"
|
||||
"houseplan-assets/de-CAtsunWz.js",
|
||||
"houseplan-assets/editor-DCuNBi0k.js",
|
||||
"houseplan-assets/fr-Bqax3ohv.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js",
|
||||
"sha256": "4d0827d46f53b2ed22be6bf7a16dd9ab4fa97a04d340f236165eb6cd281891f9",
|
||||
"rawBytes": 523356,
|
||||
"gzipBytes": 135168,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DvhfS-p7.js",
|
||||
"sha256": "0d15c5f868b3684dbf6d81ebfbe925f874f08b3e8a68db12f160f3807d22ee20",
|
||||
"rawBytes": 543272,
|
||||
"gzipBytes": 140067,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
|
||||
"houseplan-assets/houseplan-card-DCggDxve.js"
|
||||
"houseplan-assets/backdrop-pick-BOES8LdP.js",
|
||||
"houseplan-assets/houseplan-card-CZDS_78e.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js",
|
||||
"sha256": "292254483f1dd92dccbafc1600335127cf50206b5a58e53972ef514c7368c9f6",
|
||||
"rawBytes": 26195,
|
||||
"gzipBytes": 6494,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-BeUYskCk.js",
|
||||
"sha256": "c119a22e0435de0f3e7ed8c62f5813f7aeee31e1da8698f3506ab19e4a7c87a1",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6958,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
|
||||
"houseplan-assets/houseplan-card-DCggDxve.js"
|
||||
"houseplan-assets/backdrop-pick-BOES8LdP.js",
|
||||
"houseplan-assets/houseplan-card-CZDS_78e.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "c463ada4a8f02a5decf4a62a425cffb08f7d8ef22ec9c964e05474dce8b8aa16",
|
||||
"rawBytes": 1089,
|
||||
"gzipBytes": 750,
|
||||
"sha256": "d538bbccca6042eee23ee0df4506530c5bf121824167e6a30cac722ce6ba1b1c",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 797,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DCggDxve.js"
|
||||
"houseplan-assets/houseplan-card-CZDS_78e.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b76f28b2db2f8889fefaa8ea917e3afbad86383c2ea21d1fec3eba92af67c5b6";import{c as e,l as o,t,cZ as s,A as a,c_ as i,c$ as l,d0 as n,E as r,b as c}from"./houseplan-card-DCggDxve.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(h.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-CZDS_78e.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b76f28b2db2f8889fefaa8ea917e3afbad86383c2ea21d1fec3eba92af67c5b6";try{await import("./houseplan-assets/houseplan-card-DCggDxve.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).":"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__="6235a41ecc5f366d871f78c16e149901360ea2a58761e839b0f53c6527b5e9ed";try{await import("./houseplan-assets/houseplan-card-CZDS_78e.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
try:
|
||||
from homeassistant.components.http import KEY_HASS
|
||||
except ImportError: # pragma: no cover - old HA compatibility
|
||||
|
||||
@@ -12,7 +12,6 @@ from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
try: # KEY_HASS — the modern way to access hass from the aiohttp application
|
||||
@@ -21,11 +20,15 @@ except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
CONTENT_URL,
|
||||
FILES_DIR,
|
||||
MAX_EXPORT_BYTES,
|
||||
MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT,
|
||||
PLANS_DIR,
|
||||
)
|
||||
from .import_export import ImportFailure, create_preview
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
@@ -113,7 +116,7 @@ class HouseplanImportPreviewView(HomeAssistantView):
|
||||
except ImportFailure as err:
|
||||
status = 413 if err.code == "too_large" else 400
|
||||
return web.json_response({"error": err.code, "message": err.message}, status=status)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001 - the HTTP boundary must answer 400, never leak a traceback
|
||||
_LOGGER.exception("House Plan import preview failed")
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
return web.json_response(result)
|
||||
@@ -264,7 +267,7 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
break
|
||||
if pending:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
except Exception as err: # noqa: BLE001
|
||||
except Exception as err: # noqa: BLE001 - a broken multipart stream must answer 400, not crash the view
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
error = ({"error": "bad_request"}, 400)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ only the exact candidate that produced that preview.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
@@ -24,8 +23,8 @@ import voluptuous as vol
|
||||
from .const import (
|
||||
CONTENT_URL,
|
||||
EXPORT_VERSION,
|
||||
FILES_URL,
|
||||
FILES_DIR,
|
||||
FILES_URL,
|
||||
IMPORT_PREVIEW_TTL_S,
|
||||
MAX_EXPORT_BYTES,
|
||||
MAX_IMPORT_PREVIEWS_PER_USER,
|
||||
@@ -36,10 +35,6 @@ from .const import (
|
||||
VERSION,
|
||||
)
|
||||
from .store import HouseplanData
|
||||
from .wall_segment_model import (
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA,
|
||||
LAYOUT_SCHEMA,
|
||||
@@ -48,16 +43,21 @@ from .validation import (
|
||||
MAX_LAYOUT,
|
||||
MAX_MARKERS,
|
||||
MAX_SPACES,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
sanitize_filename,
|
||||
sanitize_marker_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
)
|
||||
from .wall_segment_model import (
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
|
||||
FORMAT = "houseplan-export"
|
||||
@@ -96,7 +96,7 @@ _DECOR_KIND_FIELDS = {
|
||||
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
|
||||
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
|
||||
"furniture": ("symbol", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
|
||||
}
|
||||
|
||||
|
||||
@@ -501,6 +501,14 @@ def create_export(
|
||||
continue
|
||||
kept.append(ref)
|
||||
marker["controls"] = kept or None
|
||||
# #385(г): the two neutralisation formats below differ on
|
||||
# purpose and are a PAIR — each disarms an external ref the
|
||||
# way its own model represents "no source": the badge keeps
|
||||
# its object with enabled=False/source=None (enabled is part
|
||||
# of the badge model), while value_source's absence IS auto,
|
||||
# so the key is dropped. Both count in dropped_marker_links;
|
||||
# changing either format would break round-trips of existing
|
||||
# exports.
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
ref = source.get("ref") if isinstance(source, dict) \
|
||||
@@ -510,6 +518,13 @@ def create_export(
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
value_source = marker.get("value_source")
|
||||
ref = value_source.get("ref") if isinstance(value_source, dict) \
|
||||
and value_source.get("kind") == "derived_marker_state" else None
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
marker.pop("value_source", None)
|
||||
dropped_marker_links += 1
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
@@ -699,7 +714,7 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
|
||||
if not isinstance(transfer, dict):
|
||||
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
|
||||
value = transfer.get("dropped_marker_links", 0)
|
||||
maximum = MAX_MARKERS * (MAX_CONTROLS + 1)
|
||||
maximum = MAX_MARKERS * (MAX_CONTROLS + 2)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= maximum:
|
||||
raise ImportFailure("invalid_format", "Invalid dropped marker link count")
|
||||
return value
|
||||
@@ -803,6 +818,16 @@ def _drop_invalid_import_marker_links(
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped += 1
|
||||
source = marker.get("value_source")
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
target_id = ref[len("marker:"):] \
|
||||
if isinstance(ref, str) and ref.startswith("marker:") else ""
|
||||
target = by_id.get(target_id)
|
||||
if not target_id or target is None or target.get("removed") is True \
|
||||
or target.get("is_light") is not True:
|
||||
marker.pop("value_source", None)
|
||||
dropped += 1
|
||||
return dropped
|
||||
|
||||
|
||||
@@ -1110,6 +1135,20 @@ def _repair_target_space_refs(
|
||||
preserve_related(
|
||||
marker_id, "marker.value_badge", old_target, resolve_marker,
|
||||
)
|
||||
source = marker.get("value_source")
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
old_target = ref[len("marker:"):] \
|
||||
if isinstance(ref, str) and ref.startswith("marker:") else None
|
||||
mapped = replace(
|
||||
marker_id, "marker.value_source", old_target, resolve_marker_link,
|
||||
)
|
||||
if mapped is not None:
|
||||
source["ref"] = "marker:" + mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.value_source", old_target, resolve_marker,
|
||||
)
|
||||
|
||||
# Destination wins collisions. Rekey only proven dead plan-owned keys;
|
||||
# opaque HA owners remain literal.
|
||||
@@ -1345,13 +1384,17 @@ def build_space_merge(
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
dropped_marker_links += 1
|
||||
value_source = marker.get("value_source")
|
||||
if isinstance(value_source, dict) \
|
||||
and value_source.get("kind") == "derived_marker_state":
|
||||
dropped_marker_links += 1
|
||||
marker["binding"] = "virtual"
|
||||
marker["display"] = "static_icon"
|
||||
for field in (
|
||||
"area", "controls", "tap_action", "tap_target", "tap_confirm",
|
||||
"vacuum", "is_light", "use_climate_temp", "glow_color",
|
||||
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
|
||||
"value_badge",
|
||||
"value_badge", "value_source",
|
||||
):
|
||||
marker.pop(field, None)
|
||||
output_markers.append(marker)
|
||||
@@ -1395,6 +1438,21 @@ def build_space_merge(
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
source = marker.get("value_source")
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
old_target = ref[len("marker:"):] \
|
||||
if isinstance(ref, str) and ref.startswith("marker:") else ""
|
||||
target = None if old_target in virtualized_targets else marker_map.get(old_target)
|
||||
if target:
|
||||
source["ref"] = "marker:" + target
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.value_source",
|
||||
str(marker.get("id", "?")), old_target,
|
||||
)
|
||||
else:
|
||||
marker.pop("value_source", None)
|
||||
dropped_marker_links += 1
|
||||
|
||||
output_markers_by_id = {
|
||||
str(marker.get("id")): marker for marker in output_markers
|
||||
@@ -1667,6 +1725,7 @@ def _materialize_import_candidate(
|
||||
settings = config.get("settings") or {}
|
||||
settings.pop("known_devices", None)
|
||||
settings.pop("new_device_ids", None)
|
||||
settings.pop("marker_area_snapshot", None)
|
||||
details = {
|
||||
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ ugly, not corrupt — see docs/specs/329-junction-limits.md §5.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from .wall_segment_model import (
|
||||
WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model,
|
||||
WALL_SEGMENT_MODEL_VERSION,
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -82,11 +84,11 @@ def _quantize_key_coord(value: float) -> float:
|
||||
return 0.0 if rounded == 0 else rounded
|
||||
|
||||
|
||||
def _key(point) -> str:
|
||||
def _key(point: list[float]) -> str:
|
||||
return f"{_quantize_key_coord(point[0])},{_quantize_key_coord(point[1])}"
|
||||
|
||||
|
||||
def _length(a, b) -> float:
|
||||
def _length(a: list[float], b: list[float]) -> float:
|
||||
return math.hypot(b[0] - a[0], b[1] - a[1])
|
||||
|
||||
|
||||
@@ -94,9 +96,9 @@ def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> f
|
||||
return (cm / (cell_cm or 1)) * grid_pitch
|
||||
|
||||
|
||||
def limit_segments(space: dict) -> list[dict]:
|
||||
def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
|
||||
segments: list[dict] = []
|
||||
segments: list[dict[str, Any]] = []
|
||||
for segment in space.get("wall_segments") or []:
|
||||
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
|
||||
segments.append({
|
||||
@@ -128,7 +130,7 @@ def limit_segments(space: dict) -> list[dict]:
|
||||
if _length(segment["a"], segment["b"]) > _EPS]
|
||||
|
||||
|
||||
def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
|
||||
def check_nodes(segments: list[dict[str, Any]]) -> list[tuple[str, str, float, float]]:
|
||||
"""П1 + П2: valence of a node and the narrowest wedge in it."""
|
||||
rays: dict[str, list[float]] = {}
|
||||
for segment in segments:
|
||||
@@ -163,20 +165,20 @@ def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
|
||||
return violations
|
||||
|
||||
|
||||
def _axis_degrees(segment: dict) -> float:
|
||||
def _axis_degrees(segment: dict[str, Any]) -> float:
|
||||
degrees = math.degrees(math.atan2(
|
||||
segment["b"][1] - segment["a"][1], segment["b"][0] - segment["a"][0]
|
||||
))
|
||||
return (degrees % 180 + 180) % 180
|
||||
|
||||
|
||||
def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool:
|
||||
def _collinear(left: dict[str, Any], right: dict[str, Any], tolerance_deg: float = 1.0) -> bool:
|
||||
delta = abs(_axis_degrees(left) - _axis_degrees(right))
|
||||
return min(delta, 180 - delta) <= tolerance_deg
|
||||
|
||||
|
||||
def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
|
||||
by_node: dict[str, list[dict]] = {}
|
||||
def _build_node_index(segments: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
by_node: dict[str, list[dict[str, Any]]] = {}
|
||||
for item in segments:
|
||||
for point in (item["a"], item["b"]):
|
||||
by_node.setdefault(_key(point), []).append(item)
|
||||
@@ -184,8 +186,8 @@ def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
|
||||
|
||||
|
||||
def collinear_run_length_units(
|
||||
segment: dict, segments: list[dict],
|
||||
by_node_index: dict[str, list[dict]] | None = None,
|
||||
segment: dict[str, Any], segments: list[dict[str, Any]],
|
||||
by_node_index: dict[str, list[dict[str, Any]]] | None = None,
|
||||
) -> float:
|
||||
"""Length of the WALL a segment belongs to, not of the atom.
|
||||
|
||||
@@ -218,7 +220,7 @@ def collinear_run_length_units(
|
||||
return total
|
||||
|
||||
def check_segment_lengths(
|
||||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
) -> list[tuple[str, str, float, float]]:
|
||||
"""П3: a wall is at least 20 cm and never shorter than its own thickness."""
|
||||
violations: list[tuple[str, str, float, float]] = []
|
||||
@@ -235,7 +237,7 @@ def check_segment_lengths(
|
||||
return violations
|
||||
|
||||
|
||||
def _distance_to_segment(point, a, b) -> float:
|
||||
def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length_sq = dx * dx + dy * dy
|
||||
t = 0.0 if length_sq <= _EPS else max(0.0, min(1.0, (
|
||||
@@ -245,7 +247,7 @@ def _distance_to_segment(point, a, b) -> float:
|
||||
|
||||
|
||||
def check_node_distances(
|
||||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
) -> list[tuple[str, str, float, float]]:
|
||||
"""П4: non-incident nodes and node-to-foreign-wall clearance.
|
||||
|
||||
@@ -256,19 +258,19 @@ def check_node_distances(
|
||||
suite. Pair order inside the subject follows the lexicographic key order,
|
||||
which replaces the i<j of the all-pairs loop.
|
||||
"""
|
||||
nodes: dict[str, list] = {}
|
||||
nodes: dict[str, list[Any]] = {}
|
||||
for segment in segments:
|
||||
nodes[_key(segment["a"])] = segment["a"]
|
||||
nodes[_key(segment["b"])] = segment["b"]
|
||||
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
|
||||
size = min_units if min_units > _EPS else 1.0
|
||||
|
||||
node_grid: dict[tuple[int, int], list] = {}
|
||||
node_grid: dict[tuple[int, int], list[Any]] = {}
|
||||
for node_key, point in nodes.items():
|
||||
node_grid.setdefault(
|
||||
(int(point[0] // size), int(point[1] // size)), []
|
||||
).append((node_key, point))
|
||||
segment_grid: dict[tuple[int, int], list] = {}
|
||||
segment_grid: dict[tuple[int, int], list[Any]] = {}
|
||||
for segment in segments:
|
||||
x0 = min(segment["a"][0], segment["b"][0]) - min_units
|
||||
x1 = max(segment["a"][0], segment["b"][0]) + min_units
|
||||
@@ -314,7 +316,7 @@ def check_node_distances(
|
||||
))
|
||||
return violations
|
||||
|
||||
def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
|
||||
def space_violations(space: dict[str, Any]) -> list[tuple[str, str, float, float]]:
|
||||
"""П1–П4 over one space, in the frontend's order."""
|
||||
segments = limit_segments(space)
|
||||
cell_cm = float(space.get("cell_cm") or 1)
|
||||
@@ -325,7 +327,7 @@ def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
|
||||
]
|
||||
|
||||
|
||||
def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str, dict]:
|
||||
def _migrated_spaces(config: dict[str, Any] | None, *, side: str = "previous") -> dict[str, dict[str, Any]]:
|
||||
"""Spaces of one document AFTER the wall-segment migration, by id.
|
||||
|
||||
The limits read `wall_segments`, so a document that predates the catalogue
|
||||
@@ -375,7 +377,7 @@ def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str
|
||||
}
|
||||
|
||||
|
||||
def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]:
|
||||
def space_violation_counts(spaces: dict[str, dict[str, Any]]) -> dict[str, dict[str, int]]:
|
||||
"""Violation counts per space per rule — the shape the barrier consumes
|
||||
and the rev cache (#330 §4.2) stores. Documents are NOT retained."""
|
||||
result: dict[str, dict[str, int]] = {}
|
||||
@@ -388,7 +390,7 @@ def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]
|
||||
|
||||
|
||||
def validate_junction_limits(
|
||||
config: dict, previous: dict | None = None, *,
|
||||
config: dict[str, Any], previous: dict[str, Any] | None = None, *,
|
||||
baseline_counts: dict[str, dict[str, int]] | None = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Refuse a write that ADDS a junction violation; inherit the rest.
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.69.0-beta.3"
|
||||
"version": "1.70.0-beta.2"
|
||||
}
|
||||
|
||||
@@ -297,7 +297,6 @@ def collect_plans(
|
||||
a file-system problem must not turn a durable commit into a failed call.
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
# A commit knows what it superseded. The timer only knows what nothing
|
||||
# points at *right now*, and for a plan that is a reversible state: the
|
||||
# editor detaches the image when a space switches to "draw" and says the
|
||||
|
||||
@@ -85,7 +85,11 @@ rules:
|
||||
comment: No authentication against an external service.
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: Backend covered by pure tests + HA-harness tests in CI; measuring >95% is planned.
|
||||
comment: >-
|
||||
#42 stage 1 shipped the machinery: pytest-cov in CI with a committed
|
||||
baseline gate, coverage.xml artifact and a hard fail on a silently
|
||||
skipped HA harness. The 90%/95% thresholds are raised by follow-up
|
||||
baseline bumps; the rule flips to done at >=95%.
|
||||
# ---- Gold (selected; entity/device rules are exempt — no entities) ----
|
||||
diagnostics:
|
||||
status: done
|
||||
@@ -97,13 +101,23 @@ rules:
|
||||
status: done
|
||||
comment: Missing plan files raise repair issues (translation_key broken_plan).
|
||||
docs-troubleshooting:
|
||||
status: todo
|
||||
status: done
|
||||
comment: >-
|
||||
USER-GUIDE.md #22 Troubleshooting and USER-GUIDE.ru.md #22 «Диагностика»
|
||||
cover load failures, missing devices, geometry, vacuum and reporting.
|
||||
docs-examples:
|
||||
status: todo
|
||||
status: done
|
||||
comment: >-
|
||||
USER-GUIDE carries executable YAML examples for both cards (view,
|
||||
space card with fit/light_pools, filters) and the setup walkthrough.
|
||||
# ---- Platinum ----
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: Python is annotated; mypy strict pass is planned.
|
||||
comment: >-
|
||||
#42 stage 1: mypy strict gates six pure modules (const, projection,
|
||||
coordinate_canonicalization, frontend_asset_manifest, junction_limits,
|
||||
plans) via a grow-only allowlist; the HA-boundary modules are the next
|
||||
stage.
|
||||
async-dependency:
|
||||
status: exempt
|
||||
comment: No dependencies.
|
||||
|
||||
@@ -25,7 +25,6 @@ from .coordinate_canonicalization import (
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_BG_MODES = frozenset({"static", "daynight"})
|
||||
|
||||
@@ -101,6 +100,10 @@ class HouseplanData:
|
||||
# Parsed import candidates are short-lived, user-bound and memory-only.
|
||||
# dict keeps insertion order, which lets the preview service evict oldest.
|
||||
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# #43: already-sanitized support-package bytes only. Raw config/layout is
|
||||
# never retained here; the write lock below is used to take one coherent
|
||||
# deep copy and the privacy projection immediately replaces it.
|
||||
support_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# #330 §4.2: junction-limit violation counts of the STORED document,
|
||||
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
|
||||
# memory-only, invalidated by a rev mismatch; the previous document is
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Strict, allowlisted House Plan support-package projection (#43).
|
||||
|
||||
This module deliberately has no Home Assistant imports. Besides making the
|
||||
privacy boundary easy to test, that prevents an innocent serializer helper from
|
||||
gaining access to registries, states, URLs or paths that the package must never
|
||||
contain. The caller supplies one coherent config/layout copy and bounded,
|
||||
already-classified runtime facts; this code constructs a new object field by
|
||||
field. It never serializes the source and then tries to redact it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .const import (
|
||||
EXPORT_VERSION,
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES,
|
||||
PLAN_MODEL_VERSION,
|
||||
)
|
||||
|
||||
PACKAGE_FORMAT = "houseplan-support-package"
|
||||
PACKAGE_VERSION = 1
|
||||
_SAFE_VERSION = re.compile(r"\A[0-9A-Za-z._+-]{1,32}\Z")
|
||||
_SAFE_ICON = re.compile(r"\Amdi:[a-z0-9-]{1,80}\Z")
|
||||
_LANGUAGES = frozenset({"en", "ru", "de", "fr"})
|
||||
_BROWSERS = frozenset({"chromium", "firefox", "webkit", "unknown"})
|
||||
_REGISTRY_ACCESS = frozenset({"full", "partial", "unavailable"})
|
||||
_REGISTRY_AGE = frozenset({"fresh", "stale", "unknown"})
|
||||
|
||||
|
||||
class SupportPackageError(ValueError):
|
||||
"""A stable public failure without source data in its text."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _safe_version(value: object) -> str:
|
||||
text = str(value or "unknown")
|
||||
return text if _SAFE_VERSION.fullmatch(text) else "unknown"
|
||||
|
||||
|
||||
def validate_frontend_facts(value: object) -> dict[str, Any]:
|
||||
"""Accept only the small runtime enum/boolean contract from the browser."""
|
||||
if not isinstance(value, dict):
|
||||
raise SupportPackageError("support_rejected")
|
||||
browser = value.get("browser_family")
|
||||
language = value.get("language")
|
||||
registry = value.get("registry_access")
|
||||
age = value.get("registry_age_bucket")
|
||||
major = value.get("browser_major")
|
||||
if browser not in _BROWSERS or language not in _LANGUAGES:
|
||||
raise SupportPackageError("support_rejected")
|
||||
if registry not in _REGISTRY_ACCESS or age not in _REGISTRY_AGE:
|
||||
raise SupportPackageError("support_rejected")
|
||||
if isinstance(major, bool) or not isinstance(major, int) or not 0 <= major <= 999:
|
||||
raise SupportPackageError("support_rejected")
|
||||
for key in ("coarse_pointer", "hover_capable"):
|
||||
if not isinstance(value.get(key), bool):
|
||||
raise SupportPackageError("support_rejected")
|
||||
return {
|
||||
"browser_family": browser,
|
||||
"browser_major": major,
|
||||
"language": language,
|
||||
"coarse_pointer": value["coarse_pointer"],
|
||||
"hover_capable": value["hover_capable"],
|
||||
"registry_access": registry,
|
||||
"registry_age_bucket": age,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Pseudonyms:
|
||||
namespace: str
|
||||
maps: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
|
||||
def get(self, kind: str, raw: object) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
value = str(raw)
|
||||
if not value:
|
||||
return None
|
||||
items = self.maps.setdefault(kind, {})
|
||||
if value not in items:
|
||||
items[value] = f"{kind}-{self.namespace}-{len(items) + 1}"
|
||||
return items[value]
|
||||
|
||||
|
||||
def _copy_keys(source: object, keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
if not isinstance(source, dict):
|
||||
return {}
|
||||
return {key: source[key] for key in keys if key in source}
|
||||
|
||||
|
||||
def _point(value: object) -> list[float] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
||||
return None
|
||||
if any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value):
|
||||
return None
|
||||
return [value[0], value[1]]
|
||||
|
||||
|
||||
def _points(value: object) -> list[list[float]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [point for item in value if (point := _point(item)) is not None]
|
||||
|
||||
|
||||
def _entity_ref(ids: _Pseudonyms, value: object) -> str | None:
|
||||
return ids.get("entity", value)
|
||||
|
||||
|
||||
def _binding(ids: _Pseudonyms, value: object) -> tuple[str, str]:
|
||||
text = str(value or "")
|
||||
if text == "virtual":
|
||||
return "virtual", "virtual"
|
||||
kind, separator, raw = text.partition(":")
|
||||
if separator and kind in {"device", "entity"} and raw:
|
||||
# Reuse the same per-kind namespace as controls/value sources so one
|
||||
# raw entity remains one pseudonym everywhere in this package.
|
||||
pseudo = ids.get(kind, raw)
|
||||
return kind, f"{kind}:{pseudo}"
|
||||
return "unknown", "unknown"
|
||||
|
||||
|
||||
def _custom_fill(value: object) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return _copy_keys(value, ("c", "a"))
|
||||
|
||||
|
||||
def _global_settings(value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays"))
|
||||
if not isinstance(value, dict):
|
||||
return out
|
||||
fill_colors = value.get("fill_colors")
|
||||
if isinstance(fill_colors, dict):
|
||||
out["fill_colors"] = {
|
||||
str(key): _copy_keys(item, ("c", "a"))
|
||||
for key, item in fill_colors.items()
|
||||
if isinstance(key, str) and isinstance(item, dict)
|
||||
}
|
||||
style = value.get("decor_default_style")
|
||||
if isinstance(style, dict):
|
||||
out["decor_default_style"] = _copy_keys(
|
||||
style, ("color", "opacity", "width_cm", "fill", "fill_color", "fill_opacity"),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _space_settings(value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, (
|
||||
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
|
||||
"fill_mode", "glow_enabled", "temp_min", "temp_max", "show_lqi",
|
||||
"hide_decor", "hide_openings", "label_temp", "label_hum", "label_lqi",
|
||||
"label_light", "card_font_scale", "north_deg", "bg_mode", "sun_rays",
|
||||
))
|
||||
if isinstance(value, dict) and "custom_fill" in value:
|
||||
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
|
||||
return out
|
||||
|
||||
|
||||
def _room_settings(ids: _Pseudonyms, value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, ("fill_mode", "glow", "name_scale", "label_scale"))
|
||||
if not isinstance(value, dict):
|
||||
return out
|
||||
if "custom_fill" in value:
|
||||
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
|
||||
for key in ("temp_source", "hum_source"):
|
||||
source = value.get(key)
|
||||
if source:
|
||||
kind, pseudo = _binding(ids, source)
|
||||
out[key] = pseudo
|
||||
out[f"{key}_kind"] = kind
|
||||
return out
|
||||
|
||||
|
||||
def _project_room(ids: _Pseudonyms, room: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"id": ids.get("room", room.get("id")),
|
||||
"name": f"Room {index}",
|
||||
}
|
||||
out.update(_copy_keys(room, ("x", "y", "w", "h")))
|
||||
if "poly" in room:
|
||||
out["poly"] = _points(room.get("poly"))
|
||||
if isinstance(room.get("wall_ids"), list):
|
||||
out["wall_ids"] = [ids.get("wall", item) for item in room["wall_ids"]]
|
||||
if isinstance(room.get("open_to"), list):
|
||||
out["open_to"] = [ids.get("room", item) for item in room["open_to"]]
|
||||
settings = _room_settings(ids, room.get("settings"))
|
||||
if settings:
|
||||
out["settings"] = settings
|
||||
return out
|
||||
|
||||
|
||||
def _project_decor(ids: _Pseudonyms, item: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = str(item.get("kind") or "unknown")
|
||||
out: dict[str, Any] = {"id": ids.get("decor", item.get("id")), "kind": kind}
|
||||
out.update(_copy_keys(item, (
|
||||
"color", "opacity", "width_cm", "width", "x1", "y1", "x2", "y2",
|
||||
"line_style", "x", "y", "w", "h", "angle", "fill", "fill_color",
|
||||
"fill_opacity", "size", "size_cm", "scale", "symbol", "flip_h", "flip_v",
|
||||
)))
|
||||
if kind == "text":
|
||||
out["text"] = "[redacted text]"
|
||||
if item.get("entity"):
|
||||
out["entity"] = _entity_ref(ids, item.get("entity"))
|
||||
return out
|
||||
|
||||
|
||||
def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
kind = value.get("kind")
|
||||
if kind in {"entity_state", "entity_attribute"}:
|
||||
out: dict[str, Any] = {"kind": kind, "entity_id": _entity_ref(ids, value.get("entity_id"))}
|
||||
# Attribute names can be arbitrary user strings and are not needed to
|
||||
# reproduce geometry or marker placement, so they are deliberately absent.
|
||||
return out
|
||||
if kind == "derived_lqi":
|
||||
return {"kind": kind}
|
||||
if kind == "derived_marker_state":
|
||||
raw = str(value.get("ref") or "")
|
||||
marker = raw[7:] if raw.startswith("marker:") else raw
|
||||
return {"kind": kind, "ref": f"marker:{ids.get('marker', marker)}"}
|
||||
return None
|
||||
|
||||
|
||||
def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
binding_kind, binding = _binding(ids, marker.get("binding"))
|
||||
out: dict[str, Any] = {
|
||||
"id": ids.get("marker", marker.get("id")),
|
||||
"binding": binding,
|
||||
"binding_kind": binding_kind,
|
||||
}
|
||||
out.update(_copy_keys(marker, (
|
||||
"hidden", "removed", "tap_action", "tap_confirm", "display", "ripple_color",
|
||||
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
|
||||
"use_climate_temp",
|
||||
)))
|
||||
icon = marker.get("icon")
|
||||
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
|
||||
out["icon"] = icon
|
||||
if marker.get("space"):
|
||||
out["space"] = ids.get("space", marker.get("space"))
|
||||
if marker.get("room_id"):
|
||||
out["room_id"] = ids.get("room", marker.get("room_id"))
|
||||
for key in ("light_entity", "toggle_entity", "tap_target"):
|
||||
if marker.get(key):
|
||||
out[key] = _entity_ref(ids, marker.get(key))
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
out["controls"] = [_entity_ref(ids, value) for value in controls]
|
||||
vacuum = marker.get("vacuum")
|
||||
if isinstance(vacuum, dict):
|
||||
out["vacuum"] = _copy_keys(vacuum, ("live", "trail", "trail_mode", "room_highlight"))
|
||||
for key in ("value_source",):
|
||||
projected = _project_value_source(ids, marker.get(key))
|
||||
if projected:
|
||||
out[key] = projected
|
||||
badge = marker.get("value_badge")
|
||||
if isinstance(badge, dict):
|
||||
safe_badge = _copy_keys(badge, ("enabled", "position"))
|
||||
source = _project_value_source(ids, badge.get("source"))
|
||||
if source:
|
||||
safe_badge["source"] = source
|
||||
out["value_badge"] = safe_badge
|
||||
return out
|
||||
|
||||
|
||||
def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"id": ids.get("space", space.get("id")),
|
||||
"title": f"Space {index}",
|
||||
"has_plan": bool(space.get("plan_url")),
|
||||
}
|
||||
out.update(_copy_keys(space, (
|
||||
"cell_cm", "plan_aspect", "plan_x", "plan_y", "plan_scale", "plan_scale_x",
|
||||
"plan_scale_y", "plan_angle", "view_box", "zero_wall_style",
|
||||
)))
|
||||
settings = _space_settings(space.get("settings"))
|
||||
if settings:
|
||||
out["settings"] = settings
|
||||
|
||||
out["rooms"] = [
|
||||
_project_room(ids, room, room_index)
|
||||
for room_index, room in enumerate(space.get("rooms") or [], 1)
|
||||
if isinstance(room, dict)
|
||||
]
|
||||
out["wall_segments"] = [
|
||||
{
|
||||
"id": ids.get("wall", item.get("id")),
|
||||
"a": _point(item.get("a")), "b": _point(item.get("b")), "cm": item.get("cm"),
|
||||
}
|
||||
for item in space.get("wall_segments") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["walls"] = [
|
||||
{
|
||||
"key": ids.get("wall", item.get("key")), "cm": item.get("cm"),
|
||||
**({"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
||||
if "a" in item and "b" in item else {}),
|
||||
}
|
||||
for item in space.get("walls") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["room_drafts"] = []
|
||||
for draft in space.get("room_drafts") or []:
|
||||
if not isinstance(draft, dict):
|
||||
continue
|
||||
out["room_drafts"].append({
|
||||
"id": ids.get("draft", draft.get("id")),
|
||||
"points": _points(draft.get("points")),
|
||||
"segments": [
|
||||
{
|
||||
**({"id": ids.get("wall", segment.get("id"))} if segment.get("id") else {}),
|
||||
"cm": segment.get("cm"),
|
||||
}
|
||||
for segment in draft.get("segments") or [] if isinstance(segment, dict)
|
||||
],
|
||||
})
|
||||
out["partitions"] = [
|
||||
{"id": ids.get("partition", item.get("id")), "a": _point(item.get("a")),
|
||||
"b": _point(item.get("b")), "cm": item.get("cm")}
|
||||
for item in space.get("partitions") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["wall_columns"] = [
|
||||
{"id": ids.get("column", item.get("id")), **_copy_keys(item, ("shape", "center", "cm", "angle"))}
|
||||
for item in space.get("wall_columns") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["openings"] = []
|
||||
for opening in space.get("openings") or []:
|
||||
if not isinstance(opening, dict):
|
||||
continue
|
||||
projected = {"id": ids.get("opening", opening.get("id"))}
|
||||
projected.update(_copy_keys(opening, (
|
||||
"type", "x", "y", "angle", "length", "invert", "flip_h", "flip_v",
|
||||
)))
|
||||
for key in ("contact", "lock"):
|
||||
if opening.get(key):
|
||||
projected[key] = _entity_ref(ids, opening.get(key))
|
||||
host = opening.get("host")
|
||||
if isinstance(host, dict) and host.get("kind") in {"wall", "partition"}:
|
||||
kind = host["kind"]
|
||||
projected["host"] = {
|
||||
"kind": kind,
|
||||
"id": ids.get(kind, host.get("id")),
|
||||
"t": host.get("t"),
|
||||
}
|
||||
out["openings"].append(projected)
|
||||
out["decor"] = [
|
||||
_project_decor(ids, item) for item in space.get("decor") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["open_spans"] = [
|
||||
{"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
||||
for item in space.get("open_spans") or [] if isinstance(item, dict)
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def _project_layout(ids: _Pseudonyms, layout: object) -> dict[str, Any]:
|
||||
if not isinstance(layout, dict):
|
||||
return {}
|
||||
marker_ids = ids.maps.get("marker", {})
|
||||
room_ids = ids.maps.get("room", {})
|
||||
out: dict[str, Any] = {}
|
||||
for raw_key, value in layout.items():
|
||||
if not isinstance(raw_key, str) or not isinstance(value, dict):
|
||||
continue
|
||||
if raw_key in marker_ids:
|
||||
key = marker_ids[raw_key]
|
||||
elif raw_key.startswith("rl_") and raw_key[3:] in room_ids:
|
||||
key = "rl_" + room_ids[raw_key[3:]]
|
||||
else:
|
||||
# Unknown keys may be stale device ids. Omitting them is the only
|
||||
# fail-closed choice; copying them would disclose the raw id.
|
||||
continue
|
||||
position = _copy_keys(value, ("x", "y", "k"))
|
||||
if value.get("s"):
|
||||
position["s"] = ids.get("space", value.get("s"))
|
||||
out[key] = position
|
||||
return out
|
||||
|
||||
|
||||
def _summary(config: object, layout: object) -> dict[str, Any]:
|
||||
spaces = config.get("spaces", []) if isinstance(config, dict) else []
|
||||
markers = config.get("markers", []) if isinstance(config, dict) else []
|
||||
kinds: Counter[str] = Counter()
|
||||
bindings: Counter[str] = Counter()
|
||||
lifecycles: Counter[str] = Counter()
|
||||
decor_kinds: Counter[str] = Counter()
|
||||
for marker in markers:
|
||||
if not isinstance(marker, dict):
|
||||
continue
|
||||
bindings[_binding_kind(marker.get("binding"))] += 1
|
||||
lifecycles[
|
||||
"removed" if marker.get("removed") else "hidden" if marker.get("hidden") else "active"
|
||||
] += 1
|
||||
for space in spaces:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
for opening in space.get("openings") or []:
|
||||
if isinstance(opening, dict):
|
||||
kinds[str(opening.get("type") or "unknown")] += 1
|
||||
for item in space.get("decor") or []:
|
||||
if isinstance(item, dict):
|
||||
decor_kinds[str(item.get("kind") or "unknown")] += 1
|
||||
return {
|
||||
"spaces": len(spaces),
|
||||
"rooms": sum(len(space.get("rooms") or []) for space in spaces if isinstance(space, dict)),
|
||||
"room_drafts": sum(len(space.get("room_drafts") or []) for space in spaces if isinstance(space, dict)),
|
||||
"walls": sum(len(space.get("wall_segments") or space.get("walls") or []) for space in spaces if isinstance(space, dict)),
|
||||
"partitions": sum(len(space.get("partitions") or []) for space in spaces if isinstance(space, dict)),
|
||||
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
|
||||
"openings": dict(sorted(kinds.items())),
|
||||
"decor": dict(sorted(decor_kinds.items())),
|
||||
"markers": {
|
||||
"total": len(markers),
|
||||
"lifecycle": dict(sorted(lifecycles.items())),
|
||||
"binding": dict(sorted(bindings.items())),
|
||||
},
|
||||
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
|
||||
}
|
||||
|
||||
|
||||
def _binding_kind(value: object) -> str:
|
||||
text = str(value or "")
|
||||
if text == "virtual":
|
||||
return "virtual"
|
||||
if text.startswith("device:"):
|
||||
return "device"
|
||||
if text.startswith("entity:"):
|
||||
return "entity"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def build_support_package(
|
||||
config: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
*,
|
||||
config_rev: int,
|
||||
layout_rev: int,
|
||||
card_version: str,
|
||||
integration_version: str,
|
||||
home_assistant_version: str,
|
||||
runtime: dict[str, Any],
|
||||
repairs: list[dict[str, Any]] | None = None,
|
||||
namespace: str | None = None,
|
||||
) -> tuple[bytes, dict[str, Any]]:
|
||||
"""Build canonical bytes and a bounded summary for the preview response."""
|
||||
facts = validate_frontend_facts(runtime)
|
||||
ids = _Pseudonyms(namespace or secrets.token_hex(4))
|
||||
spaces = [item for item in config.get("spaces", []) if isinstance(item, dict)]
|
||||
markers = [item for item in config.get("markers", []) if isinstance(item, dict)]
|
||||
projected_spaces = [
|
||||
_project_space(ids, space, index) for index, space in enumerate(spaces, 1)
|
||||
]
|
||||
projected_markers = [_project_marker(ids, marker) for marker in markers]
|
||||
summary = _summary(config, layout)
|
||||
package = {
|
||||
"format": PACKAGE_FORMAT,
|
||||
"version": PACKAGE_VERSION,
|
||||
"versions": {
|
||||
"card": _safe_version(card_version),
|
||||
"integration": _safe_version(integration_version),
|
||||
"home_assistant": _safe_version(home_assistant_version),
|
||||
"model": PLAN_MODEL_VERSION,
|
||||
"export_schema": EXPORT_VERSION,
|
||||
},
|
||||
"runtime": facts,
|
||||
"revisions": {"config": int(config_rev), "layout": int(layout_rev)},
|
||||
"summary": summary,
|
||||
"validation": {
|
||||
"config": "valid", "layout": "valid", "unknown_fields": "dropped",
|
||||
},
|
||||
"repairs": repairs or [],
|
||||
"plan_backup": {
|
||||
"config": {
|
||||
"model_version": int(config.get("model_version", PLAN_MODEL_VERSION)),
|
||||
"settings": _global_settings(config.get("settings")),
|
||||
"spaces": projected_spaces,
|
||||
"markers": projected_markers,
|
||||
},
|
||||
"layout": _project_layout(ids, layout),
|
||||
},
|
||||
}
|
||||
raw = (json.dumps(
|
||||
package, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
|
||||
) + "\n").encode("utf-8")
|
||||
if len(raw) > MAX_SUPPORT_ATTACHMENT_BYTES:
|
||||
raise SupportPackageError("support_package_too_large")
|
||||
return raw, {
|
||||
"size": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"spaces": summary["spaces"],
|
||||
"format": PACKAGE_FORMAT,
|
||||
"version": PACKAGE_VERSION,
|
||||
"versions": package["versions"],
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Bounded outbound submit to the compile-time House Plan support relay (#43)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import aiohttp
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import SUPPORT_RELAY_URL
|
||||
|
||||
_REPORT_ID_PREFIX = "hpr-"
|
||||
|
||||
|
||||
class SupportTransportError(RuntimeError):
|
||||
"""Stable frontend-facing error; never contains the remote response."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
async def async_submit_report(
|
||||
hass,
|
||||
*,
|
||||
message: str,
|
||||
contact: str,
|
||||
versions: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
attachment: bytes | None,
|
||||
attachment_sha256: str | None,
|
||||
filename_token: str,
|
||||
) -> str:
|
||||
"""Send once, without redirects, proxies, arbitrary hosts or reflected text."""
|
||||
target = urlsplit(SUPPORT_RELAY_URL)
|
||||
if target.scheme != "https" or target.hostname != "support.houseplan.tech":
|
||||
raise SupportTransportError("support_unavailable")
|
||||
|
||||
request: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"message": message,
|
||||
"idempotency_key": idempotency_key,
|
||||
"versions": {key: str(value) for key, value in versions.items()},
|
||||
}
|
||||
if contact:
|
||||
request["contact"] = contact
|
||||
if attachment is not None:
|
||||
request["attachment"] = {
|
||||
"size": len(attachment),
|
||||
"sha256": str(attachment_sha256 or ""),
|
||||
}
|
||||
form = aiohttp.FormData()
|
||||
form.add_field(
|
||||
"request", json.dumps(request, ensure_ascii=False, separators=(",", ":")),
|
||||
content_type="application/json",
|
||||
)
|
||||
if attachment is not None:
|
||||
form.add_field(
|
||||
"attachment", attachment,
|
||||
filename=f"houseplan-support-{filename_token[:32]}.json",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
timeout = aiohttp.ClientTimeout(total=20, sock_connect=5)
|
||||
try:
|
||||
async with session.post(
|
||||
SUPPORT_RELAY_URL, data=form, timeout=timeout, allow_redirects=False,
|
||||
) as response:
|
||||
if response.status == 429:
|
||||
raise SupportTransportError("support_rate_limited")
|
||||
if response.status == 413:
|
||||
raise SupportTransportError("support_package_too_large")
|
||||
if response.status in {400, 401, 403, 404, 405, 409, 415, 422}:
|
||||
raise SupportTransportError("support_rejected")
|
||||
if response.status != 200:
|
||||
raise SupportTransportError("support_unavailable")
|
||||
# A compromised/misconfigured relay cannot make HA buffer an
|
||||
# unbounded response or reflect its details to the browser.
|
||||
body = await response.content.read(4097)
|
||||
if len(body) > 4096:
|
||||
raise SupportTransportError("support_unavailable")
|
||||
except SupportTransportError:
|
||||
raise
|
||||
except (aiohttp.ClientError, TimeoutError, OSError):
|
||||
raise SupportTransportError("support_unavailable") from None
|
||||
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
report_id = payload.get("report_id") if isinstance(payload, dict) else None
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
report_id = None
|
||||
if not isinstance(report_id, str) or not report_id.startswith(_REPORT_ID_PREFIX):
|
||||
raise SupportTransportError("support_unavailable")
|
||||
if not 8 <= len(report_id) <= 64 or any(
|
||||
char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
|
||||
for char in report_id
|
||||
):
|
||||
raise SupportTransportError("support_unavailable")
|
||||
return report_id
|
||||
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -21,7 +22,6 @@ from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
"admin_only": "Seuls les administrateurs peuvent modifier le plan"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"admin_only": "Seuls les administrateurs peuvent modifier le plan"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -49,9 +50,12 @@ class OpeningPassageError(ValueError):
|
||||
self.space_id = space_id
|
||||
self.opening_id = opening_id
|
||||
self.fields = tuple(sorted(fields))
|
||||
super().__init__(
|
||||
f"space={space_id}; opening={opening_id}; fields={','.join(self.fields)}"
|
||||
)
|
||||
# #42: structured details as JSON — the frontend parses this instead
|
||||
# of regexing an English sentence; the legacy "space=..;.." format is
|
||||
# still accepted there for one beta.
|
||||
super().__init__(json.dumps({
|
||||
"space": space_id, "opening": opening_id, "fields": list(self.fields),
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
class PartitionOpeningHostError(ValueError):
|
||||
@@ -72,10 +76,11 @@ class PartitionOpeningJambMarginError(ValueError):
|
||||
self.opening_id = opening_id
|
||||
self.margin = margin
|
||||
self.margin_cm = margin_cm
|
||||
super().__init__(
|
||||
f"space={space_id}; opening={opening_id}; "
|
||||
f"margin={margin:.12g}; margin_cm={margin_cm:.12g}"
|
||||
)
|
||||
# #42: structured details as JSON (see OpeningPassageError).
|
||||
super().__init__(json.dumps({
|
||||
"space": space_id, "opening": opening_id,
|
||||
"margin": round(margin, 12), "margin_cm": round(margin_cm, 12),
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
class WallModelClientOutdatedError(ValueError):
|
||||
@@ -410,7 +415,7 @@ def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
|
||||
points = sorted(set(round(point, 12) for point in breakpoints
|
||||
if -_OPTIMIZE_REHOST_EPSILON <= point
|
||||
<= length + _OPTIMIZE_REHOST_EPSILON))
|
||||
for lo, hi in zip(points, points[1:]):
|
||||
for lo, hi in zip(points, points[1:], strict=False): # #42 B905: adjacent pairs, unequal length is the point
|
||||
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
|
||||
continue
|
||||
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
|
||||
@@ -805,7 +810,7 @@ def _matching_previous_marker(
|
||||
def validate_marker_value_badges(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate only new/changed badge data; dormant future/legacy data round-trips."""
|
||||
"""Validate changed badge/face sources; dormant future data round-trips."""
|
||||
markers = config.get("markers") or []
|
||||
by_id = {str(marker.get("id")): marker for marker in markers}
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
@@ -814,30 +819,14 @@ def validate_marker_value_badges(
|
||||
consumed_old_ids: set[str] = set()
|
||||
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
|
||||
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
badge = marker.get("value_badge")
|
||||
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
|
||||
if not validate_all and badge == old_badge:
|
||||
continue
|
||||
if badge is None:
|
||||
continue
|
||||
if not isinstance(badge, dict):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
|
||||
if not isinstance(badge.get("enabled"), bool):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
|
||||
if badge.get("position") not in VALUE_BADGE_POSITIONS:
|
||||
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
|
||||
source = badge.get("source")
|
||||
if badge["enabled"] and not isinstance(source, dict):
|
||||
raise MarkerControlError("value_badge_source_required", "Enabled value badge needs a source")
|
||||
if source is None:
|
||||
continue
|
||||
def validate_source(source: object, field: str) -> None:
|
||||
prefix = "value_badge" if field == "value_badge" else "value_source"
|
||||
source_error = "invalid_value_badge_source" if field == "value_badge" \
|
||||
else "invalid_value_source"
|
||||
attribute_error = "invalid_value_badge_attribute" if field == "value_badge" \
|
||||
else "invalid_value_source_attribute"
|
||||
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge source")
|
||||
raise MarkerControlError(source_error, f"Invalid {field} source")
|
||||
kind = source["kind"]
|
||||
allowed_fields = {
|
||||
"entity_state": {"kind", "entity_id"},
|
||||
@@ -846,23 +835,51 @@ def validate_marker_value_badges(
|
||||
"derived_marker_state": {"kind", "ref"},
|
||||
}[kind]
|
||||
if (known_source_fields & set(source)) - allowed_fields:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Inconsistent value badge source")
|
||||
raise MarkerControlError(source_error, f"Inconsistent {field} source")
|
||||
if kind in {"entity_state", "entity_attribute"}:
|
||||
entity_id = source.get("entity_id")
|
||||
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge entity id")
|
||||
if kind == "entity_attribute":
|
||||
if source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
|
||||
raise MarkerControlError("invalid_value_badge_attribute", "Invalid value badge attribute")
|
||||
raise MarkerControlError(source_error, f"Invalid {field} entity id")
|
||||
if kind == "entity_attribute" and source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
|
||||
raise MarkerControlError(attribute_error, f"Invalid {field} attribute")
|
||||
if kind == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) or not ref[len(MARKER_CONTROL_PREFIX):]:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid marker value badge target")
|
||||
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) \
|
||||
or not ref[len(MARKER_CONTROL_PREFIX):]:
|
||||
raise MarkerControlError(source_error, f"Invalid {field} target")
|
||||
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
|
||||
if target is None or target.get("removed") is True:
|
||||
raise MarkerControlError("value_badge_marker_missing", "Marker value badge target does not exist")
|
||||
raise MarkerControlError(f"{prefix}_marker_missing", f"{field} target does not exist")
|
||||
if target.get("is_light") is not True:
|
||||
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
|
||||
raise MarkerControlError(f"{prefix}_marker_not_light", f"{field} target is not a forced light")
|
||||
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
badge = marker.get("value_badge")
|
||||
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
|
||||
if validate_all or badge != old_badge:
|
||||
if badge is not None:
|
||||
if not isinstance(badge, dict):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
|
||||
if not isinstance(badge.get("enabled"), bool):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
|
||||
if badge.get("position") not in VALUE_BADGE_POSITIONS:
|
||||
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
|
||||
source = badge.get("source")
|
||||
if badge["enabled"] and not isinstance(source, dict):
|
||||
raise MarkerControlError(
|
||||
"value_badge_source_required", "Enabled value badge needs a source"
|
||||
)
|
||||
if source is not None:
|
||||
validate_source(source, "value_badge")
|
||||
|
||||
source = marker.get("value_source")
|
||||
old_source = None if validate_all else (old_marker or {}).get("value_source")
|
||||
if (validate_all or source != old_source) and source is not None:
|
||||
validate_source(source, "value_source")
|
||||
|
||||
|
||||
def validate_marker_light_entities(
|
||||
@@ -1073,6 +1090,7 @@ MAX_OPEN_TO = 50
|
||||
MAX_CONTROLS = 200
|
||||
MAX_PDFS = 50
|
||||
MAX_KNOWN_DEVICES = 20000
|
||||
MAX_MARKER_AREA_SNAPSHOT = MAX_KNOWN_DEVICES
|
||||
MAX_TEXT = 500 # names, models, ids
|
||||
MAX_DESCRIPTION = 4000
|
||||
MAX_URL = 2000
|
||||
@@ -1086,6 +1104,7 @@ CELL_CM_MIN = 0.1
|
||||
CELL_CM_MAX = 1000.0
|
||||
|
||||
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_NONEMPTY_TEXT = vol.All(str, vol.Length(min=1, max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
@@ -1362,6 +1381,7 @@ DECOR_SCHEMA = vol.Any(
|
||||
vol.Required("symbol"): _FURN_SYMBOL,
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
|
||||
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
@@ -1415,7 +1435,7 @@ def _room_draft_segments(value: dict) -> dict:
|
||||
"""An open draft has exactly one thickness per consecutive edge."""
|
||||
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
|
||||
raise vol.Invalid("room draft segments must match consecutive point pairs")
|
||||
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
|
||||
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:], strict=False)): # #42 B905: adjacent pairs
|
||||
raise vol.Invalid("room draft consecutive points must differ")
|
||||
return value
|
||||
|
||||
@@ -1678,7 +1698,7 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "none", "cover", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
@@ -1743,6 +1763,12 @@ MARKER_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
# Explicit source for display:value. Semantic delta validation shares
|
||||
# the badge source contract while keeping untouched future literals.
|
||||
vol.Optional("value_source"): vol.Any(
|
||||
None,
|
||||
vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
),
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
@@ -1885,6 +1911,18 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
# #377: the Background editor's default style for new decor
|
||||
# objects; absence of the key means the built-in default
|
||||
vol.Optional("decor_default_style"): vol.Schema(
|
||||
{
|
||||
vol.Optional("color"): _COLOR,
|
||||
vol.Optional("opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
vol.Optional("width_cm"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=100)),
|
||||
vol.Optional("fill"): bool,
|
||||
vol.Optional("fill_color"): _COLOR,
|
||||
vol.Optional("fill_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
),
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
@@ -1895,6 +1933,18 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("marker_area_snapshot"): vol.All(
|
||||
vol.Schema({
|
||||
_NONEMPTY_TEXT: vol.Schema({
|
||||
vol.Required("binding"): vol.All(
|
||||
_NONEMPTY_TEXT,
|
||||
vol.Match(r"^(?:device|entity):.+$"),
|
||||
),
|
||||
vol.Required("area"): _NONEMPTY_TEXT,
|
||||
}),
|
||||
}),
|
||||
vol.Length(max=MAX_MARKER_AREA_SNAPSHOT),
|
||||
),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
|
||||
@@ -15,7 +15,6 @@ from typing import Any
|
||||
|
||||
from .coordinate_canonicalization import canonicalize_config_geometry
|
||||
|
||||
|
||||
WALL_SEGMENT_MODEL_VERSION = 9
|
||||
GRID_STEP_N = 1 / 240
|
||||
EPS = 1e-9
|
||||
@@ -192,10 +191,11 @@ def _atomize(
|
||||
float(span["b"][0]), float(span["b"][1]),
|
||||
])
|
||||
if not legacy_segments:
|
||||
linked = lambda first, second: (
|
||||
str(second.get("id", "")) in (first.get("open_to") or [])
|
||||
or str(first.get("id", "")) in (second.get("open_to") or [])
|
||||
)
|
||||
def linked(first: dict, second: dict) -> bool: # #42 E731
|
||||
return (
|
||||
str(second.get("id", "")) in (first.get("open_to") or [])
|
||||
or str(first.get("id", "")) in (second.get("open_to") or [])
|
||||
)
|
||||
for first_index, first in enumerate(rooms):
|
||||
for second in rooms[first_index + 1:]:
|
||||
if not linked(first, second):
|
||||
@@ -223,7 +223,6 @@ def _atomize(
|
||||
])
|
||||
for segment in canonical_zero_segments:
|
||||
global_breaks.extend((segment[:2], segment[2:]))
|
||||
zero_segments = canonical_zero_segments + legacy_segments
|
||||
|
||||
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
|
||||
# carries an existing contour opening; its edges become atom boundaries so
|
||||
@@ -348,10 +347,15 @@ def _atomize(
|
||||
parent_index = parents[index]
|
||||
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
|
||||
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||
covered_by = lambda cuts: any(
|
||||
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in cuts
|
||||
)
|
||||
|
||||
# #42 B023/E731: the loop variable is bound as a default — the
|
||||
# closure is only called inside this iteration, but the binding
|
||||
# makes that safety structural instead of incidental.
|
||||
def covered_by(cuts: list[list[float]], *, midpoint: list[float] = midpoint) -> bool:
|
||||
return any(
|
||||
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in cuts
|
||||
)
|
||||
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
|
||||
# that carries an opening (#316 §3.1).
|
||||
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
|
||||
@@ -547,7 +551,11 @@ def _host_openings(
|
||||
continue
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
|
||||
|
||||
def eligible(segment: dict[str, Any]) -> bool:
|
||||
# #42 B023: loop variables are bound as defaults (see covered_by).
|
||||
def eligible(
|
||||
segment: dict[str, Any], *,
|
||||
centre: list[float] = centre, angle: float = angle, half: float = half,
|
||||
) -> bool:
|
||||
if float(segment.get("cm", 0)) <= 0:
|
||||
return False
|
||||
t = _project_t(centre, segment["a"], segment["b"])
|
||||
@@ -559,7 +567,10 @@ def _host_openings(
|
||||
and half >= 0 and t * span - half >= -EPS
|
||||
and t * span + half <= span + EPS)
|
||||
|
||||
def materialize(carrier: dict[str, Any]) -> None:
|
||||
def materialize(
|
||||
carrier: dict[str, Any], *,
|
||||
opening: dict[str, Any] = opening, centre: list[float] = centre,
|
||||
) -> None:
|
||||
opening["host"] = {
|
||||
"kind": "wall", "id": carrier["id"],
|
||||
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
|
||||
@@ -581,15 +592,18 @@ def _host_openings(
|
||||
if not initial_migration:
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
|
||||
|
||||
def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
def pick(
|
||||
pool: list[dict[str, Any]], *,
|
||||
current: dict[str, Any] | None = current, centre: list[float] = centre,
|
||||
) -> dict[str, Any] | None:
|
||||
if not pool:
|
||||
return None
|
||||
if current is not None and any(item is current for item in pool):
|
||||
return current
|
||||
return sorted(pool, key=lambda segment: (
|
||||
_distance_to_segment(centre, segment["a"], segment["b"]),
|
||||
-float(segment.get("cm", 0)),
|
||||
str(segment.get("id", "")),
|
||||
return sorted(pool, key=lambda candidate: (
|
||||
_distance_to_segment(centre, candidate["a"], candidate["b"]),
|
||||
-float(candidate.get("cm", 0)),
|
||||
str(candidate.get("id", "")),
|
||||
))[0]
|
||||
|
||||
# #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
CONTENT_URL,
|
||||
DEFAULT_CONFIG,
|
||||
DOMAIN,
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
EXPORT_VERSION,
|
||||
FILES_DIR,
|
||||
MAX_PLANS_BYTES,
|
||||
MAX_PLANS_FILES,
|
||||
MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
MAX_SUPPORT_CONTACT_CODEPOINTS,
|
||||
MAX_SUPPORT_MESSAGE_CODEPOINTS,
|
||||
MAX_SUPPORT_PREVIEWS_PER_USER,
|
||||
MAX_SUPPORT_PREVIEWS_TOTAL,
|
||||
PLAN_MODEL_VERSION,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
SUPPORT_PREVIEW_TTL_S,
|
||||
VERSION,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
content_manifest,
|
||||
@@ -40,43 +54,66 @@ from .import_export import (
|
||||
prepare_apply,
|
||||
revalidate_candidate,
|
||||
)
|
||||
from .junction_limits import JunctionLimitError, validate_junction_limits
|
||||
from .plans import (
|
||||
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
QuotaError,
|
||||
check_quota,
|
||||
collect_attachments,
|
||||
collect_plans,
|
||||
is_plan_file,
|
||||
plan_basename,
|
||||
plan_refs,
|
||||
reserve_filename,
|
||||
)
|
||||
from .projection import project_config, project_layout
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .store import (
|
||||
LAYOUT_STORE_CORE_KEYS,
|
||||
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
HouseplanData,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
get_data,
|
||||
get_entry,
|
||||
)
|
||||
from .store import (
|
||||
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
|
||||
)
|
||||
from .store import (
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
)
|
||||
from .support_package import SupportPackageError, build_support_package
|
||||
from .support_transport import SupportTransportError, async_submit_report
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA,
|
||||
LAYOUT_SCHEMA,
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS,
|
||||
POS_SCHEMA,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
sanitize_filename,
|
||||
valid_space_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
validate_wall_model_transition,
|
||||
)
|
||||
from .virtual_lights import (
|
||||
EVENT_VIRTUAL_LIGHT_UPDATED,
|
||||
async_toggle_virtual_light,
|
||||
async_virtual_light_snapshot,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .projection import project_config, project_layout
|
||||
from .wall_segment_model import (
|
||||
WALL_SEGMENT_MODEL_VERSION,
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
from .junction_limits import JunctionLimitError, validate_junction_limits
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError, sanitize_filename,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, validate_wall_model_transition, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -161,6 +198,9 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_export_create)
|
||||
websocket_api.async_register_command(hass, ws_import_revalidate)
|
||||
websocket_api.async_register_command(hass, ws_import_apply)
|
||||
websocket_api.async_register_command(hass, ws_support_preview)
|
||||
websocket_api.async_register_command(hass, ws_support_preview_discard)
|
||||
websocket_api.async_register_command(hass, ws_support_submit)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -189,6 +229,30 @@ def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
|
||||
connection.send_error(msg_id, err.code, err.message)
|
||||
|
||||
|
||||
def _send_support_error(connection, msg_id: int, code: str) -> None:
|
||||
"""Return only a stable code; support data never enters an error string."""
|
||||
connection.send_error(msg_id, code, code)
|
||||
|
||||
|
||||
def _prune_support_previews(rt: HouseplanData, now: float | None = None) -> None:
|
||||
current = time.monotonic() if now is None else now
|
||||
for token, preview in list(rt.support_previews.items()):
|
||||
if float(preview.get("expires", 0)) <= current:
|
||||
rt.support_previews.pop(token, None)
|
||||
|
||||
|
||||
def _support_repairs(hass: HomeAssistant) -> list[dict[str, Any]]:
|
||||
"""Expose stable House Plan repair families, never raw issue ids/placeholders."""
|
||||
counts: Counter[str] = Counter()
|
||||
registry = ir.async_get(hass)
|
||||
for domain, issue_id in list(registry.issues):
|
||||
if domain != DOMAIN:
|
||||
continue
|
||||
if str(issue_id).startswith("broken_plan_"):
|
||||
counts["broken_plan"] += 1
|
||||
return [{"code": code, "count": count} for code, count in sorted(counts.items())]
|
||||
|
||||
|
||||
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the exact non-layout portion of a layout-store document."""
|
||||
return {
|
||||
@@ -261,7 +325,7 @@ async def _commit_import_pair(
|
||||
try:
|
||||
await _persist_pair_intent(rt, rollback)
|
||||
await _converge_pair(rt, rollback)
|
||||
except Exception as rollback_error: # noqa: BLE001
|
||||
except Exception as rollback_error: # noqa: BLE001 - a failed rollback must not mask the original commit error
|
||||
_LOGGER.exception(
|
||||
"House Plan import rollback could not finish; rollback intent remains for setup"
|
||||
)
|
||||
@@ -312,7 +376,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
|
||||
_LOGGER.exception("House Plan export failed")
|
||||
connection.send_error(msg["id"], "invalid_config", "Could not create export")
|
||||
return
|
||||
@@ -486,7 +550,7 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
|
||||
_LOGGER.exception("House Plan import commit failed")
|
||||
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
|
||||
return
|
||||
@@ -1618,7 +1682,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
except vol.Invalid as err:
|
||||
connection.send_error(msg["id"], "invalid_config", str(err))
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001 - best-effort backup pruning never fails the request
|
||||
_LOGGER.exception("House Plan space delete failed")
|
||||
connection.send_error(msg["id"], "commit_failed", "Space delete failed")
|
||||
return
|
||||
@@ -2010,3 +2074,239 @@ async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveC
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
removed = await rec.async_delete(msg["marker_id"]) if rec else False
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
# ---------------- private support package / feedback (#43) ----------------
|
||||
|
||||
|
||||
def _support_string(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise vol.Invalid("support field must be a string")
|
||||
return value
|
||||
|
||||
|
||||
def _support_bool(value: object) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise vol.Invalid("support field must be a boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _support_browser_major(value: object) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 999:
|
||||
raise vol.Invalid("browser_major must be an integer in 0..999")
|
||||
return value
|
||||
|
||||
|
||||
_SUPPORT_TOKEN = vol.All(
|
||||
_support_string, vol.Length(min=16, max=128), vol.Match(r"^[0-9a-f]+$")
|
||||
)
|
||||
_SUPPORT_ID = vol.All(
|
||||
_support_string, vol.Length(min=8, max=128), vol.Match(r"^[A-Za-z0-9_.:-]+$")
|
||||
)
|
||||
_SUPPORT_VERSION = vol.All(
|
||||
_support_string, vol.Length(min=1, max=32), vol.Match(r"^[0-9A-Za-z._+-]+$")
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/support/preview",
|
||||
vol.Required("card_version"): _SUPPORT_VERSION,
|
||||
vol.Required("browser_family"): vol.In(["chromium", "firefox", "webkit", "unknown"]),
|
||||
vol.Required("browser_major"): _support_browser_major,
|
||||
vol.Required("language"): vol.In(["en", "ru", "de", "fr"]),
|
||||
vol.Required("coarse_pointer"): _support_bool,
|
||||
vol.Required("hover_capable"): _support_bool,
|
||||
vol.Required("registry_access"): vol.In(["full", "partial", "unavailable"]),
|
||||
vol.Required("registry_age_bucket"): vol.In(["fresh", "stale", "unknown"]),
|
||||
vol.Required("draft_id"): _SUPPORT_ID,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_support_preview(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Build and retain one exact, already-sanitized support-package snapshot."""
|
||||
if not _check_write(hass, connection):
|
||||
_send_support_error(connection, msg["id"], "unauthorized")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
|
||||
# The write lock guarantees that config/layout and both revisions describe
|
||||
# one accepted pair. Projection happens after the deep copy, so the lock is
|
||||
# not held while JSON is built and a large plan cannot stall an editor save.
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = copy.deepcopy({**DEFAULT_CONFIG, **(config_data.get("config") or {})})
|
||||
layout = copy.deepcopy(layout_data.get("layout") or {})
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
|
||||
# A package must not turn malformed stored data into an apparently valid
|
||||
# diagnostic artifact. Validation runs on disposable copies because the
|
||||
# schemas canonicalize coordinates.
|
||||
repairs = _support_repairs(hass)
|
||||
|
||||
def _build_snapshot() -> tuple[bytes, dict[str, Any]]:
|
||||
CONFIG_SCHEMA(copy.deepcopy(config))
|
||||
LAYOUT_SCHEMA(copy.deepcopy(layout))
|
||||
return build_support_package(
|
||||
config,
|
||||
layout,
|
||||
config_rev=config_rev,
|
||||
layout_rev=layout_rev,
|
||||
card_version=msg["card_version"],
|
||||
integration_version=VERSION,
|
||||
home_assistant_version=HA_VERSION,
|
||||
runtime={
|
||||
"browser_family": msg["browser_family"],
|
||||
"browser_major": msg["browser_major"],
|
||||
"language": msg["language"],
|
||||
"coarse_pointer": msg["coarse_pointer"],
|
||||
"hover_capable": msg["hover_capable"],
|
||||
"registry_access": msg["registry_access"],
|
||||
"registry_age_bucket": msg["registry_age_bucket"],
|
||||
},
|
||||
repairs=repairs,
|
||||
)
|
||||
|
||||
try:
|
||||
# The maximum valid package is deliberately large. Keep validation,
|
||||
# pseudonymisation and canonical JSON away from Home Assistant's event
|
||||
# loop while retaining the coherent copies captured under write_lock.
|
||||
raw, preview = await hass.async_add_executor_job(_build_snapshot)
|
||||
except (vol.Invalid, ValueError, TypeError, OverflowError) as error:
|
||||
code = error.code if isinstance(error, SupportPackageError) else "support_rejected"
|
||||
_send_support_error(connection, msg["id"], code)
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
owner = _connection_user_id(connection)
|
||||
_prune_support_previews(rt, now)
|
||||
# A refresh replaces only this card instance's draft. Other cards keep
|
||||
# their token and exact bytes.
|
||||
for old_token, record in list(rt.support_previews.items()):
|
||||
if record.get("owner") == owner and record.get("draft_id") == msg["draft_id"]:
|
||||
rt.support_previews.pop(old_token, None)
|
||||
owned = sum(1 for item in rt.support_previews.values() if item.get("owner") == owner)
|
||||
if owned >= MAX_SUPPORT_PREVIEWS_PER_USER or len(rt.support_previews) >= MAX_SUPPORT_PREVIEWS_TOTAL:
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
token = secrets.token_hex(24)
|
||||
expires = now + SUPPORT_PREVIEW_TTL_S
|
||||
rt.support_previews[token] = {
|
||||
"owner": owner,
|
||||
"draft_id": msg["draft_id"],
|
||||
"created": now,
|
||||
"expires": expires,
|
||||
"bytes": raw,
|
||||
"sha256": preview["sha256"],
|
||||
"versions": preview["versions"],
|
||||
}
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"token": token,
|
||||
"expires_in": SUPPORT_PREVIEW_TTL_S,
|
||||
"size": preview["size"],
|
||||
"sha256": preview["sha256"],
|
||||
"spaces": preview["spaces"],
|
||||
"format": preview["format"],
|
||||
"version": preview["version"],
|
||||
"text": raw.decode("utf-8"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/support/preview/discard",
|
||||
vol.Required("token"): _SUPPORT_TOKEN,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_support_preview_discard(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Idempotently discard only a preview owned by this HA user."""
|
||||
if not _check_write(hass, connection):
|
||||
_send_support_error(connection, msg["id"], "unauthorized")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
_prune_support_previews(rt)
|
||||
preview = rt.support_previews.get(msg["token"])
|
||||
if preview is not None and preview.get("owner") != _connection_user_id(connection):
|
||||
_send_support_error(connection, msg["id"], "support_preview_expired")
|
||||
return
|
||||
rt.support_previews.pop(msg["token"], None)
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/support/submit",
|
||||
vol.Required("message"): _support_string,
|
||||
vol.Optional("contact", default=""): _support_string,
|
||||
vol.Optional("preview_token"): _SUPPORT_TOKEN,
|
||||
vol.Required("idempotency_key"): _SUPPORT_ID,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_support_submit(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Submit text and, when selected, the exact previewed package bytes."""
|
||||
if not _check_write(hass, connection):
|
||||
_send_support_error(connection, msg["id"], "unauthorized")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
message = msg["message"].strip()
|
||||
contact = msg.get("contact", "").strip()
|
||||
if not message or len(message) > MAX_SUPPORT_MESSAGE_CODEPOINTS:
|
||||
_send_support_error(connection, msg["id"], "support_invalid_message")
|
||||
return
|
||||
if len(contact) > MAX_SUPPORT_CONTACT_CODEPOINTS:
|
||||
_send_support_error(connection, msg["id"], "support_rejected")
|
||||
return
|
||||
|
||||
_prune_support_previews(rt)
|
||||
token = msg.get("preview_token")
|
||||
preview = rt.support_previews.get(token) if token else None
|
||||
if token and (preview is None or preview.get("owner") != _connection_user_id(connection)):
|
||||
_send_support_error(connection, msg["id"], "support_preview_expired")
|
||||
return
|
||||
attachment = preview.get("bytes") if preview else None
|
||||
versions = preview.get("versions") if preview else {
|
||||
"card": VERSION,
|
||||
"integration": VERSION,
|
||||
"home_assistant": HA_VERSION,
|
||||
"model": PLAN_MODEL_VERSION,
|
||||
"export_schema": EXPORT_VERSION,
|
||||
}
|
||||
try:
|
||||
report_id = await async_submit_report(
|
||||
hass,
|
||||
message=message,
|
||||
contact=contact,
|
||||
versions=versions,
|
||||
idempotency_key=msg["idempotency_key"],
|
||||
attachment=attachment,
|
||||
attachment_sha256=preview.get("sha256") if preview else None,
|
||||
filename_token=token or msg["idempotency_key"].lower().replace("_", "-"),
|
||||
)
|
||||
except SupportTransportError as error:
|
||||
_send_support_error(connection, msg["id"], error.code)
|
||||
return
|
||||
if token:
|
||||
# Retry after a timeout keeps the token; only a confirmed delivery
|
||||
# consumes it. The relay's idempotency record handles uncertain first
|
||||
# attempts with the same frontend key.
|
||||
rt.support_previews.pop(token, None)
|
||||
connection.send_result(msg["id"], {"report_id": report_id})
|
||||
|
||||
@@ -7,12 +7,19 @@ import { launch } from './serve.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
import {
|
||||
assertCardContract, GLOW_CARD_CONTRACT, SPACE_GLOW_CARD_CONTRACT,
|
||||
} from './performance/card-contract.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const profile = valueArg('profile') || 'large-light-blend-v1';
|
||||
if (!['large-light-blend-v1', 'large-house-glow-overlay-v1'].includes(profile))
|
||||
if (![
|
||||
'large-light-blend-v1', 'large-house-glow-overlay-v1',
|
||||
'large-space-card-default-v1', 'large-space-card-glow-v1',
|
||||
].includes(profile))
|
||||
throw new Error(`unknown Glow profile: ${profile}`);
|
||||
const staticCardProfile = profile.startsWith('large-space-card-');
|
||||
const staticGlowProfile = profile === 'large-space-card-glow-v1';
|
||||
const parsedSamples = Number(valueArg('samples'));
|
||||
const parsedWarmups = Number(valueArg('warmups'));
|
||||
const samples = Math.max(1, Math.min(20, Number.isFinite(parsedSamples) && parsedSamples > 0 ? parsedSamples : 7));
|
||||
@@ -196,22 +203,37 @@ try {
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
const cacheSnapshot = (card) => ({
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
...(card.localName === 'houseplan-space-card' ? {
|
||||
// #380: the previous stable predates the shared static-card Glow
|
||||
// runtime. Its default profile still has a valid empty cache, and
|
||||
// the opt-in Glow profile is explicitly bootstrap-compared below.
|
||||
glowClip: card._glowRuntimeState?.clipCache?.size ?? 0,
|
||||
} : {
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
}),
|
||||
});
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = document.getElementById('host');
|
||||
const result = { sample, longTasks: {}, renderCounts: {}, poolCounts: {} };
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
|
||||
const staticCardProfile = profile.startsWith('large-space-card-');
|
||||
const card = document.createElement(staticCardProfile ? 'houseplan-space-card' : 'houseplan-card');
|
||||
card.setConfig(staticCardProfile ? {
|
||||
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
|
||||
show_button: false, light_pools: profile === 'large-space-card-glow-v1',
|
||||
} : {
|
||||
type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4,
|
||||
});
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor(statesFor(1));
|
||||
await until(() => staticCardProfile
|
||||
? !!card._snap && card._devices?.length > 0
|
||||
: card._loadOk && card._devices?.length === fixture.deviceCount);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
|
||||
if ('_glowScreenBlend' in card) {
|
||||
const probeDeadline = performance.now() + 2500;
|
||||
while (!card._glowScreenBlend && performance.now() < probeDeadline)
|
||||
@@ -259,7 +281,10 @@ try {
|
||||
result.renderedDevices = card._devices?.length ?? 0;
|
||||
result.screenBlend = card._glowScreenBlend === true;
|
||||
return result;
|
||||
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
|
||||
}, {
|
||||
fixture, profile, sample,
|
||||
cardContract: staticCardProfile ? SPACE_GLOW_CARD_CONTRACT : GLOW_CARD_CONTRACT,
|
||||
});
|
||||
const captureStarted = performance.now();
|
||||
await page.screenshot({ type: 'png' });
|
||||
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
|
||||
@@ -52,6 +53,9 @@ await page.addStyleTag({
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpEnsureHarnessEditorRuntime = ${ensureHarnessEditorRuntime.toString()};`,
|
||||
});
|
||||
const chromium = await browser.version();
|
||||
let buildFingerprint;
|
||||
try {
|
||||
@@ -187,6 +191,11 @@ try {
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
}
|
||||
card.hass = hassFor(fixture.states);
|
||||
// launch() warms its own bootstrap card, but each measured sample creates
|
||||
// a fresh card. Current lazy bundles therefore need their own controller
|
||||
// construction; monolithic stable bundles remain immediate (#380).
|
||||
if (!await window.__hpEnsureHarnessEditorRuntime(card))
|
||||
throw new Error('large-house editor runtime did not preload');
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
|| !(card._isoGeometryCache instanceof Map))) {
|
||||
@@ -346,7 +355,10 @@ try {
|
||||
target: null,
|
||||
};
|
||||
card._rszEdgeDown(quietEvent, room.id, 1);
|
||||
const plan = card._resize?.plan;
|
||||
// #380: the previous stable owns the active session directly; current
|
||||
// candidates own it through ResizeController. The explicit contract
|
||||
// above requires one of these shapes before measurements begin.
|
||||
const plan = card._resize?.plan || card._rszDrag?.plan;
|
||||
if (!plan) throw new Error('large-house resize plan was not created');
|
||||
const target = [
|
||||
plan.a[0] + plan.n[0] * card._gridPitch,
|
||||
|
||||
@@ -86,6 +86,15 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const card = window.__goldenCard;
|
||||
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
|
||||
const settleCamera = async () => {
|
||||
const started = performance.now();
|
||||
do { await frame(); }
|
||||
while (card._cameraTransition?.active && performance.now() - started < 1200);
|
||||
if (card._cameraTransition?.active)
|
||||
throw new Error(`documentation camera did not settle: ${current.id}`);
|
||||
// One quiet pair proves that the settled reactive frame reached layout.
|
||||
await frame();
|
||||
};
|
||||
|
||||
if (current.title) {
|
||||
card.setConfig({ ...card._config, title: current.title });
|
||||
@@ -142,12 +151,102 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
|
||||
throw new Error('documentation viewport does not show the device presentation preview');
|
||||
}
|
||||
|
||||
await settleCamera();
|
||||
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
|
||||
}, scenario);
|
||||
|
||||
mkdirSync(OUTPUT, { recursive: true });
|
||||
|
||||
const { page, browser } = await launch();
|
||||
// Растеризация закрепляется теми же тремя флагами, что у golden
|
||||
// (`demo/golden/run.mjs`), и по той же причине — только golden их имел с самого
|
||||
// начала, а съёмка документации запускалась без единого (#410).
|
||||
//
|
||||
// Измерено на dev SHA 184e0098: два прогона канонического workflow на одном и
|
||||
// том же коммите, одном Chromium 151.0.7922.34 и одном oxipng 10.2.0 дали три
|
||||
// разошедшихся кадра из десяти (06-device-editor, 08-room-card,
|
||||
// 09-device-info). Дельта — единицы уровней в RGB на сглаженных границах, alpha
|
||||
// не менялась: подпись субпиксельного сглаживания, а не изменения продукта.
|
||||
//
|
||||
// `--disable-lcd-text` убирает субпиксельное сглаживание (оно и плавало),
|
||||
// `--font-render-hinting=none` снимает зависимость от хинтинга,
|
||||
// `--force-color-profile=srgb` фиксирует профиль. `reducedMotion: 'reduce'`
|
||||
// добавлен к `animations: 'disabled'` у самого скриншота: первое гасит анимации
|
||||
// в CSS, второе — уже начатые переходы на момент съёмки.
|
||||
const DETERMINISTIC_ARGS = [
|
||||
'--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text',
|
||||
];
|
||||
/**
|
||||
* Режим замера стабильности (#410): `node demo/docs/capture.mjs --stability=3`.
|
||||
*
|
||||
* Отвечает на вопрос, который иначе решается гаданием: плавает ли кадр ВНУТРИ
|
||||
* одного состояния страницы или разница копится между подготовками сценария.
|
||||
* Для каждого сценария делается N снимков подряд без единой правки состояния, и
|
||||
* они сравниваются попиксельно прямо в странице — тем же приёмом, что у golden
|
||||
* (`createImageBitmap` + canvas), чтобы не тащить декодер PNG в зависимости.
|
||||
*
|
||||
* Ничего не пишет на диск и манифест не трогает: это измерение, а не съёмка.
|
||||
*/
|
||||
const unstable = [];
|
||||
const stabilityArg = process.argv.find((arg) => arg.startsWith('--stability'));
|
||||
const STABILITY_SHOTS = stabilityArg
|
||||
? Math.max(2, Number(stabilityArg.split('=')[1] || 3))
|
||||
: 0;
|
||||
|
||||
const comparePairs = (target, shots) => target.evaluate(async (base64Shots) => {
|
||||
const decode = async (base64) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
};
|
||||
const read = (bitmap) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
|
||||
};
|
||||
const frames = await Promise.all(base64Shots.map(async (shot) => {
|
||||
const bitmap = await decode(shot);
|
||||
return { data: read(bitmap), width: bitmap.width, height: bitmap.height };
|
||||
}));
|
||||
const first = frames[0];
|
||||
return frames.slice(1).map((frame, index) => {
|
||||
if (frame.width !== first.width || frame.height !== first.height) {
|
||||
return { pair: `1↔${index + 2}`, sizeMismatch: true };
|
||||
}
|
||||
let pixels = 0;
|
||||
let maxDelta = 0;
|
||||
let alphaTouched = 0;
|
||||
const box = { x0: Infinity, y0: Infinity, x1: -1, y1: -1 };
|
||||
for (let at = 0; at < first.data.length; at += 4) {
|
||||
const dr = Math.abs(first.data[at] - frame.data[at]);
|
||||
const dg = Math.abs(first.data[at + 1] - frame.data[at + 1]);
|
||||
const db = Math.abs(first.data[at + 2] - frame.data[at + 2]);
|
||||
const da = Math.abs(first.data[at + 3] - frame.data[at + 3]);
|
||||
if (!dr && !dg && !db && !da) continue;
|
||||
pixels += 1;
|
||||
maxDelta = Math.max(maxDelta, dr, dg, db);
|
||||
if (da) alphaTouched += 1;
|
||||
const pixel = at / 4;
|
||||
const x = pixel % first.width;
|
||||
const y = Math.floor(pixel / first.width);
|
||||
box.x0 = Math.min(box.x0, x); box.y0 = Math.min(box.y0, y);
|
||||
box.x1 = Math.max(box.x1, x); box.y1 = Math.max(box.y1, y);
|
||||
}
|
||||
return {
|
||||
pair: `1↔${index + 2}`,
|
||||
size: [first.width, first.height],
|
||||
pixels,
|
||||
maxDelta,
|
||||
alphaTouched,
|
||||
box: pixels ? box : null,
|
||||
};
|
||||
});
|
||||
}, shots.map((shot) => shot.toString('base64')));
|
||||
|
||||
const { page, browser } = await launch(
|
||||
undefined, undefined, DETERMINISTIC_ARGS, { reducedMotion: 'reduce' },
|
||||
);
|
||||
const browserErrors = [];
|
||||
page.on('pageerror', (error) => browserErrors.push(error.message));
|
||||
|
||||
@@ -164,12 +263,63 @@ try {
|
||||
const runtime = await applyDocumentationState(page, scenario);
|
||||
if (scenario.expectDialog && !runtime.dialog)
|
||||
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
|
||||
const clip = scenario.capture === 'room-card'
|
||||
const rawClip = scenario.capture === 'room-card'
|
||||
? await roomCardClip(page)
|
||||
: await goldenClip(page, scenario.capture);
|
||||
const image = await page.screenshot({
|
||||
// Обрезка выравнивается по целым пикселям (#410).
|
||||
//
|
||||
// Прямоугольник считается из живого DOM через getBoundingClientRect, то есть
|
||||
// приходит дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и
|
||||
// тогда сдвиг раскладки на десятую пикселя — от метрики шрифта, от
|
||||
// появившегося скроллбара — переписывает границы всех элементов на единицы
|
||||
// уровней. Ровно эта подпись и была в #410: 76 пикселей, максимум 2 уровня,
|
||||
// alpha не тронута, всё на сглаженных границах полей.
|
||||
//
|
||||
// Замер показал, где искать: три снимка подряд в одном состоянии страницы
|
||||
// совпадают побайтово у всех десяти сценариев, а между прогонами плавают
|
||||
// два. Значит дело не в рендере, а в том, что приходит на вход съёмке.
|
||||
//
|
||||
// Внешняя рамка расширяется, а не округляется к ближайшему: обрезка обязана
|
||||
// содержать цель целиком, потерять полпикселя по краю нельзя.
|
||||
const clip = rawClip && {
|
||||
x: Math.floor(rawClip.x),
|
||||
y: Math.floor(rawClip.y),
|
||||
width: Math.ceil(rawClip.x + rawClip.width) - Math.floor(rawClip.x),
|
||||
height: Math.ceil(rawClip.y + rawClip.height) - Math.floor(rawClip.y),
|
||||
};
|
||||
if (STABILITY_SHOTS && rawClip) {
|
||||
console.log(`${scenario.id} обрезка: сырая`
|
||||
+ ` ${rawClip.x},${rawClip.y} ${rawClip.width}x${rawClip.height}`
|
||||
+ ` → целая ${clip.x},${clip.y} ${clip.width}x${clip.height}`);
|
||||
}
|
||||
// Два кадра ожидания перед съёмкой — как в golden. `animations: 'disabled'`
|
||||
// гасит анимации, но не гарантирует, что уже запланированный ре-рендер
|
||||
// успел лечь в композитор до захвата.
|
||||
await page.evaluate(() => new Promise((done) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(done));
|
||||
}));
|
||||
const shotOptions = {
|
||||
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
|
||||
});
|
||||
};
|
||||
if (STABILITY_SHOTS) {
|
||||
const shots = [];
|
||||
for (let attempt = 0; attempt < STABILITY_SHOTS; attempt += 1) {
|
||||
await page.evaluate(() => new Promise((done) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(done));
|
||||
}));
|
||||
shots.push(await page.screenshot(shotOptions));
|
||||
}
|
||||
for (const result of await comparePairs(page, shots)) {
|
||||
console.log(`${scenario.id} ${result.pair}: пикселей ${result.pixels}`
|
||||
+ `, максимум ${result.maxDelta}, alpha ${result.alphaTouched}`
|
||||
+ (result.box ? `, bbox ${result.box.x0},${result.box.y0}`
|
||||
+ `..${result.box.x1},${result.box.y1}` : '')
|
||||
+ `, кадр ${result.size ? result.size.join('x') : '?'}`);
|
||||
if (result.pixels || result.sizeMismatch) unstable.push(`${scenario.id} ${result.pair}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const image = await page.screenshot(shotOptions);
|
||||
const imagePath = resolve(OUTPUT, scenario.file);
|
||||
writeFileSync(imagePath, image);
|
||||
// Хеш считается ПОСЛЕ перепаковки: манифест обязан описывать те байты,
|
||||
@@ -186,20 +336,30 @@ try {
|
||||
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
|
||||
}
|
||||
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
|
||||
const manifest = {
|
||||
version: DOC_SCREENSHOT_VERSION,
|
||||
fixture: 'synthetic-only',
|
||||
// Кто снимал. Смена браузера переписывает все картинки без содержательных
|
||||
// изменений (#246), поэтому окружение съёмки — часть доказательства.
|
||||
chromium: browser.version(),
|
||||
// Чем жали — тоже часть доказательства: без инструмента байты другие.
|
||||
oxipng: oxipngVersion,
|
||||
sourceFingerprint: fingerprint,
|
||||
captureScriptSha256: sha256(readFileSync(SCRIPT)),
|
||||
command: 'npm run build && node demo/docs/capture.mjs',
|
||||
scenarios,
|
||||
};
|
||||
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
if (STABILITY_SHOTS) {
|
||||
console.log(`\nзамер стабильности: ${STABILITY_SHOTS} снимка на сценарий, манифест не тронут`);
|
||||
if (unstable.length) {
|
||||
// Режим — не только диагностика, но и гейт: если кадр снова начнёт зависеть
|
||||
// от времени, это должно останавливать съёмку, а не печататься в лог.
|
||||
throw new Error(`кадр плавает внутри одного состояния страницы: ${unstable.join(', ')}`);
|
||||
}
|
||||
console.log('кадр не плавает ни в одном сценарии');
|
||||
} else {
|
||||
const manifest = {
|
||||
version: DOC_SCREENSHOT_VERSION,
|
||||
fixture: 'synthetic-only',
|
||||
// Кто снимал. Смена браузера переписывает все картинки без содержательных
|
||||
// изменений (#246), поэтому окружение съёмки — часть доказательства.
|
||||
chromium: browser.version(),
|
||||
// Чем жали — тоже часть доказательства: без инструмента байты другие.
|
||||
oxipng: oxipngVersion,
|
||||
sourceFingerprint: fingerprint,
|
||||
captureScriptSha256: sha256(readFileSync(SCRIPT)),
|
||||
command: 'npm run build && node demo/docs/capture.mjs',
|
||||
scenarios,
|
||||
};
|
||||
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Prepare editor internals for demo harnesses across comparison baselines.
|
||||
*
|
||||
* Since #337 current builds keep the editor in a lazy runtime and expose an
|
||||
* explicit preload method. Older stable builds are monolithic: absence of the
|
||||
* method means there is nothing to preload, not that the baseline is broken.
|
||||
* A present method remains fail-closed through its result or exception.
|
||||
*/
|
||||
export async function ensureHarnessEditorRuntime(
|
||||
card = globalThis.window?.__card,
|
||||
) {
|
||||
const ensure = card?._ensureEditorRuntime;
|
||||
if (typeof ensure !== 'function') return true;
|
||||
return Boolean(await ensure.call(card));
|
||||
}
|
||||
@@ -63,6 +63,9 @@ const previous = existsSync(manifestPath)
|
||||
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
|
||||
const witnessCheck = goldenWitnessRefusal({
|
||||
results: report.results,
|
||||
// #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог
|
||||
// обходится удалением каталога эталонов.
|
||||
sceneCount: GOLDEN_SCENARIOS.length,
|
||||
declared,
|
||||
declaredNew,
|
||||
previousHashes: previous,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 50,
|
||||
"acceptedAt": "2026-08-29T08:39:24.783Z",
|
||||
"sourceFingerprint": "e45ac741d448e089239176cab2ff317351e9b80db7d2f7434e7b7fd34c0c8e22",
|
||||
"matrixVersion": 53,
|
||||
"acceptedAt": "2026-08-30T15:29:10.420Z",
|
||||
"sourceFingerprint": "099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347",
|
||||
"chromium": "151.0.7922.34",
|
||||
"witnesses": {
|
||||
"count": 123,
|
||||
"count": 96,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
@@ -65,14 +65,14 @@
|
||||
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
|
||||
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "acc779a2a21d5c2a26f40d1a23495cafaffc460c57c1100660fb3cde554303f1",
|
||||
"device-inbox-desktop-en-light": "c04254626f613b11e1b9165abf6bfad4b04a2670acab2887e38062d7d16b4533",
|
||||
"device-inbox-desktop-ru-dark": "2e7e7175ed770fea657828beda68b62d6eb657a4b72222b1977df3d7d692b0d6",
|
||||
"device-inbox-narrow-ru-dark": "5263f518d4a415caedbb7917c79f9acbb03de234feec11e3128c235d2bd5b39f",
|
||||
"geometry-devices-editor-dark": "a9a9d22b37e03fde37120889a4d9aa50b9d318a31e416504f74e2905b5d947ff",
|
||||
"device-inbox-desktop-en-light": "23fef91974a18f12a71858e67ce981e7b0d34892883cb1c2884cd2695a38bcfc",
|
||||
"device-inbox-desktop-ru-dark": "95a4c0b93a6150b145b967b55344727287843be09bc77b25b23fb0fdaf911142",
|
||||
"device-inbox-narrow-ru-dark": "061031091fd29a3be0af435e84f1e3fe7a4e2a485e7fefc617aa24b5ec1cdb39",
|
||||
"geometry-decor-editor-dark": "4a4d40844f4f92b0e1baf169a25f8a15a507682cea677c631dc57f68e31e373f",
|
||||
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
|
||||
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
|
||||
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
|
||||
"tray-medium-group-en": "9b74b675760be423e410176c72b2aa413c19ba7411ddd2d2d0324cf5ba2e1dd6",
|
||||
"tray-medium-selection-ru": "44de98a53d7de665daf2a3f466c281caa66ef6d6cd2941125e2cbfd975f8c8f4",
|
||||
"tray-narrow-palette-en": "392eeac327c899add9792e356d75f48ae541c38bd1c2ace6bdde8e9dcdf17fd6",
|
||||
"furniture-categories-light": "13ab156ce69ff67f82b0aaa1e755d7b2ed63bda2a06f248f56d713c0d88e6fb7",
|
||||
@@ -80,6 +80,8 @@
|
||||
"furniture-variants-light": "a2f4742c392ff73b0b68ee91970a81c4550295180c51f4b9b3d3f19fc58cf2a4",
|
||||
"furniture-placement-preview-light": "282ecb96c5d251323c39ccc2fd27775d8c20a15e3a7d6b86eb2bd93dab46c312",
|
||||
"furniture-plan-art-dark": "d12db5d0a3d99caed4228ba04983f762794909efd366b999e240eba310724110",
|
||||
"furniture-transform-light": "1fbcae3d547412ecda2404428c8cc1668c0a0c66c3196dee72aba67268aed611",
|
||||
"furniture-transform-dark": "9fd5e084bbcf72880cd796e0869cd8e48bef9f5beb099d82a8663a3344a1dfa2",
|
||||
"tray-narrow-tool-ru": "3d002d2a153fe615e8dd7c9fca466525477318992d4bd4bb099dbfb3d9195c4a",
|
||||
"geometry-diagonal-45-opening-dark": "ba9462322da34b20fff1ee1f950c516855dd7ac56131e13863f576e2d0ba5177",
|
||||
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
|
||||
@@ -89,6 +91,7 @@
|
||||
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
|
||||
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
|
||||
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
|
||||
"device-value-face-cover-source-dark": "c48fa33e0a85164ee64bfce6dbc4127c96b4a1c065e4604310f1b16be144639d",
|
||||
"device-icon-state-table-light": "85c785824ccafc2d5f50e834ad2d746319d64ecb292d1b9cf7fab8661d5d410d",
|
||||
"device-icon-state-table-dark": "03a90523132c3dbe6c78d44d01fe89df93e2d0474f91ef84e7107e6e4bc7d337",
|
||||
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
|
||||
@@ -131,17 +134,19 @@
|
||||
"large-house-zoom-250-dark": "0a74099e265b38f376bb7bfd86d4a57c5d493ac3870478a7d55c48cf99e8f892",
|
||||
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
|
||||
"device-dialog-desktop-en": "e142521eae1bd40861c1d92eadbcfd6d88aefa689cd5ee9b4c1b6378c8ce712c",
|
||||
"device-dialog-mobile-ru": "7921b7d5e2c082fc4dd5053607a8228c2e63e0e72a401d86153effd54c2c6ca0",
|
||||
"device-dialog-desktop-de": "ac3f657e072006f5c1686d89da61df922991a04f69d2d7994a03d1ba93532681",
|
||||
"device-dialog-mobile-ru": "bde342feb5667a76884c081a4d9dd9bbacbbf7158f48f1c68322408bdde3d053",
|
||||
"device-dialog-desktop-de": "ef620a0b5d14f24d0d5021bcd720fe1531c84832442cf0f7964397450ed8ebc3",
|
||||
"german-view-mobile-light": "9496071b11f510f1c851a9c60583a96bfe768c1fd1e12a2b1b9183dee7e453a8",
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "77e8fc03817a784888993490cdfba675482b8c56483199c1efa512db8531516b",
|
||||
"toggle-entity-dialog-desktop-en": "e192bab83ae99bfb8d33046921f0a3009ebaa8074bd30971ab41a1eeac69ea0d",
|
||||
"toggle-entity-dialog-mobile-ru": "5143ad6846bc605dc52a97abe1b6593596fb25bb80a121f3c6279f1b7f421fee",
|
||||
"device-help-popover-light-ru": "259a50065b7f09d07db656b6cfe79d1de34397665ff8dd8a8e361f6f29a7ea6a",
|
||||
"decor-color-popover-mobile-ru": "606c41d53f9a996b24990032505180159ce032e2b4f14dfb214c79a8c4b85702",
|
||||
"decor-color-popover-desktop-en": "f626f246c8e29d1b1e5e067e0923438eb1222606388a33c3071e86307b4ca130",
|
||||
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
|
||||
"device-ripple-color-popover-mobile-ru": "4f2365818f46fe1e2b401e5835f5d29ea9c41b288d894025d7512db92f579762",
|
||||
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
|
||||
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
|
||||
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
|
||||
"device-ripple-color-popover-mobile-ru": "01c538f8b504ab9f5e8d64cb4cbbf3131c5de9b0909c996e2d61f4523801127c",
|
||||
"space-room-color-popover-desktop-ru": "ff0e633c7fbedfa04883792682d8e407aa0633bbd46ed12e0ab8bc2207be9ccb",
|
||||
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
|
||||
"backup-plan-only-export-desktop-en": "805fb7cd15959a7eee0b17150c4417e335f1b1030a438f7051c2b006a5f7d4f4",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
|
||||
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
@@ -32,6 +32,9 @@ const spanOverDoorFixture = JSON.parse(readFileSync(
|
||||
const wallUnionIsolationFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const cardVersion = JSON.parse(readFileSync(
|
||||
new URL('../../package.json', import.meta.url), 'utf8',
|
||||
)).version;
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
? makeLargeHouseFixture()
|
||||
@@ -672,6 +675,12 @@ export function prepareGoldenFixture(scenario) {
|
||||
...structuredClone(scenario.markerOverrides),
|
||||
];
|
||||
}
|
||||
if (scenario.markerAreaSnapshot) {
|
||||
fixture.config.settings = {
|
||||
...(fixture.config.settings || {}),
|
||||
marker_area_snapshot: structuredClone(scenario.markerAreaSnapshot),
|
||||
};
|
||||
}
|
||||
if (scenario.layoutOverrides) {
|
||||
const missing = Object.keys(scenario.layoutOverrides).filter((id) => !(id in (fixture.layout || {})));
|
||||
if (missing.length) throw new Error(`golden layoutOverrides reference missing item(s): ${missing.join(', ')}`);
|
||||
@@ -727,7 +736,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 }) => {
|
||||
const result = await page.evaluate(async ({ fixture, scenario, cardVersion }) => {
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -742,6 +751,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
const settleCamera = async (card) => {
|
||||
await until(() => !card._cameraTransition?.active, 1500);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
window.__goldenCard?.remove?.();
|
||||
window.__goldenEditor?.remove?.();
|
||||
window.__card?.remove?.();
|
||||
@@ -1203,6 +1217,20 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
+ `cursor=${JSON.stringify(card._cursorPt)} nearest=${JSON.stringify(nearest)}`);
|
||||
}
|
||||
}
|
||||
if (scenario.decorSelection) {
|
||||
card._decorTool = 'select';
|
||||
card._decorSel = scenario.decorSelection;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const selected = card._decorList.find((shape) => shape.id === scenario.decorSelection);
|
||||
const transformFrame = card.renderRoot.querySelector('.dtframe.dtfurnitureframe');
|
||||
if (selected?.kind !== 'furniture' || !transformFrame
|
||||
|| transformFrame.querySelectorAll('.dthandle.dtedge').length !== 4
|
||||
|| transformFrame.querySelectorAll('.dthandle').length !== 9) {
|
||||
throw new Error(`golden furniture transform frame is incomplete: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.editorTray) {
|
||||
let expectedKind = '';
|
||||
if (scenario.editorTray === 'plan-selection') {
|
||||
@@ -1342,7 +1370,14 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
total: '1', rooms: '0', devices: '1', groups: '0',
|
||||
}))
|
||||
|| !body.textContent.includes('unresolved-floor')) {
|
||||
throw new Error('golden orphan-reference Optimize dialog is incomplete');
|
||||
throw new Error('golden orphan-reference Optimize dialog is incomplete: '
|
||||
+ JSON.stringify({
|
||||
markersDetached: report?.markersDetached,
|
||||
orphanRoomLabelsRemoved: report?.orphanRoomLabelsRemoved,
|
||||
orphanDevicePositionsRemoved: report?.orphanDevicePositionsRemoved,
|
||||
liveMissingPositions: report?.liveMissingPositions,
|
||||
body: body?.textContent,
|
||||
}));
|
||||
}
|
||||
} else if (scenario.dialog === 'optimize-preflight') {
|
||||
const names = [
|
||||
@@ -1542,6 +1577,83 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await frame();
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
} else if (scenario.dialog === 'general-help') {
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const help = dialog?.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
|
||||
await help?.updateComplete;
|
||||
const trigger = help?.renderRoot?.querySelector('.trigger');
|
||||
if (!dialog || !trigger)
|
||||
throw new Error(`golden general-settings help trigger missing: ${scenario.openHelp}`);
|
||||
trigger.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
await frame();
|
||||
trigger.click();
|
||||
await help.updateComplete;
|
||||
await frame();
|
||||
const surface = help.renderRoot?.querySelector('.tooltip:popover-open')
|
||||
|| dialog.renderRoot?.querySelector('[data-hp-overlay="help"]')
|
||||
?.shadowRoot?.querySelector('.tooltip');
|
||||
const viewport = { width: innerWidth, height: innerHeight };
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const surfaceRect = surface?.getBoundingClientRect();
|
||||
const inside = (rect) => !!rect && rect.left >= -1 && rect.top >= -1
|
||||
&& rect.right <= viewport.width + 1 && rect.bottom <= viewport.height + 1;
|
||||
if (trigger.getAttribute('aria-expanded') !== 'true' || !inside(triggerRect)
|
||||
|| !inside(surfaceRect) || dialog.scrollWidth > dialog.clientWidth + 1)
|
||||
throw new Error(`golden general-settings help is clipped: ${scenario.openHelp}`);
|
||||
} else if (scenario.dialog === 'support') {
|
||||
card._haIntegrationVersion = cardVersion;
|
||||
card._openSupportDialog();
|
||||
await card.updateComplete;
|
||||
const opened = card._supportDialog;
|
||||
const text = '{"format":"houseplan-support-package","version":1}\n';
|
||||
const preview = {
|
||||
token: 'a'.repeat(48),
|
||||
expiresAt: Date.now() + 300_000,
|
||||
size: new TextEncoder().encode(text).byteLength,
|
||||
sha256: 'b'.repeat(64),
|
||||
spaces: 2,
|
||||
format: 'houseplan-support-package',
|
||||
version: 1,
|
||||
text,
|
||||
preparedAt: Date.now() - 60_000,
|
||||
};
|
||||
const patch = scenario.supportState === 'empty' ? {}
|
||||
: scenario.supportState === 'preview' ? {
|
||||
contact: 'user@example.test', message: 'The room light is not rendered.',
|
||||
attach: true, status: 'ready', preview,
|
||||
} : scenario.supportState === 'validation' ? {
|
||||
status: 'error', errorCode: 'validation.message_required',
|
||||
} : scenario.supportState === 'success' ? {
|
||||
contact: 'user@example.test', message: 'The room light is not rendered.',
|
||||
status: 'success', reportId: 'HP-43-GOLDEN',
|
||||
} : scenario.supportState === 'relay-error' ? {
|
||||
contact: '@houseplan-user', message: 'The report relay timed out.',
|
||||
attach: true, status: 'error', preview, errorCode: 'support_rate_limited',
|
||||
} : null;
|
||||
if (!opened || !patch) {
|
||||
throw new Error(`invalid golden support state: ${scenario.id}`);
|
||||
}
|
||||
card._supportDialog = { ...opened, ...patch };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const dialog = card.renderRoot.querySelector('#support-dialog');
|
||||
const body = dialog?.querySelector('.supportbody');
|
||||
const expected = scenario.supportState === 'preview' ? '.supportpreview'
|
||||
: scenario.supportState === 'validation' || scenario.supportState === 'relay-error'
|
||||
? '#support-error'
|
||||
: scenario.supportState === 'success' ? '#support-receipt' : '.supportform';
|
||||
const focus = dialog?.querySelector(expected);
|
||||
focus?.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
await frame();
|
||||
if (!dialog || !body || !focus || body.scrollWidth > body.clientWidth + 1
|
||||
|| dialog.scrollWidth > dialog.clientWidth + 1
|
||||
|| !dialog.querySelector('.aboutver')
|
||||
|| dialog.querySelectorAll('a.aboutlink').length < 3) {
|
||||
throw new Error(`golden support dialog is incomplete or clipped: ${scenario.id}`);
|
||||
}
|
||||
} else if (scenario.dialog === 'device-ripple-color') {
|
||||
card._setMode('devices');
|
||||
await card.updateComplete;
|
||||
@@ -1672,7 +1784,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
}
|
||||
}
|
||||
await document.fonts?.ready;
|
||||
await frame();
|
||||
// Camera motion is intentionally visible in production, but reviewed
|
||||
// goldens own the settled UI. Never capture a timing-dependent RAF frame.
|
||||
await settleCamera(card);
|
||||
return {
|
||||
space: card._space,
|
||||
mode: card._mode,
|
||||
@@ -1694,7 +1808,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
cachedRays: card._sunRaysCache?.rays?.length || 0,
|
||||
} } : {}),
|
||||
};
|
||||
}, { fixture, scenario });
|
||||
}, { fixture, scenario, cardVersion });
|
||||
if (scenario.tabDrag) {
|
||||
const drag = await page.evaluate((placement) => {
|
||||
const card = window.__goldenCard;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 = 50;
|
||||
export const GOLDEN_MATRIX_VERSION = 54;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -432,6 +432,37 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
w: 0.26, h: 0.17, color: '#141832', opacity: 1, width_cm: 1.2 },
|
||||
],
|
||||
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'furniture-transform-light', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
|
||||
decorOverride: [
|
||||
...decorLayerFixture.filter((shape) => shape.kind !== 'furniture'),
|
||||
{ id: 'furn-normal', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.08, y: 0.16,
|
||||
w: 0.18, h: 0.12, color: '#141832', opacity: 1, width_cm: 1.2 },
|
||||
{ id: 'furn-flip-h', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.30, y: 0.16,
|
||||
w: 0.18, h: 0.12, flip_h: true, color: '#141832', opacity: 1, width_cm: 1.2 },
|
||||
{ id: 'furn-flip-v', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.52, y: 0.16,
|
||||
w: 0.18, h: 0.12, flip_v: true, color: '#141832', opacity: 1, width_cm: 1.2 },
|
||||
{ id: 'furn-flip-hv', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.74, y: 0.16,
|
||||
w: 0.18, h: 0.12, flip_h: true, flip_v: true,
|
||||
color: '#141832', opacity: 1, width_cm: 1.2 },
|
||||
],
|
||||
decorSelection: 'furn-flip-hv', language: 'ru', theme: 'light',
|
||||
viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'furniture-transform-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
decorOverride: [
|
||||
...decorLayerFixture.filter((shape) => shape.kind !== 'furniture'),
|
||||
{ id: 'furn-normal', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.08, y: 0.16,
|
||||
w: 0.18, h: 0.12, angle: 30, color: '#d8def0', opacity: 1, width_cm: 1.2 },
|
||||
{ id: 'furn-flip-h', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.30, y: 0.16,
|
||||
w: 0.18, h: 0.12, angle: 30, flip_h: true,
|
||||
color: '#d8def0', opacity: 1, width_cm: 1.2 },
|
||||
{ id: 'furn-flip-v', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.52, y: 0.16,
|
||||
w: 0.18, h: 0.12, angle: 30, flip_v: true,
|
||||
color: '#d8def0', opacity: 1, width_cm: 1.2 },
|
||||
{ id: 'furn-flip-hv', kind: 'furniture', symbol: 'sofa_corner_right', x: 0.74, y: 0.16,
|
||||
w: 0.18, h: 0.12, angle: 30, flip_h: true, flip_v: true,
|
||||
color: '#d8def0', opacity: 1, width_cm: 1.2 },
|
||||
],
|
||||
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'tray-narrow-tool-ru', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
|
||||
editorTray: 'decor-tool', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, ...page },
|
||||
@@ -493,6 +524,25 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
'golden-climate': { s: 'golden-lighting', x: 0.80, y: 0.28 },
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'device-value-face-cover-source-dark', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', glowEnabled: false, sunRays: false,
|
||||
showNames: false,
|
||||
markerOverrides: [{
|
||||
id: 'golden-left-linkquality', binding: 'entity:cover.golden_door_half',
|
||||
display: 'value', size: 3,
|
||||
value_source: {
|
||||
kind: 'entity_attribute', entity_id: 'cover.golden_door_half',
|
||||
attribute: 'current_position',
|
||||
},
|
||||
}],
|
||||
stateOverrides: {
|
||||
'cover.golden_door_half': { attributes: { current_position: 42 } },
|
||||
},
|
||||
layoutOverrides: {
|
||||
'golden-left-linkquality': { s: 'golden-lighting', x: 0.50, y: 0.50 },
|
||||
},
|
||||
deviceOnly: 'golden-left-linkquality',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
...['light', 'dark'].map((theme) => ({
|
||||
id: `device-icon-state-table-${theme}`, fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
glowEnabled: false, sunRays: false,
|
||||
@@ -516,6 +566,30 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
position: 'bottom',
|
||||
} },
|
||||
],
|
||||
// This scenario deliberately places two devices outside their HA rooms to
|
||||
// exercise icon states at fixed coordinates. Seed the Area-following
|
||||
// provenance so #126 does not reinterpret that test-only layout as stale
|
||||
// legacy placement and turn an unrelated visual matrix into a relocation
|
||||
// scenario.
|
||||
markerAreaSnapshot: {
|
||||
'golden-light-one': { binding: 'device:golden-light-one', area: 'golden_light_left' },
|
||||
'golden-light-two': { binding: 'device:golden-light-two', area: 'golden_light_left' },
|
||||
'golden-light-three': { binding: 'device:golden-light-three', area: 'golden_light_right' },
|
||||
'golden-presence': { binding: 'device:golden-presence', area: 'golden_light_right' },
|
||||
'golden-climate': { binding: 'device:golden-climate', area: 'golden_light_right' },
|
||||
'golden-left-temperature': {
|
||||
binding: 'device:golden-left-temperature', area: 'golden_light_left',
|
||||
},
|
||||
'golden-right-temperature': {
|
||||
binding: 'device:golden-right-temperature', area: 'golden_light_right',
|
||||
},
|
||||
'golden-left-linkquality': {
|
||||
binding: 'device:golden-left-linkquality', area: 'golden_light_left',
|
||||
},
|
||||
'golden-right-linkquality': {
|
||||
binding: 'device:golden-right-linkquality', area: 'golden_light_right',
|
||||
},
|
||||
},
|
||||
stateOverrides: {
|
||||
'light.golden_light_one': { attributes: { lqi: 40 } },
|
||||
'light.golden_light_two': { attributes: { lqi: 41 } },
|
||||
@@ -739,6 +813,38 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'general-color-popover-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'general-color', language: 'en', theme: 'light',
|
||||
viewport: { width: 900, height: 900 }, ...page },
|
||||
// #86 AC7: renderer-level 200% browser zoom is 390 CSS px at DPR 2
|
||||
// (780 physical px). Keep both themes reviewed because help placement and
|
||||
// contrast are part of the visual contract, not just a DOM assertion.
|
||||
{ id: 'settings-help-zoom-200-en-light', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'general-help', openHelp: 'gs.bg_mode.help', deviceScaleFactor: 2,
|
||||
helpTextRegion: { key: 'gs.bg_mode.help', minPixels: 30 },
|
||||
language: 'en', theme: 'light', viewport: { width: 390, height: 900 }, ...page },
|
||||
{ id: 'settings-help-zoom-200-ru-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'general-help', openHelp: 'gs.bg_mode.help', deviceScaleFactor: 2,
|
||||
helpTextRegion: { key: 'gs.bg_mode.help', minPixels: 30 },
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 900 }, ...page },
|
||||
// #43 AC14: the support dialog owns reviewed desktop, phone and tablet
|
||||
// states. The six scenes deliberately cover both themes while keeping the
|
||||
// individual states named in the specification independently reviewable.
|
||||
{ id: 'support-desktop-empty-light-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'support', supportState: 'empty', language: 'en', theme: 'light',
|
||||
viewport: { width: 1000, height: 980 }, ...page },
|
||||
{ id: 'support-desktop-preview-dark-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'support', supportState: 'preview', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 980 }, ...page },
|
||||
{ id: 'support-phone-validation-light-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'support', supportState: 'validation', language: 'ru', theme: 'light',
|
||||
viewport: { width: 320, height: 760 }, ...page },
|
||||
{ id: 'support-phone-success-dark-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'support', supportState: 'success', language: 'en', theme: 'dark',
|
||||
viewport: { width: 390, height: 820 }, ...page },
|
||||
{ id: 'support-relay-error-light-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'support', supportState: 'relay-error', language: 'en', theme: 'light',
|
||||
viewport: { width: 900, height: 900 }, ...page },
|
||||
{ id: 'support-tablet-preview-dark-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'support', supportState: 'preview', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 768, height: 900 }, ...page },
|
||||
{ id: 'device-ripple-color-popover-mobile-ru', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device-ripple-color', deviceId: 'golden-light-two',
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
|
||||
@@ -771,6 +877,15 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
id: 'golden-presence', binding: 'virtual', removed: true,
|
||||
space: 'removed-floor', name: 'Retired marker',
|
||||
}],
|
||||
// The orphan-reference fixture intentionally reuses ids of live registry
|
||||
// devices. Pin their pre-#126 provenance so the lifecycle synchronizer
|
||||
// cannot consume the deliberately orphaned positions before Optimize
|
||||
// inspects them.
|
||||
markerAreaSnapshot: {
|
||||
'golden-light-one': { binding: 'device:golden-light-one', area: 'golden_light_left' },
|
||||
'golden-light-two': { binding: 'device:golden-light-two', area: 'golden_light_left' },
|
||||
'golden-presence': { binding: 'device:golden-presence', area: 'golden_light_right' },
|
||||
},
|
||||
layoutOverrides: {
|
||||
'golden-light-two': { s: 'removed-floor', x: 0.35, y: 0.72 },
|
||||
'golden-light-one': { s: 'unresolved-floor', x: 0.20, y: 0.34 },
|
||||
@@ -788,6 +903,11 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
id: 'golden-presence', binding: 'virtual', removed: true,
|
||||
space: 'removed-floor', name: 'Старый маркер',
|
||||
}],
|
||||
markerAreaSnapshot: {
|
||||
'golden-light-one': { binding: 'device:golden-light-one', area: 'golden_light_left' },
|
||||
'golden-light-two': { binding: 'device:golden-light-two', area: 'golden_light_left' },
|
||||
'golden-presence': { binding: 'device:golden-presence', area: 'golden_light_right' },
|
||||
},
|
||||
layoutOverrides: {
|
||||
'golden-light-two': { s: 'removed-floor', x: 0.35, y: 0.72 },
|
||||
'golden-light-one': { s: 'unresolved-floor', x: 0.20, y: 0.34 },
|
||||
|
||||
@@ -605,7 +605,7 @@ if (existsSync(baselineManifestPath)) {
|
||||
|
||||
const browserArgs = ['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'];
|
||||
const browserContext = { locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' };
|
||||
const { page, browser } = await launch(
|
||||
const { page: primaryPage, browser } = await launch(
|
||||
{ width: 1000, height: 900 },
|
||||
1,
|
||||
browserArgs,
|
||||
@@ -613,12 +613,30 @@ const { page, browser } = await launch(
|
||||
);
|
||||
const chromium = await browser.version();
|
||||
const results = [];
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
const pagesByScale = new Map([[1, primaryPage]]);
|
||||
const extraBrowsers = [];
|
||||
for (const scale of new Set(scenarios.map((scenario) => scenario.deviceScaleFactor || 1))) {
|
||||
if (scale === 1) continue;
|
||||
const scaled = await launch(
|
||||
{ width: 1000, height: 900 }, scale, browserArgs, browserContext,
|
||||
);
|
||||
pagesByScale.set(scale, scaled.page);
|
||||
extraBrowsers.push(scaled.browser);
|
||||
}
|
||||
const pageErrors = new Map();
|
||||
for (const page of pagesByScale.values()) {
|
||||
const errors = [];
|
||||
pageErrors.set(page, errors);
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
}
|
||||
let buildFingerprint = null;
|
||||
try {
|
||||
buildFingerprint = await assertFreshDemoBundle(page, ROOT);
|
||||
buildFingerprint = await assertFreshDemoBundle(primaryPage, ROOT);
|
||||
for (const scenario of scenarios) {
|
||||
const expectedScale = scenario.deviceScaleFactor || 1;
|
||||
const page = pagesByScale.get(expectedScale);
|
||||
if (!page) throw new Error(`golden renderer scale is unavailable: ${expectedScale}`);
|
||||
const activePageErrors = pageErrors.get(page);
|
||||
const result = {
|
||||
id: scenario.id,
|
||||
threshold: scenario.threshold,
|
||||
@@ -626,9 +644,14 @@ try {
|
||||
actualSha256: null,
|
||||
};
|
||||
try {
|
||||
pageErrors.length = 0;
|
||||
activePageErrors.length = 0;
|
||||
result.runtime = await prepareGoldenScenario(page, scenario);
|
||||
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
|
||||
const actualScale = await page.evaluate(() => devicePixelRatio);
|
||||
result.deviceScaleFactor = actualScale;
|
||||
if (Math.abs(actualScale - expectedScale) > 0.001)
|
||||
throw new Error(`golden renderer scale mismatch: ${actualScale} != ${expectedScale}`);
|
||||
if (activePageErrors.length)
|
||||
throw new Error(`browser exception: ${activePageErrors.join(' | ')}`);
|
||||
if (scenario.openingSymbolContract) {
|
||||
result.openingSymbolContract = await assertOpeningSymbolContract(
|
||||
page, scenario.openingSymbolContract,
|
||||
@@ -851,7 +874,7 @@ try {
|
||||
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await Promise.all([...extraBrowsers.map((item) => item.close()), browser.close()]);
|
||||
}
|
||||
|
||||
const expectedScenarioIds = GOLDEN_SCENARIOS.map((scenario) => scenario.id);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Build the exact snapshot consumed by the static-card half of the grid-scale
|
||||
* smoke. The main and static cards normally load through different stores; a
|
||||
* comparative fixture must therefore hand the static card one atomic
|
||||
* config+layout pair instead of mixing the demo backend config with the main
|
||||
* card's temporary layout.
|
||||
*/
|
||||
export function coherentGridScaleStaticPatch({ config, layout, revision }) {
|
||||
if (!config || typeof config !== 'object' || !Array.isArray(config.spaces)) {
|
||||
throw new Error('grid-scale static fixture requires a complete config');
|
||||
}
|
||||
if (!layout || typeof layout !== 'object' || Array.isArray(layout)) {
|
||||
throw new Error('grid-scale static fixture requires a complete layout');
|
||||
}
|
||||
const token = Number.isFinite(revision) ? revision : 0;
|
||||
return {
|
||||
config: structuredClone(config),
|
||||
configFingerprint: `grid-scale-fixture:${token}:config`,
|
||||
layout: structuredClone(layout),
|
||||
layoutFingerprint: `grid-scale-fixture:${token}:layout`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Пробы гарда исключений (#404)
|
||||
|
||||
Здесь лежат фикстуры, которые **должны падать**: каждая проверяет, что гард
|
||||
«uncaught exception внутри карточки» умеет краснеть, а не только объявлен.
|
||||
|
||||
Три причины, по которым это отдельный каталог, а не `demo/smoke_*` и не
|
||||
`demo/fixtures/`:
|
||||
|
||||
1. **Не `smoke_*`** — иначе шарды CI будут гонять заведомо красный файл и
|
||||
покрасят себя.
|
||||
2. **Не `demo/fixtures/`** — этот каталог входит в корпус `sourceFingerprint`
|
||||
(`scripts/source-fingerprint.mjs`), то есть каждый новый `.mjs` там объявляет
|
||||
устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс.
|
||||
Пробы гарда ни одного пикселя не касаются, платить пересъёмкой за них нечем.
|
||||
3. **Каталог, а не файл** — проб три, и они читаются как набор.
|
||||
|
||||
Запускает их `verify-guard.mjs`; он же вызывается из job «Смоки в браузере»
|
||||
и служит guard'ом двух мутантов в `scripts/mutation-gate.mjs`.
|
||||
@@ -0,0 +1,10 @@
|
||||
// Проба #404 AC8: закрытая страница не ломает вердикт.
|
||||
//
|
||||
// Round-trip к закрытой странице бросает, и если бы гард этого не проглатывал,
|
||||
// починка одного дефекта завела бы другой — падение вердикта вместо вердикта.
|
||||
// Здесь исключений нет вовсе, поэтому файл обязан завершаться НУЛЁМ.
|
||||
import { launch, finish } from '../serve.mjs';
|
||||
|
||||
const { browser, page } = await launch();
|
||||
await page.close();
|
||||
await finish(browser, { probe: 'closed page' });
|
||||
@@ -0,0 +1,14 @@
|
||||
// Проба #404 AC1: исключение в самом хвосте, без обращений к странице после.
|
||||
//
|
||||
// Это единственная слепая зона, из-за которой заводился #404: смок всё измерил,
|
||||
// печатает результат, а карточка в это время бросает. Событие `pageerror`
|
||||
// приходит асинхронно, и до починки счётчик читался раньше доставки.
|
||||
//
|
||||
// Файл ОБЯЗАН завершаться ненулевым кодом. Ноль означает, что гард снова слеп.
|
||||
import { launch, finish } from '../serve.mjs';
|
||||
|
||||
const { browser, page } = await launch();
|
||||
await page.evaluate(() => { setTimeout(() => { throw new Error('guard-tail-exception'); }, 0); });
|
||||
// Ни одного обращения к странице после этой строки: round-trip обязан сделать
|
||||
// сам гард, иначе доказательство ничего не доказывает.
|
||||
await finish(browser, { probe: 'tail exception' });
|
||||
@@ -0,0 +1,8 @@
|
||||
// Проба #404 AC3: необработанное отклонение промиса считается так же, как
|
||||
// исключение. Связь с #405 (оброненный промис в удалении черновика) держится
|
||||
// именно этим: там дефект приходит в карточку в виде rejection, а не throw.
|
||||
import { launch, finish } from '../serve.mjs';
|
||||
|
||||
const { browser, page } = await launch();
|
||||
await page.evaluate(() => { setTimeout(() => { Promise.reject(new Error('guard-tail-rejection')); }, 0); });
|
||||
await finish(browser, { probe: 'tail rejection' });
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Отрицательный прогон гарда исключений (#404).
|
||||
*
|
||||
* Гард объявлен в `demo/serve.mjs` с 2026-07-27 и до этой задачи не срабатывал
|
||||
* ни разу: счётчик читался раньше, чем Playwright доставлял `pageerror`. Такое
|
||||
* ловится только запуском — «проверка, которая не умеет падать» выглядит
|
||||
* идентично работающей во всём, кроме исхода.
|
||||
*
|
||||
* Почему не тест в `test/`: пробам нужен настоящий Chromium, а job «Фронтенд»,
|
||||
* где идёт `npm test`, браузеры не ставит. Тест там молча скипался бы — то есть
|
||||
* ровно тот тихий успех, против которого всё это и делается. Поэтому проверка
|
||||
* живёт в job «Смоки в браузере», где браузер есть, и вызывается один раз.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Каждая проба: чего ждём от кода возврата и что обязано быть в выводе. */
|
||||
const PROBES = [
|
||||
{
|
||||
file: 'guard_tail_exception.mjs',
|
||||
expectExit: 1,
|
||||
expectOutput: /uncaught exception\(s\) inside the card/,
|
||||
because: 'исключение в хвосте — та самая слепая зона, из-за которой заводился #404',
|
||||
},
|
||||
{
|
||||
file: 'guard_tail_rejection.mjs',
|
||||
expectExit: 1,
|
||||
expectOutput: /uncaught exception\(s\) inside the card/,
|
||||
because: 'отклонение промиса приходит тем же каналом; на этом держится связь с #405',
|
||||
},
|
||||
{
|
||||
file: 'guard_closed_page.mjs',
|
||||
expectExit: 0,
|
||||
expectOutput: /OK/,
|
||||
because: 'round-trip к закрытой странице не имеет права ронять вердикт',
|
||||
},
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const probe of PROBES) {
|
||||
const run = spawnSync(process.execPath, [resolve(HERE, probe.file)], {
|
||||
encoding: 'utf8', cwd: resolve(HERE, '../..'), timeout: 90_000,
|
||||
});
|
||||
const output = `${run.stdout || ''}${run.stderr || ''}`;
|
||||
const exitOk = run.status === probe.expectExit;
|
||||
const textOk = probe.expectOutput.test(output);
|
||||
if (exitOk && textOk) {
|
||||
console.log(`ok ${probe.file} → exit ${run.status}`);
|
||||
continue;
|
||||
}
|
||||
failed += 1;
|
||||
console.error(`FAIL ${probe.file}: ${probe.because}`);
|
||||
console.error(` ожидался exit ${probe.expectExit}, получен ${run.status}`);
|
||||
if (!textOk) console.error(` в выводе нет ${probe.expectOutput}`);
|
||||
console.error(output.split('\n').slice(-12).map((line) => ` | ${line}`).join('\n'));
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(`\nгард исключений не доказан: проб провалено ${failed} из ${PROBES.length}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nгард исключений доказан на ${PROBES.length} пробах`);
|
||||
@@ -150,8 +150,8 @@ legitimate fixture extension without weakening the separate zero-growth gate.
|
||||
|
||||
## Glow profiles
|
||||
|
||||
Both Glow profiles run deterministic 1/10/30/60-pool variants at DPR 1 and
|
||||
Chromium CPU throttling x4, but deliberately exercise different fixtures:
|
||||
The full-card Glow profiles run deterministic 1/10/30/60-pool variants at DPR
|
||||
1 and Chromium CPU throttling x4, but deliberately exercise different fixtures:
|
||||
|
||||
- `large-light-blend-v1` compares the isolated screen group with the previous
|
||||
normal-layer implementation on the shared frontend/backend schema fixture
|
||||
@@ -160,11 +160,22 @@ Chromium CPU throttling x4, but deliberately exercise different fixtures:
|
||||
independent Glow on the existing 60-room/200-device large-house fixture,
|
||||
without changing `large-house-v1`.
|
||||
|
||||
The same runner also owns the two static-card profiles introduced with #374:
|
||||
|
||||
- `large-space-card-default-v1` proves that the default-off card keeps the
|
||||
historical no-visibility-work path and a zero-entry Glow cache;
|
||||
- `large-space-card-glow-v1` measures the explicit `light_pools:true` path on
|
||||
one large static space, including HA ticks, heap/cache growth and capture.
|
||||
|
||||
```bash
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --output=artifacts/performance/glow.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --output=artifacts/performance/overlay.json
|
||||
npm run benchmark:glow -- --profile=large-space-card-default-v1 --output=artifacts/performance/space-default.json
|
||||
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --output=artifacts/performance/space-glow.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/space-candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
|
||||
```
|
||||
|
||||
Reports include per-variant state-update timings, render/pool counts, Long
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-space-card-default-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 750 },
|
||||
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1100 },
|
||||
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
|
||||
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
|
||||
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3200 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 2700,
|
||||
"maxSingleRegressionRatio": 0.5,
|
||||
"maxSingleNoiseAllowanceMs": 200,
|
||||
"maxCountP95": 12,
|
||||
"maxCountRegressionRatio": 0.5,
|
||||
"countNoiseAllowance": 2,
|
||||
"maxTotalP95Ms": 5500,
|
||||
"maxTotalRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 250
|
||||
},
|
||||
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
|
||||
"cacheEntries": { "glowClip": 0 },
|
||||
"cacheGrowth": { "glowClip": 0 },
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-space-card-glow-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 750 },
|
||||
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1100 },
|
||||
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
|
||||
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
|
||||
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3200 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 2700,
|
||||
"maxSingleRegressionRatio": 0.5,
|
||||
"maxSingleNoiseAllowanceMs": 200,
|
||||
"maxCountP95": 12,
|
||||
"maxCountRegressionRatio": 0.5,
|
||||
"countNoiseAllowance": 2,
|
||||
"maxTotalP95Ms": 5500,
|
||||
"maxTotalRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 250
|
||||
},
|
||||
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
|
||||
"cacheEntries": { "glowClip": 128 },
|
||||
"cacheGrowth": { "glowClip": 0 },
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-space-card-glow-v1",
|
||||
"minimumSamples": 3,
|
||||
"timings": {
|
||||
"stateUpdate60Ms": { "stat": "median", "hardMaxMs": 2200 },
|
||||
"screenshotCaptureMs": { "stat": "median", "hardMaxMs": 3200 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 2700,
|
||||
"maxCountP95": 12,
|
||||
"maxTotalP95Ms": 5500
|
||||
},
|
||||
"heap": { "required": true, "hardMaxGrowthBytes": 67108864 },
|
||||
"cacheEntries": { "glowClip": 128 },
|
||||
"cacheGrowth": { "glowClip": 0 },
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -34,7 +34,6 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_path',
|
||||
'_resize',
|
||||
'_serverCfg',
|
||||
'_settingsDialog',
|
||||
'_tool',
|
||||
@@ -46,6 +45,13 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_wallFaceBatch', '_wallFaceGraphCache',
|
||||
]),
|
||||
// #380: v1.68.1 owns the same resize session directly on the card; newer
|
||||
// bundles moved it into ResizeController. A comparison target must expose
|
||||
// one of the two explicit shapes; the current member retains its object
|
||||
// type check and is also verified against current production source.
|
||||
fieldAlternatives: Object.freeze([
|
||||
Object.freeze({ current: '_resize', legacy: '_rszDrag' }),
|
||||
]),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_cleanFloorCache: 'map',
|
||||
@@ -89,6 +95,19 @@ export const GLOW_CARD_CONTRACT = Object.freeze({
|
||||
}),
|
||||
});
|
||||
|
||||
export const SPACE_GLOW_CARD_CONTRACT = Object.freeze({
|
||||
label: 'Static-card Glow performance profiles',
|
||||
methods: Object.freeze([]),
|
||||
fields: Object.freeze(['_devices', '_loading', '_snap']),
|
||||
optionalFields: Object.freeze(['_glowRuntimeState', '_glowScreenBlend']),
|
||||
fieldTypes: Object.freeze({
|
||||
_devices: 'array',
|
||||
_loading: 'boolean',
|
||||
_glowRuntimeState: 'object',
|
||||
_glowScreenBlend: 'boolean',
|
||||
}),
|
||||
});
|
||||
|
||||
/** Single fail-fast implementation injected into both browser runners. Keep
|
||||
* this function self-contained: runners serialize it with `toString()`. */
|
||||
export function assertCardContract(card, contract) {
|
||||
@@ -102,11 +121,19 @@ export function assertCardContract(card, contract) {
|
||||
.map((name) => `${name}()`);
|
||||
const missingFields = contract.fields
|
||||
.filter((name) => !(name in card) || card[name] === undefined);
|
||||
const invalidFields = [...contract.fields, ...(contract.optionalFields || [])]
|
||||
const missingAlternatives = (contract.fieldAlternatives || [])
|
||||
.filter((choice) => !Object.values(choice)
|
||||
.some((name) => name in card && card[name] !== undefined))
|
||||
.map((choice) => Object.values(choice).join('|'));
|
||||
const alternativeFields = (contract.fieldAlternatives || [])
|
||||
.flatMap((choice) => Object.values(choice));
|
||||
const invalidFields = [
|
||||
...contract.fields, ...(contract.optionalFields || []), ...alternativeFields,
|
||||
]
|
||||
.filter((name) => name in card && contract.fieldTypes?.[name]
|
||||
&& !matches(card[name], contract.fieldTypes[name]))
|
||||
.map((name) => `${name}:${contract.fieldTypes[name]}`);
|
||||
const missing = [...missingMethods, ...missingFields];
|
||||
const missing = [...missingMethods, ...missingFields, ...missingAlternatives];
|
||||
if (missing.length || invalidFields.length) {
|
||||
const details = [
|
||||
missing.length ? `missing private API: ${missing.join(', ')}` : '',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// via request interception (no HTTP server needed). Usage: const {page,browser}=await launch();
|
||||
import { chromium } from 'playwright';
|
||||
import { assertFreshDemoBundleUnlessAllowed } from './bundle-freshness.mjs';
|
||||
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
@@ -16,6 +17,51 @@ const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+
|
||||
// prints them and sets the exit code.
|
||||
const _failures = [];
|
||||
let _pageErrors = 0;
|
||||
/**
|
||||
* Открытые страницы — для round-trip'а перед чтением счётчика (#404).
|
||||
*
|
||||
* Ссылок на страницы у `finish(browser, out)` нет, а менять её сигнатуру
|
||||
* нельзя: так её зовут 205 смоков. Поэтому страницы регистрируются там, где
|
||||
* создаются.
|
||||
*/
|
||||
const _livePages = new Set();
|
||||
|
||||
/**
|
||||
* События `pageerror` Playwright доставляет асинхронно по CDP, а гард читал
|
||||
* счётчик синхронно (#404). Если исключение возникло после последнего обращения
|
||||
* смока к странице, счётчик к моменту проверки ещё нулевой, а `browser.close()`
|
||||
* уносит недоставленное событие. В логе это видно дословно: `EXC` печатается
|
||||
* ПОСЛЕ результата и ДО `OK`.
|
||||
*
|
||||
* Круговой запрос к странице вытесняет ранее поставленные макрозадачи, поэтому
|
||||
* всё, что страница успела произвести до этого момента, к нам уже дошло.
|
||||
*
|
||||
* Честная граница: исключение, возникшее ПОСЛЕ этого round-trip'а — например, в
|
||||
* обработчике `beforeunload` при закрытии браузера, — не учитывается. Ловить его
|
||||
* значит ждать неизвестно чего неизвестно сколько; контракт формулируется как
|
||||
* «всё, что произошло до вызова вердикта».
|
||||
*/
|
||||
async function roundTripLivePages() {
|
||||
for (const page of _livePages) {
|
||||
try { await page.evaluate(() => 0); } catch { /* закрыта, упала или в навигации */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписать страницу, созданную ВНЕ `launch()` (#404, Medium-1 ревью ТЗ).
|
||||
*
|
||||
* Таких мест два — `smoke_zoom_flash` открывает вторую страницу на своём
|
||||
* контексте, `smoke_svg_sandbox` создаёт три. Их исключения не считал никто:
|
||||
* первая печатала своё `EXC2` мимо счётчика, остальные три не имели слушателя
|
||||
* вовсе. Регистрация в `launchInternal` их не покрывает по построению, поэтому
|
||||
* гард отдаёт наружу ровно одну функцию — и её вызов виден в диффе смока.
|
||||
*/
|
||||
export function watchPage(page) {
|
||||
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.stack || e.message); });
|
||||
_livePages.add(page);
|
||||
page.on('close', () => _livePages.delete(page));
|
||||
return page;
|
||||
}
|
||||
|
||||
/** Assert one named fact. `expected` defaults to true. */
|
||||
export function check(name, actual, expected = true) {
|
||||
@@ -30,9 +76,39 @@ export function checkAll(out, expected = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тот же вердикт по исключениям в карточке, что у `finish()`, для смоков со
|
||||
* своей логикой выхода (#407).
|
||||
*
|
||||
* Счётчик `_pageErrors` живёт здесь, а читал его только `finish()`. Шесть
|
||||
* смоков `finish()` не вызывали вовсе — у каждого была своя ручная развязка
|
||||
* (`if (!ok) process.exit(1)`) либо `throw` из try/finally, и ни одна не
|
||||
* спрашивала про исключения. То есть необработанное исключение внутри карточки
|
||||
* во время этих шести проходило незамеченным всегда: в лог печаталось `EXC`, а
|
||||
* прогон оставался зелёным.
|
||||
*
|
||||
* Функция ничего не бросает намеренно. Смок с ручной развязкой продолжает
|
||||
* решать сам, что печатать и когда выходить; здесь только выставляется код
|
||||
* возврата, который дальше уже не отнять — `process.exitCode` переживёт любой
|
||||
* последующий `console.log`.
|
||||
*
|
||||
* @returns true, если карточка бросала — чтобы вызывающий мог добавить своё
|
||||
* сообщение, не считая исключения заново.
|
||||
*/
|
||||
export async function reportPageErrors() {
|
||||
await roundTripLivePages();
|
||||
if (!_pageErrors) return false;
|
||||
console.error(`FAILED: ${_pageErrors} uncaught exception(s) inside the card`);
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Print the result, report failures, close the browser, set the exit code. */
|
||||
export async function finish(browser, out) {
|
||||
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
|
||||
// Порядок обязателен: сначала дать странице доставить события, потом читать
|
||||
// счётчик (#404). Обратный порядок и был дефектом.
|
||||
await roundTripLivePages();
|
||||
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
|
||||
await browser?.close?.();
|
||||
if (_failures.length) {
|
||||
@@ -57,8 +133,10 @@ async function launchInternal(
|
||||
const page = await (await browser.newContext({
|
||||
viewport, deviceScaleFactor: scale, ...contextOptions,
|
||||
})).newPage();
|
||||
// audit T1: an exception inside the card used to be logged and ignored
|
||||
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.stack || e.message); });
|
||||
// audit T1: an exception inside the card used to be logged and ignored.
|
||||
// Подписка и регистрация — одной функцией: разъехавшись, они дали бы
|
||||
// страницу, чьи исключения считаются, но доставки которых никто не ждёт (#404).
|
||||
watchPage(page);
|
||||
await page.route('**/*', (r) => {
|
||||
const u = new URL(r.request().url());
|
||||
let p = decodeURIComponent(u.pathname);
|
||||
@@ -88,8 +166,11 @@ async function launchInternal(
|
||||
// Existing product smokes exercise editor internals directly. They preload
|
||||
// the new #337 runtime without changing mode; the dedicated network smoke
|
||||
// uses launchColdView() and proves the real cold-View boundary separately.
|
||||
// Comparative performance runs also launch the previous stable through this
|
||||
// file. A pre-#337 monolithic card has no preload method and is already
|
||||
// ready; a current card still has to return truthy (#380).
|
||||
if (preloadEditorRuntime) {
|
||||
const ready = await page.evaluate(() => window.__card._ensureEditorRuntime());
|
||||
const ready = await page.evaluate(ensureHarnessEditorRuntime);
|
||||
if (!ready) throw new Error('editor runtime did not preload for browser smoke');
|
||||
}
|
||||
return { page, browser };
|
||||
|
||||
@@ -34,10 +34,35 @@ const res = await page.evaluate(async () => {
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
const pb = c._pos(b);
|
||||
c._deviceDrag = {
|
||||
id: b.id, spaceId: b.space, displayName: b.name, pointerId: 74,
|
||||
source: null, sx: 0, sy: 0, ox: pb.x, oy: pb.y, moved: true,
|
||||
before: { s: b.space, x: pb.x / 1000, y: pb.y / 1000 },
|
||||
start: c._devicePlacementForCanvas(b, pb.x, pb.y),
|
||||
};
|
||||
const keptDrag = c._deviceDrag;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
c._drag = null; c.requestUpdate(); await c.updateComplete;
|
||||
// #400: направляющая обязана идти ОТ ДРУГОГО значка, а не от самого себя.
|
||||
// Прежняя проверка `guides() >= 1` этого не различала: точка всегда
|
||||
// совпадает сама с собой в пределах допуска, поэтому исключение
|
||||
// перетаскиваемого маркера могло не работать вовсе — и не работало,
|
||||
// потому что читало `_drag`, который в этом режиме всегда null.
|
||||
out.devGuideComesFromAnotherMarker = (() => {
|
||||
const key = (list) => list.map((point) => point.join(','));
|
||||
const withDrag = key(c._editorRuntime._alignCandidates());
|
||||
c._deviceDrag = null;
|
||||
const withoutDrag = key(c._editorRuntime._alignCandidates());
|
||||
c._deviceDrag = keptDrag;
|
||||
// Исключение снимает РОВНО один кандидат — перетаскиваемый маркер.
|
||||
// Сравнение по разнице списков, а не по заранее посчитанной точке:
|
||||
// позиция значка живёт вместе с перетаскиванием и к моменту проверки
|
||||
// уже другая.
|
||||
const removed = withoutDrag.filter((point) => !withDrag.includes(point));
|
||||
return withDrag.length === withoutDrag.length - 1 && removed.length === 1;
|
||||
})();
|
||||
c._deviceDrag = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuideGone = guides() === 0;
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
// #126: authoritative HA Area changes supersede stale layout on the full card
|
||||
// and on the read-only hosted space card before persistence can help it.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const paint = async (card = c) => {
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
};
|
||||
const calls = [];
|
||||
let rejectKettleRelocation = false;
|
||||
const hass = window.__mkHass();
|
||||
const callWS = hass.callWS;
|
||||
hass.callWS = async (message) => {
|
||||
calls.push(structuredClone(message));
|
||||
if (rejectKettleRelocation && message.type === 'houseplan/config/set'
|
||||
&& message.config?.settings?.marker_area_snapshot?.d_kettle?.area === 'living_room') {
|
||||
rejectKettleRelocation = false;
|
||||
throw new Error('synthetic config failure');
|
||||
}
|
||||
return callWS(message);
|
||||
};
|
||||
c.hass = hass;
|
||||
await paint();
|
||||
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...(c._serverCfg.settings.marker_area_snapshot || {}),
|
||||
d_light1: { binding: 'device:d_light1', area: 'living_room' },
|
||||
},
|
||||
new_device_ids: [],
|
||||
},
|
||||
};
|
||||
c._layout = { ...c._layout, d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
await paint();
|
||||
const oldLeft = Number.parseFloat(
|
||||
c.renderRoot.querySelector('.dev[data-id="d_light1"]')?.style.left || '0',
|
||||
);
|
||||
|
||||
window.__setRegistryArea('device', 'd_light1', 'kitchen');
|
||||
await wait(450);
|
||||
await paint();
|
||||
const moved = c._devices.find((device) => device.id === 'd_light1');
|
||||
const movedEl = c.renderRoot.querySelector('.dev[data-id="d_light1"]');
|
||||
const newLeft = Number.parseFloat(movedEl?.style.left || '0');
|
||||
const firstDeleteCount = calls.filter(
|
||||
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_light1',
|
||||
).length;
|
||||
const firstAttentionShown = c._serverCfg.settings.new_device_ids?.includes('d_light1')
|
||||
&& !!movedEl?.querySelector('.newdot');
|
||||
|
||||
// An identical authoritative callback must not start another persistence loop.
|
||||
window.__setRegistryArea('device', 'd_light1', 'kitchen');
|
||||
await wait(300);
|
||||
const secondDeleteCount = calls.filter(
|
||||
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_light1',
|
||||
).length;
|
||||
|
||||
// A dialog opened before another Area transition may show the effective
|
||||
// room, but saving an unrelated field must not persist that stale display as
|
||||
// an explicit House Plan override.
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...c._serverCfg.settings.marker_area_snapshot,
|
||||
d_lamp: { binding: 'device:d_lamp', area: 'living_room' },
|
||||
},
|
||||
},
|
||||
};
|
||||
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.42, y: 0.5 } };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices');
|
||||
c._openMarkerDialog(c._devices.find((device) => device.id === 'd_lamp'));
|
||||
await paint();
|
||||
window.__setRegistryArea('device', 'd_lamp', 'kitchen');
|
||||
await wait(450);
|
||||
const roomDraftRefreshed = c._markerDialog?.room === 'f1#kitchen'
|
||||
&& c._markerDialog?.roomTouched === false;
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Floor lamp renamed' };
|
||||
await c._saveMarker();
|
||||
const savedLamp = c._serverCfg.markers.find((marker) => marker.id === 'd_lamp');
|
||||
c._setMode('view');
|
||||
await paint();
|
||||
|
||||
// Delete is committed before provenance. A rejected config write restores
|
||||
// the old local snapshot and the next authoritative pass retries safely.
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...c._serverCfg.settings.marker_area_snapshot,
|
||||
d_kettle: { binding: 'device:d_kettle', area: 'kitchen' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const kettlePoint = { s: 'f1', x: 0.72, y: 0.15 };
|
||||
c._layout = { ...c._layout, d_kettle: kettlePoint };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
rejectKettleRelocation = true;
|
||||
window.__setRegistryArea('device', 'd_kettle', 'living_room');
|
||||
await wait(500);
|
||||
const failedConfigRestoredAndRetryable = c._layout.d_kettle?.s === kettlePoint.s
|
||||
&& c._layout.d_kettle?.x === kettlePoint.x
|
||||
&& c._layout.d_kettle?.y === kettlePoint.y
|
||||
&& c._serverCfg.settings.marker_area_snapshot?.d_kettle?.area === 'kitchen'
|
||||
&& c._areaRelocationSyncKey === ''
|
||||
&& calls.some((message) => message.type === 'houseplan/layout/update'
|
||||
&& message.device_id === 'd_kettle');
|
||||
window.__setRegistryArea('device', 'd_kettle', 'living_room');
|
||||
await wait(500);
|
||||
await paint();
|
||||
const configRetrySucceeded = c._serverCfg.settings.marker_area_snapshot?.d_kettle?.area
|
||||
=== 'living_room' && c._serverCfg.settings.new_device_ids?.includes('d_kettle');
|
||||
|
||||
// Cross-space registry movement uses the same production coordinator: the
|
||||
// old Garden point is deleted and the device joins the f1 autogrid.
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...c._serverCfg.settings.marker_area_snapshot,
|
||||
d_gate: { binding: 'device:d_gate', area: 'garden' },
|
||||
},
|
||||
},
|
||||
};
|
||||
c._layout = { ...c._layout, d_gate: { s: 'garden', x: 0.73, y: 0.22 } };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
window.__setRegistryArea('device', 'd_gate', 'living_room');
|
||||
await wait(450);
|
||||
await paint();
|
||||
const crossSpaceDevice = c._devices.find((device) => device.id === 'd_gate');
|
||||
const crossSpaceDeleteCount = calls.filter(
|
||||
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_gate',
|
||||
).length;
|
||||
|
||||
// An explicitly persisted entity binding follows its own registry Area,
|
||||
// independently of a device marker.
|
||||
window.__setRegistryArea('entity', 'sun.sun', 'living_room');
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
...c._serverCfg.markers.filter((marker) => marker.id !== 'entity_sun'),
|
||||
{ id: 'entity_sun', binding: 'entity:sun.sun' },
|
||||
],
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...c._serverCfg.settings.marker_area_snapshot,
|
||||
entity_sun: { binding: 'entity:sun.sun', area: 'living_room' },
|
||||
},
|
||||
},
|
||||
};
|
||||
c._layout = { ...c._layout, entity_sun: { s: 'f1', x: 0.20, y: 0.30 } };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
window.__setRegistryArea('entity', 'sun.sun', 'kitchen');
|
||||
await wait(450);
|
||||
await paint();
|
||||
const entityDevice = c._devices.find((device) => device.id === 'entity_sun');
|
||||
|
||||
// A user-owned room override remains authoritative when HA changes Area.
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
...c._serverCfg.markers.filter((marker) => marker.id !== 'd_tv'),
|
||||
{ id: 'd_tv', binding: 'device:d_tv', space: 'f1', area: 'living_room' },
|
||||
],
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...c._serverCfg.settings.marker_area_snapshot,
|
||||
d_tv: { binding: 'device:d_tv', area: 'living_room' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const explicitPoint = { s: 'f1', x: 0.13, y: 0.50 };
|
||||
c._layout = { ...c._layout, d_tv: explicitPoint };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
const explicitDeletesBefore = calls.filter(
|
||||
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_tv',
|
||||
).length;
|
||||
window.__setRegistryArea('device', 'd_tv', 'kitchen');
|
||||
await wait(350);
|
||||
await paint();
|
||||
const explicitDevice = c._devices.find((device) => device.id === 'd_tv');
|
||||
const explicitDeletesAfter = calls.filter(
|
||||
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_tv',
|
||||
).length;
|
||||
|
||||
// A markerless light group is a composite, not a direct entity marker. Its
|
||||
// Area may change, but #126 must not discard its saved point or flag it.
|
||||
window.__addRegistryEntity('light.area_group', 'living_room', 'on', 'group');
|
||||
await wait(150);
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
await paint();
|
||||
const compositeId = 'lg_light.area_group';
|
||||
const compositePoint = { s: 'f1', x: 0.30, y: 0.40 };
|
||||
c._layout = { ...c._layout, [compositeId]: compositePoint };
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...c._serverCfg.settings.marker_area_snapshot,
|
||||
[compositeId]: { binding: 'entity:light.area_group', area: 'living_room' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const compositeDeletesBefore = calls.filter(
|
||||
(message) => message.type === 'houseplan/layout/delete' && message.device_id === compositeId,
|
||||
).length;
|
||||
const compositeAttentionBefore = c._newIds.has(compositeId);
|
||||
window.__setRegistryArea('entity', 'light.area_group', 'kitchen');
|
||||
await wait(350);
|
||||
await paint();
|
||||
const compositeDevice = c._devices.find((device) => device.id === compositeId);
|
||||
const compositeDeletesAfter = calls.filter(
|
||||
(message) => message.type === 'houseplan/layout/delete' && message.device_id === compositeId,
|
||||
).length;
|
||||
|
||||
// Cold-start legacy backfill: the first authoritative build sees a saved
|
||||
// point strictly inside the old Area room but no provenance. It must run
|
||||
// through the real card startup and preserve delete-before-config ordering.
|
||||
const coldConfig = structuredClone(c._serverCfg);
|
||||
coldConfig.settings.marker_area_snapshot = {
|
||||
...coldConfig.settings.marker_area_snapshot,
|
||||
};
|
||||
delete coldConfig.settings.marker_area_snapshot.d_motion;
|
||||
coldConfig.settings.new_device_ids = (coldConfig.settings.new_device_ids || [])
|
||||
.filter((id) => id !== 'd_motion');
|
||||
const coldLayout = { ...c._layout, d_motion: { s: 'f1', x: 0.40, y: 0.75 } };
|
||||
window.__setRegistryArea('device', 'd_motion', 'kitchen');
|
||||
await wait(100);
|
||||
const coldCalls = [];
|
||||
let coldRev = 40;
|
||||
const coldHass = window.__mkHass();
|
||||
const coldBaseCallWS = coldHass.callWS;
|
||||
coldHass.callWS = async (message) => {
|
||||
coldCalls.push(structuredClone(message));
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
return { config: coldConfig, rev: coldRev, can_write: true };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') {
|
||||
return { layout: coldLayout, rev: coldRev };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/delete') {
|
||||
delete coldLayout[message.device_id];
|
||||
return { ok: true, rev: ++coldRev };
|
||||
}
|
||||
if (message.type === 'houseplan/config/set') {
|
||||
Object.assign(coldConfig, structuredClone(message.config));
|
||||
return { ok: true, rev: ++coldRev };
|
||||
}
|
||||
return coldBaseCallWS(message);
|
||||
};
|
||||
const coldCard = document.createElement('houseplan-card');
|
||||
coldCard.setConfig({ type: 'custom:houseplan-card', title: 'Cold Area backfill' });
|
||||
document.body.append(coldCard);
|
||||
coldCard.hass = coldHass;
|
||||
for (let attempt = 0; attempt < 20 && !coldCard._loadedOnce; attempt += 1) await wait(100);
|
||||
await wait(450);
|
||||
await paint(coldCard);
|
||||
const coldDeleteIndex = coldCalls.findIndex(
|
||||
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_motion',
|
||||
);
|
||||
const coldConfigIndex = coldCalls.findIndex(
|
||||
(message) => message.type === 'houseplan/config/set'
|
||||
&& message.config?.settings?.marker_area_snapshot?.d_motion?.area === 'kitchen',
|
||||
);
|
||||
const coldDevice = coldCard._devices.find((device) => device.id === 'd_motion');
|
||||
const coldStartBackfilled = coldDevice?.area === 'kitchen'
|
||||
&& !coldCard._layout.d_motion
|
||||
&& coldCard._serverCfg.settings.marker_area_snapshot?.d_motion?.area === 'kitchen'
|
||||
&& coldCard._serverCfg.settings.new_device_ids?.includes('d_motion')
|
||||
&& coldDeleteIndex >= 0 && coldConfigIndex > coldDeleteIndex;
|
||||
coldCard.remove();
|
||||
|
||||
// A separate read-only card receives an old snapshot/layout but the already
|
||||
// authoritative new registry Area. It must be truthful without any writes.
|
||||
const staticConfig = structuredClone(c._serverCfg);
|
||||
staticConfig.settings.marker_area_snapshot.d_leak = {
|
||||
binding: 'device:d_leak', area: 'living_room',
|
||||
};
|
||||
staticConfig.settings.new_device_ids = [];
|
||||
const staticLayout = { ...c._layout, d_leak: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
const staticCalls = [];
|
||||
const staticHass = window.__mkHass();
|
||||
const staticCallWS = staticHass.callWS;
|
||||
staticHass.callWS = async (message) => {
|
||||
staticCalls.push(structuredClone(message));
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
return { config: staticConfig, rev: 20, can_write: false };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') return { layout: staticLayout, rev: 20 };
|
||||
return staticCallWS(message);
|
||||
};
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
staticCard.hass = staticHass;
|
||||
document.body.append(staticCard);
|
||||
await wait(500);
|
||||
await paint(staticCard);
|
||||
const staticEl = staticCard.renderRoot.querySelector('.dev[data-id="d_leak"]');
|
||||
const staticLeft = Number.parseFloat(staticEl?.style.left || '0');
|
||||
|
||||
// Lifecycle metadata is bounded by the authoritative registry, not by
|
||||
// explicit editor cleanup alone. A disappeared device with no saved marker
|
||||
// must lose its snapshot on the next full pass.
|
||||
const orphanId = 'device-that-disappeared';
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...c._serverCfg.settings.marker_area_snapshot,
|
||||
[orphanId]: { binding: `device:${orphanId}`, area: 'living_room' },
|
||||
},
|
||||
},
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
await wait(450);
|
||||
const authoritativeOrphanRemoved = !c._serverCfg.settings.marker_area_snapshot?.[orphanId];
|
||||
|
||||
// A separate connection whose full registry calls fail models startup or a
|
||||
// limited account. The exact same orphan evidence is not authoritative and
|
||||
// therefore must not be destructive.
|
||||
const limitedConfig = structuredClone(c._serverCfg);
|
||||
limitedConfig.settings.marker_area_snapshot[orphanId] = {
|
||||
binding: `device:${orphanId}`, area: 'living_room',
|
||||
};
|
||||
const limitedHass = window.__mkHass();
|
||||
const limitedCallWS = limitedHass.callWS;
|
||||
limitedHass.connection = {};
|
||||
limitedHass.callWS = async (message) => {
|
||||
if (message.type === 'config/device_registry/list'
|
||||
|| message.type === 'config/entity_registry/list') throw new Error('synthetic limited registry');
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
return { config: limitedConfig, rev: 30, can_write: true };
|
||||
}
|
||||
return limitedCallWS(message);
|
||||
};
|
||||
const limitedCard = document.createElement('houseplan-card');
|
||||
limitedCard.setConfig({ type: 'custom:houseplan-card', title: 'Limited registry' });
|
||||
limitedCard.hass = limitedHass;
|
||||
document.body.append(limitedCard);
|
||||
await wait(500);
|
||||
await paint(limitedCard);
|
||||
const nonAuthoritativeOrphanPreserved = !!limitedCard._serverCfg?.settings
|
||||
?.marker_area_snapshot?.[orphanId];
|
||||
limitedCard.remove();
|
||||
|
||||
return {
|
||||
movedToRegistryArea: moved?.area === 'kitchen' && moved?.space === 'f1',
|
||||
staleLayoutDeleted: !c._layout.d_light1,
|
||||
provenanceAdvanced: c._serverCfg.settings.marker_area_snapshot?.d_light1?.area === 'kitchen',
|
||||
attentionShown: firstAttentionShown,
|
||||
autoGridReplacedOldPoint: oldLeft < 40 && newLeft > 50,
|
||||
deleteFirstAndOnce: firstDeleteCount === 1 && secondDeleteCount === 1,
|
||||
configWritten: calls.some((message) => message.type === 'houseplan/config/set'),
|
||||
roomDraftRefreshed,
|
||||
noStaleExplicitOverride: !!savedLamp
|
||||
&& savedLamp.area === undefined && savedLamp.room_id === undefined,
|
||||
failedConfigRestoredAndRetryable,
|
||||
configRetrySucceeded,
|
||||
crossSpaceRelocated: crossSpaceDevice?.space === 'f1'
|
||||
&& crossSpaceDevice?.area === 'living_room'
|
||||
&& !c._layout.d_gate
|
||||
&& c._serverCfg.settings.marker_area_snapshot?.d_gate?.area === 'living_room'
|
||||
&& crossSpaceDeleteCount === 1,
|
||||
standaloneEntityRelocated: entityDevice?.bindingKind === 'entity'
|
||||
&& entityDevice?.bindingRef === 'sun.sun'
|
||||
&& entityDevice?.area === 'kitchen'
|
||||
&& !c._layout.entity_sun
|
||||
&& c._serverCfg.settings.marker_area_snapshot?.entity_sun?.area === 'kitchen',
|
||||
explicitOverrideExcluded: explicitDevice?.area === 'living_room'
|
||||
&& c._layout.d_tv?.s === explicitPoint.s
|
||||
&& c._layout.d_tv?.x === explicitPoint.x
|
||||
&& c._layout.d_tv?.y === explicitPoint.y
|
||||
&& explicitDeletesAfter === explicitDeletesBefore,
|
||||
compositeGroupExcluded: compositeDevice?.marker == null
|
||||
&& compositeDevice?.area === 'kitchen'
|
||||
&& c._layout[compositeId]?.x === compositePoint.x
|
||||
&& c._layout[compositeId]?.y === compositePoint.y
|
||||
&& compositeDeletesAfter === compositeDeletesBefore
|
||||
&& c._newIds.has(compositeId) === compositeAttentionBefore
|
||||
&& !c._areaRelocationIds.has(compositeId),
|
||||
coldStartBackfilled,
|
||||
staticReadOnlyProjection: staticCard._areaRelocationIds.has('d_leak')
|
||||
&& staticLeft > 50 && !!staticEl?.querySelector('.newdot'),
|
||||
staticMadeNoWrites: !staticCalls.some((message) =>
|
||||
message.type === 'houseplan/config/set'
|
||||
|| ['houseplan/layout/set', 'houseplan/layout/update', 'houseplan/layout/delete']
|
||||
.includes(message.type)),
|
||||
authoritativeOrphanRemoved,
|
||||
nonAuthoritativeOrphanPreserved,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,239 @@
|
||||
// #403: a rejected Area-provenance write restores deleted manual placement,
|
||||
// while relocation invalidates only history commands owned by that device.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const paint = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const settleRelocation = async () => {
|
||||
await c._areaRelocationWrite.catch(() => undefined);
|
||||
await wait(80);
|
||||
await paint();
|
||||
};
|
||||
const samePlacement = (left, right) => !!left && !!right
|
||||
&& left.s === right.s && left.x === right.x && left.y === right.y;
|
||||
|
||||
let serverConfig = structuredClone(c._serverCfg);
|
||||
let serverLayout = structuredClone(c._layout);
|
||||
let configRev = Math.max(30, Number(c._cfgRev) || 0);
|
||||
let layoutRev = Math.max(40, Number(c._layoutRev) || 0);
|
||||
const calls = [];
|
||||
let rejectConfig = null;
|
||||
let rejectRestoreId = null;
|
||||
let delayConfig = null;
|
||||
let releaseDelayedConfig = null;
|
||||
const baseCallWS = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
calls.push({ type: 'config/get' });
|
||||
return { config: structuredClone(serverConfig), rev: configRev, can_write: true };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') {
|
||||
return { layout: structuredClone(serverLayout), rev: layoutRev };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/delete') {
|
||||
calls.push({ type: 'layout/delete', id: message.device_id });
|
||||
delete serverLayout[message.device_id];
|
||||
return { ok: true, rev: ++layoutRev };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/update') {
|
||||
const row = { type: 'layout/update', id: message.device_id };
|
||||
calls.push(row);
|
||||
if (rejectRestoreId === message.device_id) {
|
||||
rejectRestoreId = null;
|
||||
row.result = 'rejected';
|
||||
throw new Error('synthetic placement restore failure');
|
||||
}
|
||||
serverLayout[message.device_id] = structuredClone(message.pos);
|
||||
return { ok: true, rev: ++layoutRev };
|
||||
}
|
||||
if (message.type === 'houseplan/config/set') {
|
||||
const row = {
|
||||
type: 'config/set',
|
||||
snapshot: structuredClone(message.config?.settings?.marker_area_snapshot || {}),
|
||||
attention: [...(message.config?.settings?.new_device_ids || [])],
|
||||
};
|
||||
calls.push(row);
|
||||
if (rejectConfig
|
||||
&& row.snapshot[rejectConfig.id]?.area === rejectConfig.area) {
|
||||
const rejection = rejectConfig;
|
||||
rejectConfig = null;
|
||||
row.result = 'rejected';
|
||||
const error = new Error('synthetic config failure');
|
||||
if (rejection.code) error.code = rejection.code;
|
||||
throw error;
|
||||
}
|
||||
if (delayConfig && row.snapshot[delayConfig.id]?.area === delayConfig.area) {
|
||||
const delayed = delayConfig;
|
||||
delayConfig = null;
|
||||
row.result = 'delayed';
|
||||
await new Promise((resolve) => { releaseDelayedConfig = resolve; });
|
||||
row.result = 'accepted';
|
||||
if (delayed.id === '') throw new Error('unreachable delayed fixture');
|
||||
}
|
||||
serverConfig = structuredClone(message.config);
|
||||
return { ok: true, rev: ++configRev };
|
||||
}
|
||||
return baseCallWS(message);
|
||||
} };
|
||||
c._serverStorage = true;
|
||||
c._serverCanWrite = true;
|
||||
c._layoutRev = layoutRev;
|
||||
c._cfgRev = configRev;
|
||||
|
||||
const installScenario = async ({ id, oldArea, point }) => {
|
||||
await settleRelocation();
|
||||
window.__setRegistryArea('device', id, oldArea);
|
||||
await wait(120);
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...(c._serverCfg.settings.marker_area_snapshot || {}),
|
||||
[id]: { binding: `device:${id}`, area: oldArea },
|
||||
},
|
||||
new_device_ids: (c._serverCfg.settings.new_device_ids || [])
|
||||
.filter((candidate) => candidate !== id),
|
||||
},
|
||||
};
|
||||
c._layout = { ...c._layout, [id]: structuredClone(point) };
|
||||
serverConfig = structuredClone(c._serverCfg);
|
||||
serverLayout = structuredClone(c._layout);
|
||||
c._cfgContentFingerprint = '';
|
||||
c._layoutContentFingerprint = '';
|
||||
c._areaRelocationSyncKey = '';
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
await settleRelocation();
|
||||
};
|
||||
const pushMove = (id, name, fromX, toX) => {
|
||||
const device = c._devices.find((candidate) => candidate.id === id);
|
||||
if (!device) return false;
|
||||
c._devicePositionHistory.push({
|
||||
name,
|
||||
before: { deviceId: id, spaceId: device.space,
|
||||
placement: { s: device.space, x: fromX, y: 0.31 } },
|
||||
after: { deviceId: id, spaceId: device.space,
|
||||
placement: { s: device.space, x: toX, y: 0.31 } },
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const stableId = 'd_lamp';
|
||||
const relocatedId = 'd_light1';
|
||||
const original = { s: 'f1', x: 0.22, y: 0.22 };
|
||||
await installScenario({ id: relocatedId, oldArea: 'living_room', point: original });
|
||||
c._devicePositionHistory.clear();
|
||||
const historyFixtureReady = pushMove(stableId, 'Move lamp', 0.28, 0.34)
|
||||
&& pushMove(relocatedId, 'Move relocating light', 0.22, 0.27);
|
||||
rejectConfig = { id: relocatedId, area: 'kitchen', code: null };
|
||||
window.__setRegistryArea('device', relocatedId, 'kitchen');
|
||||
await settleRelocation();
|
||||
|
||||
const rejectedConfigIndex = calls.findIndex((row) => row.type === 'config/set'
|
||||
&& row.result === 'rejected' && row.snapshot[relocatedId]?.area === 'kitchen');
|
||||
const restoreIndex = calls.findIndex((row, index) => index > rejectedConfigIndex
|
||||
&& row.type === 'layout/update' && row.id === relocatedId);
|
||||
const restoredLocally = samePlacement(c._layout[relocatedId], original);
|
||||
const restoredOnServer = samePlacement(serverLayout[relocatedId], original);
|
||||
const unrelatedHistorySurvives = historyFixtureReady
|
||||
&& c._devicePositionHistory.size === 1
|
||||
&& c._devicePositionHistory.undoName === 'Move lamp';
|
||||
const relocatedHistoryRemoved = c._devicePositionHistory.undoName !== 'Move relocating light'
|
||||
&& !c._devicePositionHistory.canRedo;
|
||||
|
||||
const beforeUndoRelocated = structuredClone(c._layout[relocatedId]);
|
||||
await c._runDevicePositionHistory('undo');
|
||||
const unrelatedUndoStillWorks = c._devicePositionHistory.canRedo
|
||||
&& samePlacement(c._layout[relocatedId], beforeUndoRelocated);
|
||||
|
||||
// Retry with a delayed config response. Between delete and acknowledgement
|
||||
// the old point must not reappear; after release the ordinary #126 result
|
||||
// (advanced snapshot + attention) must complete.
|
||||
delayConfig = { id: relocatedId, area: 'kitchen' };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
for (let attempt = 0; attempt < 80 && !releaseDelayedConfig; attempt += 1) await wait(10);
|
||||
await paint();
|
||||
const movedDuringDelayedWrite = c._devices.find((device) => device.id === relocatedId);
|
||||
const stalePointSuppressedWhilePending = !!releaseDelayedConfig
|
||||
&& !c._layout[relocatedId]
|
||||
&& movedDuringDelayedWrite?.area === 'kitchen';
|
||||
releaseDelayedConfig?.();
|
||||
releaseDelayedConfig = null;
|
||||
await settleRelocation();
|
||||
const successfulRetry = !serverLayout[relocatedId]
|
||||
&& c._serverCfg.settings.marker_area_snapshot?.[relocatedId]?.area === 'kitchen'
|
||||
&& c._serverCfg.settings.new_device_ids?.includes(relocatedId);
|
||||
|
||||
// The conflict branch must restore before it reloads and retries. Call
|
||||
// ordering is the durable witness even though the retry then deletes the
|
||||
// restored point again and completes normally.
|
||||
const conflictId = 'd_kettle';
|
||||
const conflictPoint = { s: 'f1', x: 0.72, y: 0.15 };
|
||||
await installScenario({ id: conflictId, oldArea: 'kitchen', point: conflictPoint });
|
||||
c._devicePositionHistory.clear();
|
||||
pushMove(stableId, 'Move lamp before conflict', 0.30, 0.36);
|
||||
const conflictStart = calls.length;
|
||||
rejectConfig = { id: conflictId, area: 'living_room', code: 'conflict' };
|
||||
window.__setRegistryArea('device', conflictId, 'living_room');
|
||||
await settleRelocation();
|
||||
const conflictRows = calls.slice(conflictStart);
|
||||
const conflictReject = conflictRows.findIndex((row) => row.type === 'config/set'
|
||||
&& row.result === 'rejected' && row.snapshot[conflictId]?.area === 'living_room');
|
||||
const conflictRestore = conflictRows.findIndex((row, index) => index > conflictReject
|
||||
&& row.type === 'layout/update' && row.id === conflictId);
|
||||
const conflictReload = conflictRows.findIndex((row, index) => index > conflictRestore
|
||||
&& row.type === 'config/get');
|
||||
const conflictRetryDelete = conflictRows.findIndex((row, index) => index > conflictReload
|
||||
&& row.type === 'layout/delete' && row.id === conflictId);
|
||||
const conflictRestoresBeforeRetry = conflictReject >= 0
|
||||
&& conflictRestore > conflictReject
|
||||
&& conflictReload > conflictRestore
|
||||
&& conflictRetryDelete > conflictReload;
|
||||
const conflictEventuallyCompletes = !serverLayout[conflictId]
|
||||
&& c._serverCfg.settings.marker_area_snapshot?.[conflictId]?.area === 'living_room'
|
||||
&& c._serverCfg.settings.new_device_ids?.includes(conflictId);
|
||||
const conflictKeepsUnrelatedHistory = c._devicePositionHistory.undoName
|
||||
=== 'Move lamp before conflict';
|
||||
|
||||
// If even layout/update fails, the existing attention channel is persisted
|
||||
// instead of losing the manual point silently.
|
||||
const fallbackId = 'd_motion';
|
||||
const fallbackPoint = { s: 'f1', x: 0.40, y: 0.75 };
|
||||
await installScenario({ id: fallbackId, oldArea: 'living_room', point: fallbackPoint });
|
||||
rejectConfig = { id: fallbackId, area: 'kitchen', code: null };
|
||||
rejectRestoreId = fallbackId;
|
||||
window.__setRegistryArea('device', fallbackId, 'kitchen');
|
||||
await settleRelocation();
|
||||
const fallbackEl = c.renderRoot.querySelector(`.dev[data-id="${fallbackId}"]`);
|
||||
const failedRestoreLeavesAttention = !serverLayout[fallbackId]
|
||||
&& c._serverCfg.settings.new_device_ids?.includes(fallbackId)
|
||||
&& serverConfig.settings.new_device_ids?.includes(fallbackId)
|
||||
&& c._newIds.has(fallbackId)
|
||||
&& !!fallbackEl?.querySelector('.newdot');
|
||||
|
||||
return {
|
||||
historyFixtureReady,
|
||||
rejectedWriteRestoresAfterDelete: rejectedConfigIndex >= 0
|
||||
&& restoreIndex > rejectedConfigIndex && restoredLocally && restoredOnServer,
|
||||
unrelatedHistorySurvives,
|
||||
relocatedHistoryRemoved,
|
||||
unrelatedUndoStillWorks,
|
||||
stalePointSuppressedWhilePending,
|
||||
successfulRetry,
|
||||
conflictRestoresBeforeRetry,
|
||||
conflictEventuallyCompletes,
|
||||
conflictKeepsUnrelatedHistory,
|
||||
failedRestoreLeavesAttention,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -12,7 +12,7 @@ const out = {};
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const nativeConfirm = window.confirm;
|
||||
const confirmDanger = c._confirmDanger;
|
||||
|
||||
// Пикер читает `showEntities` и `bindingFilter` из открытого диалога, поэтому
|
||||
// спрашивать его при закрытом бессмысленно: отдельные сущности устройств не
|
||||
@@ -30,10 +30,10 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
const remove = async (device) => {
|
||||
c._openMarkerDialog(device);
|
||||
await c.updateComplete;
|
||||
window.confirm = () => true;
|
||||
c._confirmDanger = async () => true;
|
||||
await c._deleteMarker();
|
||||
await c.updateComplete;
|
||||
window.confirm = nativeConfirm;
|
||||
c._confirmDanger = confirmDanger;
|
||||
};
|
||||
const markers = () => c._serverCfg.markers || [];
|
||||
const tombstones = () => markers().filter((m) => m.removed === true);
|
||||
|
||||
@@ -44,6 +44,12 @@ const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
const started = performance.now();
|
||||
while (c._cameraTransition?.active) {
|
||||
if (performance.now() - started > 1000) throw new Error('camera transition did not settle');
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout, space]);
|
||||
|
||||
// ---------------------------------------------------------------- 01: hidden
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// #402: подтверждение опасного действия не принадлежит ни одной ветке
|
||||
// render(). Раньше `hp-confirm` рендерился только в финальной ветке цепочки
|
||||
// ранних return, и в онбординге («ещё нет ни одного пространства») кнопка
|
||||
// удаления плана была мертва: диалог не появлялся, промис не разрешался
|
||||
// никогда. Отдельный файл, а не дополнение smoke_danger_confirmation:
|
||||
// там оснастка проверяет матрицу мутаций и держит открытыми неполные
|
||||
// фикстуры диалогов, а здесь нужен чистый рендер.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
// Touch-эмуляция намеренно: `docs/TOUCH-SUPPORT.md` § Safety floor запрещает
|
||||
// «best effort» в обходе подтверждения разрушающего действия, а сломанная
|
||||
// ветка пробивала этот пол одинаково на мыши и на пальце. Проверяем там, где
|
||||
// он обязан держаться.
|
||||
const { page, browser } = await launch(
|
||||
{ width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true },
|
||||
);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const settleElement = async (element) => {
|
||||
await element.updateComplete;
|
||||
const shell = element.querySelector('hp-dialog');
|
||||
if (shell) await shell.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const deepActiveElement = () => {
|
||||
let active = document.activeElement;
|
||||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||||
return active;
|
||||
};
|
||||
const directConfirm = async (kind, action, key) => {
|
||||
const element = document.createElement('hp-confirm');
|
||||
element.token = Math.floor(Math.random() * 1_000_000) + 1;
|
||||
element.request = {
|
||||
key,
|
||||
kind,
|
||||
title: kind === 'destructive' ? 'Delete?' : 'Unlock?',
|
||||
message: kind === 'destructive'
|
||||
? 'The plan and all its rooms will be deleted.'
|
||||
: 'House Plan will send an unlock command.',
|
||||
confirmLabel: kind === 'destructive' ? 'Delete' : 'Unlock',
|
||||
cancelLabel: 'Cancel',
|
||||
};
|
||||
const decision = new Promise((resolve) => element.addEventListener(
|
||||
'hp-confirm-decision', (event) => resolve(event.detail.accepted), { once: true },
|
||||
));
|
||||
document.body.append(element);
|
||||
await settleElement(element);
|
||||
const shell = element.querySelector('hp-dialog');
|
||||
const native = shell?.shadowRoot?.querySelector('dialog');
|
||||
const body = element.querySelector('.danger-confirm-body');
|
||||
const cancel = element.querySelector('button[autofocus]');
|
||||
const semantics = native?.getAttribute('role') === 'alertdialog'
|
||||
&& native.getAttribute('aria-describedby') === body?.id
|
||||
&& !!body?.textContent?.trim();
|
||||
const focused = deepActiveElement() === cancel;
|
||||
const stayedNative = !!native && !shell?.shadowRoot?.querySelector('ha-dialog');
|
||||
if (action === 'escape') {
|
||||
cancel?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
} else {
|
||||
cancel?.click();
|
||||
}
|
||||
const accepted = await decision;
|
||||
element.remove();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return { semantics, focused, stayedNative, cancelled: accepted === false };
|
||||
};
|
||||
const dialogs = () => root().querySelectorAll('hp-confirm').length;
|
||||
const request = (key) => ({
|
||||
key,
|
||||
kind: 'destructive',
|
||||
title: card._t('confirm.delete_plan_title'),
|
||||
message: card._t('confirm.delete_plan_body'),
|
||||
confirmLabel: card._t('btn.delete'),
|
||||
cancelLabel: card._t('btn.cancel'),
|
||||
});
|
||||
const decide = async (accepted) => {
|
||||
const element = root().querySelector('hp-confirm');
|
||||
element?.dispatchEvent(new CustomEvent('hp-confirm-decision', {
|
||||
detail: { token: card._dangerConfirm.token, accepted },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
await settle();
|
||||
};
|
||||
|
||||
await settle();
|
||||
|
||||
result.standaloneStartsWithoutHaDialog = !customElements.get('ha-dialog');
|
||||
const noHaDestructive = await directConfirm('destructive', 'click', 'no-ha-delete');
|
||||
result.noHaDestructiveIsDescribedAlert = noHaDestructive.semantics;
|
||||
result.noHaDestructiveFocusesCancel = noHaDestructive.focused;
|
||||
result.noHaDestructiveCancelResolvesFalse = noHaDestructive.cancelled;
|
||||
const noHaWarning = await directConfirm('warning', 'escape', 'no-ha-unlock');
|
||||
result.noHaWarningIsDescribedAlert = noHaWarning.semantics;
|
||||
result.noHaWarningFocusesCancel = noHaWarning.focused;
|
||||
result.noHaWarningEscapeResolvesFalse = noHaWarning.cancelled;
|
||||
|
||||
// Основная ветка: ровно один диалог, а не два (риск двойного рендера при
|
||||
// выносе — если бы блок остался и в ветке, и в обёртке).
|
||||
const single = card._confirmDanger(request('single'));
|
||||
await settle();
|
||||
result.mainBranchRendersExactlyOneConfirm = dialogs() === 1;
|
||||
card._cancelDangerConfirm();
|
||||
result.mainBranchCancelResolvesFalse = (await single) === false;
|
||||
|
||||
const savedCfg = card._serverCfg;
|
||||
const savedModel = card._model;
|
||||
const enterBranch = async (spaces) => {
|
||||
card._serverCfg = { ...savedCfg, spaces };
|
||||
card._model = spaces.length ? savedModel : [];
|
||||
card._cfgEpoch += 1;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
};
|
||||
const leaveBranch = async () => {
|
||||
card._serverCfg = savedCfg;
|
||||
card._model = savedModel;
|
||||
card._cfgEpoch += 1;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
};
|
||||
|
||||
// Ветка онбординга — буквальный сценарий issue: здесь живёт корзина
|
||||
// сохранённого плана.
|
||||
await enterBranch([]);
|
||||
result.onboardingBranchIsActuallyEntered = !root().querySelector('.stage');
|
||||
const onboarding = card._confirmDanger(request('onboarding'));
|
||||
await settle();
|
||||
result.onboardingBranchShowsConfirm = dialogs() === 1;
|
||||
await decide(false);
|
||||
result.onboardingBranchResolves = (await onboarding) === false;
|
||||
await leaveBranch();
|
||||
|
||||
// Открытое подтверждение переживает смену ветки: раньше оно исчезало вместе
|
||||
// с веткой, оставляя вызывающего с неразрешённым промисом.
|
||||
const survivor = card._confirmDanger(request('survivor'));
|
||||
await settle();
|
||||
const openedInMainBranch = dialogs() === 1;
|
||||
await enterBranch([]);
|
||||
const stillOpenInOnboarding = dialogs() === 1;
|
||||
await decide(true);
|
||||
result.openConfirmSurvivesBranchChange = openedInMainBranch && stillOpenInOnboarding
|
||||
&& (await survivor) === true;
|
||||
await leaveBranch();
|
||||
|
||||
// Тап по «Отмена» — тот же путь, что и клик: на touch подтверждение обязано
|
||||
// спрашиваться, а не деградировать (TOUCH-SUPPORT § Safety floor).
|
||||
await enterBranch([]);
|
||||
const byTap = card._confirmDanger(request('touch'));
|
||||
await settle();
|
||||
const tapDialog = root().querySelector('hp-confirm');
|
||||
// Кнопки живут в hp-dialog внутри hp-confirm; «Отмена» — первая в футере
|
||||
// (она же с autofocus, см. #32 §6.1).
|
||||
const footer = tapDialog?.renderRoot?.querySelector('.danger-confirm-footer')
|
||||
|| tapDialog?.shadowRoot?.querySelector('.danger-confirm-footer');
|
||||
const cancelButton = footer?.querySelector('button');
|
||||
const tapPoint = cancelButton?.getBoundingClientRect();
|
||||
if (cancelButton && tapPoint) {
|
||||
for (const type of ['pointerdown', 'pointerup', 'click']) {
|
||||
cancelButton.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0,
|
||||
clientX: tapPoint.left + tapPoint.width / 2,
|
||||
clientY: tapPoint.top + tapPoint.height / 2,
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
await settle();
|
||||
result.touchTapOnCancelResolvesFalse = !!cancelButton && (await byTap) === false
|
||||
&& dialogs() === 0;
|
||||
await leaveBranch();
|
||||
|
||||
// Соседние подтверждения остались в основной ветке и не поехали за компанию.
|
||||
const source = card.constructor.toString?.() || '';
|
||||
result.neighbourConfirmsUntouched = card._tapConfirm === null
|
||||
&& card._vacCalConfirm === null && typeof source === 'string';
|
||||
|
||||
// Неготовая карточка отказывает сразу: подвесить запрос — это и есть дефект.
|
||||
const savedHass = card.hass;
|
||||
card.hass = undefined;
|
||||
const refused = card._confirmDanger(request('not-ready'));
|
||||
card.hass = savedHass;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
result.notReadyCardRefusesInsteadOfHanging = (await refused) === 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
|
||||
// DOM. Alert confirmations must still avoid it: the pinned component does
|
||||
// not forward its reflected `type` to the actual dialog role.
|
||||
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);
|
||||
|
||||
const ordinary = document.createElement('hp-dialog');
|
||||
ordinary.title = 'Ordinary device editor';
|
||||
ordinary.describedBy = 'ordinary-description';
|
||||
ordinary.innerHTML = '<p id="ordinary-description">Ordinary dialog description</p>';
|
||||
document.body.append(ordinary);
|
||||
await ordinary.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const haShell = ordinary.shadowRoot.querySelector('ha-dialog');
|
||||
result.ordinaryDialogUsesHaBranch = !!haShell
|
||||
&& !ordinary.shadowRoot.querySelector('dialog');
|
||||
result.ordinaryDialogForwardsHaAria = haShell?.ariaLabelledBy?.startsWith('hp-dialog-title-')
|
||||
&& haShell?.ariaDescribedBy === 'ordinary-description';
|
||||
ordinary.remove();
|
||||
|
||||
const haDestructive = await directConfirm('destructive', 'click', 'ha-delete');
|
||||
result.haDestructiveStaysNativeAlert = haDestructive.semantics
|
||||
&& haDestructive.stayedNative;
|
||||
result.haDestructiveFocusesCancel = haDestructive.focused;
|
||||
result.haDestructiveCancelResolvesFalse = haDestructive.cancelled;
|
||||
const haWarning = await directConfirm('warning', 'escape', 'ha-unlock');
|
||||
result.haWarningStaysNativeAlert = haWarning.semantics && haWarning.stayedNative;
|
||||
result.haWarningFocusesCancel = haWarning.focused;
|
||||
result.haWarningEscapeResolvesFalse = haWarning.cancelled;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const element = document.createElement('hp-confirm');
|
||||
element.token = 4_060_000;
|
||||
element.request = {
|
||||
key: 'accessibility-probe', kind: 'destructive', title: 'Accessibility probe',
|
||||
message: 'All rooms in this plan will be permanently deleted.',
|
||||
confirmLabel: 'Delete', cancelLabel: 'Cancel',
|
||||
};
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
await element.querySelector('hp-dialog')?.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const accessibilitySnapshot = await page.getByRole('alertdialog', {
|
||||
name: 'Accessibility probe',
|
||||
}).ariaSnapshot();
|
||||
out.realAccessibilityTreeIncludesConsequence = accessibilitySnapshot
|
||||
.includes('All rooms in this plan will be permanently deleted.');
|
||||
await page.evaluate(() => document.querySelector('hp-confirm')?.remove());
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,283 @@
|
||||
// #32: one House Plan confirmation surface owns destructive actions in View,
|
||||
// onboarding and every editor. This smoke covers the real dialog contract,
|
||||
// then exercises every migrated mutation class through both lazy runtimes.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 320, height: 760 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
await card._ensureOnboardingRuntime();
|
||||
const editor = card._editorRuntime;
|
||||
const onboarding = card._onboardingRuntime;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const deepActive = () => {
|
||||
let active = document.activeElement;
|
||||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||||
return active;
|
||||
};
|
||||
const request = (key = 'delete-plan') => ({
|
||||
key,
|
||||
kind: 'destructive',
|
||||
title: card._t('confirm.delete_plan_title'),
|
||||
message: card._t('confirm.delete_plan_body'),
|
||||
objectName: 'A deliberately very long plan filename — Grundriss für das Dachgeschoss.png',
|
||||
confirmLabel: card._t('btn.delete'),
|
||||
cancelLabel: card._t('btn.cancel'),
|
||||
});
|
||||
const visible = () => {
|
||||
const component = root().querySelector('hp-confirm');
|
||||
const dialog = component?.querySelector('hp-dialog');
|
||||
return {
|
||||
component,
|
||||
dialog,
|
||||
native: dialog?.shadowRoot?.querySelector('dialog'),
|
||||
surface: dialog?.shadowRoot?.querySelector('.surface'),
|
||||
footer: dialog?.querySelector('.danger-confirm-footer'),
|
||||
buttons: [...(dialog?.querySelectorAll('.danger-confirm-footer button') || [])],
|
||||
};
|
||||
};
|
||||
|
||||
const opener = document.createElement('button');
|
||||
opener.textContent = 'danger action';
|
||||
root().append(opener);
|
||||
opener.focus();
|
||||
const cancelled = card._confirmDanger(request());
|
||||
await settle();
|
||||
let ui = visible();
|
||||
result.sharedDialogVisible = !!ui.component && !!ui.native?.open
|
||||
&& ui.dialog?.title === card._t('confirm.delete_plan_title');
|
||||
result.cancelHasInitialFocus = deepActive() === ui.buttons[0]
|
||||
&& ui.buttons[0]?.hasAttribute('autofocus');
|
||||
result.narrowContentFits = !!ui.surface && !!ui.footer
|
||||
&& ui.surface.getBoundingClientRect().left >= -1
|
||||
&& ui.surface.getBoundingClientRect().right <= innerWidth + 1
|
||||
&& ui.surface.scrollWidth <= ui.surface.clientWidth + 1
|
||||
&& ui.footer.scrollWidth <= ui.footer.clientWidth + 1
|
||||
&& ui.buttons.every((button) => button.getBoundingClientRect().right <= innerWidth + 1);
|
||||
ui.buttons[0].click();
|
||||
result.cancelButtonIsSafe = await cancelled === false;
|
||||
await settle();
|
||||
result.cancelRestoresFocus = deepActive() === opener;
|
||||
|
||||
const accepted = card._confirmDanger(request());
|
||||
await settle();
|
||||
ui = visible();
|
||||
ui.buttons[1].click();
|
||||
result.acceptButtonResolvesOnce = await accepted === true;
|
||||
|
||||
const escaped = card._confirmDanger(request('escape'));
|
||||
await settle();
|
||||
deepActive()?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
result.escapeIsSafe = await escaped === false;
|
||||
|
||||
const closed = card._confirmDanger(request('close'));
|
||||
await settle();
|
||||
visible().dialog.shadowRoot.querySelector('.close').click();
|
||||
result.closeIsSafe = await closed === false;
|
||||
|
||||
const scrimmed = card._confirmDanger(request('scrim'));
|
||||
await settle();
|
||||
const native = visible().native;
|
||||
native.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
result.scrimIsSafe = await scrimmed === false;
|
||||
|
||||
const first = card._confirmDanger(request('first'));
|
||||
const second = card._confirmDanger(request('second'));
|
||||
await settle();
|
||||
visible().buttons[1].click();
|
||||
result.replacementCancelsOldRequest = await first === false && await second === true;
|
||||
|
||||
const localeFits = async (language, expectedCancel) => {
|
||||
card._config = { ...(card._config || {}), language };
|
||||
card.requestUpdate();
|
||||
for (let attempt = 0; attempt < 50 && card._t('btn.cancel') !== expectedCancel; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await settle();
|
||||
}
|
||||
const pending = card._confirmDanger(request(`locale-${language}`));
|
||||
await settle();
|
||||
const current = visible();
|
||||
const fits = current.buttons[0]?.textContent.trim() === expectedCancel
|
||||
&& current.surface.scrollWidth <= current.surface.clientWidth + 1
|
||||
&& current.footer.scrollWidth <= current.footer.clientWidth + 1;
|
||||
current.buttons[0].click();
|
||||
await pending;
|
||||
return fits;
|
||||
};
|
||||
result.germanNarrowFits = await localeFits('de', 'Abbrechen');
|
||||
result.frenchNarrowFits = await localeFits('fr', 'Annuler');
|
||||
opener.remove();
|
||||
|
||||
// From here the UI promise is replaced with deterministic decisions so the
|
||||
// action matrix can prove mutation ownership independently of layout.
|
||||
const originalConfirm = card._confirmDanger;
|
||||
let decision = false;
|
||||
card._confirmDanger = async () => decision;
|
||||
let geometryCommits = 0;
|
||||
editor._geometrySnapshot = () => ({ spaceId: 'danger-space' });
|
||||
editor._commitPhysicalGeometry = () => { geometryCommits++; return true; };
|
||||
|
||||
const baseSpace = () => ({
|
||||
id: 'danger-space', title: 'Danger space', cell_cm: 5,
|
||||
view_box: [0, 0, 1, 1], rooms: [], settings: {},
|
||||
});
|
||||
const setDraft = (draft) => {
|
||||
card._space = 'danger-space';
|
||||
card._serverCfg = { spaces: [{ ...baseSpace(), room_drafts: [draft] }], markers: [], settings: {} };
|
||||
};
|
||||
|
||||
setDraft({ id: 'draft-whole', points: [[0, 0], [1, 0]], segments: [{ cm: 10 }] });
|
||||
card._physicalDialog = { kind: 'draft', id: 'draft-whole', segment: 0, cm: '10', length: '1 m' };
|
||||
await editor._deleteDraftWhole();
|
||||
const wholeCancel = card._serverCfg.spaces[0].room_drafts.length === 1 && geometryCommits === 0;
|
||||
decision = true;
|
||||
await editor._deleteDraftWhole();
|
||||
result.draftWholeCancelAccept = wholeCancel
|
||||
&& !card._serverCfg.spaces[0].room_drafts && geometryCommits === 1;
|
||||
|
||||
decision = false;
|
||||
setDraft({
|
||||
id: 'draft-segment', points: [[0, 0], [0.5, 0], [1, 0]],
|
||||
segments: [{ cm: 10 }, { cm: 10 }],
|
||||
});
|
||||
card._physicalDialog = { kind: 'draft', id: 'draft-segment', segment: 0, cm: '10', length: '1 m' };
|
||||
await editor._deleteDraftSegment();
|
||||
const segmentCancel = card._serverCfg.spaces[0].room_drafts[0].segments.length === 2
|
||||
&& geometryCommits === 1;
|
||||
decision = true;
|
||||
await editor._deleteDraftSegment();
|
||||
result.draftSegmentCancelAccept = segmentCancel
|
||||
&& card._serverCfg.spaces[0].room_drafts[0].segments.length === 1
|
||||
&& geometryCommits === 2;
|
||||
|
||||
let configSaves = 0;
|
||||
editor._saveConfigNow = async () => { configSaves++; };
|
||||
card._serverCfg = {
|
||||
spaces: [baseSpace()], settings: {},
|
||||
markers: [{ id: 'danger-marker', binding: 'entity:switch.danger', space: 'danger-space' }],
|
||||
};
|
||||
card._devices = [];
|
||||
// #404 AC5: диалог собирает сам продукт, а не фикстура.
|
||||
//
|
||||
// Ручная фикстура врала: у объявленного типа больше сорока обязательных
|
||||
// полей, и рендер падал на первом же недостающем — сначала
|
||||
// `_bindingHasHaPage(undefined).split(':')`, а после добавления binding и
|
||||
// bindingMode на следующем. Дописывать поля по одному значит гоняться за
|
||||
// типом; штатный путь «новый маркер» отдаёт полный объект с дефолтами
|
||||
// продукта, и переопределить остаётся ровно то, что нужно этой проверке.
|
||||
//
|
||||
// Дефекта поведения здесь нет и не было: все 15 мест в src/, создающих
|
||||
// диалог, идут этим же путём. Врал только смок — и гард этого не видел.
|
||||
editor._openMarkerDialog();
|
||||
card._markerDialog = {
|
||||
...card._markerDialog,
|
||||
devId: 'danger-marker', name: 'Danger marker',
|
||||
binding: 'entity:switch.danger', bindingMode: 'ha', busy: false,
|
||||
};
|
||||
decision = false;
|
||||
await editor._deleteMarker();
|
||||
const markerCancel = card._serverCfg.markers.length === 1 && configSaves === 0;
|
||||
decision = true;
|
||||
await editor._deleteMarker();
|
||||
result.markerCancelAccept = markerCancel && card._serverCfg.markers.length === 1
|
||||
&& card._serverCfg.markers[0].id === 'danger-marker'
|
||||
&& card._serverCfg.markers[0].removed === true
|
||||
&& card._serverCfg.markers[0].hidden === true && configSaves === 1;
|
||||
|
||||
const wsCalls = [];
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
callWS: async (message) => {
|
||||
wsCalls.push(message);
|
||||
if (message.type === 'houseplan/config/get') return { config: card._serverCfg, rev: 1 };
|
||||
if (message.type === 'houseplan/layout/get') return { layout: card._layout, rev: 1 };
|
||||
return {};
|
||||
},
|
||||
};
|
||||
const setPlanDialog = () => {
|
||||
card._spaceDialog = {
|
||||
mode: 'edit', spaceId: 'danger-space', title: 'Danger space', planUrl: null,
|
||||
planFile: null, source: 'draw', showBorders: true, showNames: true,
|
||||
zeroWallStyle: 'dashed', displayTouched: true, hideDecor: false,
|
||||
hideOpenings: false, roomColor: '#888888', roomOpacity: 1, bgColor: null,
|
||||
bgMode: null, northDeg: null, sunRays: null, fillMode: 'custom', customFill: null,
|
||||
glowEnabled: true, tempMin: 15, tempMax: 30, showLqi: true, cardFontScale: 1,
|
||||
labelTemp: true, labelHum: true, labelLqi: true, labelLight: true, cellCm: 5,
|
||||
busy: false, saved: [{ name: 'old.png', url: '/old.png', size: 1, modified: 1, used_by: [] }],
|
||||
};
|
||||
};
|
||||
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
|
||||
setPlanDialog();
|
||||
decision = false;
|
||||
const before = wsCalls.length;
|
||||
await runtime._deleteServerPlan('old.png');
|
||||
const cancelSafe = wsCalls.length === before;
|
||||
decision = true;
|
||||
await runtime._deleteServerPlan('old.png');
|
||||
result[`${name}PlanCancelAccept`] = cancelSafe
|
||||
&& wsCalls.filter((call) => call.type === 'houseplan/plans/delete').length
|
||||
=== (name === 'editor' ? 1 : 2);
|
||||
}
|
||||
|
||||
card._saveConfigDebounced.cancel();
|
||||
card._persistLayout.cancel();
|
||||
card._writeChain = Promise.resolve();
|
||||
card._adoptStructuralResponses = () => ({ configChanged: false, layoutChanged: false });
|
||||
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
|
||||
card._space = 'another-space';
|
||||
card._serverCfg = { spaces: [baseSpace()], markers: [], settings: {} };
|
||||
setPlanDialog();
|
||||
decision = false;
|
||||
const before = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length;
|
||||
await runtime._deleteSpace();
|
||||
const cancelSafe = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before;
|
||||
decision = true;
|
||||
await runtime._deleteSpace();
|
||||
result[`${name}SpaceCancelAccept`] = cancelSafe
|
||||
&& wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before + 1;
|
||||
}
|
||||
|
||||
const services = [];
|
||||
card._openingEntityAvailable = () => true;
|
||||
card._openingInfo = {
|
||||
id: 'door', type: 'door', rx: 0.5, ry: 0.5, len_cm: 90, lock: 'lock.danger',
|
||||
};
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: {
|
||||
...card.hass.states,
|
||||
'lock.danger': { entity_id: 'lock.danger', state: 'locked', attributes: { friendly_name: 'Door' } },
|
||||
},
|
||||
callService: async (...args) => { services.push(args); },
|
||||
};
|
||||
decision = false;
|
||||
await card._lockAction('lock.danger', 'unlock');
|
||||
const unlockCancel = services.length === 0;
|
||||
decision = true;
|
||||
await card._lockAction('lock.danger', 'unlock');
|
||||
result.unlockCancelAccept = unlockCancel && services.length === 1
|
||||
&& services[0][0] === 'lock' && services[0][1] === 'unlock';
|
||||
|
||||
let resolveRace;
|
||||
card._confirmDanger = () => new Promise((resolve) => { resolveRace = resolve; });
|
||||
setDraft({ id: 'race-draft', points: [[0, 0], [1, 0]], segments: [{ cm: 10 }] });
|
||||
card._physicalDialog = { kind: 'draft', id: 'race-draft', segment: 0, cm: '10', length: '1 m' };
|
||||
const raced = editor._deleteDraftWhole();
|
||||
card._space = 'changed-space';
|
||||
resolveRace(true);
|
||||
await raced;
|
||||
result.staleContextCannotMutate = card._serverCfg.spaces[0].room_drafts.length === 1
|
||||
&& geometryCommits === 2;
|
||||
card._confirmDanger = originalConfirm;
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,91 @@
|
||||
// #377: the Background editor's default style persists into
|
||||
// settings.decor_default_style — one debounced write per palette drag (AC4),
|
||||
// and a card born with the key draws new objects with the saved style (AC5).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const base = c.hass.callWS;
|
||||
const writes = [];
|
||||
let rev = 40;
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
writes.push(JSON.parse(JSON.stringify(m.config.settings?.decor_default_style ?? null)));
|
||||
rev += 1;
|
||||
return { ok: true, rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
c._cfgRev = rev;
|
||||
|
||||
// войти в Background-редактор — там живёт рантайм с _updateDecorStyle
|
||||
[...sr().querySelectorAll('.modetab')][2].click(); await settleMode();
|
||||
out.decorMode = c._mode === 'decor';
|
||||
const runtime = c._editorRuntime;
|
||||
out.hasRuntime = !!runtime;
|
||||
|
||||
// AC4: серия быстрых изменений (драг по палитре) = одна запись после дебаунса
|
||||
runtime._updateDecorStyle({ ...c._decorStyle, color: '#101010' });
|
||||
runtime._updateDecorStyle({ ...c._decorStyle, color: '#404040' });
|
||||
runtime._updateDecorStyle({ ...c._decorStyle, color: '#8b0000' });
|
||||
out.instantStyle = c._decorStyle.color === '#8b0000';
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
out.noEagerWrite = writes.length === 0; // дебаунс ещё держит
|
||||
// 750 мс > внутренний дебаунс _saveConfig (500 мс): если бы персист ушёл
|
||||
// сразу (без своего дебаунса в 1 с), запись уже долетела бы до callWS
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.stillNoWriteBeforeDebounce = writes.length === 0;
|
||||
await new Promise((r) => setTimeout(r, 550));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
out.oneWrite = writes.length === 1;
|
||||
out.writeCarriesKey = !!writes[0] && writes[0].color === '#8b0000'
|
||||
&& writes[0].width_cm === 3.6 && writes[0].fill === false;
|
||||
|
||||
// возврат в дефолт = запись БЕЗ ключа
|
||||
runtime._updateDecorStyle({
|
||||
color: '#607d8b', opacity: 1, widthCm: 3.6,
|
||||
fill: false, fillColor: '#607d8b', fillOpacity: 0.25,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
out.defaultRemovesKey = writes.length === 2 && writes[1] === null;
|
||||
|
||||
// AC5: карта, рождённая с ключом, сеет _decorStyle из него.
|
||||
// Чистый reload: кэш мгновенного рендера убираем, чтобы посев шёл из
|
||||
// серверного конфига (после настоящей записи кэш нёс бы тот же ключ).
|
||||
localStorage.removeItem('houseplan_card_cfg_v1');
|
||||
const fresh = document.createElement('houseplan-card');
|
||||
fresh.setConfig({ type: 'custom:houseplan-card' });
|
||||
fresh.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
const r = await base(m);
|
||||
const config = JSON.parse(JSON.stringify(r.config));
|
||||
config.settings = { ...(config.settings || {}),
|
||||
decor_default_style: { color: '#8b0000', width_cm: 5 } };
|
||||
return { ...r, config, rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
document.body.appendChild(fresh);
|
||||
const t0 = Date.now();
|
||||
while (!fresh._serverCfg && Date.now() - t0 < 6000) await new Promise((r) => setTimeout(r, 60));
|
||||
out.seeded = fresh._decorStyle.color === '#8b0000'
|
||||
&& fresh._decorStyle.widthCm === 5
|
||||
&& fresh._decorStyle.fillColor === '#607d8b'; // частичный ключ наследует дефолт
|
||||
fresh.remove();
|
||||
return out;
|
||||
});
|
||||
checkAll(res, ['decorMode', 'hasRuntime', 'instantStyle', 'noEagerWrite',
|
||||
'stillNoWriteBeforeDebounce', 'oneWrite',
|
||||
'writeCarriesKey', 'defaultRemovesKey', 'seeded']);
|
||||
await finish(browser);
|
||||
@@ -1,5 +1,5 @@
|
||||
// Smoke: the full houseplan-card honours the #space=<id> deep-link (hashchange).
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, reportPageErrors } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
@@ -22,5 +22,8 @@ const res = await page.evaluate(async () => {
|
||||
await browser.close();
|
||||
const ok = res.after === res.target && res.afterBad === res.target;
|
||||
console.log(JSON.stringify(res));
|
||||
// #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан
|
||||
// именно остановить: иначе строка успеха печатается после «FAILED».
|
||||
if (await reportPageErrors()) process.exit(1);
|
||||
if (!ok) { console.error('FAIL deeplink smoke'); process.exit(1); }
|
||||
console.log('OK deep-link: full card switches to #space=<id>, ignores invalid ids');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// #29: one lifecycle catalog replaces the separate Add / hidden-device paths.
|
||||
// #29: one lifecycle catalog replaces the separate hidden-device paths.
|
||||
// #363 restores a direct Add shortcut without creating a second catalog.
|
||||
// This smoke exercises the real Lit dialog and verifies that browsing it is
|
||||
// read-only while nested marker flows return to the same catalog state.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
@@ -21,8 +22,9 @@ const out = await page.evaluate(async () => {
|
||||
const toolbar = [...root().querySelectorAll('.editbar .btn')];
|
||||
const labels = toolbar.map((node) => node.textContent.trim());
|
||||
const result = {
|
||||
oneCatalogEntryPoint: labels.some((label) => /Devices/i.test(label))
|
||||
&& !labels.some((label) => /Hidden and disabled|^Add$/i.test(label)),
|
||||
catalogAndDirectAddEntryPoints: labels.some((label) => /Devices/i.test(label))
|
||||
&& labels.some((label) => /^Add$/i.test(label))
|
||||
&& !labels.some((label) => /Hidden and disabled/i.test(label)),
|
||||
};
|
||||
|
||||
const before = JSON.stringify({
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const settle = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const idle = async () => {
|
||||
const started = performance.now();
|
||||
while (c._devicePositionBusy && performance.now() - started < 1500) await wait(10);
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
[...sr().querySelectorAll('.modetab')][1].click();
|
||||
await settle();
|
||||
const deviceNode = () => sr().querySelector('.dev:not(.ha-disabled)');
|
||||
let node = deviceNode();
|
||||
const deviceId = node?.getAttribute('data-id');
|
||||
const device = c._devices.find((candidate) => candidate.id === deviceId);
|
||||
if (!node || !device) return { fixtureReady: false };
|
||||
out.fixtureReady = true;
|
||||
|
||||
const originalCallWs = c.hass.callWS;
|
||||
let revision = 20;
|
||||
let serverLayout = structuredClone(c._layout);
|
||||
const writes = [];
|
||||
let failNextUpdate = false;
|
||||
let failNextDelete = false;
|
||||
c.hass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
// #397 AC3: the reconnect path reads BOTH answers, and a config that
|
||||
// differs clears the history for its own reason (`configChanged` in
|
||||
// `_adoptStructuralResponses`). To isolate the layout question the
|
||||
// fake server echoes the config the card already holds.
|
||||
return { config: structuredClone(c._serverCfg), rev: c._cfgRev };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') {
|
||||
return { layout: structuredClone(serverLayout), rev: revision };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/update') {
|
||||
if (failNextUpdate) { failNextUpdate = false; throw new Error('update refused'); }
|
||||
writes.push({ type: 'update', id: message.device_id, pos: structuredClone(message.pos) });
|
||||
serverLayout = { ...serverLayout, [message.device_id]: structuredClone(message.pos) };
|
||||
return { rev: ++revision };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/delete') {
|
||||
if (failNextDelete) { failNextDelete = false; throw new Error('delete refused'); }
|
||||
writes.push({ type: 'delete', id: message.device_id });
|
||||
delete serverLayout[message.device_id];
|
||||
return { rev: ++revision };
|
||||
}
|
||||
return originalCallWs(message);
|
||||
} };
|
||||
c._serverStorage = true;
|
||||
c._layoutRev = revision;
|
||||
|
||||
const fire = (target, type, x, y, pointerId = 74) => target.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId, pointerType: 'mouse', button: 0, buttons: type === 'pointerup' ? 0 : 1,
|
||||
clientX: x, clientY: y, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
const drag = async ({ dx = 80, cancel = false, cancelType = 'pointercancel', moves = 10, pointerId = 74 } = {}) => {
|
||||
node = deviceNode();
|
||||
const rect = node.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
fire(node, 'pointerdown', x, y, pointerId);
|
||||
for (let index = 1; index <= moves; index++) {
|
||||
fire(node, 'pointermove', x + dx * index / moves, y, pointerId);
|
||||
}
|
||||
const writesBeforeEnd = writes.length;
|
||||
fire(node, cancel ? cancelType : 'pointerup', x + dx, y, pointerId);
|
||||
await idle();
|
||||
return writesBeforeEnd;
|
||||
};
|
||||
const beginPreview = (dx, pointerId) => {
|
||||
node = deviceNode();
|
||||
const rect = node.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
fire(node, 'pointerdown', x, y, pointerId);
|
||||
fire(node, 'pointermove', x + dx, y, pointerId);
|
||||
return { node, x, y };
|
||||
};
|
||||
|
||||
const explicit = c._devicePlacementForCanvas(device, c._pos(device).x, c._pos(device).y);
|
||||
c._layout = { ...c._layout, [deviceId]: { ...explicit, k: 0, future: 'kept' } };
|
||||
serverLayout = structuredClone(c._layout);
|
||||
c._layoutContentFingerprint = '';
|
||||
c._devicePositionHistory.clear();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
const writesBeforeRelease = await drag();
|
||||
out.previewDoesNotPersist = writesBeforeRelease === 0;
|
||||
out.oneDragOneWrite = writes.length === 1 && writes[0].type === 'update';
|
||||
out.oneDragOneCommand = c._devicePositionHistory.size === 1;
|
||||
out.futureFieldsSurviveDrag = c._layout[deviceId].k === 0
|
||||
&& c._layout[deviceId].future === 'kept';
|
||||
out.undoEnabled = !sr().querySelector('[data-device-position-history="undo"]').disabled;
|
||||
// #397 AC1: after a write the local copy IS what went over the wire.
|
||||
out.localCopyEqualsTheWire = writes.length > 0
|
||||
&& JSON.stringify(c._layout[deviceId]) === JSON.stringify({
|
||||
...c._layout[deviceId], ...writes[writes.length - 1].pos,
|
||||
})
|
||||
&& JSON.stringify(serverLayout[deviceId]) === JSON.stringify(writes[writes.length - 1].pos);
|
||||
|
||||
const afterDrag = structuredClone(c._layout[deviceId]);
|
||||
sr().querySelector('[data-device-position-history="undo"]').click();
|
||||
await idle();
|
||||
// #397: the card now keeps what it sent — the canonical position — so a
|
||||
// restored placement may differ from the raw `explicit` by the lattice snap
|
||||
// (< 1e-9 of the plan, invisible). The equality is therefore stated to that
|
||||
// precision, and the snap itself is pinned separately below: a real logical
|
||||
// drift would exceed it by orders of magnitude.
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-9;
|
||||
out.undoRestoresExactStart = near(c._layout[deviceId].x, explicit.x)
|
||||
&& near(c._layout[deviceId].y, explicit.y)
|
||||
&& c._layout[deviceId].k === 0
|
||||
&& c._devicePositionHistory.canRedo;
|
||||
sr().querySelector('[data-device-position-history="redo"]').click();
|
||||
await idle();
|
||||
out.redoRestoresExactEnd = JSON.stringify(c._layout[deviceId]) === JSON.stringify(afterDrag);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await idle();
|
||||
out.keyboardUndoWorks = c._devicePositionHistory.canRedo
|
||||
&& near(c._layout[deviceId].x, explicit.x) && near(c._layout[deviceId].y, explicit.y);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyZ', ctrlKey: true, shiftKey: true,
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await idle();
|
||||
out.keyboardRedoWorks = JSON.stringify(c._layout[deviceId]) === JSON.stringify(afterDrag);
|
||||
|
||||
// Escape and an Undo request during preview abort only the uncommitted drag.
|
||||
const beforeEscape = structuredClone(c._layout[deviceId]);
|
||||
const writesBeforeEscape = writes.length;
|
||||
beginPreview(95, 741);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', code: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.escapeAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
|
||||
&& writes.length === writesBeforeEscape && c._deviceDrag === null;
|
||||
const historySizeBeforePreviewUndo = c._devicePositionHistory.size;
|
||||
beginPreview(105, 742);
|
||||
sr().querySelector('[data-device-position-history="undo"]').click();
|
||||
await c.updateComplete;
|
||||
out.undoDuringDragOnlyAborts = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
|
||||
&& writes.length === writesBeforeEscape
|
||||
&& c._devicePositionHistory.size === historySizeBeforePreviewUndo
|
||||
&& c._deviceDrag === null;
|
||||
|
||||
// Leaving the editor is another abort boundary; re-enter for the remaining cases.
|
||||
beginPreview(115, 743);
|
||||
[...sr().querySelectorAll('.modetab')][0].click();
|
||||
await settle();
|
||||
out.modeSwitchAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
|
||||
&& writes.length === writesBeforeEscape && c._deviceDrag === null;
|
||||
[...sr().querySelectorAll('.modetab')][1].click();
|
||||
await settle();
|
||||
|
||||
// A no-op after Undo must not destroy the redo branch.
|
||||
sr().querySelector('[data-device-position-history="undo"]').click();
|
||||
await idle();
|
||||
const redoName = c._devicePositionHistory.redoName;
|
||||
const writesBeforeNoop = writes.length;
|
||||
await drag({ dx: 0, moves: 0, pointerId: 75 });
|
||||
out.noopKeepsRedo = c._devicePositionHistory.redoName === redoName
|
||||
&& writes.length === writesBeforeNoop;
|
||||
sr().querySelector('[data-device-position-history="redo"]').click();
|
||||
await idle();
|
||||
|
||||
// Cancellation restores the raw before-state and performs no final write.
|
||||
const beforeCancel = structuredClone(c._layout[deviceId]);
|
||||
const writesBeforeCancel = writes.length;
|
||||
await drag({ dx: 120, cancel: true, pointerId: 76 });
|
||||
out.cancelRestoresWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
|
||||
&& writes.length === writesBeforeCancel;
|
||||
await drag({ dx: 120, cancel: true, cancelType: 'lostpointercapture', pointerId: 761 });
|
||||
out.lostCaptureRestoresWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
|
||||
&& writes.length === writesBeforeCancel;
|
||||
|
||||
// A second pointer invalidates the first single-pointer transaction.
|
||||
node = deviceNode();
|
||||
let rect = node.getBoundingClientRect();
|
||||
let x = rect.left + rect.width / 2;
|
||||
let y = rect.top + rect.height / 2;
|
||||
fire(node, 'pointerdown', x, y, 762);
|
||||
fire(node, 'pointermove', x + 100, y, 762);
|
||||
fire(node, 'pointerdown', x, y, 763);
|
||||
await c.updateComplete;
|
||||
out.secondPointerAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
|
||||
&& writes.length === writesBeforeCancel && c._deviceDrag === null;
|
||||
|
||||
// An auto-positioned marker round-trips through delete/update.
|
||||
c._devicePositionHistory.clear();
|
||||
delete c._layout[deviceId];
|
||||
delete serverLayout[deviceId];
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await drag({ dx: 90, pointerId: 77 });
|
||||
const autoAfter = structuredClone(c._layout[deviceId]);
|
||||
const beforeAutoUndo = writes.length;
|
||||
sr().querySelector('[data-device-position-history="undo"]').click();
|
||||
await idle();
|
||||
out.autoUndoDeletesExplicitPlacement = !(deviceId in c._layout)
|
||||
&& writes[beforeAutoUndo]?.type === 'delete';
|
||||
sr().querySelector('[data-device-position-history="redo"]').click();
|
||||
await idle();
|
||||
out.autoRedoRestoresPlacement = JSON.stringify(c._layout[deviceId]) === JSON.stringify(autoAfter)
|
||||
&& writes.at(-1)?.type === 'update';
|
||||
|
||||
// Failed final update never enters history; failed Undo restores stack direction.
|
||||
c._devicePositionHistory.clear();
|
||||
const beforeFailedDrag = structuredClone(c._layout[deviceId]);
|
||||
failNextUpdate = true;
|
||||
await drag({ dx: 110, pointerId: 78 });
|
||||
out.failedDragRollsBack = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeFailedDrag)
|
||||
&& !c._devicePositionHistory.canUndo;
|
||||
delete c._layout[deviceId];
|
||||
delete serverLayout[deviceId];
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await drag({ dx: 100, pointerId: 79 });
|
||||
const beforeFailedDelete = structuredClone(c._layout[deviceId]);
|
||||
failNextDelete = true;
|
||||
sr().querySelector('[data-device-position-history="undo"]').click();
|
||||
await idle();
|
||||
out.failedUndoRestoresStackDirection = JSON.stringify(c._layout[deviceId])
|
||||
=== JSON.stringify(beforeFailedDelete)
|
||||
&& c._devicePositionHistory.canUndo && !c._devicePositionHistory.canRedo;
|
||||
|
||||
// Native text history stays native even with an available command.
|
||||
const input = document.createElement('input');
|
||||
sr().append(input);
|
||||
const sizeBeforeInput = c._devicePositionHistory.size;
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await wait();
|
||||
out.nativeInputHistoryNotIntercepted = c._devicePositionHistory.size === sizeBeforeInput;
|
||||
input.remove();
|
||||
|
||||
// Same-content reload is an own/reconnect echo; different content is remote
|
||||
// authority. #397: the server snapshot is NOT copied from the card here — it
|
||||
// already holds what actually went over the wire (the fake WS above stores
|
||||
// `message.pos`). The former `serverLayout = structuredClone(c._layout)`
|
||||
// erased by hand the very divergence this check exists to catch: the wire
|
||||
// carries the canonical position while `_layout` kept the raw one, so the
|
||||
// card mistook its own echo for a remote edit and cleared the stack. With
|
||||
// the assignment gone the check reddens on the unfixed code.
|
||||
const ownEchoServerPos = structuredClone(serverLayout[deviceId]);
|
||||
const ownEchoLocalPos = structuredClone(c._layout[deviceId]);
|
||||
await c._reloadLayoutOnly();
|
||||
out.sameContentReloadKeepsHistory = c._devicePositionHistory.canUndo;
|
||||
// The point of the check is only meaningful if the two sides were equal to
|
||||
// begin with: pin that explicitly instead of assuming it.
|
||||
out.ownEchoMatchesWhatWentOverTheWire =
|
||||
JSON.stringify(ownEchoServerPos) === JSON.stringify(ownEchoLocalPos);
|
||||
serverLayout = {
|
||||
...serverLayout,
|
||||
[deviceId]: { ...serverLayout[deviceId], x: serverLayout[deviceId].x + 0.01 },
|
||||
};
|
||||
await c._reloadLayoutOnly();
|
||||
out.remoteContentClearsHistory = !c._devicePositionHistory.canUndo
|
||||
&& !c._devicePositionHistory.canRedo;
|
||||
|
||||
// #397 AC5b: the echo of a DELETE must not clear the stack either — the
|
||||
// delete branch removes a key instead of replacing a value, so proving the
|
||||
// update branch says nothing about it.
|
||||
const echoProbe = c._devices.find((candidate) => candidate.id !== deviceId
|
||||
&& candidate.bindingStatus?.kind !== 'ha_disabled');
|
||||
if (echoProbe) {
|
||||
// The probe position is deliberately NON-canonical (0.024999999999999942
|
||||
// snaps to 0.025): a round number would travel unchanged and the checks
|
||||
// below would pass for the wrong reason — there would be no divergence to
|
||||
// detect in the first place.
|
||||
const rawProbe = { s: echoProbe.space, x: 0.024999999999999942, y: 0.7 / 7 };
|
||||
c._layout = { ...c._layout, [echoProbe.id]: structuredClone(rawProbe) };
|
||||
await c._persistDevicePlacement(echoProbe.id, structuredClone(rawProbe));
|
||||
c._devicePositionHistory.push({
|
||||
name: 'echo probe move',
|
||||
before: { deviceId: echoProbe.id, spaceId: echoProbe.space, placement: null },
|
||||
after: { deviceId: echoProbe.id, spaceId: echoProbe.space,
|
||||
placement: structuredClone(rawProbe) },
|
||||
});
|
||||
// #397 AC3: the reconnect path (`_loadFromServer` →
|
||||
// `_adoptStructuralResponses`) is the OTHER reader of the same value: it
|
||||
// compares the stored `_layoutContentFingerprint` with the server answer
|
||||
// instead of recomputing it. It must be exercised right after a write,
|
||||
// before any reload has had a chance to align the two sides — otherwise
|
||||
// the check passes for the wrong reason.
|
||||
const reconnectBefore = c._devicePositionHistory.canUndo;
|
||||
await c._loadFromServer();
|
||||
out.reconnectKeepsHistory = reconnectBefore && c._devicePositionHistory.canUndo;
|
||||
|
||||
await c._persistDevicePlacement(echoProbe.id, null);
|
||||
await c._reloadLayoutOnly();
|
||||
out.deleteEchoKeepsHistory = c._devicePositionHistory.canUndo
|
||||
&& c._layout[echoProbe.id] === undefined;
|
||||
|
||||
// #397 AC7: while a write is in flight the card is the authority — a
|
||||
// server answer holding the OLD position must not win the merge.
|
||||
const inFlightPos = { s: echoProbe.space, x: 0.63, y: 0.21 };
|
||||
serverLayout = { ...serverLayout, [echoProbe.id]: { s: echoProbe.space, x: 0.1, y: 0.1 } };
|
||||
c._sentPos.set(echoProbe.id, structuredClone(inFlightPos));
|
||||
c._layout = { ...c._layout, [echoProbe.id]: structuredClone(inFlightPos) };
|
||||
await c._reloadLayoutOnly();
|
||||
out.inFlightPositionWinsTheMerge =
|
||||
JSON.stringify(c._layout[echoProbe.id]) === JSON.stringify(inFlightPos);
|
||||
c._sentPos.delete(echoProbe.id);
|
||||
} else {
|
||||
out.deleteEchoKeepsHistory = null;
|
||||
out.reconnectKeepsHistory = null;
|
||||
out.inFlightPositionWinsTheMerge = null;
|
||||
}
|
||||
|
||||
// A valid command owns its original space and makes the result visible there.
|
||||
const otherDevice = c._devices.find((candidate) => candidate.space !== device.space
|
||||
&& candidate.bindingStatus?.kind !== 'ha_disabled');
|
||||
if (otherDevice) {
|
||||
const otherBefore = { s: otherDevice.space, x: 0.55, y: 0.55 };
|
||||
const otherAfter = { s: otherDevice.space, x: 0.6, y: 0.55 };
|
||||
c._layout = { ...c._layout, [otherDevice.id]: structuredClone(otherAfter) };
|
||||
serverLayout = structuredClone(c._layout);
|
||||
c._layoutContentFingerprint = '';
|
||||
c._devicePositionHistory.push({
|
||||
name: 'other-space move',
|
||||
before: { deviceId: otherDevice.id, spaceId: otherDevice.space, placement: otherBefore },
|
||||
after: { deviceId: otherDevice.id, spaceId: otherDevice.space, placement: otherAfter },
|
||||
});
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
sr().querySelector('[data-device-position-history="undo"]').click();
|
||||
await idle();
|
||||
await settle();
|
||||
const observedOther = c._layout[otherDevice.id];
|
||||
out.otherSpaceUndoIsVisible = c._space === otherDevice.space
|
||||
&& observedOther?.s === otherBefore.s
|
||||
&& observedOther?.x === otherBefore.x
|
||||
&& observedOther?.y === otherBefore.y;
|
||||
c._commitSpace(device.space);
|
||||
await settle();
|
||||
} else {
|
||||
out.otherSpaceUndoIsVisible = false;
|
||||
}
|
||||
|
||||
// Stale commands fail closed instead of recreating a deleted/rebound/disabled marker.
|
||||
const staleCommand = (id, spaceId) => ({
|
||||
name: 'stale move',
|
||||
before: { deviceId: id, spaceId, placement: { s: spaceId, x: 0.4, y: 0.4 } },
|
||||
after: { deviceId: id, spaceId, placement: { s: spaceId, x: 0.45, y: 0.4 } },
|
||||
});
|
||||
const writesBeforeStale = writes.length;
|
||||
c._devicePositionHistory.clear();
|
||||
c._devicePositionHistory.push(staleCommand('removed-device', device.space));
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
sr().querySelector('[data-device-position-history="undo"]').click();
|
||||
await c.updateComplete;
|
||||
out.deletedDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
|
||||
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
|
||||
|
||||
c._devicePositionHistory.push(staleCommand(device.id, 'garden'));
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
sr().querySelector('[data-device-position-history="undo"]').click();
|
||||
await c.updateComplete;
|
||||
out.reboundDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
|
||||
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
|
||||
|
||||
const devicesBeforeDisable = c._devices;
|
||||
c._devices = devicesBeforeDisable.map((candidate) => candidate.id === device.id
|
||||
? { ...candidate, bindingStatus: { kind: 'ha_disabled' } } : candidate);
|
||||
c._devicePositionHistory.push(staleCommand(device.id, device.space));
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
sr().querySelector('[data-device-position-history="undo"]').click();
|
||||
await c.updateComplete;
|
||||
out.disabledDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
|
||||
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
|
||||
c._devices = devicesBeforeDisable;
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res, {});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,135 @@
|
||||
// #44: the Discovery-filters section makes group_lights and
|
||||
// exclude_integrations visible, previewable and saved in one write.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 980, height: 820 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = () => c.renderRoot || c.shadowRoot;
|
||||
// #126 seeds registry-Area provenance on the first authoritative build.
|
||||
// Start this write-count contract only after that unrelated bootstrap write.
|
||||
await c._areaRelocationWrite;
|
||||
const writes = [];
|
||||
const base = c.hass.callWS;
|
||||
let rev = 70;
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
writes.push(JSON.parse(JSON.stringify({
|
||||
group_lights: m.config.settings?.group_lights ?? null,
|
||||
exclude_integrations: m.config.settings?.exclude_integrations ?? null,
|
||||
})));
|
||||
rev += 1;
|
||||
return { ok: true, rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
c._cfgRev = rev;
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await c._writeChain;
|
||||
await c._areaRelocationWrite;
|
||||
writes.length = 0;
|
||||
const result = {};
|
||||
|
||||
c._setMode('devices');
|
||||
await c.updateComplete;
|
||||
c._openDeviceInbox();
|
||||
await c.updateComplete;
|
||||
const tabs = [...root().querySelectorAll('.device-inbox-tabs [role="tab"]')];
|
||||
tabs[1]?.click(); // «Доступны»
|
||||
await c.updateComplete;
|
||||
const section = root().querySelector('.device-inbox-discovery');
|
||||
result.sectionOnAvailable = !!section;
|
||||
section?.querySelector('summary')?.click();
|
||||
await c.updateComplete;
|
||||
|
||||
// AC1: тумблер группировки
|
||||
const runtime = c._editorRuntime;
|
||||
const dialog = () => c._deviceInbox;
|
||||
const toggle = root().querySelector('.device-inbox-discovery .srcrow input');
|
||||
result.toggleDefaultOn = !!toggle?.checked;
|
||||
toggle.checked = false;
|
||||
toggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
|
||||
// AC2: добавить исключение — превью-счётчик «скроется» ненулевой
|
||||
// демо: все устройства платформы 'demo' — исключение честно всё материализует
|
||||
const anyIntegration = 'demo';
|
||||
// AC3: явный маркер той же интеграции обязан пережить запись фильтров
|
||||
c._serverCfg = { ...c._serverCfg, markers: [...(c._serverCfg.markers || []), {
|
||||
id: 'explicit-44', binding: 'device:d_lamp', space: c._serverCfg.spaces[0].id,
|
||||
}] };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
await c._areaRelocationWrite;
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await c._writeChain;
|
||||
writes.length = 0;
|
||||
const beforeExcluded = runtime._discoveryFilterState(dialog()).excluded.length;
|
||||
c._deviceInbox = { ...dialog(), draftExcluded: [
|
||||
...runtime._discoveryFilterState(dialog()).excluded, anyIntegration,
|
||||
] };
|
||||
await c.updateComplete;
|
||||
const preview = runtime._discoveryFilterPreview(dialog());
|
||||
result.previewHides = preview.hide > 0;
|
||||
result.previewLightsCounted = preview.lights >= 0;
|
||||
|
||||
// Save: одна запись, ключи в конфиге
|
||||
await runtime._saveDiscoveryFilters(dialog());
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
result.oneWrite = writes.length === 1;
|
||||
result.writeCarriesBoth = writes[0]
|
||||
&& writes[0].group_lights === false
|
||||
&& Array.isArray(writes[0].exclude_integrations)
|
||||
&& writes[0].exclude_integrations.includes(anyIntegration)
|
||||
&& writes[0].exclude_integrations.length === beforeExcluded + 1;
|
||||
|
||||
// AC3: явный маркер пережил запись фильтров (в т.ч. в конфиге)
|
||||
result.markersUntouched = (c._serverCfg.markers || [])
|
||||
.some((marker) => marker.id === 'explicit-44');
|
||||
|
||||
// AC1-обратно: вернуть группировку и рекомендуемые → ключи удаляются
|
||||
const d2 = c._deviceInbox;
|
||||
c._deviceInbox = { ...d2, draftGroupLights: true, draftExcluded: null };
|
||||
await c.updateComplete;
|
||||
await runtime._saveDiscoveryFilters(c._deviceInbox);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
c._saveConfigDebounced?.flush?.();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
result.secondWrite = writes.length === 2;
|
||||
result.defaultsRemoveKeys = writes[1]
|
||||
&& writes[1].group_lights === null && writes[1].exclude_integrations === null;
|
||||
|
||||
// AC4: причина с именем интеграции — строим строку и проверяем текст
|
||||
const excludedNow = new Set([anyIntegration]);
|
||||
c._serverCfg = { ...c._serverCfg, settings: {
|
||||
...c._serverCfg.settings, exclude_integrations: [...excludedNow],
|
||||
} };
|
||||
c._deviceInboxMemo = null; c._regSignature = '';
|
||||
c._maybeRebuildDevices?.(); c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await c.updateComplete;
|
||||
// причина с именем интеграции живёт на строках reason=excluded_integration
|
||||
// (категория строк — какой была до задачи; вкладку не навязываем)
|
||||
const tabsNow = [...root().querySelectorAll('.device-inbox-tabs [role="tab"]')];
|
||||
let reasonNames = false;
|
||||
for (const tab of tabsNow) {
|
||||
tab.click();
|
||||
await c.updateComplete;
|
||||
const reasons = [...root().querySelectorAll('.device-inbox-reason')]
|
||||
.map((node) => node.textContent || '');
|
||||
if (reasons.some((text) => text.includes(anyIntegration))) { reasonNames = true; break; }
|
||||
}
|
||||
result.reasonNamesIntegration = reasonNames;
|
||||
|
||||
c._closeDeviceInbox?.();
|
||||
return result;
|
||||
});
|
||||
checkAll(out, ['sectionOnAvailable', 'toggleDefaultOn', 'previewHides', 'previewLightsCounted',
|
||||
'oneWrite', 'writeCarriesBoth', 'markersUntouched', 'secondWrite', 'defaultsRemoveKeys',
|
||||
'reasonNamesIntegration']);
|
||||
await finish(browser);
|
||||
@@ -35,21 +35,35 @@ const out = await page.evaluate(async () => {
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
const beginDeviceDrag = (device, position, pointerId) => {
|
||||
const saved = c._layout[device.id];
|
||||
c._deviceDrag = {
|
||||
id: device.id, spaceId: device.space, displayName: device.name,
|
||||
pointerId, source: null, sx: 400, sy: 400,
|
||||
ox: position.x, oy: position.y, moved: false,
|
||||
before: saved ? { x: saved.x, y: saved.y, ...(saved.s ? { s: saved.s } : {}) } : null,
|
||||
start: c._devicePlacementForCanvas(device, position.x, position.y),
|
||||
};
|
||||
};
|
||||
const awaitDeviceWrite = async () => {
|
||||
while (c._devicePositionBusy) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
// ---- (a) a DEVICE MARKER goes to 2.5 / 2.2 ---------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => !d.virtual);
|
||||
const p0 = c._pos(dev);
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y), dev);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev);
|
||||
await c.updateComplete;
|
||||
beginDeviceDrag(dev, p0, 11);
|
||||
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y, 400, 400,
|
||||
{ pointerId: 11 }), dev);
|
||||
c._pointerUp(new PointerEvent('pointerup', { pointerId: 11 }), dev);
|
||||
await awaitDeviceWrite();
|
||||
const saved = c._layout[dev.id];
|
||||
o.markerLeftTheOldCanvas = near(saved.x, 2.5) && near(saved.y, 2.2);
|
||||
o.markerIsOnTheGrid = onGrid(saved.x) && onGrid(saved.y);
|
||||
|
||||
// ---- (b) it SURVIVES a rebuild ---------------------------------------
|
||||
c._drag = null;
|
||||
c._modelCache = null; c._frame = null; c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const back = c._pos(c._devices.find((d) => d.id === dev.id));
|
||||
@@ -104,20 +118,21 @@ const out = await page.evaluate(async () => {
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev2 = c._devices.find((d) => d.id !== dev.id && !d.virtual);
|
||||
const q0 = c._pos(dev2);
|
||||
c._drag = { id: dev2.id, sx: 400, sy: 400, ox: q0.x, oy: q0.y, moved: false };
|
||||
c._pointerMove(mk(9e9, 9e9), dev2);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev2);
|
||||
c._drag = null;
|
||||
beginDeviceDrag(dev2, q0, 12);
|
||||
c._pointerMove(mk(9e9, 9e9, 400, 400, { pointerId: 12 }), dev2);
|
||||
c._pointerUp(new PointerEvent('pointerup', { pointerId: 12 }), dev2);
|
||||
await awaitDeviceWrite();
|
||||
o.garbageStillClamped = c._layout[dev2.id].x === 5000 && c._layout[dev2.id].y === 5000;
|
||||
|
||||
// ---- (f) Shift cannot land between the nodes ---------------------------
|
||||
const dev3 = c._devices.find((d) => d.id !== dev.id && d.id !== dev2.id && !d.virtual);
|
||||
const r0 = c._pos(dev3);
|
||||
const halfStep = NORM_W / 240 / 2;
|
||||
c._drag = { id: dev3.id, sx: 400, sy: 400, ox: r0.x, oy: r0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y, 400, 400, { shiftKey: true }), dev3);
|
||||
c._drag = null;
|
||||
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);
|
||||
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
|
||||
c._cancelDeviceDrag();
|
||||
|
||||
c._setMode('view');
|
||||
return o;
|
||||
|
||||
@@ -156,7 +156,19 @@ const res = await page.evaluate(async () => {
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
out.directSwitch = c._mode === 'devices';
|
||||
out.devBar = !!sr().querySelector('.editbar.devbar');
|
||||
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 2; // devices catalog + icon rules (#29)
|
||||
const deviceToolbarButtons = [...sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)')];
|
||||
const addDeviceButton = deviceToolbarButtons[0];
|
||||
out.devBarBtns = deviceToolbarButtons.length === 5;
|
||||
out.deviceHistoryControlsArePersistent = !!sr().querySelector('[data-device-position-history="undo"]')
|
||||
&& !!sr().querySelector('[data-device-position-history="redo"]')
|
||||
&& sr().querySelector('[data-device-position-history="undo"]').disabled
|
||||
&& sr().querySelector('[data-device-position-history="redo"]').disabled;
|
||||
out.addDeviceShortcutUsesNativeKeyboardSemantics = addDeviceButton?.tagName === 'BUTTON'
|
||||
&& addDeviceButton.disabled === false;
|
||||
out.addDeviceShortcut = addDeviceButton?.textContent.trim() === c._t('devbar.add')
|
||||
&& addDeviceButton.title === c._t('title.add_device')
|
||||
&& addDeviceButton.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:plus-box-outline'
|
||||
&& deviceToolbarButtons[1]?.textContent.trim() === c._t('device_inbox.button');
|
||||
const swapChrome = sr().querySelector('.editorchrome');
|
||||
const swapInner = swapChrome.querySelector('.editorchrome-inner');
|
||||
out.editorSwapAnimatesHeight = c._modeTransitionBusy
|
||||
@@ -164,6 +176,12 @@ const res = await page.evaluate(async () => {
|
||||
out.editorSwapAnimatesContent = c._modeTransitionBusy && !!swapInner
|
||||
&& (c._modeTransitionVisual?.toolbarContentOpacity ?? 1) < 1;
|
||||
await settleMode();
|
||||
addDeviceButton.click(); await c.updateComplete;
|
||||
out.addDeviceShortcutOpensNewDialog = !!c._markerDialog
|
||||
&& c._markerDialog.name === ''
|
||||
&& c._markerDialog.bindingMode === 'virtual'
|
||||
&& !Object.prototype.hasOwnProperty.call(c._markerDialog, 'devId');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
|
||||
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
|
||||
// 6) крестик на панели → Просмотр
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// and `await import(entry)` + `createElement(...).setConfig(...)` stays valid.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
import { check, checkAll } from './serve.mjs';
|
||||
import { check, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const entry = readFileSync('dist/houseplan-card.js');
|
||||
const page404 = `<!doctype html><meta charset="utf-8"><body><script type="module">
|
||||
@@ -41,13 +41,20 @@ const run = async (locale, expected) => {
|
||||
|
||||
const en = await run('en-US');
|
||||
const ru = await run('ru-RU');
|
||||
const fr = await run('fr-FR');
|
||||
const out = {
|
||||
entrySurvivesMissingChunk: en.pageErrors === 0 && ru.pageErrors === 0,
|
||||
entrySurvivesMissingChunk: en.pageErrors === 0 && ru.pageErrors === 0 && fr.pageErrors === 0,
|
||||
englishMessageVisible: en.text.includes('House Plan was updated')
|
||||
&& en.text.includes('reload the page'),
|
||||
russianMessageVisible: ru.text.includes('House Plan обновился')
|
||||
&& ru.text.includes('перезагрузите страницу'),
|
||||
frenchMessageVisible: fr.text.includes('House Plan a été mis à jour')
|
||||
&& fr.text.includes('recharger la page'),
|
||||
};
|
||||
checkAll(out);
|
||||
check('smoke_entry_stale', Object.values(out).every(Boolean));
|
||||
if (Object.values(out).every(Boolean)) console.log('OK');
|
||||
// #407: до этого смок не мог провалиться в принципе. `check`/`checkAll`
|
||||
// складывали неудачи в `_failures`, но их никто не печатал и код возврата не
|
||||
// выставлял — ровно тот паттерн «печатали булевы значения и всегда выходили
|
||||
// нулём», который описан в шапке serve.mjs как исправленный в 2026-07-27.
|
||||
// Браузеры закрыты внутри run(), поэтому finish() получает undefined.
|
||||
await finish(undefined, out);
|
||||
|
||||
@@ -10,7 +10,13 @@ const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const PITCH = 1000 / 240;
|
||||
window.confirm = () => true;
|
||||
// The product owns dangerous confirmations through hp-confirm now. This
|
||||
// smoke covers geometry transactions, while smoke_danger_confirmation
|
||||
// exercises the real dialog; keep the decision deterministic here.
|
||||
let confirmDecision = true;
|
||||
c._confirmDanger = () => new Promise((resolve) => {
|
||||
setTimeout(() => resolve(confirmDecision), 0);
|
||||
});
|
||||
// Exercise hp-dialog's HA branch without depending on the Home Assistant
|
||||
// frontend in the standalone demo. The fixed one-line fallback below
|
||||
// reproduces ha-dialog-header's public custom-property contract: Houseplan
|
||||
@@ -187,15 +193,55 @@ const out = await page.evaluate(async () => {
|
||||
c._physicalDialog = null;
|
||||
await c.updateComplete;
|
||||
|
||||
c._physicalSel = { kind: 'partition', id: sp.partitions[0].id };
|
||||
c._deletePhysicalSelection();
|
||||
const partitionId = sp.partitions[0].id;
|
||||
sp.openings = [{
|
||||
id: 'hosted-opening', type: 'door', width_cm: 90,
|
||||
host: { kind: 'partition', id: partitionId, t: 0.5 },
|
||||
}];
|
||||
c._physicalSel = { kind: 'partition', id: partitionId };
|
||||
const hostedDelete = c._deletePhysicalSelection();
|
||||
o.hostedPartitionDeleteReturnsPromise = typeof hostedDelete?.then === 'function';
|
||||
await hostedDelete;
|
||||
o.hostedPartitionWaitsForDecision = !!c._partitionDeleteDialog
|
||||
&& c._serverCfg.spaces[0].partitions?.some((partition) => partition.id === partitionId);
|
||||
c._partitionDeleteDialog = null;
|
||||
delete sp.openings;
|
||||
|
||||
c._physicalSel = { kind: 'partition', id: partitionId };
|
||||
const partitionDelete = c._deletePhysicalSelection();
|
||||
o.partitionDeleteReturnsPromise = typeof partitionDelete?.then === 'function';
|
||||
await partitionDelete;
|
||||
o.deleteRemovesPartition = !c._serverCfg.spaces[0].partitions;
|
||||
|
||||
const draftId = c._serverCfg.spaces[0].room_drafts[0].id;
|
||||
const historyBeforeCancel = c._geometryHistory.size;
|
||||
confirmDecision = false;
|
||||
c._physicalSel = { kind: 'draft', id: draftId, segment: 0 };
|
||||
c._deletePhysicalSelection();
|
||||
const cancelledDelete = c._deletePhysicalSelection();
|
||||
o.cancelledDraftDeleteReturnsPromise = typeof cancelledDelete?.then === 'function';
|
||||
await cancelledDelete;
|
||||
o.cancelledDraftDeleteKeepsOutlineAndHistory = c._serverCfg.spaces[0].room_drafts?.[0]?.id === draftId
|
||||
&& c._geometryHistory.size === historyBeforeCancel;
|
||||
|
||||
confirmDecision = true;
|
||||
c._physicalSel = { kind: 'draft', id: draftId, segment: 0 };
|
||||
const draftDelete = c._deletePhysicalSelection();
|
||||
o.draftDeleteReturnsPromise = typeof draftDelete?.then === 'function';
|
||||
await draftDelete;
|
||||
o.deleteOnDraftRemovesWholeOutline = !c._serverCfg.spaces[0].room_drafts;
|
||||
|
||||
const columnId = c._serverCfg.spaces[0].wall_columns[0].id;
|
||||
c._physicalSel = { kind: 'column', id: columnId };
|
||||
const columnDelete = c._deletePhysicalSelection();
|
||||
o.columnDeleteReturnsPromise = typeof columnDelete?.then === 'function';
|
||||
await columnDelete;
|
||||
o.deleteRemovesColumn = !c._serverCfg.spaces[0].wall_columns;
|
||||
|
||||
c._physicalSel = null;
|
||||
const emptyDelete = c._deletePhysicalSelection();
|
||||
o.emptyDeleteReturnsPromise = typeof emptyDelete?.then === 'function';
|
||||
await emptyDelete;
|
||||
|
||||
drawAndFinish([400, 100], [500, 100], 120);
|
||||
o.invalidThicknessCreatesNothing = !c._serverCfg.spaces[0].partitions;
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// #371: French is the second lazy locale — community-contributed dictionary
|
||||
// (@OUARZA). This smoke pins the fr-specific wiring only: automatic selection
|
||||
// from an fr-CA profile, one lazy chunk per page, and the initial graph free
|
||||
// of French. The generic failure machinery (retry, fallback, render gate,
|
||||
// failure toast) is locale-agnostic and stays proven by smoke_german_locale.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launchColdView, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
|
||||
const frPath = manifest.files.map((f) => f.path).find((p) => /(?:^|\/)fr-[^/]+\.js$/.test(p));
|
||||
if (!frPath) throw new Error('French locale chunk is absent from the bundle manifest');
|
||||
const frName = frPath.split('/').at(-1);
|
||||
|
||||
const { page, browser } = await launchColdView();
|
||||
const requested = [];
|
||||
page.on('request', (r) => requested.push(new URL(r.url()).pathname));
|
||||
const out = await page.evaluate(async (frChunk) => {
|
||||
const card = window.__card;
|
||||
const out = {};
|
||||
const waitFor = async (predicate, timeout = 8000) => {
|
||||
const started = Date.now();
|
||||
while (!predicate() && Date.now() - started < timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
return !!predicate();
|
||||
};
|
||||
// профиль HA fr-CA → французский, без явного выбора
|
||||
card.hass = { ...card.hass, locale: { ...card.hass.locale, language: 'fr-CA' } };
|
||||
card.requestUpdate();
|
||||
out.frenchCommits = await waitFor(() => card._t('btn.save') === 'Enregistrer');
|
||||
out.frenchTitleGlossary = card._t('space.header') === 'Espace';
|
||||
out.langAttrIsFr = await waitFor(() => card.getAttribute('lang') === 'fr');
|
||||
return out;
|
||||
}, frName);
|
||||
out.frChunkRequestedOnce = requested.filter((p) => p.endsWith(`/${frName}`)).length === 1;
|
||||
out.initialGraphHasNoFrench = !manifest.initialViewFiles.some((p) => /(?:^|\/)fr-[^/]+\.js$/.test(p))
|
||||
&& manifest.lazyLocaleFiles.some((p) => /(?:^|\/)fr-[^/]+\.js$/.test(p));
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -8,8 +8,9 @@
|
||||
// конфиг; touch/pen не рисуют hover и сохраняют только чистый tap;
|
||||
// 5) магнит к стене: предмет прижимается к ближайшей стене и доворачивается
|
||||
// по ней; Shift временно обходит магнит, сохраняя grid snap;
|
||||
// 6) у выделенного предмета та же рамка, что у текстового блока, — угловые
|
||||
// ручки (НЕзависимые ширина и глубина) и ручка поворота с шагом 5°;
|
||||
// 6) у выделенного предмета расширенная рамка: угловые и четыре одноосевые
|
||||
// ручки; угол сохраняет пропорции без Shift, с Shift оси независимы;
|
||||
// поворот свободный, а Shift привязывает его к 45°;
|
||||
// 7) во время растягивания угла показываются живые плашки размеров;
|
||||
// 8) фигура пишется в конфиг и переживает пересборку, хуки card-mod на месте.
|
||||
// ПАДАЕТ на сборке до этой задачи: инструмента «Мебель» нет, палитры нет,
|
||||
@@ -314,14 +315,28 @@ const res = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
|
||||
// ================= 7. рамка, углы, плашки, поворот =======================
|
||||
// Previous movement deliberately exercised the wall magnet and may have
|
||||
// rotated the piece. Isolate the transform fixture at 0° and without flips.
|
||||
c._curSpaceCfg.decor = c._decorList.map((shape) => {
|
||||
if (shape.id !== sofaId) return shape;
|
||||
const clean = { ...shape };
|
||||
delete clean.angle; delete clean.flip_h; delete clean.flip_v;
|
||||
return clean;
|
||||
});
|
||||
c._cfgEpoch++;
|
||||
c._decorSel = sofaId; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.sameFrameAsTheTextBlock = !!frame()
|
||||
&& frame().querySelectorAll('.dthandle').length === 5
|
||||
&& frame().querySelectorAll('.dtknob').length === 5
|
||||
out.furnitureFrameHasFourSideHandles = !!frame()
|
||||
&& frame().classList.contains('dtfurnitureframe')
|
||||
&& frame().querySelectorAll('.dthandle').length === 9
|
||||
&& frame().querySelectorAll('.dtknob').length === 9
|
||||
&& frame().querySelectorAll('.dthandle.dtedge').length === 4
|
||||
&& !!frame().querySelector('.dtrot');
|
||||
out.sideHandlesExposeAxisCursors = [...frame().querySelectorAll('.dthandle.dtedge')]
|
||||
.every((handle) => ['ew-resize', 'ns-resize'].includes(getComputedStyle(handle).cursor));
|
||||
out.rotationHandleUsesCircularCursor = getComputedStyle(frame().querySelector('.dtrot')).cursor.includes('url(');
|
||||
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;
|
||||
@@ -329,11 +344,26 @@ const res = await page.evaluate(async () => {
|
||||
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
|
||||
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
|
||||
|
||||
// угол: НЕзависимые ширина и глубина + живые плашки
|
||||
const visiblePath = el(sofaId);
|
||||
const hitPath = sr().querySelector(`.dfurniturehit[data-id="${sofaId}"]`);
|
||||
out.selectionHaloUsesTheRealArtworkPath = !!visiblePath && !!hitPath
|
||||
&& hitPath.getAttribute('d') === visiblePath.getAttribute('d')
|
||||
&& hitPath.getAttribute('transform') === visiblePath.getAttribute('transform')
|
||||
&& Number(hitPath.getAttribute('stroke-width')) > Number(visiblePath.getAttribute('stroke-width'))
|
||||
&& getComputedStyle(hitPath).pointerEvents === 'stroke'
|
||||
&& /transparent|rgba\(0, 0, 0, 0\)/.test(getComputedStyle(hitPath).stroke);
|
||||
|
||||
// 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 handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
|
||||
const se = handles[3] || stageEl(); // SE — четвёртый угол
|
||||
// #400: угловая ручка ищется по РОЛИ, а не по индексу в DOM. Индекс держался
|
||||
// на порядке отрисовки, а порядок — это решение о приоритете хита (углы
|
||||
// рисуются последними, чтобы выигрывать нажатие у осевых на мелкой мебели),
|
||||
// и оно имеет право меняться. Юго-восточный угол — второй `nwse`.
|
||||
const cornerHandles = handles.filter((el) => !el.classList.contains('dtrot')
|
||||
&& !el.classList.contains('dtedge'));
|
||||
const se = cornerHandles[2] || stageEl(); // SE — [1, 1, 'nwse']
|
||||
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 });
|
||||
@@ -341,29 +371,60 @@ const res = await page.evaluate(async () => {
|
||||
const grown = sofaNow();
|
||||
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
|
||||
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
|
||||
&& near(((grown.h * 1000) / PITCH) % 1, 0, 1e-6);
|
||||
&& near(grown.h * 1000, b.h + 20, 1e-6);
|
||||
out.shiftResizeIsSubGrid = !near((grown.h * 1000) / PITCH,
|
||||
Math.round((grown.h * 1000) / PITCH), 1e-4);
|
||||
out.oppositeCornerStaysPut = near(grown.x * 1000, b.x, 1e-6) && near(grown.y * 1000, b.y, 1e-6);
|
||||
const plates = [...sr().querySelectorAll('.measurelabel.furnmeasure')];
|
||||
out.twoLivePlates = plates.length === 2;
|
||||
out.platesShowRealLengths = plates.length === 2
|
||||
&& plates.every((p) => /\d/.test(p.textContent))
|
||||
&& plates.some((p) => /m|м|′/.test(p.textContent));
|
||||
// …и без Shift размер прилипает к сетке
|
||||
// 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;
|
||||
const snapped = sofaNow();
|
||||
const wCells = (snapped.w * 1000) / PITCH;
|
||||
const hCells = (snapped.h * 1000) / PITCH;
|
||||
// Normalised config is rounded to six decimals, so a mathematical cell 27
|
||||
// may read back as 26.9999999. Compare with the nearest integer, not `% 1`.
|
||||
out.sizeSnapsToTheGrid = near(wCells, Math.round(wCells), 1e-4)
|
||||
&& near(hCells, Math.round(hCells), 1e-4);
|
||||
const proportional = sofaNow();
|
||||
const wCells = (proportional.w * 1000) / PITCH;
|
||||
out.defaultResizePreservesRatio = near(proportional.w / proportional.h, before.w / before.h, 1e-5);
|
||||
out.defaultResizeIsSubGrid = !near(wCells, Math.round(wCells), 1e-4);
|
||||
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
|
||||
await c.updateComplete;
|
||||
out.cornerDragEnded = !c._dtDrag;
|
||||
out.noPlatesAfterTheDrag = !sr().querySelector('.measurelabel.furnmeasure');
|
||||
|
||||
// поворот: шаг 5°, Shift мимо шага — та же механика, что у текста
|
||||
// A middle handle changes only one axis and keeps the opposite edge fixed.
|
||||
const edgeBefore = { ...sofaNow() };
|
||||
const eb = c._dtBox;
|
||||
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;
|
||||
const edgeAfter = sofaNow();
|
||||
out.edgeHandleChangesOneAxisContinuously = near(edgeAfter.h, edgeBefore.h, 1e-9)
|
||||
&& near(edgeAfter.w * 1000, edgeBefore.w * 1000 + 13.7, 1e-6)
|
||||
&& near(edgeAfter.x, edgeBefore.x, 1e-9) && near(edgeAfter.y, edgeBefore.y, 1e-9);
|
||||
ev('pointerup', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
|
||||
await c.updateComplete;
|
||||
|
||||
// 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 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;
|
||||
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);
|
||||
ev('pointercancel', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
out.crossingCancelRestoresTheObject = JSON.stringify(sofaNow()) === JSON.stringify(crossBefore);
|
||||
|
||||
// Furniture rotation is free; Shift snaps to the nearest 45°.
|
||||
await sleep(40); await c.updateComplete;
|
||||
const cx = (sofaNow().x + sofaNow().w / 2) * 1000;
|
||||
const cy = (sofaNow().y + sofaNow().h / 2) * 1000;
|
||||
@@ -376,12 +437,14 @@ const res = await page.evaluate(async () => {
|
||||
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
|
||||
await c.updateComplete;
|
||||
out.snapsTo5 = (sofaNow().angle || 0) === 0;
|
||||
const fine = sofaNow().angle;
|
||||
out.rotationIsFreeWithoutShift = fine > 0.5 && fine < 5;
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const fine = sofaNow().angle;
|
||||
out.shiftGoesPastTheStep = fine > 0.5 && fine < 5;
|
||||
ev('pointerup', stageEl(), cx + 100, cy + 2, { shiftKey: true });
|
||||
out.shiftSnapsRotationTo45 = (sofaNow().angle || 0) === 0;
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 100);
|
||||
await c.updateComplete;
|
||||
ev('pointerup', stageEl(), cx + 100, cy + 100);
|
||||
await c.updateComplete;
|
||||
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
|
||||
|
||||
@@ -403,7 +466,41 @@ const res = await page.evaluate(async () => {
|
||||
out.objectDialogSelectsStoredSymbol = sr().querySelector(
|
||||
'.body select.namein option:checked',
|
||||
)?.value === stored.symbol;
|
||||
c._decorShapeDialog = null;
|
||||
const sizeInputs = [...sr().querySelectorAll('hp-dialog input[type="number"][step="any"]')];
|
||||
const flipInputs = [...sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')];
|
||||
out.furniturePropertiesExposeSignedSizesAndTwoFlips = sizeInputs.length === 2 && flipInputs.length === 2;
|
||||
if (sizeInputs[0]) {
|
||||
sizeInputs[0].value = String(-Math.abs(Number(sizeInputs[0].value)));
|
||||
sizeInputs[0].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
}
|
||||
out.negativeWidthSynchronisesHorizontalCheckbox = !!c._decorShapeDialog?.flipH
|
||||
&& !!sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')[0]?.checked;
|
||||
const currentFlipInputs = [...sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')];
|
||||
if (currentFlipInputs[1]) {
|
||||
currentFlipInputs[1].checked = true;
|
||||
currentFlipInputs[1].dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
}
|
||||
const currentSizes = [...sr().querySelectorAll('hp-dialog input[type="number"][step="any"]')];
|
||||
out.verticalCheckboxSynchronisesNegativeDepth = !!c._decorShapeDialog?.flipV
|
||||
&& Number(currentSizes[1]?.value) < 0;
|
||||
if (currentSizes[0]) {
|
||||
currentSizes[0].value = '0';
|
||||
currentSizes[0].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
}
|
||||
out.zeroSizeBlocksSave = sr().querySelector('hp-dialog button.btn.primary')?.disabled === true
|
||||
&& sr().querySelector('hp-dialog input[type="number"][step="any"]')?.getAttribute('aria-invalid') === 'true';
|
||||
c._decorShapeDialog = {
|
||||
...c._decorShapeDialog,
|
||||
sizeWField: String(-Math.abs(Number(stored.w) * 1000 * CELL / PITCH / 100)),
|
||||
};
|
||||
c._decorSaveShape(); await c.updateComplete;
|
||||
const propertySaved = sofaNow();
|
||||
out.propertySavePersistsPositiveExtentsAndFlags = propertySaved.w > 0 && propertySaved.h > 0
|
||||
&& propertySaved.flip_h === true && propertySaved.flip_v === true
|
||||
&& c._decorShapeDialog === null;
|
||||
|
||||
// ================= 9. инертность и удаление ==============================
|
||||
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
|
||||
|
||||
@@ -7,6 +7,7 @@ const out = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
const toScreen = (x, y) => {
|
||||
@@ -107,6 +108,67 @@ const out = await page.evaluate(async () => {
|
||||
out.escapeClosesPalette = !c._furnPalette && c._decorTool === 'select';
|
||||
out.escapeDetachesListeners = shiftRemoves === addsBeforeEscape;
|
||||
|
||||
// #400 AC1/AC2: на мелкой мебели угловая и осевая ручки перекрываются —
|
||||
// радиус хита один и тот же (1.8 % вида), и на объекте уже, чем 4·hr, круги
|
||||
// пересекаются. Побеждает нарисованный последним. Угол обязан быть сверху:
|
||||
// боком масштабируется одна ось, углом — обе, и мелкая мебель — как раз тот
|
||||
// случай, где пропорциональное изменение нужнее всего.
|
||||
//
|
||||
// Проверяется настоящим pointerdown в геометрический угол рамки:
|
||||
// elementFromPoint через shadow root тут бесполезен (возвращает саму
|
||||
// карточку), а какой обработчик сработал — видно только по вызову.
|
||||
const handleProbe = async (size) => {
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._decorTool = 'select';
|
||||
c._curSpaceCfg.decor = [{ id: 'probe', kind: 'furniture', symbol: 'fridge',
|
||||
x: 0.3, y: 0.3, w: size, h: size, angle: 0, color: '#ff00ff', opacity: 1, width_cm: 8 }];
|
||||
c._cfgEpoch++; c._decorSel = 'probe';
|
||||
c._editorRuntime?._syncDecorFrame?.();
|
||||
c.requestUpdate(); await c.updateComplete; await frame();
|
||||
const box = sr().querySelector('.dtframe');
|
||||
if (!box) return { frame: false };
|
||||
const handles = [...box.querySelectorAll('.dthandle')];
|
||||
const corner = handles.find((el) => !el.classList.contains('dtrot')
|
||||
&& !el.classList.contains('dtedge'));
|
||||
if (!corner) return { frame: true, corner: false };
|
||||
const r = corner.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2;
|
||||
const cy = r.top + r.height / 2;
|
||||
// Кто получит событие в этой точке: считаем нажатия на каждой ручке.
|
||||
let hitEdge = 0; let hitCorner = 0;
|
||||
const mark = (el) => {
|
||||
const isEdge = el.classList.contains('dtedge');
|
||||
const listener = () => { if (isEdge) hitEdge++; else hitCorner++; };
|
||||
el.addEventListener('pointerdown', listener, true);
|
||||
return () => el.removeEventListener('pointerdown', listener, true);
|
||||
};
|
||||
const off = handles.filter((el) => !el.classList.contains('dtrot')).map(mark);
|
||||
const top = sr().elementsFromPoint
|
||||
? sr().elementsFromPoint(cx, cy)
|
||||
: document.elementsFromPoint(cx, cy);
|
||||
const target = top.find((el) => el.classList && el.classList.contains('dthandle'));
|
||||
if (target) {
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: cx, clientY: cy, pointerId: 91, isPrimary: true, button: 0,
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
off.forEach((fn) => fn());
|
||||
c._dtDrag = null;
|
||||
return { frame: true, corner: true, hitCorner, hitEdge,
|
||||
targetIsCorner: !!target && !target.classList.contains('dtedge') };
|
||||
};
|
||||
const small = await handleProbe(0.04); // 40 см на 10-метровом плане
|
||||
const big = await handleProbe(0.16); // 160 см
|
||||
out.smallFurnitureCornerWinsTheHit = small.targetIsCorner === true && small.hitCorner === 1
|
||||
&& small.hitEdge === 0;
|
||||
out.largeFurnitureKeepsBothHandles = big.targetIsCorner === true && big.hitCorner === 1
|
||||
&& big.hitEdge === 0;
|
||||
out.handleProbeDiagnostics = { small, big };
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorSel = null;
|
||||
c._setMode('background'); await c.updateComplete;
|
||||
|
||||
window.addEventListener = addReal;
|
||||
window.removeEventListener = removeReal;
|
||||
// прибрать поставленный диван
|
||||
@@ -116,5 +178,6 @@ const out = await page.evaluate(async () => {
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(out);
|
||||
const { handleProbeDiagnostics, ...checks } = out;
|
||||
checkAll(checks);
|
||||
await finish(browser, out);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -22,10 +21,10 @@ const res = await page.evaluate(async () => {
|
||||
.find((row) => row.textContent.trim() === c._t('gs.wall_group'));
|
||||
out.glowRadiusInsideGlowGroup = !!glowRadius && !!wallGroup
|
||||
&& !!(glowRadius.compareDocumentPosition(wallGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
|
||||
out.aboutVersion = sr().querySelector('hp-dialog .aboutver')?.textContent.trim() ?? null;
|
||||
out.aboutLinks = [...sr().querySelectorAll('hp-dialog a.aboutlink')].map((a) => ({
|
||||
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
|
||||
// #43: About moved into the dedicated Help & Feedback dialog and must not
|
||||
// survive as a duplicate at the bottom of General Settings.
|
||||
out.aboutMovedOut = !sr().querySelector('hp-dialog .aboutver')
|
||||
&& sr().querySelectorAll('hp-dialog a.aboutlink').length === 0;
|
||||
// 2) сменить цвет light_on и сохранить
|
||||
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
|
||||
await c._saveSettingsDialog(); await c.updateComplete;
|
||||
@@ -54,30 +53,12 @@ const res = await page.evaluate(async () => {
|
||||
out.spaceLqiOverridesCardDefault = sr().querySelectorAll('.dev .lqi').length > 0;
|
||||
return out;
|
||||
});
|
||||
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
|
||||
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
|
||||
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
|
||||
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
|
||||
const assetsRoot = new URL('./srv/assets/', import.meta.url);
|
||||
const assetManifest = JSON.parse(readFileSync(new URL('houseplan-assets.json', assetsRoot), 'utf8'));
|
||||
const bundle = assetManifest.files
|
||||
.filter((file) => file.path.endsWith('.js'))
|
||||
.map((file) => readFileSync(new URL(file.path, assetsRoot), 'utf8'))
|
||||
.join('\n');
|
||||
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
|
||||
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
|
||||
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
|
||||
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"rows": 15, // 11 цветов (включая wall_fill) + радиус свечения + фон
|
||||
// + «Оптимизировать планы» (docs/CANVAS.md §9)
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-source glow", "Walls", "Stage background", "Sun", "Backup and transfer", "Plan maintenance", "About"],
|
||||
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
|
||||
"aboutLinks": [
|
||||
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
|
||||
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
|
||||
],
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-source glow", "Walls", "Stage background", "Sun", "Backup and transfer", "Plan maintenance"],
|
||||
"aboutMovedOut": true,
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"newSpaceUsesDaynight": true,
|
||||
"floorImportUsesDaynight": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Browser pixel smoke for the isolated additive group (#19). */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, reportPageErrors } from './serve.mjs';
|
||||
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/glow/additive-pools.json', import.meta.url), 'utf8',
|
||||
@@ -87,20 +87,53 @@ try {
|
||||
.filter((pool) => pool.hasAttribute('clip-path')).length;
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const staticOffCard = document.createElement('houseplan-space-card');
|
||||
staticOffCard.setConfig({
|
||||
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
|
||||
show_button: false,
|
||||
});
|
||||
staticOffCard.hass = hass;
|
||||
document.body.appendChild(staticOffCard);
|
||||
await until(() => staticOffCard.renderRoot?.querySelector('.hp-static-stage'));
|
||||
await staticOffCard.updateComplete;
|
||||
const staticBase = staticOffCard.renderRoot.querySelector('.glow-base-layer .glow-base');
|
||||
const staticOffPools = staticOffCard.renderRoot.querySelectorAll('.glow-pool, .glowlayer').length;
|
||||
const baseParity = !!fullBase && !!staticBase
|
||||
&& fullBase.getAttribute('fill') === staticBase.getAttribute('fill')
|
||||
&& fullBase.getAttribute('fill-opacity') === staticBase.getAttribute('fill-opacity');
|
||||
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({
|
||||
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
|
||||
show_button: false,
|
||||
show_button: false, light_pools: true,
|
||||
});
|
||||
staticCard.hass = hass;
|
||||
document.body.appendChild(staticCard);
|
||||
await until(() => staticCard.renderRoot?.querySelector('.hp-static-stage'));
|
||||
await until(() => staticCard._glowScreenBlend === true, 3000, 'static screen probe');
|
||||
await until(() => staticCard.renderRoot.querySelectorAll('.glow-pool').length === 60,
|
||||
10000, 'static Glow pools');
|
||||
await staticCard.updateComplete;
|
||||
const staticBase = staticCard.renderRoot.querySelector('.glow-base-layer .glow-base');
|
||||
const staticPools = staticCard.renderRoot.querySelectorAll('.glow-pool, .glowlayer').length;
|
||||
const baseParity = !!fullBase && !!staticBase
|
||||
&& fullBase.getAttribute('fill') === staticBase.getAttribute('fill')
|
||||
&& fullBase.getAttribute('fill-opacity') === staticBase.getAttribute('fill-opacity');
|
||||
await frame();
|
||||
const staticPoolGroup = staticCard.renderRoot.querySelector('.glow-pools');
|
||||
const staticPoolNodes = [...staticCard.renderRoot.querySelectorAll('.glow-pool')];
|
||||
const staticClippedPools = staticPoolNodes.filter((pool) => pool.hasAttribute('clip-path')).length;
|
||||
const poolSnapshot = (root, pool) => {
|
||||
const clipId = pool?.getAttribute('clip-path')?.match(/^url\(#(.+)\)$/)?.[1] || '';
|
||||
const clip = [...root.querySelectorAll('clipPath')].find((node) => node.id === clipId);
|
||||
return {
|
||||
source: pool?.parentElement?.getAttribute('data-glow-source') || '',
|
||||
cx: pool?.getAttribute('cx') || '', cy: pool?.getAttribute('cy') || '',
|
||||
r: pool?.getAttribute('r') || '', parts: pool?.getAttribute('data-lit-parts') || '',
|
||||
lit: [...(clip?.querySelectorAll('.glow-lit') || [])].map((path) => path.getAttribute('d') || ''),
|
||||
};
|
||||
};
|
||||
const fullFirst = poolSnapshot(card.renderRoot, card.renderRoot.querySelector('.glow-pool'));
|
||||
const staticFirst = poolSnapshot(staticCard.renderRoot, staticPoolNodes[0]);
|
||||
const staticPointerEvents = getComputedStyle(
|
||||
staticCard.renderRoot.querySelector('.hp-static-stage'),
|
||||
).pointerEvents;
|
||||
staticOffCard.remove();
|
||||
staticCard.remove();
|
||||
|
||||
// The fixture deliberately has Temperature mode without temperature
|
||||
@@ -129,7 +162,10 @@ try {
|
||||
baseLayerOrder: follows(dataTunnel, fullBase)
|
||||
&& follows(fullBase, baseTunnel) && follows(baseTunnel, poolFrame),
|
||||
fullBase: !!fullBase, baseTunnel: !!baseTunnel,
|
||||
staticBase: !!staticBase, staticPools, baseParity,
|
||||
staticBase: !!staticBase, staticOffPools, baseParity,
|
||||
staticPools: staticPoolNodes.length, staticClippedPools,
|
||||
staticBlend: staticPoolGroup?.getAttribute('data-blend'),
|
||||
staticPointerEvents, fullFirst, staticFirst,
|
||||
customFill: customRoom?.style.getPropertyValue('--room-fill'),
|
||||
customOpacity: customRoom?.style.getPropertyValue('--room-fill-op'),
|
||||
customBase: !!customBase, customBaseTunnel: !!customBaseTunnel,
|
||||
@@ -170,13 +206,23 @@ try {
|
||||
if (!close(pixel(out.forward, 3), [106, 123, 140, 255])) throw new Error('non-pool sector/background changed');
|
||||
if (out.clippedPools !== out.pools) throw new Error(`lost per-source clips: ${out.clippedPools}/${out.pools}`);
|
||||
if (!out.baseLayerOrder) throw new Error('data/base/tunnel/pool layer order changed');
|
||||
if (!out.fullBase || !out.baseTunnel || !out.staticBase || out.staticPools !== 0 || !out.baseParity)
|
||||
throw new Error(`missing-data Glow fallback/parity failed: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}, parity=${out.baseParity}`);
|
||||
if (!out.fullBase || !out.baseTunnel || !out.staticBase || out.staticOffPools !== 0 || !out.baseParity)
|
||||
throw new Error(`missing-data Glow fallback/parity failed: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}, offPools=${out.staticOffPools}, parity=${out.baseParity}`);
|
||||
if (out.staticPools !== out.pools || out.staticClippedPools !== out.staticPools)
|
||||
throw new Error(`static pools/clips differ: pools=${out.staticPools}/${out.pools}, clips=${out.staticClippedPools}`);
|
||||
if (out.staticBlend !== 'screen' || out.staticPointerEvents !== 'none')
|
||||
throw new Error(`static Glow composition changed: blend=${out.staticBlend}, pointer=${out.staticPointerEvents}`);
|
||||
if (JSON.stringify(out.staticFirst) !== JSON.stringify(out.fullFirst))
|
||||
throw new Error(`full/static source geometry differs: ${JSON.stringify(out.fullFirst)} vs ${JSON.stringify(out.staticFirst)}`);
|
||||
if (out.customFill !== '#486a8f' || Number(out.customOpacity) !== 0.42)
|
||||
throw new Error(`custom data fill changed: ${out.customFill}/${out.customOpacity}`);
|
||||
if (out.customBase || out.customBaseTunnel || !out.customLayerOrder)
|
||||
throw new Error(`data fill was tinted/reordered by Glow base: full=${out.customBase}, tunnel=${out.customBaseTunnel}, order=${out.customLayerOrder}`);
|
||||
console.log(JSON.stringify({ ok: true, blend: out.blend, pools: out.pools, staticParity: true }));
|
||||
// #407: проверки выше бросают на своей регрессии, но про исключения внутри
|
||||
// карточки не спрашивает ни одна. Бросаем и здесь — тогда `finally` закроет
|
||||
// браузер, а строка успеха не напечатается после «FAILED».
|
||||
if (await reportPageErrors()) throw new Error('uncaught exception inside the card — see EXC above');
|
||||
console.log(JSON.stringify({ ok: true, blend: out.blend, pools: out.pools, staticParity: true, staticPools: out.staticPools }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Issue #239: a finer coordinate grid changes precision, never the visible plan.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { coherentGridScaleStaticPatch } from './grid-scale-static-fixture.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 860 }, 1, [], {
|
||||
reducedMotion: 'reduce',
|
||||
@@ -168,8 +169,16 @@ await page.evaluate(async () => {
|
||||
while (!compact.renderRoot?.querySelector('.hp-static-stage') && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
while ((compact._loading || !compact._snap) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
await compact.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
return {
|
||||
config: card._serverCfg,
|
||||
layout: sharedLayout,
|
||||
revision: card._cfgEpoch,
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
@@ -240,7 +249,21 @@ const capture = async (cellCm, mode, {
|
||||
const pixels = await stableScreenshot(stage);
|
||||
let staticPixels = null;
|
||||
if (staticCard) {
|
||||
await page.evaluate(() => window.__makeStaticGridCard());
|
||||
const source = await page.evaluate(() => window.__makeStaticGridCard());
|
||||
const patch = coherentGridScaleStaticPatch(source);
|
||||
await page.evaluate(async (nextPatch) => {
|
||||
const compact = document.querySelector('#grid-static-host houseplan-space-card');
|
||||
if (!compact) throw new Error('grid-scale static fixture card disappeared');
|
||||
if (!compact._snap) throw new Error('grid-scale static fixture did not load its base snapshot');
|
||||
// Preserve runtime-only Set/Map values from the in-page snapshot. Only
|
||||
// config+layout cross the Playwright boundary; both come from one scale.
|
||||
compact._snap = { ...compact._snap, ...nextPatch };
|
||||
compact._loadedOnce = true;
|
||||
compact._refreshDevices();
|
||||
compact.requestUpdate();
|
||||
await compact.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
}, patch);
|
||||
staticPixels = await stableScreenshot(staticStage());
|
||||
}
|
||||
return { metrics, pixels, staticPixels };
|
||||
@@ -262,7 +285,14 @@ const pixelDiff = async (left, right) => page.evaluate(async ([a, b]) => {
|
||||
const decode = async (base64) => createImageBitmap(await (await fetch(`data:image/png;base64,${base64}`)).blob());
|
||||
const [first, second] = await Promise.all([decode(a), decode(b)]);
|
||||
if (first.width !== second.width || first.height !== second.height) {
|
||||
return { sameSize: false, changed: Infinity, maxDelta: Infinity, meanDelta: Infinity };
|
||||
return {
|
||||
sameSize: false,
|
||||
firstSize: [first.width, first.height],
|
||||
secondSize: [second.width, second.height],
|
||||
changed: Infinity,
|
||||
maxDelta: Infinity,
|
||||
meanDelta: Infinity,
|
||||
};
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = first.width; canvas.height = first.height;
|
||||
|
||||
@@ -71,9 +71,22 @@ const out = await page.evaluate(async () => {
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mkDelta(137.7 - p0.x + OFF, 251.3 - p0.y + OFF), dev);
|
||||
c._drag = null;
|
||||
const devNode = sr().querySelector(`.dev[data-id="${dev.id}"]`);
|
||||
const devRect = devNode.getBoundingClientRect();
|
||||
const stageRect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const deviceView = c._viewOr(c._baseVb());
|
||||
const startX = devRect.left + devRect.width / 2;
|
||||
const startY = devRect.top + devRect.height / 2;
|
||||
const dxPx = ((137.7 - p0.x + OFF) / deviceView.w) * stageRect.width;
|
||||
const dyPx = ((251.3 - p0.y + OFF) / deviceView.h) * stageRect.height;
|
||||
const devicePointer = (type, x, y, buttons) => devNode.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 174, pointerType: 'mouse', button: 0, buttons,
|
||||
clientX: x, clientY: y, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
devicePointer('pointerdown', startX, startY, 1);
|
||||
devicePointer('pointermove', startX + dxPx, startY + dyPx, 1);
|
||||
devicePointer('pointerup', startX + dxPx, startY + dyPx, 0);
|
||||
while (c._devicePositionBusy) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
|
||||
|
||||
// ---- 1c) a ROOM LABEL too ---------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Issue #68: contextual help trigger parity, dialog overlay ownership and the
|
||||
// real non-Popover fallback. Run by the prerelease smoke suite, not on edits.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { launch, launchColdView, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 390, height: 780 }, 1);
|
||||
await page.keyboard.press('Tab');
|
||||
@@ -252,9 +252,200 @@ const res = await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
out.secondEscapeClosesDialog = !card._markerDialog;
|
||||
|
||||
// #86 Party 1: all general-settings placements are real help controls and
|
||||
// opening one of them cannot mutate its associated setting.
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
const generalKeys = ['gs.glow_radius.help', 'gs.bg_mode.help', 'gs.north.help'];
|
||||
out.party1GeneralInventory = generalKeys.every((key) => !!helpButton(key));
|
||||
const generalBefore = JSON.stringify({
|
||||
glowRadius: card._settingsDialog?.glowRadius,
|
||||
bgMode: card._settingsDialog?.bgMode,
|
||||
northDeg: card._settingsDialog?.northDeg,
|
||||
});
|
||||
helpButton('gs.bg_mode.help')?.click();
|
||||
await frame();
|
||||
out.party1GeneralHelpIsReadOnly = generalBefore === JSON.stringify({
|
||||
glowRadius: card._settingsDialog?.glowRadius,
|
||||
bgMode: card._settingsDialog?.bgMode,
|
||||
northDeg: card._settingsDialog?.northDeg,
|
||||
});
|
||||
card._settingsDialog = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
// The regular editor and cold-install onboarding render separate space
|
||||
// dialog templates; this smoke covers the editor copy while the source/i18n
|
||||
// contract covers both implementations.
|
||||
card._openSpaceDialog('edit', card._space);
|
||||
await card.updateComplete;
|
||||
const spaceKeys = [
|
||||
'space.cell_cm.help', 'space.zero_wall_style.help', 'space.bg_mode.help',
|
||||
'space.north.help', 'space.fill_mode.help',
|
||||
];
|
||||
out.party1SpaceInventory = spaceKeys.every((key) => !!helpButton(key));
|
||||
const spaceBefore = JSON.stringify({
|
||||
cellCm: card._spaceDialog?.cellCm,
|
||||
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
|
||||
bgMode: card._spaceDialog?.bgMode,
|
||||
northDeg: card._spaceDialog?.northDeg,
|
||||
fillMode: card._spaceDialog?.fillMode,
|
||||
});
|
||||
helpButton('space.zero_wall_style.help')?.click();
|
||||
await frame();
|
||||
out.party1SpaceHelpIsReadOnly = spaceBefore === JSON.stringify({
|
||||
cellCm: card._spaceDialog?.cellCm,
|
||||
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
|
||||
bgMode: card._spaceDialog?.bgMode,
|
||||
northDeg: card._spaceDialog?.northDeg,
|
||||
fillMode: card._spaceDialog?.fillMode,
|
||||
});
|
||||
card._spaceDialog = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
card._openDeviceInbox();
|
||||
await card.updateComplete;
|
||||
const hiddenBefore = card._showHidden;
|
||||
const hiddenHelpButton = helpButton('device_inbox.show_hidden.help');
|
||||
hiddenHelpButton?.click();
|
||||
await frame();
|
||||
const hiddenSurface = helpSurface('device_inbox.show_hidden.help');
|
||||
const hiddenBox = hiddenSurface?.getBoundingClientRect();
|
||||
out.party1ShowHiddenHelpIsReadOnly = !!hiddenHelpButton
|
||||
&& card._showHidden === hiddenBefore;
|
||||
out.party1ShowHiddenHelpInsideNarrowViewport = !!hiddenBox
|
||||
&& hiddenBox.left >= (visualViewport?.offsetLeft || 0)
|
||||
&& hiddenBox.top >= (visualViewport?.offsetTop || 0)
|
||||
&& hiddenBox.right <= (visualViewport?.offsetLeft || 0) + (visualViewport?.width || innerWidth)
|
||||
&& hiddenBox.bottom <= (visualViewport?.offsetTop || 0) + (visualViewport?.height || innerHeight);
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
// Empty-install onboarding owns a separate lazy runtime and a second copy of
|
||||
// the space dialog. Exercise that production chunk, not merely the regular
|
||||
// editor template or its source inventory (#86 review r1 M1).
|
||||
const onboarding = await launchColdView({ width: 390, height: 780 });
|
||||
await onboarding.page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card._onboardingShown = false;
|
||||
card._serverCfg = { ...card._serverCfg, spaces: [] };
|
||||
card._model = [];
|
||||
card.hass = { ...card.hass, floors: {}, areas: {} };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
});
|
||||
await onboarding.page.waitForFunction(() => {
|
||||
const card = window.__card;
|
||||
return card._onboardingRuntime && !card._editorRuntime
|
||||
&& card.renderRoot.querySelector('hp-dialog');
|
||||
});
|
||||
const onboardingHelp = await onboarding.page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
const keys = [
|
||||
'space.cell_cm.help', 'space.zero_wall_style.help', 'space.bg_mode.help',
|
||||
'space.north.help', 'space.fill_mode.help',
|
||||
];
|
||||
const help = (key) => root.querySelector(`hp-help[data-help-key="${key}"]`);
|
||||
const before = JSON.stringify({
|
||||
cellCm: card._spaceDialog?.cellCm,
|
||||
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
|
||||
bgMode: card._spaceDialog?.bgMode,
|
||||
northDeg: card._spaceDialog?.northDeg,
|
||||
fillMode: card._spaceDialog?.fillMode,
|
||||
});
|
||||
const zeroWallHelp = help('space.zero_wall_style.help');
|
||||
zeroWallHelp?.shadowRoot?.querySelector('.trigger')?.click();
|
||||
await zeroWallHelp?.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const after = JSON.stringify({
|
||||
cellCm: card._spaceDialog?.cellCm,
|
||||
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
|
||||
bgMode: card._spaceDialog?.bgMode,
|
||||
northDeg: card._spaceDialog?.northDeg,
|
||||
fillMode: card._spaceDialog?.fillMode,
|
||||
});
|
||||
return {
|
||||
inventory: keys.every((key) => !!help(key)?.shadowRoot?.querySelector('.trigger')),
|
||||
selectedHelpOpened: zeroWallHelp?.shadowRoot
|
||||
?.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true',
|
||||
readOnly: before === after,
|
||||
editorStillLazy: !!card._onboardingRuntime && !card._editorRuntime,
|
||||
};
|
||||
});
|
||||
res.party1ColdOnboardingInventory = onboardingHelp.inventory;
|
||||
res.party1ColdOnboardingHelpOpens = onboardingHelp.selectedHelpOpened;
|
||||
res.party1ColdOnboardingHelpIsReadOnly = onboardingHelp.readOnly;
|
||||
res.party1ColdOnboardingKeepsEditorLazy = onboardingHelp.editorStillLazy;
|
||||
await onboarding.browser.close();
|
||||
|
||||
// Browser zoom at 200% on a 780 px-wide display exposes a 390 CSS px viewport
|
||||
// at DPR 2. Playwright cannot change Chromium's toolbar preference, so create
|
||||
// that exact renderer contract directly; unlike CSS `zoom`, this preserves the
|
||||
// coordinate space used by fixed/popover surfaces and catches real clipping.
|
||||
const zoomed = await launch({ width: 390, height: 900 }, 2);
|
||||
const zoomedLayout = await zoomed.page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
const help = root.querySelector('hp-help[data-help-key="gs.bg_mode.help"]');
|
||||
const trigger = help?.shadowRoot?.querySelector('.trigger');
|
||||
const stage = root.querySelector('.stage');
|
||||
const dialogBody = root.querySelector('hp-dialog .body');
|
||||
const stageRect = () => {
|
||||
const box = stage?.getBoundingClientRect();
|
||||
return box ? [box.left, box.top, box.width, box.height].map((value) => Math.round(value)) : null;
|
||||
};
|
||||
const settleStage = async () => {
|
||||
let previous = null;
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
await frame();
|
||||
const current = stageRect();
|
||||
if (JSON.stringify(current) === JSON.stringify(previous)) return current;
|
||||
previous = current;
|
||||
}
|
||||
return previous;
|
||||
};
|
||||
trigger?.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
await frame();
|
||||
const before = await settleStage();
|
||||
trigger?.click();
|
||||
await help?.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const triggerBox = trigger?.getBoundingClientRect();
|
||||
const surface = help?.shadowRoot?.querySelector('.tooltip')
|
||||
|| root.querySelector('hp-dialog')?.shadowRoot
|
||||
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
|
||||
const surfaceBox = surface?.getBoundingClientRect();
|
||||
const viewport = window.visualViewport;
|
||||
const left = viewport?.offsetLeft || 0;
|
||||
const top = viewport?.offsetTop || 0;
|
||||
const right = left + (viewport?.width || innerWidth);
|
||||
const bottom = top + (viewport?.height || innerHeight);
|
||||
const inside = (box) => !!box && box.width > 0 && box.height > 0
|
||||
&& box.left >= left && box.top >= top && box.right <= right && box.bottom <= bottom;
|
||||
return {
|
||||
zoomApplied: devicePixelRatio === 2 && innerWidth === 390,
|
||||
triggerInside: inside(triggerBox),
|
||||
tooltipInside: inside(surfaceBox),
|
||||
noHorizontalDialogOverflow: !!dialogBody
|
||||
&& dialogBody.scrollWidth <= dialogBody.clientWidth + 1,
|
||||
stageStable: JSON.stringify(await settleStage()) === JSON.stringify(before),
|
||||
};
|
||||
});
|
||||
res.party1BrowserZoom200Applied = zoomedLayout.zoomApplied;
|
||||
res.party1BrowserZoom200TriggerInside = zoomedLayout.triggerInside;
|
||||
res.party1BrowserZoom200TooltipInside = zoomedLayout.tooltipInside;
|
||||
res.party1BrowserZoom200NoHorizontalOverflow = zoomedLayout.noHorizontalDialogOverflow;
|
||||
res.party1BrowserZoom200StageStable = zoomedLayout.stageStable;
|
||||
await zoomed.browser.close();
|
||||
|
||||
res.keyboardFocus = keyboardFocus;
|
||||
|
||||
checkAll(res);
|
||||
|
||||
@@ -79,10 +79,10 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
const virtualB = c._devices.find((x) => x.virtual && x.name === 'delete-one-b');
|
||||
c._openMarkerDialog(virtualA); await c.updateComplete;
|
||||
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
|
||||
const savedConfirm = window.confirm;
|
||||
window.confirm = () => true;
|
||||
const savedConfirmDanger = c._confirmDanger;
|
||||
c._confirmDanger = async () => true;
|
||||
await c._deleteMarker(); await c.updateComplete;
|
||||
window.confirm = savedConfirm;
|
||||
c._confirmDanger = savedConfirmDanger;
|
||||
o.deleteOnlySelectedVirtual = !!virtualA && !!virtualB
|
||||
&& !(c._serverCfg.markers || []).some((m) => m.id === virtualA.id)
|
||||
&& (c._serverCfg.markers || []).some((m) => m.id === virtualB.id);
|
||||
@@ -93,9 +93,9 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
const oldPos = { s: readd.space, x: 0.913, y: 0.917 };
|
||||
c._layout = { ...c._layout, [readd.id]: oldPos };
|
||||
c._openMarkerDialog(readd); await c.updateComplete;
|
||||
window.confirm = () => true;
|
||||
c._confirmDanger = async () => true;
|
||||
await c._deleteMarker(); await c.updateComplete;
|
||||
window.confirm = savedConfirm;
|
||||
c._confirmDanger = savedConfirmDanger;
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
c._markerDialog = {
|
||||
...c._markerDialog,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Asserts device icon badges sit exactly on their anchor point (no content-box/border drift).
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, reportPageErrors } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 2);
|
||||
const bad = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
@@ -24,5 +24,8 @@ const bad = await page.evaluate(() => {
|
||||
return bad;
|
||||
});
|
||||
await browser.close();
|
||||
// #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан
|
||||
// именно остановить: иначе строка успеха печатается после «FAILED».
|
||||
if (await reportPageErrors()) process.exit(1);
|
||||
if (bad.length) { console.error('FAIL icon-center: badges off anchor', JSON.stringify(bad.slice(0, 5))); process.exit(1); }
|
||||
console.log('OK icon-center: all device badges centred on their anchor');
|
||||
|
||||