Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa97e7e0bc | ||
|
|
72eae1059c | ||
|
|
11abc0292f | ||
|
|
c447e41d4d | ||
|
|
c22b39e8fc | ||
|
|
842f9dc30e | ||
|
|
9daa2e91fd | ||
|
|
6fdb7dce1a | ||
|
|
7af6d742b9 | ||
|
|
040db9ad12 | ||
|
|
48bcdafab9 | ||
|
|
6731691ad8 | ||
|
|
2310e6d88a | ||
|
|
424e613c6f | ||
|
|
517a7101e7 | ||
|
|
a84338b042 | ||
|
|
2d5fec09d6 | ||
|
|
5ce3ceeca7 | ||
|
|
ef22b236f8 | ||
|
|
a797752c89 | ||
|
|
833e8e5472 | ||
|
|
9b05dd598d | ||
|
|
cd17a0b00f | ||
|
|
3a68efa62f | ||
|
|
2dd1731cc4 | ||
|
|
e158f8fdcc | ||
|
|
edf532e217 | ||
|
|
56e01148f8 | ||
|
|
d31ad3c562 | ||
|
|
5c09591ce9 | ||
|
|
5dc9016645 | ||
|
|
6f89002e3a | ||
|
|
4c8ba981e7 | ||
|
|
15dc8adc4f | ||
|
|
ad8e7a50cc | ||
|
|
f287bddd97 | ||
|
|
5ff80f3bdc | ||
|
|
e6016a966f | ||
|
|
142bd611b1 | ||
|
|
1290927f10 | ||
|
|
84e62dcd0f | ||
|
|
f7b811a621 | ||
|
|
950403debd | ||
|
|
009fed9bc0 | ||
|
|
10c0f3c95a | ||
|
|
f7abf14abd | ||
|
|
8b8b9ed90d | ||
|
|
7b759f316b | ||
|
|
b583d663e3 | ||
|
|
88a28775e7 | ||
|
|
cd029a0415 | ||
|
|
bf83246b7b | ||
|
|
4089c912c6 | ||
|
|
0b411dd802 | ||
|
|
07b0b3dec2 | ||
|
|
2ef32417bd | ||
|
|
016c75f539 | ||
|
|
71d369a295 | ||
|
|
f66e671b89 | ||
|
|
41e2cfffc6 | ||
|
|
a0c4c1cdd8 | ||
|
|
edfea67ddd | ||
|
|
57ba75b9da | ||
|
|
804b282f5f | ||
|
|
a356ec29ab | ||
|
|
67bf85e7d2 | ||
|
|
2453ec0d7f | ||
|
|
fb265282cc | ||
|
|
c9a83af50e | ||
|
|
f08c4adabe | ||
|
|
6e93aa705c | ||
|
|
dd2e0e9b08 | ||
|
|
d01d0926be | ||
|
|
74d19d4d14 | ||
|
|
56f31dc199 | ||
|
|
db57180956 | ||
|
|
bd9409b33d | ||
|
|
b203e8faeb | ||
|
|
63249ffd97 | ||
|
|
327c35f606 | ||
|
|
fe7b28f3a7 | ||
|
|
19e92e0cc0 | ||
|
|
9bde4b1a6d | ||
|
|
1bf90ee0d8 | ||
|
|
fcee724638 | ||
|
|
f69ac71ef7 | ||
|
|
3540d24f18 | ||
|
|
25ea8fefab | ||
|
|
9ec3636a42 | ||
|
|
07d0c2ef86 | ||
|
|
053007414d | ||
|
|
fc22d9a6c5 | ||
|
|
fbbaed22de | ||
|
|
29ce5d9e65 | ||
|
|
3a8aae06d1 | ||
|
|
a75c729d87 | ||
|
|
a55ba3de8b | ||
|
|
2f0dc44f27 | ||
|
|
1c3404fdd0 | ||
|
|
3e335b8808 | ||
|
|
e2bb90b59b | ||
|
|
a80fa1fa5e | ||
|
|
0e53b1b0d6 | ||
|
|
fa015907d4 | ||
|
|
5d04e9b7c2 | ||
|
|
89789d8fa6 | ||
|
|
6846ffb828 | ||
|
|
c8755b7c51 | ||
|
|
fe15d863ce | ||
|
|
299da593d5 | ||
|
|
32e3a79dcd | ||
|
|
ffb10843c9 | ||
|
|
5fb510290a | ||
|
|
932773773f | ||
|
|
6c7958c6d0 | ||
|
|
a449edc545 | ||
|
|
e88c23b8ee | ||
|
|
acad3b32c1 | ||
|
|
b443a333e0 | ||
|
|
66fa8f476c | ||
|
|
047363c2d3 | ||
|
|
1e8503bd46 | ||
|
|
135497b272 | ||
|
|
0c2a5dedea | ||
|
|
c1676cf26a | ||
|
|
c65cbcc96c | ||
|
|
01fe48de00 | ||
|
|
9baf533c90 | ||
|
|
563a850aac | ||
|
|
9e5ff0b8a0 | ||
|
|
3fe0f8c443 | ||
|
|
742b3279a2 | ||
|
|
9f77e3e932 | ||
|
|
083621342a | ||
|
|
4f20befd77 | ||
|
|
2aaabc48d6 | ||
|
|
b9bf210804 | ||
|
|
d6007dc444 | ||
|
|
54c5ca3840 | ||
|
|
57fc434d4f | ||
|
|
9530ee2e5a |
@@ -132,9 +132,17 @@ jobs:
|
||||
- name: Install candidate and baseline dependencies
|
||||
run: npm ci --prefix candidate && npm ci --prefix baseline
|
||||
|
||||
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
working-directory: candidate
|
||||
run: npx playwright install --with-deps chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build both exact source trees
|
||||
run: |
|
||||
|
||||
@@ -299,16 +299,20 @@ jobs:
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
|
||||
Low либо правится, либо снимается с записью. Жёлтый вердикт
|
||||
допустим при полностью выполненных AC, если изменение не решает
|
||||
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
|
||||
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
|
||||
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
|
||||
же — без High это жёлтый вердикт и возврат автору, отдельный issue
|
||||
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
|
||||
обслуживание issue дороже правки на месте); Low либо правится,
|
||||
либо снимается с записью. Жёлтый вердикт допустим и при полностью
|
||||
выполненных AC, если изменение не решает заявленный сценарий или
|
||||
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
|
||||
отменяет AC и не даёт права менять скоуп.
|
||||
|
||||
Каждую Medium-находку заведи отдельным issue со ссылкой на
|
||||
#${{ github.event.issue.number }} и метками: тип, приоритет,
|
||||
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
|
||||
запрещено §12.
|
||||
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
|
||||
поведения, который в этой ветке чинить нельзя) заведи отдельным
|
||||
issue со ссылкой на #${{ github.event.issue.number }} и метками:
|
||||
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
|
||||
считается и прямо запрещено §12.
|
||||
|
||||
Напиши полный документ ревью в файл
|
||||
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
|
||||
@@ -319,7 +323,8 @@ jobs:
|
||||
|
||||
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
|
||||
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
|
||||
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
|
||||
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
|
||||
|
||||
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
|
||||
без него метка не переставится и конвейер встанет.
|
||||
|
||||
@@ -116,6 +116,98 @@ jobs:
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
|
||||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||||
# же входами уже завершилась успешно.
|
||||
#
|
||||
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
|
||||
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
|
||||
# здесь эквивалентность входов доказана хешем.
|
||||
#
|
||||
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
|
||||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
golden: ${{ steps.probe.outputs.golden }}
|
||||
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
|
||||
backend: ${{ steps.probe.outputs.backend }}
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
id: m_smoke
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер golden
|
||||
id: m_golden
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ steps.keys.outputs.golden }}
|
||||
lookup-only: true
|
||||
- name: Маркер performance_smoke
|
||||
id: m_perf
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ steps.keys.outputs.backend }}
|
||||
lookup-only: true
|
||||
- name: Что переиспользуем
|
||||
id: probe
|
||||
env:
|
||||
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
|
||||
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
|
||||
PERF: ${{ steps.m_perf.outputs.cache-hit }}
|
||||
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
|
||||
run: |
|
||||
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
|
||||
# успех, который уже дважды стоил нам дня (#171, #207).
|
||||
waive() {
|
||||
if [ "$2" = "true" ]; then
|
||||
echo "$1=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
|
||||
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$1=false" >> "$GITHUB_OUTPUT"
|
||||
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
}
|
||||
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
|
||||
waive smoke "$SMOKE"
|
||||
waive golden "$GOLDEN"
|
||||
waive performance_smoke "$PERF"
|
||||
waive backend "$BACKEND"
|
||||
|
||||
hacs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
@@ -160,7 +252,8 @@ jobs:
|
||||
|
||||
smoke:
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -169,8 +262,20 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build a fresh bundle for the smokes
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Smoke suite
|
||||
@@ -194,11 +299,28 @@ jobs:
|
||||
with:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
|
||||
|
||||
golden:
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.golden != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -207,8 +329,20 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the exact source under review
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture or verify golden matrix
|
||||
@@ -227,13 +361,33 @@ jobs:
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "golden прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
|
||||
performance_smoke:
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: frontend
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -241,8 +395,20 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the exact candidate source
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture the heaviest Glow state
|
||||
@@ -257,10 +423,26 @@ jobs:
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "performance_smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
|
||||
backend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -273,3 +455,19 @@ jobs:
|
||||
- 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
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "backend прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
|
||||
|
||||
@@ -255,10 +255,15 @@ The exchange happens in **issue comments** — there is no local message bus. Ve
|
||||
format:
|
||||
|
||||
```text
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
|
||||
```
|
||||
|
||||
High blocks. Medium must become its own issue. Low is fixed or waived with a note
|
||||
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
|
||||
with no High findings the verdict is yellow, the author fixes it and the fix
|
||||
passes another review cycle — no separate issue (owner's decision 2026-08-19,
|
||||
#202: filing and servicing an issue costs far more than fixing in place). Only
|
||||
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
|
||||
never patched from this branch. Low is fixed or waived with a note
|
||||
in the review document. A yellow verdict is legitimate even when every acceptance
|
||||
criterion passes, if the change does not solve the stated scenario or degrades a
|
||||
neighbouring one.
|
||||
@@ -359,8 +364,12 @@ fingerprint is a hard failure, not a warning.
|
||||
|
||||
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
|
||||
success` run for the candidate's SHA, not "the last green one"; a new push cancels
|
||||
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
|
||||
an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
|
||||
@@ -131,8 +131,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
|
||||
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
|
||||
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
|
||||
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
|
||||
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
|
||||
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
|
||||
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
|
||||
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
|
||||
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
|
||||
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
|
||||
Low либо правится, либо снимается решением ревьюера с записью.
|
||||
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
|
||||
4 циклов (§4).
|
||||
|
||||
@@ -187,7 +191,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **High блокируют.** Medium **обязаны** превратиться в issue.
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4).
|
||||
|
||||
@@ -231,8 +237,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
ревью-гейт.
|
||||
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
|
||||
отклонить или арбитраж (§4).
|
||||
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
|
||||
решением ревьюера с записью в документе.
|
||||
8. **High блокирует. Medium в скоупе чинится в текущем issue** (без High —
|
||||
жёлтый вердикт и повторный цикл); Medium вне скоупа становится отдельным
|
||||
issue (#202). Low либо правится, либо снимается решением ревьюера с записью
|
||||
в документе.
|
||||
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
@@ -445,7 +453,8 @@ issue #NN
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
|
||||
High: N · Medium: N → #… · Документ: docs/reviews/…`
|
||||
High: N · Medium: N → в задаче | #… · Документ: docs/reviews/…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору)
|
||||
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
|
||||
|
||||
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
|
||||
@@ -686,8 +695,8 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
```
|
||||
|
||||
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
|
||||
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
|
||||
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
|
||||
вердикту, а не модель.**
|
||||
|
||||
@@ -811,7 +820,8 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
|
||||
- закрытие issue до выпуска беты с зелёным CI;
|
||||
- переоткрытие закрытого issue вместо нового бага;
|
||||
- Medium-находки, оставленные как TODO в документе ревью;
|
||||
- Medium-находки, оставленные как TODO в документе ревью: в скоупе — чинятся
|
||||
в текущем issue, вне скоупа — становятся отдельным (#202);
|
||||
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
|
||||
- ревью-документы вне репозитория;
|
||||
- попутные правки «раз уж я здесь»;
|
||||
|
||||
@@ -46,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.65.0-beta.1"
|
||||
VERSION = "1.65.0-beta.6"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
|
||||
@@ -48,9 +48,11 @@ from .validation import (
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
)
|
||||
|
||||
FORMAT = "houseplan-export"
|
||||
@@ -264,7 +266,8 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
_pick_fields(
|
||||
opening,
|
||||
("id", "type", "x", "y", "angle", "length")
|
||||
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v")),
|
||||
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v"))
|
||||
+ (("host",) if opening.get("host") else ()),
|
||||
)
|
||||
for opening in space.get("openings") or []
|
||||
]
|
||||
@@ -840,6 +843,16 @@ def build_space_merge(
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("open_to"):
|
||||
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
|
||||
# Opening ownership is part of the same space-local id graph. Remap the
|
||||
# nested reference together with the partition itself; otherwise the
|
||||
# invariant validator correctly rejects a copied space whose host.id still
|
||||
# names the source partition.
|
||||
for opening in space.get("openings") or []:
|
||||
host = opening.get("host") if isinstance(opening, dict) else None
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
old_host_id = str(host.get("id"))
|
||||
if old_host_id in id_map:
|
||||
host["id"] = id_map[old_host_id]
|
||||
space["id"] = new_space_id
|
||||
space["title"] = _unique_title(
|
||||
str(space.get("title") or old_space_id), current_config.get("spaces") or []
|
||||
@@ -980,7 +993,11 @@ def build_space_merge(
|
||||
validate_marker_light_entities(merged_config, current_config)
|
||||
validate_marker_value_badges(merged_config, current_config)
|
||||
validate_opening_passages(merged_config, current_config)
|
||||
except (MarkerControlError, OpeningPassageError) as err:
|
||||
validate_partition_opening_hosts(merged_config, current_config)
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
try:
|
||||
merged_layout = LAYOUT_SCHEMA(merged_layout)
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.65.0-beta.1"
|
||||
"version": "1.65.0-beta.6"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -24,11 +25,33 @@ import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
|
||||
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
|
||||
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
|
||||
"""Whether an ended current run may be reopened for this point.
|
||||
|
||||
Store timestamps are untrusted persisted data. Only finite JSON-number
|
||||
timestamps and a non-negative inclusive grace interval are accepted;
|
||||
malformed values and wall-clock rollback fail closed into a new run.
|
||||
"""
|
||||
if not isinstance(run, dict) or run.get("map_id") != map_id:
|
||||
return False
|
||||
ended = run.get("ended")
|
||||
if (
|
||||
isinstance(ended, bool)
|
||||
or not isinstance(ended, (int, float))
|
||||
or isinstance(now, bool)
|
||||
or not isinstance(now, (int, float))
|
||||
):
|
||||
return False
|
||||
elapsed = now - ended
|
||||
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
|
||||
|
||||
|
||||
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
|
||||
"""Map-id normalisation contract, shared with the frontend.
|
||||
|
||||
@@ -64,7 +87,10 @@ class TrailBook:
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
|
||||
if resumed:
|
||||
cur["ended"] = None
|
||||
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
@@ -73,7 +99,9 @@ class TrailBook:
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
return False
|
||||
# Clearing ended is observable state even if the source repeats
|
||||
# the dock point: it must still reach Store and live cards.
|
||||
return resumed
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
@@ -85,7 +113,7 @@ class TrailBook:
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and not cur.get("ended"):
|
||||
if cur and cur.get("ended") is None:
|
||||
cur["ended"] = now
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -46,6 +46,112 @@ class OpeningPassageError(ValueError):
|
||||
)
|
||||
|
||||
|
||||
class PartitionOpeningHostError(ValueError):
|
||||
"""A write tried to strip explicit host identity from a surviving opening."""
|
||||
|
||||
code = "invalid_partition_opening_host"
|
||||
|
||||
|
||||
class PartitionOpeningJambMarginError(ValueError):
|
||||
"""A direct geometry write leaves no physical jamb at a wall endpoint."""
|
||||
|
||||
code = "invalid_partition_opening_jamb_margin"
|
||||
|
||||
def __init__(
|
||||
self, space_id: str, opening_id: str, margin: float, margin_cm: float
|
||||
) -> None:
|
||||
self.space_id = space_id
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
# One normalized canvas width contains this many physical grid cells. Keep in
|
||||
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
|
||||
# user setting.
|
||||
NORMALIZED_CANVAS_CELLS = 240.0
|
||||
|
||||
|
||||
def validate_partition_opening_hosts(
|
||||
config: dict, previous: dict | None = None
|
||||
) -> None:
|
||||
"""Validate hosted-opening write deltas without rejecting legacy reads.
|
||||
|
||||
Deleting the opening together with its partition remains valid. Surviving
|
||||
records keep their host, while new/direct geometry changes reserve half the
|
||||
actual wall depth at both endpoints. Rigid host translation and unrelated
|
||||
edits round-trip existing near-end records unchanged.
|
||||
"""
|
||||
old_spaces = {
|
||||
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
|
||||
}
|
||||
for space in config.get("spaces") or []:
|
||||
space_id = str(space.get("id", ""))
|
||||
old_space = old_spaces.get(space_id)
|
||||
old_openings = {
|
||||
str(opening.get("id")): opening
|
||||
for opening in (old_space or {}).get("openings") or []
|
||||
}
|
||||
partitions = {
|
||||
str(partition.get("id")): partition
|
||||
for partition in space.get("partitions") or []
|
||||
}
|
||||
old_partitions = {
|
||||
str(partition.get("id")): partition
|
||||
for partition in (old_space or {}).get("partitions") or []
|
||||
}
|
||||
for opening in space.get("openings") or []:
|
||||
opening_id = str(opening.get("id", ""))
|
||||
old = old_openings.get(opening_id)
|
||||
if old and old.get("host") is not None and opening.get("host") is None:
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; host removed"
|
||||
)
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
partition = partitions.get(str(host.get("id", "")))
|
||||
if partition is None:
|
||||
# SPACE_SCHEMA owns missing-host diagnostics.
|
||||
continue
|
||||
old_host = (old or {}).get("host")
|
||||
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
old_span = None
|
||||
if old_partition is not None:
|
||||
old_ax, old_ay = old_partition["a"]
|
||||
old_bx, old_by = old_partition["b"]
|
||||
old_span = ((old_bx - old_ax) ** 2 + (old_by - old_ay) ** 2) ** 0.5
|
||||
strict = (
|
||||
old is None
|
||||
or old_host is None
|
||||
or old_host.get("id") != host.get("id")
|
||||
or old_host.get("t") != host.get("t")
|
||||
or old.get("length") != opening.get("length")
|
||||
or old_partition is None
|
||||
or old_partition.get("cm") != partition.get("cm")
|
||||
or abs(old_span - span) > 1e-9
|
||||
)
|
||||
if not strict:
|
||||
continue
|
||||
cell_cm = float(space.get("cell_cm", 5))
|
||||
margin_cm = float(partition["cm"]) / 2
|
||||
margin = margin_cm / cell_cm / NORMALIZED_CANVAS_CELLS
|
||||
along = float(host["t"]) * span
|
||||
half = float(opening["length"]) / 2
|
||||
if (along - half < margin - 1e-9
|
||||
or along + half > span - margin + 1e-9):
|
||||
raise PartitionOpeningJambMarginError(
|
||||
space_id, opening_id, margin, margin_cm
|
||||
)
|
||||
|
||||
|
||||
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
|
||||
|
||||
|
||||
@@ -767,6 +873,15 @@ WALL_COLUMN_SCHEMA = vol.All(
|
||||
_strict_wall_column,
|
||||
)
|
||||
|
||||
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("kind"): vol.Equal("partition"),
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def _space_geometry_invariants(value: dict) -> dict:
|
||||
"""All stored geometry shares ids; draft segments also have a space cap."""
|
||||
@@ -784,6 +899,29 @@ def _space_geometry_invariants(value: dict) -> dict:
|
||||
)
|
||||
if draft_segments > MAX_DRAFT_SEGMENTS:
|
||||
raise vol.Invalid("too many saved room-draft segments")
|
||||
partitions = {
|
||||
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
|
||||
}
|
||||
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
|
||||
for opening in value.get("openings", []):
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
partition = partitions.get(host["id"])
|
||||
if partition is None:
|
||||
raise vol.Invalid("partition opening host must exist in the same space")
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
length = float(opening["length"])
|
||||
along = float(host["t"]) * span
|
||||
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
|
||||
raise vol.Invalid("partition opening must fit inside its host")
|
||||
lo, hi = along - length / 2, along + length / 2
|
||||
occupied = hosted_intervals.setdefault(host["id"], [])
|
||||
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
|
||||
raise vol.Invalid("partition openings must not overlap")
|
||||
occupied.append((lo, hi))
|
||||
return value
|
||||
|
||||
|
||||
@@ -849,6 +987,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Optional("invert"): bool,
|
||||
vol.Optional("flip_h"): bool,
|
||||
vol.Optional("flip_v"): bool,
|
||||
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
@@ -57,8 +57,9 @@ from .virtual_lights import (
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError, sanitize_filename,
|
||||
validate_opening_passages,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
)
|
||||
@@ -1256,7 +1257,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
except (MarkerControlError, OpeningPassageError) as err:
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
@@ -1367,7 +1372,11 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
except (MarkerControlError, OpeningPassageError) as err:
|
||||
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -1,72 +1,89 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 24,
|
||||
"acceptedAt": "2026-08-17T10:51:28.998Z",
|
||||
"sourceFingerprint": "b73e5020215967d2d13e2a08ce303916e2bdbcbd45c94c40c6e56336a8097656",
|
||||
"matrixVersion": 32,
|
||||
"acceptedAt": "2026-08-19T20:33:45.393Z",
|
||||
"sourceFingerprint": "c80008b7bfa2031d1b7f7931429102e39c8267c144c34d948a617b96dba5f40e",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
|
||||
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
|
||||
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
|
||||
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
|
||||
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
|
||||
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
|
||||
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
|
||||
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
|
||||
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
|
||||
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
|
||||
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
|
||||
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
|
||||
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
|
||||
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
|
||||
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
|
||||
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
|
||||
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
|
||||
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
|
||||
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
|
||||
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
|
||||
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
|
||||
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
|
||||
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
|
||||
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
|
||||
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
|
||||
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
|
||||
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
|
||||
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
|
||||
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
|
||||
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
|
||||
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
|
||||
"isometric-live-layers-dark": "5173bbd51f31cb7aff42dbe68dd63596a7f5436beabdf626b9159661fae5a8b7",
|
||||
"isometric-no-borders-dark": "7bf00cd6c5832a72b85f891a3b3e029910d2ef48c33b286a907fddfd90db6e07",
|
||||
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
|
||||
"isometric-large-warm-remount-dark": "5a0b0a27aae68c0da18a43237f366fbe41282f376aa3b75c097df928a0c64f05",
|
||||
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
|
||||
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
|
||||
"room-label-parity-view-dark": "8351de36e484a5932997a4ca45aa681cf28313a2e669eb9c967058871a2e4b67",
|
||||
"room-label-parity-plan-dark": "e782f0386a6b1e75c7bb9f135e9ec01a9ff4b540f0384d518c7ea9f2198683ea",
|
||||
"room-label-parity-view-light": "ac6d507c2868d4ab0eb772d42c45df89888b956c73759f2e8514b7976ec9fce6",
|
||||
"room-label-parity-plan-light": "84afb43102cebb53bede8d2af2dac43506c0a7c69ca329df2cf5dc57ab3e1dbd",
|
||||
"washer-active-cycle-dark": "9db6bea75edf51c851f557119519e44e5121c98d77d6e60b4c515c313117331b",
|
||||
"washer-idle-cycle-dark": "5ecf8f0f298ee13b056d69cf49276215f231a518f8aae1c429bde1548c9f1d05",
|
||||
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
|
||||
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
|
||||
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
|
||||
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
|
||||
"geometry-plan-editor-dark": "a3828bdd877c23d283dcd30dc88287c2104c8d39e849aa826a76190d7daa30ae",
|
||||
"plan-snap-endpoint-light": "2df0bd2abcb615d6bd1a4422b29747f95a1edd7b02d4ae3a6b333e24b27e9f78",
|
||||
"plan-snap-line-gaps-dark": "a29d42ae49a0a513dc36e933bdd2b5f3cb97aa40b0e7ec897c602fe65a5a5fe8",
|
||||
"wall-junctions-plan-preview-light": "cdb96edabd5b04a47e5026eed4255e06b802bd8ba7f0c7ba2cab66c4ae2f371a",
|
||||
"wall-junctions-plan-t-dark": "ec2b29ca87bba9b41e4c22ce94d2c4fe06c6d9c0833152d3a6224371763ae6bc",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "78f36b340606e239d36dcb4b28433694d4ed4cc9c632f28c9bb3ebdc78311790",
|
||||
"junction-patch-resilience-view-dark": "d531f2a01def73fb074e55543c8543112af158df65f24dc549e0da5a23f1af40",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "c398f53391bc697e859ba389bac0ce586526d6f14c85f32d9a4b314bebe902ab",
|
||||
"opening-placement-passage-thick-wall-dark": "98bb63f72886986d71d598fffcaa4fb8e7dce3e7f3a27b5afb6012f9eac4537c",
|
||||
"opening-placement-passage-thick-wall-light": "2f19063a0e792c7bec92f3e4069739954dcdd79eb4933355a575aec5463b9ade",
|
||||
"geometry-devices-editor-dark": "2ec2647797c71f6c6406f6e33fcbf70d5cb6d36a01a65c70ecabc7ad11e86183",
|
||||
"geometry-decor-editor-dark": "46798a59d99982c4e73596861e20b6e466f6e7aa1d70a26bf917e1bd3bf73cb1",
|
||||
"tray-wide-selection-en": "457b53c4e5ae5f691457f98f3c2fd255e5b7ed1c599af92c303c93c11f87b7d4",
|
||||
"tray-wide-tool-ru": "1fcc25ee374c95f51c71b33720daa37f82a92ccd52119b8bdb48de5484591e49",
|
||||
"tray-medium-group-en": "3bebd852929ff67cff11827d09c4859e32e17fa0be36fcabd4a591c4728a4827",
|
||||
"tray-medium-selection-ru": "e40dcdadf3ebdbec200175dc6648382daa91a232d081c8add1f6646c7d1a0ee5",
|
||||
"tray-narrow-palette-en": "861adc403fbf0aff1e45b27fc07c4856f3ca409b58ef87cc4a81d870a09dbb9a",
|
||||
"tray-narrow-tool-ru": "ccf11295b104cecd4bf1e1bc95417003e2c40cfba29b5555ac9ba021b90d16a6",
|
||||
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
|
||||
"openings-thick-wall-dark": "eb5d093c4b1a96282afb7dd6079b0ce4724c93f14015e2e32b4e4522b4001366",
|
||||
"openings-filled-tunnel-dark": "76d047978bf8a33f49b3d1eb385f4d16d2a0cb57e6cd6afcb31d17cb1c39fe40",
|
||||
"openings-hidden-view-dark": "155ee4838ca1b603eb4ea8865e32f1265d2b83dc6c280190aaac93a526d3fe06",
|
||||
"lighting-glow-sun-dark": "8d2365a82c1d33bcf15a14185dcd608ad6e43f600b4ca3f9014ceacb09996fbf",
|
||||
"device-value-badge-positions-dark": "b8b7415e1e40256d328003badb21a8f1346c6b174a1b2680479243378a4b5ec2",
|
||||
"device-icon-state-table-light": "5c0839b476052b50e48ccd4381941d1c94bf43f11d9d8368d3912395db156e4c",
|
||||
"device-icon-state-table-dark": "98d56b5e810a96291529e3f64379fa9337dab20bad0fd0e4909a8822d2fdf8b8",
|
||||
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
|
||||
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
|
||||
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
|
||||
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
|
||||
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
|
||||
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
|
||||
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
|
||||
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
|
||||
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
|
||||
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
|
||||
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
|
||||
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
|
||||
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
|
||||
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
|
||||
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
|
||||
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
|
||||
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
|
||||
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
|
||||
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
|
||||
"backup-plan-only-export-desktop-en": "2833ee45acac2936e76546e9ff5d0031932c02fbf66fe904c1be94dc3e33dc05",
|
||||
"lighting-fill-light-axis-split-dark": "48d9de651a7974e34e21a7eaa66416f7fff8f2f2b56afd35918a9e0f8eda1152",
|
||||
"lighting-fill-temp-axis-split-dark": "890e0f168ca71e505b82b62620d0d25fe2848d7cc15d58b0b6b42e34f59950f9",
|
||||
"lighting-fill-lqi-axis-split-dark": "d808545d529e2179282662c8ef1a258d90a28c71bd90daa6e5b4fdf1e99cd5d2",
|
||||
"lighting-temp-glow-dark": "07bdcc9028f502e46d3c7d899d0ebc7ee55a8215f76592ebb584db7be7a486b2",
|
||||
"lighting-temp-glow-light": "0f15bf8a6222130916b95fa2cc97d62ee39020266b0010026212451f5ca5eded",
|
||||
"lighting-custom-glow-dark": "8ae8fcafd8913fc70c71d3634feae914963e3ceb918becc92c1e5b7875b24fe9",
|
||||
"lighting-opaque-glow-two-doorways-dark": "288880fab3b7bac3c8664e0d4eb3743ff416be23630ecf5d2c8365093abc208b",
|
||||
"lighting-custom-glow-light": "2f4c027bae8da9ac7f87a8ef99af26f9ad1dbde062a3c041582aaffdeb3ed3e8",
|
||||
"lighting-temp-glow-no-sources-dark": "e188aaab4e019b9f18c72b1e2e4dc2fd2ed3d48f956fb1ba97d080ec2053eedb",
|
||||
"lighting-temp-glow-room-override-dark": "f5b1abf84ca35da41302f72ccb333c168de0c602451f700e5769dd382f035731",
|
||||
"lighting-manual-auto-spill-overlap-dark": "18494c809465c26d03286d41326ff10274cad490a3babe415cfe3a462aebd80d",
|
||||
"hover-over-glow-dark": "8758d1525a8a2d1eef42e7f01ad121a233dab1e422b60af1a46a13955312571d",
|
||||
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
|
||||
"large-house-zoom-040-dark": "e94562031dab35d9aa9cb6b50c8cc7e04855db4e9aeee735222284d450ed254d",
|
||||
"large-house-zoom-250-dark": "493f5d361504b7653168ab0bb41c592b72db22013eb9ed0e1fb5c4688f8e9830",
|
||||
"large-house-warm-remount-dark": "01ada4b9d8ea5daef221b5b95e1c98296a155cf1b51c3c1915c667cf4d485830",
|
||||
"device-dialog-desktop-en": "01fde8cd44f0b229e8416f3e89e9fc8985a5ab83451a8ba172f5ed5724e0c1c6",
|
||||
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
|
||||
"toggle-entity-dialog-desktop-en": "80ac0d11106990707ab5624193d1180161b5436bde0e55a6eb61c71e382d88c2",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "9c3e3a9b498c4169c8aee8b2595d017a4b5c78822ad983198ca55dd856672566",
|
||||
"decor-color-popover-mobile-ru": "af002e4c3106c008bbdb58ed1f00d8f8415dc86a8dfab2552defb4d16d8bbe71",
|
||||
"decor-color-popover-desktop-en": "50f8816484d1767d8c0eabf117151a7ff2be75272b3509d9a13221855ad02b72",
|
||||
"general-color-popover-desktop-en": "da85d0159802afce90fd87ecf940ef1b97ffd57c27fbdc79469cc11082b9a482",
|
||||
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
|
||||
"space-room-color-popover-desktop-ru": "aac2b1c6ba7126d5e0942c8f3e3c62d378b3b39bcf8eb2f61dd8e28efeb983c4",
|
||||
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
|
||||
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
|
||||
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
@@ -1,5 +1,10 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const junctionPatchFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
|
||||
));
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
? makeLargeHouseFixture()
|
||||
@@ -139,11 +144,43 @@ export function prepareGoldenFixture(scenario) {
|
||||
wall_columns: [],
|
||||
});
|
||||
}
|
||||
if (scenario.junctionPatchResilience) {
|
||||
fixture.config.spaces.push({
|
||||
...structuredClone(junctionPatchFixture),
|
||||
id: scenario.space,
|
||||
title: 'Junction patch resilience',
|
||||
view_box: [0, 0, 1, 1],
|
||||
settings: {
|
||||
...(junctionPatchFixture.settings || {}),
|
||||
fill_mode: 'none',
|
||||
show_borders: true,
|
||||
show_names: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
const requireSpace = () => {
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
return space;
|
||||
};
|
||||
if (scenario.roomLabelParity) {
|
||||
const space = requireSpace();
|
||||
if (space.id !== 'golden-lighting' || space.rooms.length !== 2)
|
||||
throw new Error(`golden roomLabelParity requires the two-room lighting fixture: ${space.id}`);
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
show_names: true,
|
||||
label_temp: true,
|
||||
label_hum: true,
|
||||
label_lqi: true,
|
||||
label_light: true,
|
||||
};
|
||||
fixture.layout = {
|
||||
...(fixture.layout || {}),
|
||||
'rl_light-left': { s: space.id, x: 0.28, y: 0.24 },
|
||||
'rl_light-right': { s: space.id, x: 0.72, y: 0.24 },
|
||||
};
|
||||
}
|
||||
if (scenario.deviceName) {
|
||||
if (!scenario.deviceId || !fixture.devices?.[scenario.deviceId])
|
||||
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
|
||||
@@ -276,9 +313,13 @@ export function prepareGoldenFixture(scenario) {
|
||||
|
||||
export async function prepareGoldenScenario(page, scenario) {
|
||||
await stableEnvironment(page, scenario);
|
||||
// Scenarios share one Playwright page, including its pointer position. A
|
||||
// scenario that deliberately hovers a marker must not leave the next one
|
||||
// capturing an unrelated room hover at the same viewport coordinates.
|
||||
await page.mouse.move(0, 0);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
|
||||
return page.evaluate(async ({ fixture, scenario }) => {
|
||||
const result = await page.evaluate(async ({ fixture, scenario }) => {
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -380,6 +421,14 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
}
|
||||
if (scenario.roomLabelParity) {
|
||||
const labels = [...card.renderRoot.querySelectorAll('.roomlabel')];
|
||||
if (labels.length !== 2
|
||||
|| labels.some((label) => !label.querySelector('.rlgo') || !label.querySelector('.rlmetrics'))
|
||||
|| labels.some((label) => !label.querySelector('.rlmetrics')?.textContent?.trim())) {
|
||||
throw new Error(`golden room-label parity core is incomplete: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.projection === 'iso' && typeof card._setProjection === 'function') {
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
@@ -416,7 +465,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const { tool, anchor, pointer, expectedKind } = scenario.planSnap;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!['draw', 'partition'].includes(tool) || !validPoint(pointer)
|
||||
if (tool !== 'draw' || !validPoint(pointer)
|
||||
|| (anchor != null && !validPoint(anchor))
|
||||
|| !['endpoint', 'line'].includes(expectedKind)) {
|
||||
throw new Error(`invalid golden planSnap contract: ${scenario.id}`);
|
||||
@@ -447,7 +496,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
}
|
||||
if (scenario.openingPreview) {
|
||||
const { type, pointer } = scenario.openingPreview;
|
||||
if (!['window', 'door', 'gate'].includes(type)
|
||||
if (!['window', 'door', 'passage', 'gate'].includes(type)
|
||||
|| !Array.isArray(pointer) || pointer.length !== 2
|
||||
|| !pointer.every(Number.isFinite)) {
|
||||
throw new Error(`invalid golden openingPreview: ${scenario.id}`);
|
||||
@@ -471,7 +520,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const preview = card.renderRoot.querySelector(`.opening-preview[data-kind="${type}"]`);
|
||||
if (!preview || !preview.querySelector('.op-leaf')) {
|
||||
const expectedGeometry = type === 'passage'
|
||||
? !!preview?.querySelector('.passage-preview-cut')
|
||||
&& preview.querySelectorAll('.passage-preview-boundary').length === 2
|
||||
: !!preview?.querySelector('.op-leaf');
|
||||
if (!preview || !expectedGeometry) {
|
||||
const intervals = card._openingPlacementIntervalsCache?.value || [];
|
||||
const nearest = intervals.map((interval) => {
|
||||
const [px, py] = card._cursorPt || [0, 0];
|
||||
@@ -663,6 +716,68 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
if (!trigger) throw new Error('golden decor color trigger missing');
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
} else if (scenario.dialog === 'general-color') {
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const picker = [...(dialog?.querySelectorAll('hp-color-opacity') || [])]
|
||||
.find((item) => item.label === card._t('gs.light_on'));
|
||||
await picker?.updateComplete;
|
||||
const trigger = picker?.renderRoot?.querySelector('.trigger');
|
||||
if (!trigger) throw new Error('golden general-settings color trigger missing');
|
||||
trigger.scrollIntoView({ block: 'center' });
|
||||
await frame();
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
} else if (scenario.dialog === 'device-ripple-color') {
|
||||
card._setMode('devices');
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
const device = card._devices.find((item) => item.id === scenario.deviceId);
|
||||
if (!device) throw new Error(`golden ripple device missing: ${scenario.deviceId}`);
|
||||
card._openMarkerDialog(device);
|
||||
card._markerDialog = { ...card._markerDialog, display: 'icon_ripple' };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const picker = [...(dialog?.querySelectorAll('hp-color-opacity') || [])]
|
||||
.find((item) => item.label === card._t('marker.activity_color'));
|
||||
await picker?.updateComplete;
|
||||
const trigger = picker?.renderRoot?.querySelector('.trigger');
|
||||
if (!trigger) throw new Error('golden ripple color trigger missing');
|
||||
trigger.scrollIntoView({ block: 'center' });
|
||||
await frame();
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
} else if (scenario.dialog === 'space-room-color') {
|
||||
card._openSpaceDialog('edit', scenario.space);
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const picker = [...(dialog?.querySelectorAll('hp-color-opacity') || [])]
|
||||
.find((item) => item.label === card._t('space.room_color'));
|
||||
await picker?.updateComplete;
|
||||
const trigger = picker?.renderRoot?.querySelector('.trigger');
|
||||
if (!trigger) throw new Error('golden space room-color trigger missing');
|
||||
trigger.scrollIntoView({ block: 'center' });
|
||||
await frame();
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
}
|
||||
if (scenario.deviceClassOverrides) {
|
||||
for (const [id, classes] of Object.entries(scenario.deviceClassOverrides)) {
|
||||
const marker = card.renderRoot.querySelector(`[data-hp="device"][data-id="${CSS.escape(id)}"]`);
|
||||
if (!marker || !Array.isArray(classes) || !classes.length) {
|
||||
throw new Error(`invalid golden device class override: ${scenario.id}/${id}`);
|
||||
}
|
||||
marker.classList.add(...classes);
|
||||
}
|
||||
}
|
||||
if (scenario.focusDevice) {
|
||||
const marker = card.renderRoot.querySelector(
|
||||
`[data-hp="device"][data-id="${CSS.escape(scenario.focusDevice)}"]`,
|
||||
);
|
||||
if (!marker) throw new Error(`golden focus device missing: ${scenario.focusDevice}`);
|
||||
marker.focus({ focusVisible: true });
|
||||
}
|
||||
if (scenario.dayCycle) {
|
||||
const environment = card.renderRoot.querySelector('.hp-day-cycle-env');
|
||||
@@ -697,6 +812,27 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
} } : {}),
|
||||
};
|
||||
}, { fixture, scenario });
|
||||
if (scenario.hoverDevice) {
|
||||
const point = await page.evaluate((id) => {
|
||||
const marker = window.__goldenCard?.renderRoot?.querySelector(
|
||||
`[data-hp="device"][data-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
if (!marker) return null;
|
||||
const rect = marker.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}, scenario.hoverDevice);
|
||||
if (!point) throw new Error(`golden hover device missing: ${scenario.hoverDevice}`);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
if (scenario.hideHoverTooltip) {
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__goldenCard;
|
||||
card._tip = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function goldenClip(page, capture) {
|
||||
|
||||
@@ -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 = 26;
|
||||
export const GOLDEN_MATRIX_VERSION = 32;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -44,6 +44,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'room-label-parity-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
roomLabelParity: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'room-label-parity-plan-dark', fixture: 'visual', space: 'golden-lighting', mode: 'plan',
|
||||
roomLabelParity: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'room-label-parity-view-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
roomLabelParity: true, theme: 'light', viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'room-label-parity-plan-light', fixture: 'visual', space: 'golden-lighting', mode: 'plan',
|
||||
roomLabelParity: true, theme: 'light', viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'washer-active-cycle-dark', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-appliance', mode: 'view',
|
||||
stateOverrides: { 'sensor.golden_washer_status': { state: 'start' } },
|
||||
@@ -93,6 +101,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'wall-junctions-view-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'junction-patch-resilience-plan-dark', fixture: 'visual',
|
||||
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'junction-patch-resilience-view-dark', fixture: 'visual',
|
||||
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
@@ -104,6 +118,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] },
|
||||
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'opening-placement-passage-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'plan', openingPreview: { type: 'passage', pointer: [0.48, 0.65] },
|
||||
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'opening-placement-passage-thick-wall-light', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'plan', openingPreview: { type: 'passage', pointer: [0.48, 0.65] },
|
||||
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
|
||||
@@ -175,6 +197,57 @@ 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 },
|
||||
...['light', 'dark'].map((theme) => ({
|
||||
id: `device-icon-state-table-${theme}`, fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
glowEnabled: false, sunRays: false,
|
||||
markerOverrides: [
|
||||
{ id: 'golden-light-one', binding: 'device:golden-light-one' },
|
||||
{ id: 'golden-light-two', binding: 'device:golden-light-two' },
|
||||
{ id: 'golden-light-three', binding: 'device:golden-light-three' },
|
||||
{ id: 'golden-presence', binding: 'device:golden-presence' },
|
||||
{ id: 'golden-climate', binding: 'device:golden-climate' },
|
||||
{ id: 'golden-left-temperature', binding: 'device:golden-left-temperature', display: 'value' },
|
||||
{ id: 'golden-right-temperature', binding: 'device:golden-right-temperature', display: 'static_icon' },
|
||||
{ id: 'golden-left-linkquality', binding: 'device:golden-left-linkquality', value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.golden_left_linkquality' },
|
||||
position: 'right',
|
||||
} },
|
||||
{ id: 'golden-right-linkquality', binding: 'device:golden-right-linkquality', value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.golden_right_linkquality' },
|
||||
position: 'bottom',
|
||||
} },
|
||||
],
|
||||
stateOverrides: {
|
||||
'light.golden_light_one': { attributes: { lqi: 40 } },
|
||||
'light.golden_light_two': { attributes: { lqi: 41 } },
|
||||
'light.golden_light_three': { attributes: { lqi: 180 } },
|
||||
'sensor.golden_left_temperature': { state: 'Complete long localized state' },
|
||||
'sensor.golden_right_temperature': { state: 'unavailable' },
|
||||
},
|
||||
layoutOverrides: {
|
||||
'golden-light-one': { s: 'golden-lighting', x: 0.16, y: 0.25 },
|
||||
'golden-light-two': { s: 'golden-lighting', x: 0.34, y: 0.25 },
|
||||
'golden-light-three': { s: 'golden-lighting', x: 0.66, y: 0.25 },
|
||||
'golden-presence': { s: 'golden-lighting', x: 0.84, y: 0.25 },
|
||||
'golden-climate': { s: 'golden-lighting', x: 0.16, y: 0.72 },
|
||||
'golden-left-temperature': { s: 'golden-lighting', x: 0.34, y: 0.72 },
|
||||
'golden-right-temperature': { s: 'golden-lighting', x: 0.66, y: 0.72 },
|
||||
'golden-left-linkquality': { s: 'golden-lighting', x: 0.77, y: 0.55 },
|
||||
'golden-right-linkquality': { s: 'golden-lighting', x: 0.84, y: 0.72 },
|
||||
},
|
||||
deviceClassOverrides: {
|
||||
'golden-light-two': ['lock-locked'],
|
||||
'golden-light-three': ['lock-unlocked'],
|
||||
'golden-presence': ['virtual'],
|
||||
'golden-climate': ['alarm', 'sel'],
|
||||
},
|
||||
focusDevice: 'golden-climate',
|
||||
hoverDevice: 'golden-presence',
|
||||
hideHoverTooltip: true,
|
||||
theme, viewport: { width: 1000, height: 900 }, ...stage,
|
||||
})),
|
||||
{ id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
// The golden screenshot is backed by a second, sun-layer-hidden capture.
|
||||
// A real painted ray must account for enough changed pixels; DOM-only
|
||||
@@ -269,6 +342,18 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'decor-color', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'decor-color-popover-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'decor-color', language: 'en', theme: 'light',
|
||||
viewport: { width: 760, height: 820 }, ...page },
|
||||
{ id: 'general-color-popover-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'general-color', language: 'en', theme: 'light',
|
||||
viewport: { width: 900, 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 },
|
||||
{ id: 'space-room-color-popover-desktop-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'space-room-color', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 900, height: 1000 }, ...page },
|
||||
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-full', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, ...page },
|
||||
|
||||
@@ -63,7 +63,11 @@ const res = await page.evaluate(async () => {
|
||||
};
|
||||
c._openSettingsDialog();
|
||||
await c.updateComplete;
|
||||
out.dialogHasBgRow = !!sr().querySelector('hp-dialog .colorrow input[type=color]');
|
||||
const settingsDialog = sr().querySelector('hp-dialog');
|
||||
const bgPicker = [...settingsDialog.querySelectorAll('hp-color-opacity')]
|
||||
.find((picker) => picker.label === c._t('gs.bg_color'));
|
||||
out.dialogHasBgRow = !!bgPicker && bgPicker.showOpacity === false
|
||||
&& !settingsDialog.querySelector('input[type=color]');
|
||||
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
|
||||
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
|
||||
await c._saveSettingsDialog();
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true },
|
||||
);
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (card._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await card.updateComplete;
|
||||
};
|
||||
|
||||
[...root().querySelectorAll('.modetab')][2]?.click();
|
||||
await settleMode();
|
||||
card._decorTool = 'line';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
const picker = root().querySelector('.editor-secondary hp-color-opacity');
|
||||
const trigger = picker?.shadowRoot?.querySelector('.trigger');
|
||||
const events = [];
|
||||
picker?.addEventListener('hp-color-opacity-change', (event) => events.push(event.detail));
|
||||
trigger?.click();
|
||||
await picker?.updateComplete;
|
||||
await frame();
|
||||
const surface = () => picker?.shadowRoot?.querySelector('.picker');
|
||||
const ranges = () => [...(surface()?.querySelectorAll('input[type="range"]') || [])];
|
||||
|
||||
out.oneSurfaceNoNativePicker = !!surface()
|
||||
&& surface().getAttribute('role') === 'dialog'
|
||||
&& !surface().querySelector('input[type="color"]');
|
||||
out.allDimensionsVisible = !!surface()?.querySelector('.sv-field')
|
||||
&& ranges().length === 4
|
||||
&& !!surface()?.querySelector('input[type="text"]')
|
||||
&& !!surface()?.querySelector('input[type="number"]');
|
||||
out.englishLabels = surface()?.getAttribute('aria-label') === 'Color picker'
|
||||
&& ranges().map((input) => input.getAttribute('aria-label')).join('|')
|
||||
=== 'Hue|Saturation|Brightness|Opacity';
|
||||
const rect = surface()?.getBoundingClientRect();
|
||||
out.narrowViewportFits = !!rect && rect.left >= 0 && rect.right <= innerWidth
|
||||
&& rect.top >= 0 && rect.bottom <= innerHeight
|
||||
&& surface().scrollWidth <= surface().clientWidth;
|
||||
|
||||
const hue = ranges()[0];
|
||||
const hueStyle = getComputedStyle(hue);
|
||||
out.hueTrackContract = hue?.classList.contains('hue-range')
|
||||
&& hue.min === '0' && hue.max === '359' && hue.step === '1'
|
||||
&& hueStyle.getPropertyValue('--hp-picker-hue-track').includes('linear-gradient')
|
||||
&& hue.getBoundingClientRect().height >= 40;
|
||||
const thumbShadow = hueStyle.getPropertyValue('--hp-picker-hue-thumb-shadow');
|
||||
out.hueThumbContrastContract = thumbShadow.includes('0 0 0 2px')
|
||||
&& thumbShadow.includes('0 0 0 3px');
|
||||
ranges()[1].value = '100';
|
||||
ranges()[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
ranges()[2].value = '100';
|
||||
ranges()[2].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
hue.value = '120';
|
||||
hue.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await picker.updateComplete;
|
||||
out.hueUpdatesSharedDraft = picker.color === '#00ff00'
|
||||
&& card._decorStyle.color === '#00ff00';
|
||||
|
||||
const beforeShift = Number(ranges()[0].value);
|
||||
ranges()[0].dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'ArrowRight', shiftKey: true, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await picker.updateComplete;
|
||||
out.shiftArrowUsesTenStep = Math.round(picker._hue) === beforeShift + 10;
|
||||
|
||||
const opacity = ranges().at(-1);
|
||||
opacity.value = '37';
|
||||
opacity.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await picker.updateComplete;
|
||||
out.opacitySharesTheSameSurface = picker.opacity === 0.37
|
||||
&& card._decorStyle.opacity === 0.37;
|
||||
|
||||
let hex = surface().querySelector('input[type="text"]');
|
||||
hex.value = '#0af';
|
||||
hex.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await picker.updateComplete;
|
||||
out.shortHexNormalizesAndEmits = picker.color === '#00aaff'
|
||||
&& events.at(-1)?.color === '#00aaff';
|
||||
|
||||
const eventCount = events.length;
|
||||
hex = surface().querySelector('input[type="text"]');
|
||||
hex.value = '#12';
|
||||
hex.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
hex.dispatchEvent(new FocusEvent('blur'));
|
||||
await picker.updateComplete;
|
||||
hex = surface().querySelector('input[type="text"]');
|
||||
out.invalidHexNeverEmits = events.length === eventCount
|
||||
&& hex.value === '#00aaff' && hex.getAttribute('aria-invalid') === 'true';
|
||||
|
||||
const field = surface().querySelector('.sv-field');
|
||||
const fieldRect = field.getBoundingClientRect();
|
||||
const eventsBeforePointer = events.length;
|
||||
field.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
pointerId: 41, pointerType: 'touch', clientX: fieldRect.left + fieldRect.width * .25,
|
||||
clientY: fieldRect.top + fieldRect.height * .25, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
field.dispatchEvent(new PointerEvent('pointermove', {
|
||||
pointerId: 41, pointerType: 'touch', clientX: fieldRect.right + 20,
|
||||
clientY: fieldRect.bottom + 20, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
field.dispatchEvent(new PointerEvent('pointercancel', {
|
||||
pointerId: 41, pointerType: 'touch', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await frame();
|
||||
out.pointerCancelAddsNoEvent = events.length === eventsBeforePointer + 1
|
||||
&& events.at(-1)?.color === picker.color
|
||||
&& picker._activePointerId === null;
|
||||
|
||||
picker.showOpacity = false;
|
||||
await picker.updateComplete;
|
||||
out.colorOnlyKeepsColorControls = ranges().length === 3
|
||||
&& !surface().querySelector('input[type="number"]');
|
||||
picker.showOpacity = true;
|
||||
await picker.updateComplete;
|
||||
|
||||
card._config = { ...card._config, language: 'ru' };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await picker.updateComplete;
|
||||
out.cardLanguageOwnsCopy = surface()?.getAttribute('aria-label') === 'Выбор цвета'
|
||||
&& surface()?.querySelector('input[type="text"]')?.getAttribute('aria-label') === 'Цвет HEX';
|
||||
|
||||
const escapeControl = surface()?.querySelector('input[type="range"]');
|
||||
escapeControl?.focus();
|
||||
escapeControl?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await picker.updateComplete;
|
||||
await frame();
|
||||
out.escapeClosesFirstAndRefocuses = trigger?.getAttribute('aria-expanded') === 'false'
|
||||
&& picker.shadowRoot.activeElement === trigger;
|
||||
|
||||
picker.disabled = true;
|
||||
await picker.updateComplete;
|
||||
trigger?.click();
|
||||
await picker.updateComplete;
|
||||
out.disabledDoesNotOpen = trigger?.getAttribute('aria-expanded') === 'false';
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -0,0 +1,123 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 390, height: 1000 });
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const change = (picker, detail) => picker.dispatchEvent(new CustomEvent(
|
||||
'hp-color-opacity-change', { detail, bubbles: true, composed: true },
|
||||
));
|
||||
const pickerByLabel = (dialog, label) => [...dialog.querySelectorAll('hp-color-opacity')]
|
||||
.find((picker) => picker.label === label);
|
||||
const nativeColors = () => root().querySelectorAll('input[type="color"]').length;
|
||||
|
||||
card._openSettingsDialog();
|
||||
card._settingsDialog = { ...card._settingsDialog, bgMode: 'static', bgColor: null };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
let dialog = root().querySelector('hp-dialog');
|
||||
const generalPickers = [...dialog.querySelectorAll('hp-color-opacity')];
|
||||
const lightOn = pickerByLabel(dialog, card._t('gs.light_on'));
|
||||
const tempCold = pickerByLabel(dialog, card._t('gs.temp_cold'));
|
||||
const globalBg = pickerByLabel(dialog, card._t('gs.bg_color'));
|
||||
out.generalSettingsUsesTwelveUnifiedPickers = generalPickers.length === 12
|
||||
&& generalPickers.filter((picker) => picker.showOpacity).length === 11
|
||||
&& globalBg?.showOpacity === false
|
||||
&& nativeColors() === 0;
|
||||
const previousTemp = { ...card._settingsDialog.colors.temp_cold };
|
||||
change(lightOn, { color: '#123456', opacity: 0.37 });
|
||||
await card.updateComplete;
|
||||
out.generalColorAndOpacityStayAtomic = card._settingsDialog.colors.light_on.c === '#123456'
|
||||
&& card._settingsDialog.colors.light_on.a === 0.37
|
||||
&& JSON.stringify(card._settingsDialog.colors.temp_cold) === JSON.stringify(previousTemp);
|
||||
|
||||
lightOn.renderRoot.querySelector('.trigger').click();
|
||||
await lightOn.updateComplete;
|
||||
await frame();
|
||||
tempCold.renderRoot.querySelector('.trigger').click();
|
||||
await tempCold.updateComplete;
|
||||
await frame();
|
||||
out.generalPickersAreExclusive = lightOn.renderRoot.querySelector('.trigger')
|
||||
?.getAttribute('aria-expanded') === 'false'
|
||||
&& tempCold.renderRoot.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true';
|
||||
tempCold.renderRoot.querySelector('.trigger').click();
|
||||
await tempCold.updateComplete;
|
||||
|
||||
const originalGlobalBg = card._settingsDialog.bgColor;
|
||||
globalBg.renderRoot.querySelector('.trigger').click();
|
||||
await globalBg.updateComplete;
|
||||
globalBg.renderRoot.querySelector('.trigger').click();
|
||||
await globalBg.updateComplete;
|
||||
out.globalBackgroundOpenCloseDoesNotMaterialize = originalGlobalBg === null
|
||||
&& card._settingsDialog.bgColor === null;
|
||||
change(globalBg, { color: '#0a2a4a', opacity: 0.12 });
|
||||
await card.updateComplete;
|
||||
dialog = root().querySelector('hp-dialog');
|
||||
const defaultButton = [...dialog.querySelectorAll('button')]
|
||||
.find((button) => button.textContent.trim() === card._t('gs.bg_default'));
|
||||
const explicitGlobalBg = card._settingsDialog.bgColor;
|
||||
defaultButton?.click();
|
||||
await card.updateComplete;
|
||||
out.globalBackgroundIgnoresAlphaAndResets = explicitGlobalBg === '#0a2a4a'
|
||||
&& card._settingsDialog.bgColor === null;
|
||||
|
||||
card._settingsDialog = null;
|
||||
card._setMode('devices');
|
||||
card._openMarkerDialog();
|
||||
card._markerDialog = { ...card._markerDialog, display: 'icon_ripple', rippleColor: '', rippleSize: 4.5 };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
dialog = root().querySelector('hp-dialog');
|
||||
const ripple = pickerByLabel(dialog, card._t('marker.activity_color'));
|
||||
out.rippleUsesColorOnlyPicker = !!ripple && ripple.showOpacity === false
|
||||
&& ripple.color === '#3ea6ff' && nativeColors() === 0;
|
||||
const rippleLabelBox = ripple?.renderRoot.querySelector('.label')?.getBoundingClientRect();
|
||||
const rippleSizeBox = dialog.querySelector('.ripple-sizerow .opl')?.getBoundingClientRect();
|
||||
out.rippleLabelsDoNotOverlapOnMobile = !!rippleLabelBox && !!rippleSizeBox
|
||||
&& (rippleLabelBox.bottom <= rippleSizeBox.top || rippleLabelBox.right <= rippleSizeBox.left);
|
||||
const rippleSize = card._markerDialog.rippleSize;
|
||||
change(ripple, { color: '#abcdef', opacity: 0.05 });
|
||||
await card.updateComplete;
|
||||
out.rippleChangeLeavesSizeAndAlphaModelAlone = card._markerDialog.rippleColor === '#abcdef'
|
||||
&& card._markerDialog.rippleSize === rippleSize
|
||||
&& !Object.hasOwn(card._markerDialog, 'rippleOpacity');
|
||||
|
||||
card._markerDialog = null;
|
||||
card._setMode('view');
|
||||
card._openSpaceDialog('edit', card._space);
|
||||
card._spaceDialog = { ...card._spaceDialog, bgMode: 'static', bgColor: null };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
dialog = root().querySelector('hp-dialog');
|
||||
const roomColor = pickerByLabel(dialog, card._t('space.room_color'));
|
||||
let spaceBg = pickerByLabel(dialog, card._t('space.bg_color'));
|
||||
out.spaceUsesOpacityAndColorOnlyModes = roomColor?.showOpacity === true
|
||||
&& spaceBg?.showOpacity === false && nativeColors() === 0;
|
||||
change(roomColor, { color: '#fedcba', opacity: 0.63 });
|
||||
await card.updateComplete;
|
||||
out.roomColorAndOpacityStayAtomic = card._spaceDialog.roomColor === '#fedcba'
|
||||
&& card._spaceDialog.roomOpacity === 0.63;
|
||||
const serverSpaceBefore = JSON.stringify(card._serverCfg.spaces.find((space) => space.id === card._space));
|
||||
spaceBg = pickerByLabel(root().querySelector('hp-dialog'), card._t('space.bg_color'));
|
||||
change(spaceBg, { color: '#102030', opacity: 0.2 });
|
||||
await card.updateComplete;
|
||||
dialog = root().querySelector('hp-dialog');
|
||||
const inheritButton = [...dialog.querySelectorAll('button')]
|
||||
.find((button) => button.textContent.trim() === card._t('space.bg_inherit'));
|
||||
const explicitSpaceBg = card._spaceDialog.bgColor;
|
||||
inheritButton?.click();
|
||||
await card.updateComplete;
|
||||
card._spaceDialog = null;
|
||||
await card.updateComplete;
|
||||
out.spaceBackgroundIgnoresAlphaAndCancelPersistsNothing = explicitSpaceBg === '#102030'
|
||||
&& JSON.stringify(card._serverCfg.spaces.find((space) => space.id === card._space)) === serverSpaceBefore;
|
||||
|
||||
out.noNativeColorInputInAnyMigratedDialog = nativeColors() === 0;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -7,9 +7,10 @@
|
||||
// --hp-open #ff9f43, not just a border), and the travelling states carried it
|
||||
// underneath the breathing ring. Now a cover returns no plate class at all:
|
||||
//
|
||||
// closed / open / ajar -> neutral plate, the ICON tells the state
|
||||
// closed / open / ajar -> neutral core, the ICON tells the state
|
||||
// opening / closing -> neutral plate + activity-transition when the
|
||||
// marker opts into «Icon + activity»
|
||||
// unknown / unavailable -> package unavailable core, without a morph
|
||||
//
|
||||
// The frame itself is NOT gone: door/window binary sensors and locks still
|
||||
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
|
||||
@@ -33,7 +34,8 @@ const out = await page.evaluate(async () => {
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)'); // «включено»
|
||||
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
|
||||
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
|
||||
const NEUTRAL = probe('light-dark(#fff, #252525)'); // package theme fallback
|
||||
const AMBER = probe('#F0A00C'); // package working/unlocked core
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
|
||||
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
|
||||
@@ -71,7 +73,7 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
const devEl = () => sr().querySelector('.devlayer .dev');
|
||||
const cls = () => [...(devEl()?.classList || [])];
|
||||
const plate = () => getComputedStyle(devEl()).backgroundColor;
|
||||
const plate = () => getComputedStyle(devEl()?.querySelector('.device-core')).backgroundColor;
|
||||
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
|
||||
|
||||
@@ -105,7 +107,7 @@ const out = await page.evaluate(async () => {
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const ring = devEl()?.querySelector('.device-pulse.continuous.transition i:first-child');
|
||||
const cs = ring ? getComputedStyle(ring) : null;
|
||||
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '2.4s';
|
||||
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '3.6s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
@@ -134,9 +136,12 @@ const out = await page.evaluate(async () => {
|
||||
// one the name rule «ворота|garage|gate» hands out, and it morphs too
|
||||
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
|
||||
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
|
||||
// …and an unreadable state morphs nothing, plate still neutral
|
||||
// …and an unreadable state morphs nothing and uses the package unavailable core.
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
plateIsNeutral('unknown');
|
||||
o.unknownPlateUnavailable = plate() === probe('#B5BAC1');
|
||||
o.unknownNotYellow = plate() !== YELLOW;
|
||||
o.unknownNotOrange = plate() !== ORANGE;
|
||||
o.unknownNoPlateClass = !cls().includes('on') && !cls().includes('open');
|
||||
o.unknownNoMorph = icon() === 'mdi:garage-variant';
|
||||
o.unknownNoRing = !cls().includes('activity-transition');
|
||||
|
||||
@@ -164,17 +169,21 @@ const out = await page.evaluate(async () => {
|
||||
const winEl = elOf(dev('d_window'));
|
||||
o.lockMarkerFound = !!lockEl;
|
||||
o.windowMarkerFound = !!winEl;
|
||||
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
|
||||
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
|
||||
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('lock-unlocked')
|
||||
&& getComputedStyle(lockEl.querySelector('.device-core')).backgroundColor === AMBER;
|
||||
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
|
||||
&& getComputedStyle(winEl).backgroundColor === ORANGE;
|
||||
&& getComputedStyle(winEl.querySelector('.device-core')).backgroundColor === ORANGE;
|
||||
// …and a locked lock is neutral again, so the frame still MEANS something
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
|
||||
const lockedEl = elOf(dev('d_lock'));
|
||||
const lockedCore = lockedEl?.querySelector('.device-core');
|
||||
o.lockedLockIsNeutral = !!lockedEl?.classList.contains('lock-locked')
|
||||
&& getComputedStyle(lockedCore).backgroundColor === NEUTRAL
|
||||
&& getComputedStyle(lockedCore).color === 'rgb(0, 0, 0)';
|
||||
|
||||
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
|
||||
// only thing it has to say «открыт» with (deliberately left alone)
|
||||
|
||||
@@ -37,7 +37,7 @@ const out = await page.evaluate(async () => {
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)');
|
||||
const ORANGE = probe('var(--hp-open)');
|
||||
const NEUTRAL = probe('var(--hp-bg)');
|
||||
const NEUTRAL = probe('light-dark(#fff, #252525)');
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
|
||||
// =======================================================================
|
||||
@@ -197,7 +197,7 @@ const out = await page.evaluate(async () => {
|
||||
const el = d && elOf(d);
|
||||
o[tag + 'MarkerFound'] = !!el;
|
||||
if (!el) return;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const bg = getComputedStyle(el.querySelector('.device-core')).backgroundColor;
|
||||
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = bg !== YELLOW;
|
||||
o[tag + 'NotOrange'] = bg !== ORANGE;
|
||||
@@ -212,7 +212,9 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
// …and at rest (closed) they are plain, with no ring
|
||||
await push('closed');
|
||||
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
|
||||
const closedBg = (ref) => getComputedStyle(
|
||||
elOf(dev(ref)).querySelector('.device-core'),
|
||||
).backgroundColor;
|
||||
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
|
||||
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
|
||||
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('activity-transition');
|
||||
|
||||
@@ -174,7 +174,7 @@ const out = await page.evaluate(async () => {
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
|
||||
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
const a1 = afterAnim();
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-pulse-continuous' && a1?.dur === '2.4s';
|
||||
o.pulseUsesPackageContinuousRing = a1?.name === 'hp-pulse-continuous' && a1?.dur === '3.6s';
|
||||
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
|
||||
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Issue #179: package shell/state/motion/a11y contract in the real shared renderer.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1120, height: 900 }, 1, [], { colorScheme: 'dark' },
|
||||
);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const marker = (id, patch) => ({
|
||||
...(c._serverCfg.markers || []).find((item) => item.id === id),
|
||||
id, binding: `device:${id}`, ...patch,
|
||||
});
|
||||
const replacements = new Map([
|
||||
['d_light1', marker('d_light1', { display: 'badge' })],
|
||||
['d_motion', marker('d_motion', { display: 'icon_ripple' })],
|
||||
['d_tv', marker('d_tv', { display: 'value' })],
|
||||
['d_temp', marker('d_temp', {
|
||||
display: 'badge',
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
|
||||
position: 'right',
|
||||
},
|
||||
})],
|
||||
['d_kettle', marker('d_kettle', { display: 'icon_ripple', tap_action: 'more-info' })],
|
||||
]);
|
||||
c._serverCfg.markers = [
|
||||
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
|
||||
...replacements.values(),
|
||||
];
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
themes: { ...(c.hass.themes || {}), darkMode: true },
|
||||
states: {
|
||||
...c.hass.states,
|
||||
'binary_sensor.hall_motion': {
|
||||
...c.hass.states['binary_sensor.hall_motion'],
|
||||
state: 'on',
|
||||
attributes: {
|
||||
...c.hass.states['binary_sensor.hall_motion'].attributes,
|
||||
device_class: 'presence', linkquality: 64,
|
||||
},
|
||||
},
|
||||
'media_player.tv': {
|
||||
...c.hass.states['media_player.tv'],
|
||||
state: 'A very long localized device value without clipping',
|
||||
},
|
||||
'sensor.living_temp': {
|
||||
...c.hass.states['sensor.living_temp'],
|
||||
state: '12345678901234567890',
|
||||
attributes: {
|
||||
...c.hass.states['sensor.living_temp'].attributes,
|
||||
linkquality: 30,
|
||||
},
|
||||
},
|
||||
'switch.kettle': {
|
||||
...c.hass.states['switch.kettle'],
|
||||
state: 'unavailable',
|
||||
},
|
||||
},
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._cfgEpoch++;
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(220);
|
||||
|
||||
const selector = (id) => `.dev[data-id="${id}"]`;
|
||||
const beforeUnavailable = await page.$eval(selector('d_kettle'), (node) => ({
|
||||
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
|
||||
shell: getComputedStyle(node.querySelector('.device-shell')).borderColor,
|
||||
}));
|
||||
await page.hover(selector('d_kettle'));
|
||||
// The production core/shell hover transition is 150 ms. Sampling in the same
|
||||
// frame only proves the starting color and lets a restored unavailable hover
|
||||
// escape the mutation guard.
|
||||
await page.waitForTimeout(220);
|
||||
const afterUnavailable = await page.$eval(selector('d_kettle'), (node) => ({
|
||||
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
|
||||
shell: getComputedStyle(node.querySelector('.device-shell')).borderColor,
|
||||
}));
|
||||
|
||||
const out = await page.evaluate(async ({ beforeUnavailable, afterUnavailable }) => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const node = (id) => sr().querySelector(`.dev[data-id="${id}"]`);
|
||||
const rect = (element) => element?.getBoundingClientRect();
|
||||
const light = node('d_light1');
|
||||
const lightShell = light?.querySelector('.device-shell');
|
||||
const lightCore = light?.querySelector('.device-core');
|
||||
const shellRect = rect(lightShell);
|
||||
const coreRect = rect(lightCore);
|
||||
const presence = node('d_motion');
|
||||
const unavailable = node('d_kettle');
|
||||
const text = node('d_tv');
|
||||
const double = node('d_temp');
|
||||
const doubleShellRect = rect(double?.querySelector('.device-shell'));
|
||||
const doubleCoreRect = rect(double?.querySelector('.device-core'));
|
||||
const doubleValueRect = rect(double?.querySelector('.value-badge'));
|
||||
const pseudo = getComputedStyle(light, '::before');
|
||||
|
||||
const lockNode = node('d_lock');
|
||||
lockNode.querySelector('.device-core').style.transition = 'none';
|
||||
const darkThemeClassProjected = lockNode.classList.contains('theme-dark');
|
||||
const darkThemeCore = getComputedStyle(lockNode.querySelector('.device-core')).backgroundColor;
|
||||
lockNode.classList.remove('theme-dark');
|
||||
lockNode.classList.add('theme-light');
|
||||
const lightThemeCore = getComputedStyle(lockNode.querySelector('.device-core')).backgroundColor;
|
||||
lockNode.classList.remove('theme-light');
|
||||
lockNode.classList.add('theme-dark');
|
||||
|
||||
let moreInfo = 0;
|
||||
c._openMoreInfo = () => { moreInfo++; };
|
||||
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
||||
unavailable.dispatchEvent(enter);
|
||||
const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
|
||||
unavailable.dispatchEvent(space);
|
||||
|
||||
const aria = unavailable.getAttribute('aria-label') || '';
|
||||
const viewKeyboard = unavailable.getAttribute('role') === 'button'
|
||||
&& unavailable.getAttribute('tabindex') === '0'
|
||||
&& enter.defaultPrevented && space.defaultPrevented && moreInfo === 2;
|
||||
|
||||
await c._setMode('devices');
|
||||
await c.updateComplete;
|
||||
const editorNode = node('d_light1');
|
||||
editorNode.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Enter', bubbles: true, cancelable: true,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
const editorKeyboardOpensSettings = c._markerDialog?.devId === 'd_light1';
|
||||
c._markerDialog = null;
|
||||
await c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
const planNode = node('d_light1');
|
||||
const planNotInTabOrder = !planNode?.hasAttribute('role') && !planNode?.hasAttribute('tabindex');
|
||||
|
||||
return {
|
||||
sharedShellGeometry: !!shellRect && !!coreRect
|
||||
&& Math.abs(shellRect.width / coreRect.width - 1.26875) < 0.03,
|
||||
packageShadowColor: getComputedStyle(lightShell).boxShadow.includes('37, 40, 45'),
|
||||
noBackdropBlur: getComputedStyle(lightShell).backdropFilter === 'none',
|
||||
activeUsesPackageAmber: getComputedStyle(lightCore).backgroundColor === 'rgb(240, 160, 12)',
|
||||
lightThemeCoreIsWhite: lightThemeCore === 'rgb(255, 255, 255)',
|
||||
darkThemeCoreIs252525: darkThemeClassProjected && darkThemeCore === 'rgb(37, 37, 37)',
|
||||
hitAreaAtLeast44: parseFloat(pseudo.width) >= 44 && parseFloat(pseudo.height) >= 44,
|
||||
presenceContinuousGreen: presence?.querySelector('.device-pulse.continuous.reason-presence')
|
||||
&& getComputedStyle(presence.querySelector('.device-pulse i')).borderColor === 'rgb(29, 194, 29)'
|
||||
&& getComputedStyle(presence.querySelector('.device-pulse i')).animationDuration === '3.6s',
|
||||
lockedStateProjected: node('d_lock')?.classList.contains('lock-locked')
|
||||
&& node('d_lock')?.getAttribute('data-state') === 'locked',
|
||||
lqiBandsProjected: node('d_temp')?.getAttribute('data-lqi-band') === 'low'
|
||||
&& node('d_window')?.getAttribute('data-lqi-band') === 'mid'
|
||||
&& node('d_lamp')?.getAttribute('data-lqi-band') === 'high',
|
||||
unavailableHasNoHoverOrMotion: JSON.stringify(beforeUnavailable) === JSON.stringify(afterUnavailable)
|
||||
&& !unavailable.querySelector('.device-pulse'),
|
||||
unavailableAriaIsReadable: /unavailable/i.test(aria) && /LQI|signal/i.test(aria),
|
||||
textIsComplete: text?.querySelector('.valtext')?.textContent
|
||||
=== 'A very long localized device value without clipping'
|
||||
&& getComputedStyle(text.querySelector('.valtext')).textOverflow !== 'ellipsis'
|
||||
&& getComputedStyle(text.querySelector('.valtext')).overflow === 'visible'
|
||||
&& text.getAttribute('aria-label')?.includes('A very long localized device value without clipping'),
|
||||
doubleUsesOneShell: !!doubleShellRect && !!doubleCoreRect && !!doubleValueRect
|
||||
&& doubleShellRect.left <= Math.min(doubleCoreRect.left, doubleValueRect.left)
|
||||
&& doubleShellRect.right >= Math.max(doubleCoreRect.right, doubleValueRect.right),
|
||||
viewKeyboard,
|
||||
editorKeyboardOpensSettings,
|
||||
planNotInTabOrder,
|
||||
};
|
||||
}, { beforeUnavailable, afterUnavailable });
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -12,7 +12,7 @@ const res = await page.evaluate(async () => {
|
||||
const semantic = ['on', 'open', 'alarm', 'unavail', 'valonly', 'activity-running', 'activity-event', 'activity-presence', 'activity-transition'];
|
||||
return {
|
||||
classes: semantic.filter((name) => node.classList.contains(name)),
|
||||
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
|
||||
icon: node.querySelector('.device-core > ha-icon')?.getAttribute('icon') || '',
|
||||
value: node.querySelector('.valtext')?.textContent?.trim() || '',
|
||||
badge: node.querySelector('.value-badge')?.textContent?.trim() || '',
|
||||
badgePosition: [...(node.querySelector('.value-badge')?.classList || [])]
|
||||
|
||||
@@ -50,7 +50,12 @@ const res = await page.evaluate(async () => {
|
||||
// 3) повторный клик по активной вкладке — ничего
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
out.reclickNoop = c._mode === 'plan';
|
||||
const enteringTabCross = tabs()[0].querySelector('.closex');
|
||||
out.tabCrossCloseStartsDuringEnter = c._modeTransitionBusy;
|
||||
enteringTabCross.click();
|
||||
await settleMode();
|
||||
out.tabCrossCloseDuringEnterWorks = c._mode === 'view';
|
||||
tabs()[0].click(); await settleMode();
|
||||
// HP-UX-11: transient tool controls live in a stage overlay. Opening the
|
||||
// thickness controls must not resize/refit the stage or move pinned Close.
|
||||
const stageBeforeContext = sr().querySelector('.stage').getBoundingClientRect();
|
||||
@@ -171,10 +176,75 @@ const res = await page.evaluate(async () => {
|
||||
out.barCloseWorks = c._mode === 'view'
|
||||
&& getComputedStyle(chrome).visibility === 'hidden'
|
||||
&& chrome.getBoundingClientRect().height < 1;
|
||||
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
|
||||
tabs()[1].click(); await settleMode();
|
||||
tabs()[1].querySelector('.closex').click(); await settleMode();
|
||||
out.tabCrossWorks = c._mode === 'view';
|
||||
// 7) X in every active tab keeps its 13 px glyph/layout footprint, but its
|
||||
// real hit target is at least 24 px. Exercise the newly covered edge rather
|
||||
// than calling the element's centre programmatically.
|
||||
const tabCrossChecks = [];
|
||||
for (let index = 0; index < tabs().length; index++) {
|
||||
tabs()[index].click(); await settleMode();
|
||||
const cross = tabs()[index].querySelector('.closex');
|
||||
const rect = cross.getBoundingClientRect();
|
||||
const style = getComputedStyle(cross);
|
||||
const hit = sr().elementFromPoint(rect.left + 1, rect.top + rect.height / 2);
|
||||
const horizontalFootprint = rect.width
|
||||
+ parseFloat(style.marginLeft) + parseFloat(style.marginRight);
|
||||
const verticalFootprint = rect.height
|
||||
+ parseFloat(style.marginTop) + parseFloat(style.marginBottom);
|
||||
const glyphStays13 = style.getPropertyValue('--mdc-icon-size').trim() === '13px';
|
||||
hit?.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
clientX: rect.left + 1,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
}));
|
||||
await settleMode();
|
||||
tabCrossChecks.push({
|
||||
target: rect.width >= 24 && rect.height >= 24,
|
||||
// Capture computed style before the click removes the active X from DOM.
|
||||
glyph: glyphStays13,
|
||||
footprint: Math.abs(horizontalFootprint - 15) <= 0.1
|
||||
&& Math.abs(verticalFootprint - 13) <= 0.1,
|
||||
edgeHit: hit === cross,
|
||||
closed: c._mode === 'view',
|
||||
});
|
||||
}
|
||||
out.tabCrossTargetsAtLeast24 = tabCrossChecks.every((check) => check.target);
|
||||
out.tabCrossGlyphStays13 = tabCrossChecks.every((check) => check.glyph);
|
||||
out.tabCrossKeepsLayoutFootprint = tabCrossChecks.every((check) => check.footprint);
|
||||
out.tabCrossExpandedEdgeWorks = tabCrossChecks.every((check) => check.edgeHit && check.closed);
|
||||
out.tabCrossWorks = tabCrossChecks.every((check) => check.closed);
|
||||
|
||||
// Existing navigation contracts are not rewritten by the CSS fix: an open
|
||||
// Walls chain finishes on the same click, while the hard geometry limit
|
||||
// keeps the draft and gives explicit feedback.
|
||||
tabs()[0].click(); await settleMode();
|
||||
const chainSpace = c._curSpaceCfg;
|
||||
const savedPartitions = chainSpace.partitions ? [...chainSpace.partitions] : undefined;
|
||||
const savedSaveConfig = c._saveConfig;
|
||||
c._saveConfig = () => {};
|
||||
const partitionCount = (chainSpace.partitions || []).length;
|
||||
c._tool = 'draw';
|
||||
c._path = [[100, 100], [200, 100], [250, 150]];
|
||||
c._draftSegmentCms = [15, 15];
|
||||
tabs()[0].querySelector('.closex').click(); await settleMode();
|
||||
out.tabCrossFinishesWallChain = c._mode === 'view'
|
||||
&& (chainSpace.partitions || []).length === partitionCount + 2
|
||||
&& c._path.length === 0;
|
||||
chainSpace.partitions = savedPartitions;
|
||||
tabs()[0].click(); await settleMode();
|
||||
c._tool = 'draw';
|
||||
c._path = [[100, 100], [200, 100]];
|
||||
c._draftSegmentCms = [15];
|
||||
chainSpace.partitions = new Array(2000);
|
||||
tabs()[0].querySelector('.closex').click();
|
||||
out.tabCrossLimitKeepsDraftWithFeedback = c._mode === 'plan'
|
||||
&& c._path.length === 2
|
||||
&& c._toast === c._t('toast.physical_limit');
|
||||
chainSpace.partitions = savedPartitions;
|
||||
c._path = [];
|
||||
c._draftSegmentCms = [];
|
||||
c._saveConfig = savedSaveConfig;
|
||||
c._setMode('view', false); await c.updateComplete;
|
||||
|
||||
// Layout-independent editor history: physical KeyZ works when `key` is the
|
||||
// Russian «я», while an input keeps its native browser history.
|
||||
|
||||
@@ -10,6 +10,15 @@ const res = await page.evaluate(async () => {
|
||||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||||
return active;
|
||||
};
|
||||
const composedContains = (root, target) => {
|
||||
let node = target;
|
||||
while (node) {
|
||||
if (node === root) return true;
|
||||
const tree = node.getRootNode?.();
|
||||
node = node.parentNode || (tree instanceof ShadowRoot ? tree.host : null);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// A11Y-02: the shared native fallback carries the same modal/focus contract
|
||||
// that ha-dialog supplies in Home Assistant.
|
||||
@@ -26,7 +35,9 @@ const res = await page.evaluate(async () => {
|
||||
&& native?.getAttribute('aria-modal') === 'true'
|
||||
&& !!titleId && !!hp.shadowRoot.getElementById(titleId)?.textContent.trim();
|
||||
let active = deepActive();
|
||||
out.initialFocus = !!active && (hp.contains(active) || hp.shadowRoot.contains(active));
|
||||
// Unified colour controls put the first focus target in their own shadow
|
||||
// root. Test composed ownership, not only light-DOM containment.
|
||||
out.initialFocus = !!active && composedContains(hp, active);
|
||||
const focusable = [...hp.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])')];
|
||||
const last = focusable.at(-1);
|
||||
last?.focus();
|
||||
|
||||