Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75ad20a1df | ||
|
|
adc1d15b98 | ||
|
|
a14de0a122 | ||
|
|
0e5ee030fe | ||
|
|
c749d68a4d | ||
|
|
0c93be9e85 | ||
|
|
9f25f17c83 | ||
|
|
1521d3716a | ||
|
|
fbb426afc9 | ||
|
|
c5ba699cf1 | ||
|
|
b7bfc92d4e | ||
|
|
120d41317c | ||
|
|
5cae1fdf82 | ||
|
|
ec6f77b014 | ||
|
|
efcdb269e8 | ||
|
|
9f5ebec6f2 | ||
|
|
3490faae0d | ||
|
|
6063eead10 | ||
|
|
b89456ca81 | ||
|
|
6c3d376b2b | ||
|
|
c08d5a88ae | ||
|
|
0bd6094ca8 | ||
|
|
d197381894 | ||
|
|
4d1b62b57c | ||
|
|
303f710e03 | ||
|
|
270cf634e6 | ||
|
|
4e82976b4a | ||
|
|
6feb0189ee | ||
|
|
ce304646c8 | ||
|
|
4fc0f7bd32 | ||
|
|
93200eb56a | ||
|
|
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 |
@@ -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: |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.4"
|
||||
VERSION = "1.65.0-beta.9"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
|
||||
@@ -52,6 +52,7 @@ from .validation import (
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
)
|
||||
|
||||
FORMAT = "houseplan-export"
|
||||
@@ -993,7 +994,10 @@ def build_space_merge(
|
||||
validate_marker_value_badges(merged_config, current_config)
|
||||
validate_opening_passages(merged_config, current_config)
|
||||
validate_partition_opening_hosts(merged_config, current_config)
|
||||
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
|
||||
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.4"
|
||||
"version": "1.65.0-beta.9"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,30 +52,103 @@ class PartitionOpeningHostError(ValueError):
|
||||
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:
|
||||
"""Prevent an older writer from silently downgrading hosted openings.
|
||||
"""Validate hosted-opening write deltas without rejecting legacy reads.
|
||||
|
||||
Deleting the opening together with its partition remains valid. Only a
|
||||
surviving record that previously had an explicit host must keep one.
|
||||
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 []:
|
||||
old_space = old_spaces.get(str(space.get("id", "")))
|
||||
if not old_space:
|
||||
continue
|
||||
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.get("openings") or []
|
||||
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 []:
|
||||
old = old_openings.get(str(opening.get("id", "")))
|
||||
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.get('id', '')}; opening={opening.get('id', '')}; host removed"
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ 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,
|
||||
PartitionOpeningHostError, sanitize_filename,
|
||||
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,
|
||||
@@ -1258,7 +1258,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
|
||||
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
|
||||
@@ -1370,7 +1373,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
|
||||
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
// Issue #211: human-reviewable Reference SVG <-> Runtime matrix.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mdiLightbulbSpot } from '@mdi/js';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const artifactDir = resolve('artifacts/device-icon-reference');
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
|
||||
const referenceAsset = (theme, file, coreSize) => {
|
||||
let source = readFileSync(resolve('demo/srv/reference/device-icons', theme, file), 'utf8');
|
||||
if (theme === 'Dark' && file === 'Unlock.svg') source = source.replaceAll('#1DC21D', '#F0A00C');
|
||||
const nativeWidth = Number(source.match(/<svg[^>]*width="([\d.]+)"/)?.[1] || 127);
|
||||
return {
|
||||
url: `data:image/svg+xml;base64,${Buffer.from(source).toString('base64')}`,
|
||||
displayWidth: nativeWidth * coreSize / 80,
|
||||
};
|
||||
};
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1280, height: 960 }, 1, [], { colorScheme: 'dark' },
|
||||
);
|
||||
|
||||
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
|
||||
|
||||
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', icon: 'mdi:lightbulb-spot' })],
|
||||
['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',
|
||||
},
|
||||
})],
|
||||
]);
|
||||
c._serverCfg.markers = [
|
||||
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
|
||||
...replacements.values(),
|
||||
];
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: {
|
||||
...c.hass.states,
|
||||
'sensor.living_temp': {
|
||||
...c.hass.states['sensor.living_temp'],
|
||||
state: '23',
|
||||
attributes: { ...c.hass.states['sensor.living_temp']?.attributes, unit_of_measurement: '%' },
|
||||
},
|
||||
'media_player.tv': {
|
||||
...c.hass.states['media_player.tv'],
|
||||
state: 'Working',
|
||||
},
|
||||
},
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._cfgEpoch++;
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const qaStyle = document.createElement('style');
|
||||
qaStyle.textContent = '.devtip{display:none!important}';
|
||||
(c.renderRoot || c.shadowRoot).append(qaStyle);
|
||||
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
|
||||
});
|
||||
|
||||
const selector = (id) => `.dev[data-id="${id}"]`;
|
||||
|
||||
async function runtimePng(theme, row, size) {
|
||||
await page.mouse.move(1, 1);
|
||||
await page.evaluate(({ id, themeName, classes, px, clearValues }) => {
|
||||
const node = (window.__card.renderRoot || window.__card.shadowRoot)
|
||||
.querySelector(`.dev[data-id="${id}"]`);
|
||||
for (const marker of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.dev'))
|
||||
marker.style.visibility = marker === node ? 'visible' : 'hidden';
|
||||
node.classList.remove(...[
|
||||
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
|
||||
'sel', 'lock-locked', 'lock-unlocked',
|
||||
]);
|
||||
node.classList.add(`theme-${themeName}`, ...classes);
|
||||
node.style.setProperty('--device-base-size', `${px}px`);
|
||||
node.style.setProperty('--dev-scale', '1');
|
||||
node.querySelector('.device-core')?.style.setProperty('transition', 'none');
|
||||
node.querySelector('.device-shell-frame')?.style.setProperty('transition', 'none');
|
||||
if (clearValues) node.querySelectorAll('.value-badge').forEach((value) => value.remove());
|
||||
node.blur();
|
||||
}, {
|
||||
id: row.id,
|
||||
themeName: theme.toLowerCase(),
|
||||
classes: row.classes || [],
|
||||
px: size,
|
||||
clearValues: row.clearValues || false,
|
||||
});
|
||||
if (row.hover) {
|
||||
await page.hover(selector(row.id));
|
||||
await page.waitForTimeout(180);
|
||||
}
|
||||
if (row.focus) {
|
||||
await page.$eval(selector(row.id), (node) => node.focus());
|
||||
}
|
||||
await page.$eval(selector(row.id), (node) => {
|
||||
for (const tooltip of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.devtip'))
|
||||
tooltip.style.setProperty('display', 'none', 'important');
|
||||
node.querySelector('.lqi')?.style.setProperty('display', 'none');
|
||||
});
|
||||
const clip = await page.$eval(selector(row.id), (node) => {
|
||||
const shell = node.querySelector('.device-shell-frame').getBoundingClientRect();
|
||||
const pad = 22;
|
||||
return {
|
||||
x: Math.max(0, shell.left - pad),
|
||||
y: Math.max(0, shell.top - pad),
|
||||
width: shell.width + pad * 2,
|
||||
height: shell.height + pad * 2,
|
||||
};
|
||||
});
|
||||
return (await page.screenshot({ clip })).toString('base64');
|
||||
}
|
||||
|
||||
const rows = [
|
||||
{ label: 'Default', file: 'Icon Default.svg', id: 'd_light1' },
|
||||
{ label: 'Hover', file: 'Icon Hover.svg', id: 'd_light1', hover: true },
|
||||
{ label: 'Active', file: 'Icon Active.svg', id: 'd_light1', classes: ['on'] },
|
||||
{ label: 'Lock', file: 'Lock.svg', id: 'd_lock', classes: ['lock-locked'] },
|
||||
{ label: 'Unlock', file: 'Unlock.svg', id: 'd_lock', classes: ['lock-unlocked'] },
|
||||
{ label: 'Selected', file: 'Selected.svg', id: 'd_light1', classes: ['sel'] },
|
||||
{ label: 'Focus', file: 'Focus Visible.svg', id: 'd_light1', focus: true },
|
||||
{ label: 'Alert', file: 'Alert Value.svg', id: 'd_temp', classes: ['alarm'] },
|
||||
{ label: 'Virtual', file: 'Virtual Device Default.svg', id: 'd_motion', classes: ['virtual'] },
|
||||
{ label: 'Unavailable', file: 'Unavailable.svg', id: 'd_light1', classes: ['unavail'] },
|
||||
{ label: 'Text', file: 'Text Default.svg', id: 'd_tv' },
|
||||
{ label: 'Double Right', file: 'Double Default Right.svg', id: 'd_temp' },
|
||||
];
|
||||
|
||||
const matrix = [];
|
||||
for (const theme of ['Light', 'Dark']) {
|
||||
for (const row of rows) {
|
||||
matrix.push({
|
||||
theme,
|
||||
row,
|
||||
size: 56,
|
||||
runtime: await runtimePng(theme, row, 56),
|
||||
});
|
||||
}
|
||||
for (const size of [32, 96]) {
|
||||
const row = rows[0];
|
||||
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
const body = matrix.map(({ theme, row, size, runtime }) => {
|
||||
const reference = referenceAsset(theme, row.file, size);
|
||||
return `
|
||||
<tr>
|
||||
<td>${theme}</td><td>${escapeHtml(row.label)}</td><td>${size}px</td>
|
||||
<td class="preview"><img style="width:${reference.displayWidth}px" src="${reference.url}" alt="Reference ${escapeHtml(row.label)}"></td>
|
||||
<td class="preview runtime"><img src="data:image/png;base64,${runtime}" alt="Runtime ${escapeHtml(row.label)}"></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const html = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Device icon reference/runtime matrix</title>
|
||||
<style>
|
||||
body{margin:24px;background:#777;color:#111;font:16px system-ui,sans-serif}
|
||||
h1,p{max-width:1100px} table{border-collapse:collapse;width:100%;background:#aaa}
|
||||
th,td{border:1px solid #555;padding:8px;text-align:left} th{position:sticky;top:0;background:#ddd;z-index:2}
|
||||
.preview{width:38%;text-align:center;background:linear-gradient(135deg,#d5d5d5 50%,#666 50%)}
|
||||
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
|
||||
</style></head><body>
|
||||
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
|
||||
<p>Issue #211. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default also covers 32/56/96 px. Dark Unlock is evaluated using the owner's amber override from #179.</p>
|
||||
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
|
||||
<tbody>${body}</tbody></table></body></html>`;
|
||||
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
|
||||
writeFileSync(htmlPath, html);
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
await page.screenshot({
|
||||
path: resolve(artifactDir, 'device-icons-reference-runtime.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await browser.close();
|
||||
console.log(`OK device icon reference/runtime matrix: ${htmlPath}`);
|
||||
@@ -1,81 +1,87 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 30,
|
||||
"acceptedAt": "2026-08-19T11:34:09.162Z",
|
||||
"sourceFingerprint": "ad0031892ee8d0c3d21fbefccea8be7c7c89aa3b89e3afceaf2c88f6024ab533",
|
||||
"matrixVersion": 32,
|
||||
"acceptedAt": "2026-08-20T08:32:47.949Z",
|
||||
"sourceFingerprint": "599e6528b07f4762db20025ae84f888e0a61f173c9605195a9a9348c9b08393a",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"split-zero-divider-taper-dark": "4c63083b1cb9e1079fa56e6991a035124d0ce22527489712a7a9161f1ccf2107",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
|
||||
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
|
||||
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-live-layers-dark": "7744e9b005c307778308e7f41cf89c893436b6175321baafa72c3ed14a6236a8",
|
||||
"isometric-no-borders-dark": "f5f68092afc878cc061e81f73c4676c306879b89e4efdf6d32cea3275f3190ac",
|
||||
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
|
||||
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
|
||||
"isometric-large-warm-remount-dark": "b01bd4bc1e0b33a82faf7a6effb02a887ba867875b751b96978c95ad262f839b",
|
||||
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
|
||||
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
|
||||
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
|
||||
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
|
||||
"room-label-parity-view-dark": "3a636de579cd2d1de338d017ed6b53c3e2f70a901a0f5c81dfca85870d1b4bd5",
|
||||
"room-label-parity-plan-dark": "e9b2253f94eb74d43e22a3bbc1229f34a3a923b777fddb069dad3dae516583cd",
|
||||
"room-label-parity-view-light": "6eb2ba0023932c9b4e82a45b7ca9565a9ed1b89d14e543facbaf1f84aa5db9ff",
|
||||
"room-label-parity-plan-light": "333139affbf8936554072aebcaf58b4640779adbd6a64686c0929ca071063733",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
|
||||
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
|
||||
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
|
||||
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
|
||||
"geometry-plan-editor-dark": "0e7b01e74c82ab5072bffbfaddfaa21b07089662b19f491a7c6783a4152af9fb",
|
||||
"plan-snap-endpoint-light": "6187b35a780db09626bd9b8a06461dc133e4027722e965908811226f260d327a",
|
||||
"plan-snap-line-gaps-dark": "5ae0634f63718be4b2c1fcabd2b474ecbc980fe96a3bdd2987ca8356472b1076",
|
||||
"wall-junctions-plan-preview-light": "a8a04b5c611a880880b6b6d64d10eb50576a5a377c984a5b397075b225739bd5",
|
||||
"wall-junctions-plan-t-dark": "1812cbf5f68a0431efed7823990020bd910f98e8e78c8e658f3012829395931a",
|
||||
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
|
||||
"junction-patch-resilience-plan-dark": "c1511849cd59b2b1ce50c040e572cf431e9de7029d7c4f2dbbfa1c168cd29cf3",
|
||||
"junction-patch-resilience-view-dark": "140ee1df8eaf997ebf9adf3c6f0658f1728a2339102e079e68a4ce86eead9f2c",
|
||||
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
|
||||
"opening-placement-door-thick-wall-dark": "4393770bb97cb051d76f5b2aba9d314a8789a20278ccafb31c048300ccb4d8cf",
|
||||
"opening-placement-passage-thick-wall-dark": "514420fc90aab872bb6149cc41eea23bcfb673db358d5a9e54e18bb612182a08",
|
||||
"opening-placement-passage-thick-wall-light": "139fbd14d655482d43f1dbceba38a8f22b931a86677997986b75d1160216a6bd",
|
||||
"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": "995477b887a302c25d8a0fe540a1b8ee56369e21100cdb367a3bf37c8dcc99c1",
|
||||
"tray-wide-tool-ru": "0fa205ba48803255f8e0dc51286b7fc151ccfd319dfb932fd4927fff124d0396",
|
||||
"tray-medium-group-en": "b6170877b12de6ff7b03e4c3a0099141a7fa010448bac914ce6dafe321594b83",
|
||||
"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": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
|
||||
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
|
||||
"openings-thick-wall-dark": "0c33cdb4637ee9788e75931ba142099e1d068b770e26ca24f4a59f8631f3373f",
|
||||
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
|
||||
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
|
||||
"lighting-glow-sun-dark": "3f02abbcb3379b2e224ba7dd3126ec4cb736c0f71edbcdd2f2961c33d7d1b12a",
|
||||
"device-value-badge-positions-dark": "831f04e8b97e4cb473e2c9e53976a5422dd3718eb74086ed2503ffc6d3f5ad87",
|
||||
"device-icon-state-table-light": "c2bc269fb9600294e4eca4eeda2316da1cb4c150b5e9d49c69ce74ce52f26875",
|
||||
"device-icon-state-table-dark": "aea23ced341b8838a0616518da85585e978f53c8cfe61f3c7e023c724c2ee550",
|
||||
"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",
|
||||
"lighting-fill-light-axis-split-dark": "4cb3f7591f005768d1d8e42f651184a7317b439318302d6bc48fcca000266c82",
|
||||
"lighting-fill-temp-axis-split-dark": "47383a2e81b34cd7cf385a0f33efe755906c1cb20de8345dc69f599597378801",
|
||||
"lighting-fill-lqi-axis-split-dark": "480b518fb2da24fc7d70297a4456a92485f5f5629a6bc951a36eedcc7631e5dc",
|
||||
"lighting-temp-glow-dark": "a5c8f7b981d4f310ca8cf6c19f72236dc29abdd88a1d60be6ef916dd9091f7b8",
|
||||
"lighting-temp-glow-light": "86620d7b68e797469fbec36438bf2725bd913bc8b4641dbe75c03098210d17f7",
|
||||
"lighting-custom-glow-dark": "86a1ba7ac8e6bc9515a4df550065c60fd600ce6898bdc5447122c9fc87fb3965",
|
||||
"lighting-opaque-glow-two-doorways-dark": "0cff53678f935bb9cee4570a47bf34a05d71b1fd512d76158e846c0b1aa7a5c5",
|
||||
"lighting-custom-glow-light": "2568f1ca7be270faabd2c97d955fd6e27dec00e921d8e6f21ab7425a86e7f958",
|
||||
"lighting-temp-glow-no-sources-dark": "ee37a7dda19a0bfe02405d4e9cf61023cecb623896388a4197adc4a5afe6b584",
|
||||
"lighting-temp-glow-room-override-dark": "fa20be4d2a29710143f437d5049c6e71959c6a56df2a12a9d2e9728e7833d9ed",
|
||||
"lighting-manual-auto-spill-overlap-dark": "695380b4d5809ecee27dbe060c70573d75ac423fdb19823dc9f112d4adf9e272",
|
||||
"hover-over-glow-dark": "d505d1514e492dd3f5e299ebace0392e14734f21626310a8efdc0d148a00ddf0",
|
||||
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
|
||||
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
|
||||
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
|
||||
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
|
||||
"device-dialog-desktop-en": "343b79ef61e1fb71906bce79db50d4d1d73b2af09ca86ed550fd91c7e2b03164",
|
||||
"large-house-zoom-040-dark": "cae0d852e1d13c77a57b4a040ba4f3e7f263cfaf716bd406c022985db560c2e5",
|
||||
"large-house-zoom-250-dark": "9d956270ad71d2e3326d36ebff1eca4e6bc3079b79b6c22ab4ef619a12347ca8",
|
||||
"large-house-warm-remount-dark": "c92f73b080617cf7ad83a0269bb6ee25a7c3481570ff8452c1cf811e8572ab1d",
|
||||
"device-dialog-desktop-en": "ea341f6ea9db5f61bbcb6fd7ca5078fd2f347023de4279cb70bf190c1379c364",
|
||||
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
|
||||
"toggle-entity-dialog-desktop-en": "013e2d01382435d995e8deece19e07e9bcced4fe80952cbab765e642b3d641d6",
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "3f14b5f159d4911f01f3a5d8f9db579bbc5da617a46653fe2d98c1e612a3f05c",
|
||||
"device-help-popover-light-ru": "c74c83ecfb4ee6cd693d87d4516e9ee4920ad453f5b7b9606cd98853b7273568",
|
||||
"decor-color-popover-mobile-ru": "a731bbd8c794457fceb6f281124c20ef403fc3a50d2e3d6a640ad6389e90eeae",
|
||||
"decor-color-popover-desktop-en": "11189416fd3752310979444aa92ce38764996655871009ff028ba9b4b31959a8",
|
||||
"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": "a5d62d68097af1ff5d2556be0c889efc25768ffc61af5d302f0b5775e082f296",
|
||||
"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: 177 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 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 |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 354 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: 198 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 357 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 |
@@ -163,6 +163,24 @@ export function prepareGoldenFixture(scenario) {
|
||||
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>'}`);
|
||||
@@ -295,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) => {
|
||||
@@ -399,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;
|
||||
@@ -435,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}`);
|
||||
@@ -733,6 +763,22 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
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');
|
||||
const active = card.renderRoot.querySelector('.hp-day-cycle-bg.active');
|
||||
@@ -766,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 = 30;
|
||||
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' } },
|
||||
@@ -189,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
|
||||
|
||||
@@ -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,24 @@ 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;
|
||||
// …and a locked lock is neutral again, so the frame still MEANS something
|
||||
&& getComputedStyle(winEl.querySelector('.device-core')).backgroundColor === ORANGE;
|
||||
// …and a locked lock keeps the distinct package face: dark core + white glyph.
|
||||
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');
|
||||
const lockedFace = lockedEl?.classList.contains('theme-dark')
|
||||
? 'rgb(37, 37, 37)'
|
||||
: 'rgb(0, 0, 0)';
|
||||
o.lockedLockKeepsPackageFace = !!lockedEl?.classList.contains('lock-locked')
|
||||
&& getComputedStyle(lockedCore).backgroundColor === lockedFace
|
||||
&& getComputedStyle(lockedCore).color === 'rgb(255, 255, 255)';
|
||||
|
||||
// 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,573 @@
|
||||
// Issues #179/#211: package shell/state/motion/a11y contract in the shared renderer.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mdiLightbulbSpot } from '@mdi/js';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const referenceSvg = (theme, name) => readFileSync(
|
||||
new URL(`./srv/reference/device-icons/${theme}/${name}`, import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const tagAttribute = (tag, name) => tag.match(new RegExp(`${name}="([^"]+)"`))?.[1] || '';
|
||||
const coreTag = (svg) => svg.match(/<rect[^>]*width="80"[^>]*height="80"[^>]*rx="40"[^>]*>/)?.[0] || '';
|
||||
const shellTag = (svg) => svg.match(/<(?:path|rect)[^>]*stroke="[^"]+"[^>]*stroke-width="[^"]+"[^>]*>/)?.[0] || '';
|
||||
const glyphTag = (svg) => [...svg.matchAll(/<path[^>]*fill="[^"]+"[^>]*>/g)].at(0)?.[0] || '';
|
||||
const canonicalHex = (value) => ({ white: '#FFFFFF', black: '#000000' }[value.toLowerCase()] || value.toUpperCase());
|
||||
const browserColor = (value, opacity = null) => {
|
||||
const hex = canonicalHex(value).replace('#', '');
|
||||
const full = hex.length === 3 ? [...hex].map((char) => char + char).join('') : hex;
|
||||
const channels = [0, 2, 4].map((offset) => Number.parseInt(full.slice(offset, offset + 2), 16));
|
||||
return opacity == null
|
||||
? `rgb(${channels.join(', ')})`
|
||||
: `rgba(${channels.join(', ')}, ${Number(opacity)})`;
|
||||
};
|
||||
const svgState = (theme, name) => {
|
||||
const svg = referenceSvg(theme, name);
|
||||
const core = coreTag(svg);
|
||||
const shell = shellTag(svg);
|
||||
const glyph = glyphTag(svg);
|
||||
return {
|
||||
core: browserColor(tagAttribute(core, 'fill')),
|
||||
glyph: browserColor(tagAttribute(glyph, 'fill')),
|
||||
shell: browserColor(
|
||||
tagAttribute(shell, 'stroke'),
|
||||
tagAttribute(shell, 'stroke-opacity') || null,
|
||||
),
|
||||
shellWidth: Number(tagAttribute(shell, 'stroke-width')) / 80,
|
||||
};
|
||||
};
|
||||
const reference = {
|
||||
light: {
|
||||
default: svgState('Light', 'Icon Default.svg'),
|
||||
hover: svgState('Light', 'Icon Hover.svg'),
|
||||
active: svgState('Light', 'Icon Active.svg'),
|
||||
lock: svgState('Light', 'Lock.svg'),
|
||||
unlock: svgState('Light', 'Unlock.svg'),
|
||||
unavailable: svgState('Light', 'Unavailable.svg'),
|
||||
alert: svgState('Light', 'Alert Value.svg'),
|
||||
},
|
||||
dark: {
|
||||
default: svgState('Dark', 'Icon Default.svg'),
|
||||
hover: svgState('Dark', 'Icon Hover.svg'),
|
||||
active: svgState('Dark', 'Icon Active.svg'),
|
||||
lock: svgState('Dark', 'Lock.svg'),
|
||||
unlock: svgState('Dark', 'Unlock.svg'),
|
||||
unavailable: svgState('Dark', 'Unavailable.svg'),
|
||||
alert: svgState('Dark', 'Alert Value.svg'),
|
||||
},
|
||||
};
|
||||
// Owner decision in #179 overrides the stale green Dark/Unlock asset.
|
||||
reference.dark.unlock = {
|
||||
...reference.dark.unlock,
|
||||
core: reference.light.unlock.core,
|
||||
glyph: reference.dark.active.glyph,
|
||||
shell: reference.light.unlock.shell,
|
||||
};
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1120, height: 900 }, 1, [], { colorScheme: 'dark' },
|
||||
);
|
||||
|
||||
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
|
||||
|
||||
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', icon: 'mdi:lightbulb-spot' })],
|
||||
['d_motion', marker('d_motion', { display: 'icon_ripple' })],
|
||||
['d_tv', marker('d_tv', { display: 'value' })],
|
||||
['d_temp', marker('d_temp', {
|
||||
display: 'badge',
|
||||
tap_action: 'more-info',
|
||||
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-frame')).borderColor,
|
||||
opacity: getComputedStyle(node).opacity,
|
||||
}));
|
||||
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-frame')).borderColor,
|
||||
opacity: getComputedStyle(node).opacity,
|
||||
}));
|
||||
|
||||
await page.hover(selector('d_tv'));
|
||||
await page.waitForTimeout(220);
|
||||
const ordinaryHover = await page.$eval(selector('d_tv'), (node) => ({
|
||||
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
|
||||
glyph: getComputedStyle(node.querySelector('.device-core')).color,
|
||||
shell: getComputedStyle(node.querySelector('.device-shell-frame')).borderColor,
|
||||
}));
|
||||
await page.$eval(selector('d_tv'), (node) => {
|
||||
node.querySelector('.device-core').style.transition = 'none';
|
||||
node.querySelector('.device-shell-frame').style.transition = 'none';
|
||||
node.classList.remove('theme-dark');
|
||||
node.classList.add('theme-light');
|
||||
});
|
||||
const lightHover = await page.$eval(selector('d_tv'), (node) => ({
|
||||
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
|
||||
glyph: getComputedStyle(node.querySelector('.device-core')).color,
|
||||
shell: getComputedStyle(node.querySelector('.device-shell-frame')).borderColor,
|
||||
}));
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
window.__capsuleActionCount = 0;
|
||||
window.__capsuleOriginalMoreInfo = c._openMoreInfo;
|
||||
c._openMoreInfo = () => { window.__capsuleActionCount++; };
|
||||
});
|
||||
const capsulePositions = [];
|
||||
for (const [index, position] of ['right', 'bottom', 'left', 'top'].entries()) {
|
||||
await page.mouse.move(1, 1);
|
||||
await page.$eval(selector('d_temp'), (node, nextPosition) => {
|
||||
const shell = node.querySelector('.device-shell');
|
||||
const badge = node.querySelector('.value-badge');
|
||||
for (const name of ['right', 'bottom', 'left', 'top']) {
|
||||
shell.classList.remove(`pos-${name}`);
|
||||
badge.classList.remove(`pos-${name}`);
|
||||
}
|
||||
shell.classList.add(`pos-${nextPosition}`);
|
||||
badge.classList.add(`pos-${nextPosition}`);
|
||||
}, position);
|
||||
const valuePoint = await page.$eval(selector('d_temp'), (node, nextPosition) => {
|
||||
const value = node.querySelector('.value-badge').getBoundingClientRect();
|
||||
const center = { x: value.left + value.width / 2, y: value.top + value.height / 2 };
|
||||
if (nextPosition === 'right') center.x = value.right - 1;
|
||||
if (nextPosition === 'left') center.x = value.left + 1;
|
||||
if (nextPosition === 'bottom') center.y = value.bottom - 1;
|
||||
if (nextPosition === 'top') center.y = value.top + 1;
|
||||
return center;
|
||||
}, position);
|
||||
await page.mouse.move(valuePoint.x, valuePoint.y);
|
||||
await page.waitForTimeout(220);
|
||||
const hover = await page.$eval(selector('d_temp'), (node, point) => {
|
||||
return {
|
||||
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
|
||||
hovered: node.matches(':hover'),
|
||||
target: node.getRootNode().elementFromPoint(point.x, point.y)?.className || '',
|
||||
};
|
||||
}, valuePoint);
|
||||
await page.mouse.click(valuePoint.x, valuePoint.y);
|
||||
const actionCount = await page.evaluate(() => window.__capsuleActionCount);
|
||||
capsulePositions.push({ position, hover, actionAccepted: actionCount === index + 1 });
|
||||
}
|
||||
const capsuleCorePoint = await page.$eval(selector('d_temp'), (node) => {
|
||||
const core = node.querySelector('.device-core').getBoundingClientRect();
|
||||
return { x: core.left + core.width / 2, y: core.top + core.height / 2 };
|
||||
});
|
||||
await page.mouse.click(capsuleCorePoint.x, capsuleCorePoint.y);
|
||||
const capsuleActions = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const count = window.__capsuleActionCount;
|
||||
c._openMoreInfo = window.__capsuleOriginalMoreInfo;
|
||||
delete window.__capsuleOriginalMoreInfo;
|
||||
delete window.__capsuleActionCount;
|
||||
return count;
|
||||
});
|
||||
await page.$eval(selector('d_tv'), (node) => {
|
||||
node.classList.remove('theme-light');
|
||||
node.classList.add('theme-dark');
|
||||
});
|
||||
await page.mouse.move(1, 1);
|
||||
await page.waitForTimeout(220);
|
||||
|
||||
const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable, ordinaryHover, lightHover, capsulePositions, capsuleActions, reference }) => {
|
||||
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 lightFrame = light?.querySelector('.device-shell-frame');
|
||||
const lightCore = light?.querySelector('.device-core');
|
||||
const shellRect = rect(lightFrame);
|
||||
const coreRect = rect(lightCore);
|
||||
const presence = node('d_motion');
|
||||
const unavailable = node('d_kettle');
|
||||
const text = node('d_tv');
|
||||
const lqiSamples = ['d_temp', 'd_motion', 'd_window', 'd_lamp'].map((id) => {
|
||||
const marker = node(id);
|
||||
const label = marker?.querySelector('.lqi');
|
||||
const value = Number(label?.textContent?.trim());
|
||||
if (!label || !Number.isFinite(value)) return null;
|
||||
const hue = Math.max(0, Math.min(120, Math.round(((value - 40) / 140) * 120)));
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = `hsl(${hue}, 85%, 55%)`;
|
||||
sr().append(probe);
|
||||
const expected = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return { value, actual: getComputedStyle(label).color, expected };
|
||||
}).filter(Boolean);
|
||||
const textCoreRect = rect(text?.querySelector('.device-core'));
|
||||
const textCoreRadius = parseFloat(
|
||||
getComputedStyle(text?.querySelector('.device-core')).borderTopLeftRadius,
|
||||
);
|
||||
const double = node('d_temp');
|
||||
const doubleShellRect = rect(double?.querySelector('.device-shell-frame'));
|
||||
const doubleCoreRect = rect(double?.querySelector('.device-core'));
|
||||
const doubleValueRect = rect(double?.querySelector('.value-badge'));
|
||||
const pseudo = getComputedStyle(light, '::before');
|
||||
lightCore.style.transition = 'none';
|
||||
lightFrame.style.transition = 'none';
|
||||
text.querySelector('.device-core').style.transition = 'none';
|
||||
text.querySelector('.device-shell-frame').style.transition = 'none';
|
||||
const coreStyle = getComputedStyle(lightCore);
|
||||
const activeShellStyle = getComputedStyle(lightFrame);
|
||||
const valueRadius = parseFloat(getComputedStyle(double?.querySelector('.value-badge')).borderTopLeftRadius);
|
||||
const coreRadiusText = coreStyle.borderTopLeftRadius;
|
||||
const radiusIsHalf = (value, rectangle) => value.endsWith('%')
|
||||
? Math.abs(parseFloat(value) - 50) <= 0.01
|
||||
: Math.abs(parseFloat(value) - rectangle.width / 2) <= 0.5;
|
||||
|
||||
const lockNode = node('d_lock');
|
||||
lockNode.querySelector('.device-core').style.transition = 'none';
|
||||
lockNode.querySelector('.device-shell-frame').style.transition = 'none';
|
||||
const darkThemeClassProjected = lockNode.classList.contains('theme-dark');
|
||||
const darkThemeCore = getComputedStyle(lockNode.querySelector('.device-core')).backgroundColor;
|
||||
const darkLockGlyph = getComputedStyle(lockNode.querySelector('.device-core')).color;
|
||||
const darkLockShell = getComputedStyle(lockNode.querySelector('.device-shell-frame')).borderColor;
|
||||
const originalLockClasses = lockNode.className;
|
||||
const originalLockStyle = lockNode.getAttribute('style') || '';
|
||||
lockNode.style.setProperty('--device-base-size', '50.4px');
|
||||
lockNode.style.setProperty('--dev-scale', '1');
|
||||
const stateClasses = [
|
||||
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
|
||||
'sel', 'lock-locked', 'lock-unlocked',
|
||||
];
|
||||
const sampleState = (theme, classes = []) => {
|
||||
lockNode.classList.remove(...stateClasses);
|
||||
lockNode.classList.add(`theme-${theme}`, ...classes);
|
||||
const core = getComputedStyle(lockNode.querySelector('.device-core'));
|
||||
const shell = getComputedStyle(lockNode.querySelector('.device-shell-frame'));
|
||||
return {
|
||||
core: core.backgroundColor,
|
||||
glyph: core.color,
|
||||
shell: shell.borderColor,
|
||||
shellWidth: parseFloat(shell.borderTopWidth),
|
||||
shellStyle: shell.borderStyle,
|
||||
opacity: getComputedStyle(lockNode).opacity,
|
||||
};
|
||||
};
|
||||
const stateMatrix = Object.fromEntries(['light', 'dark'].map((theme) => [theme, {
|
||||
default: sampleState(theme),
|
||||
active: sampleState(theme, ['on']),
|
||||
lock: sampleState(theme, ['lock-locked']),
|
||||
unlock: sampleState(theme, ['lock-unlocked']),
|
||||
alert: sampleState(theme, ['alarm']),
|
||||
virtual: sampleState(theme, ['virtual']),
|
||||
unavailable: sampleState(theme, ['unavail']),
|
||||
}]));
|
||||
lockNode.className = originalLockClasses;
|
||||
lockNode.setAttribute('style', originalLockStyle);
|
||||
|
||||
const originalLightStyle = light.getAttribute('style') || '';
|
||||
const originalDoubleStyle = double.getAttribute('style') || '';
|
||||
const sizeMatrix = [
|
||||
{ configured: 32, effective: 28.8 },
|
||||
{ configured: 56, effective: 50.4 },
|
||||
{ configured: 96, effective: 86.4 },
|
||||
].map(({ configured, effective }) => {
|
||||
light.style.setProperty('--device-base-size', `${effective}px`);
|
||||
light.style.setProperty('--dev-scale', '1');
|
||||
double.style.setProperty('--device-base-size', `${effective}px`);
|
||||
double.style.setProperty('--dev-scale', '1');
|
||||
const sizedCore = rect(light.querySelector('.device-core'));
|
||||
const sizedShell = rect(light.querySelector('.device-shell-frame'));
|
||||
const sizedIcon = rect(light.querySelector('ha-icon'));
|
||||
const sizedValue = rect(double.querySelector('.value-badge'));
|
||||
const iconSvg = light.querySelector('ha-icon')?.shadowRoot?.querySelector('svg');
|
||||
const pathBox = iconSvg?.querySelector('path')?.getBBox();
|
||||
return {
|
||||
configured,
|
||||
effective,
|
||||
core: sizedCore?.width || 0,
|
||||
shell: sizedShell?.width || 0,
|
||||
coreRadius: getComputedStyle(light.querySelector('.device-core')).borderTopLeftRadius,
|
||||
valueHeight: sizedValue?.height || 0,
|
||||
valueRadius: parseFloat(getComputedStyle(double.querySelector('.value-badge')).borderTopLeftRadius),
|
||||
iconViewport: sizedIcon?.width || 0,
|
||||
paintedWidth: pathBox && sizedIcon ? pathBox.width / 24 * sizedIcon.width : 0,
|
||||
paintedHeight: pathBox && sizedIcon ? pathBox.height / 24 * sizedIcon.height : 0,
|
||||
};
|
||||
});
|
||||
light.setAttribute('style', originalLightStyle);
|
||||
double.setAttribute('style', originalDoubleStyle);
|
||||
|
||||
light.classList.remove('theme-dark');
|
||||
light.classList.add('theme-light');
|
||||
const lightActiveGlyph = getComputedStyle(lightCore).color;
|
||||
const lightActiveShell = getComputedStyle(lightFrame).borderColor;
|
||||
light.classList.remove('theme-light');
|
||||
light.classList.add('theme-dark');
|
||||
|
||||
const defaultShell = getComputedStyle(text.querySelector('.device-shell-frame')).borderColor;
|
||||
text.classList.remove('theme-dark');
|
||||
text.classList.add('theme-light');
|
||||
const lightThemeCore = getComputedStyle(text.querySelector('.device-core')).backgroundColor;
|
||||
text.classList.remove('theme-light');
|
||||
text.classList.add('theme-dark');
|
||||
text.classList.add('sel');
|
||||
const selectedShell = getComputedStyle(text.querySelector('.device-shell-frame')).borderColor;
|
||||
const selectedCoreDecoration = getComputedStyle(text.querySelector('.device-core')).boxShadow;
|
||||
text.classList.remove('sel');
|
||||
text.focus({ focusVisible: true });
|
||||
const focusVisibleMatched = text.matches(':focus-visible');
|
||||
const focusShell = getComputedStyle(text.querySelector('.device-shell-frame')).borderColor;
|
||||
const focusCoreDecoration = getComputedStyle(text.querySelector('.device-core')).boxShadow;
|
||||
text.blur();
|
||||
|
||||
const originalCallService = c.hass.callService;
|
||||
let actionDispatches = 0;
|
||||
c.hass.callService = async () => { actionDispatches++; };
|
||||
light.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await Promise.resolve();
|
||||
const pressAnimation = lightShell.getAnimations().find((animation) =>
|
||||
animation.effect?.getTiming().duration === 200);
|
||||
const pressFrames = pressAnimation?.effect?.getKeyframes?.() || [];
|
||||
const acceptedActionFeedback = actionDispatches === 1
|
||||
&& pressAnimation?.effect?.getTiming().duration === 200
|
||||
&& pressFrames.some((frame) => frame.scale === '0.95');
|
||||
await new Promise((resolve) => setTimeout(resolve, 240));
|
||||
const feedbackLifecycleBounded = !lightShell.getAnimations().some((animation) =>
|
||||
animation.effect?.getTiming().duration === 200);
|
||||
c._reducedMotion = true;
|
||||
light.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await Promise.resolve();
|
||||
const reducedAnimation = lightShell.getAnimations().find((animation) =>
|
||||
animation.effect?.getTiming().duration === 200);
|
||||
const reducedFrames = reducedAnimation?.effect?.getKeyframes?.() || [];
|
||||
const reducedMotionUsesNoScale = !!reducedAnimation
|
||||
&& reducedFrames.every((frame) => frame.scale == null)
|
||||
&& reducedFrames.some((frame) => frame.outlineWidth === '2px');
|
||||
c._cancelDevicePressFeedback();
|
||||
c._reducedMotion = false;
|
||||
const enterAction = new KeyboardEvent('keydown', {
|
||||
key: 'Enter', bubbles: true, composed: true, cancelable: true,
|
||||
});
|
||||
light.dispatchEvent(enterAction);
|
||||
await Promise.resolve();
|
||||
const keyboardAnimation = lightShell.getAnimations().find((animation) =>
|
||||
animation.effect?.getTiming().duration === 200);
|
||||
const keyboardActionFeedback = enterAction.defaultPrevented && actionDispatches === 3
|
||||
&& keyboardAnimation?.effect?.getKeyframes?.().some((frame) => frame.scale === '0.95');
|
||||
c._cancelDevicePressFeedback();
|
||||
c.hass.callService = originalCallService;
|
||||
|
||||
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 informationalActionHasNoFeedback = unavailable.querySelector('.device-shell')
|
||||
.getAnimations().every((animation) => animation.effect?.getTiming().duration !== 200);
|
||||
|
||||
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 {
|
||||
__debug: {
|
||||
shellWidth: shellRect?.width,
|
||||
coreWidth: coreRect?.width,
|
||||
shellCoreRatio: shellRect && coreRect ? shellRect.width / coreRect.width : null,
|
||||
coreRadiusText,
|
||||
valueHeight: doubleValueRect?.height,
|
||||
valueRadius,
|
||||
lightActiveGlyph,
|
||||
lightActiveShell,
|
||||
lightClasses: light.className,
|
||||
lightShellVariable: getComputedStyle(light).getPropertyValue('--device-shell-stroke'),
|
||||
lightThemeCore,
|
||||
focusVisibleMatched,
|
||||
focusShell,
|
||||
defaultShell,
|
||||
focusCoreDecoration,
|
||||
stateMatrix,
|
||||
sizeMatrix,
|
||||
reference,
|
||||
lightHover,
|
||||
ordinaryHover,
|
||||
capsulePositions,
|
||||
capsuleActions,
|
||||
lqiSamples,
|
||||
},
|
||||
referenceAssetsLoaded: Object.values(reference).every((theme) => Object.values(theme)
|
||||
.every((state) => state.core.startsWith('rgb')
|
||||
&& state.glyph.startsWith('rgb') && state.shell.startsWith('rgb'))),
|
||||
geometryMatchesAt32_56_96: sizeMatrix.every((sample) =>
|
||||
Math.abs(sample.core - sample.effective) <= 0.5
|
||||
&& Math.abs(sample.shell / sample.core - 1.26875) < 0.03
|
||||
&& sample.coreRadius === '50%'
|
||||
&& Math.abs(sample.valueHeight / sample.core - 0.7875) < 0.015
|
||||
&& Math.abs(sample.valueRadius - sample.valueHeight / 2) <= 0.5
|
||||
&& Math.abs(sample.iconViewport / sample.core - 0.55) < 0.01
|
||||
&& Math.abs(sample.paintedWidth / sample.core - 0.458333) < 0.015
|
||||
&& Math.abs(sample.paintedHeight / sample.core - 0.458333) < 0.015),
|
||||
referenceStateMatrixMatches: ['light', 'dark'].every((theme) =>
|
||||
['default', 'active', 'lock', 'unlock', 'alert'].every((state) =>
|
||||
stateMatrix[theme][state].core === reference[theme][state].core
|
||||
&& stateMatrix[theme][state].glyph === reference[theme][state].glyph
|
||||
&& stateMatrix[theme][state].shell === reference[theme][state].shell
|
||||
&& Math.abs(stateMatrix[theme][state].shellWidth
|
||||
- Math.max(1, Math.floor(reference[theme][state].shellWidth * 50.4))) <= 0.5)),
|
||||
referenceHoverMatches: lightHover.core === reference.light.hover.core
|
||||
&& lightHover.glyph === reference.light.hover.glyph
|
||||
&& lightHover.shell === reference.light.default.shell
|
||||
&& ordinaryHover.core === reference.dark.hover.core
|
||||
&& ordinaryHover.glyph === reference.dark.hover.glyph
|
||||
&& ordinaryHover.shell === reference.dark.default.shell,
|
||||
virtualUsesThemeDefaultWithDashedShell: ['light', 'dark'].every((theme) =>
|
||||
stateMatrix[theme].virtual.core === reference[theme].default.core
|
||||
&& stateMatrix[theme].virtual.glyph === reference[theme].default.glyph
|
||||
&& stateMatrix[theme].virtual.shell === reference[theme].default.shell
|
||||
&& stateMatrix[theme].virtual.shellStyle === 'dashed'),
|
||||
sharedShellGeometry: !!shellRect && !!coreRect
|
||||
&& Math.abs(shellRect.width / coreRect.width - 1.26875) < 0.03,
|
||||
iconCoreIsCircle: !!coreRect && radiusIsHalf(coreRadiusText, coreRect),
|
||||
textCoreIsStadium: !!textCoreRect && textCoreRect.width > textCoreRect.height
|
||||
&& Math.abs(textCoreRadius - textCoreRect.height / 2) <= 0.5,
|
||||
valueBadgeIsPill: !!doubleValueRect
|
||||
&& Math.abs(valueRadius - doubleValueRect.height / 2) <= 0.5,
|
||||
packageShadowColor: getComputedStyle(lightFrame).boxShadow.includes('37, 40, 45'),
|
||||
noBackdropBlur: getComputedStyle(lightFrame).backdropFilter === 'none',
|
||||
activeUsesPackageAmber: getComputedStyle(lightCore).backgroundColor === 'rgb(240, 160, 12)'
|
||||
&& activeShellStyle.borderColor === 'rgb(240, 160, 12)'
|
||||
&& getComputedStyle(lightCore).color === 'rgb(37, 37, 37)',
|
||||
lightActiveUsesWhiteGlyphAndAmberShell: lightActiveGlyph === 'rgb(255, 255, 255)'
|
||||
&& lightActiveShell === 'rgb(240, 160, 12)',
|
||||
darkDefaultShellUsesPackageStroke: defaultShell === 'rgba(37, 37, 37, 0.75)',
|
||||
darkHoverChangesCoreOnly: ordinaryHover.core === 'rgb(12, 130, 240)'
|
||||
&& ordinaryHover.glyph === 'rgb(37, 37, 37)'
|
||||
&& ordinaryHover.shell === 'rgba(37, 37, 37, 0.75)',
|
||||
selectionDecoratesCoreNotShell: selectedShell === defaultShell
|
||||
&& selectedCoreDecoration.includes('240, 160, 12'),
|
||||
focusDecoratesCoreNotShell: focusVisibleMatched && focusShell === defaultShell
|
||||
&& focusCoreDecoration.includes('12, 130, 240'),
|
||||
acceptedActionFeedback,
|
||||
feedbackLifecycleBounded,
|
||||
reducedMotionUsesNoScale,
|
||||
keyboardActionFeedback,
|
||||
informationalActionHasNoFeedback,
|
||||
lightThemeCoreIsWhite: lightThemeCore === 'rgb(255, 255, 255)',
|
||||
darkThemeCoreIs252525: darkThemeClassProjected && darkThemeCore === 'rgb(37, 37, 37)',
|
||||
darkLockUsesWhiteGlyphAndDarkShell: darkLockGlyph === 'rgb(255, 255, 255)'
|
||||
&& darkLockShell === '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',
|
||||
lqiUsesContinuousComputedColor: lqiSamples.length >= 3
|
||||
&& lqiSamples.every((sample) => sample.actual === sample.expected)
|
||||
&& lqiSamples.some((sample) => sample.value > 41 && sample.value < 179
|
||||
&& sample.actual !== 'rgb(240, 160, 12)'),
|
||||
unavailableHasNoHoverOrMotion: JSON.stringify(beforeUnavailable) === JSON.stringify(afterUnavailable)
|
||||
&& beforeUnavailable.opacity === '0.35' && !unavailable.querySelector('.device-pulse'),
|
||||
valueCapsuleOwnsHoverAndActionAtEveryPosition: capsulePositions.length === 4
|
||||
&& capsulePositions.every(({ hover, actionAccepted }) => hover.hovered
|
||||
&& hover.core === 'rgb(12, 130, 240)'
|
||||
&& String(hover.target).includes('device-shell-frame')
|
||||
&& actionAccepted)
|
||||
&& capsuleActions === 5,
|
||||
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, ordinaryHover, lightHover, capsulePositions, capsuleActions, reference });
|
||||
|
||||
const { __debug, ...out } = result;
|
||||
if (Object.values(out).some((value) => value !== true)) {
|
||||
console.error(`device icon debug: ${JSON.stringify(__debug, null, 2)}`);
|
||||
}
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,186 @@
|
||||
// Issue #213: dense fractional geometry plus painted-centroid proof at common DPRs.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const dprs = [1, 1.25, 1.5, 2];
|
||||
const paintedSizes = [24, 24.25, 31.75, 32, 47.25, 50.4, 55.75, 63.25, 79.75, 95.5, 111.75, 112];
|
||||
const evidence = [];
|
||||
|
||||
for (const dpr of dprs) {
|
||||
const { page, browser } = await launch(
|
||||
{ width: 980, height: 760 }, dpr, [], { colorScheme: 'light' },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, themes: { ...(c.hass.themes || {}), darkMode: false } };
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const marker = root.querySelector('.dev[data-id="d_light1"]');
|
||||
const layer = marker.closest('.devlayer');
|
||||
for (const node of layer.children) {
|
||||
if (node !== marker) node.style.display = 'none';
|
||||
}
|
||||
for (const node of layer.parentElement.children) {
|
||||
if (node !== layer) node.style.visibility = 'hidden';
|
||||
}
|
||||
marker.className = 'dev theme-light';
|
||||
marker.style.left = '50%';
|
||||
marker.style.top = '50%';
|
||||
marker.style.setProperty('--dev-scale', '1');
|
||||
marker.style.setProperty('--device-face-bg', 'rgb(0 255 0)');
|
||||
marker.style.setProperty('--device-face-fg', 'rgb(0 255 0)');
|
||||
marker.style.setProperty('--device-shell-stroke', 'rgb(255 0 0)');
|
||||
for (const extra of marker.querySelectorAll(
|
||||
'.device-pulse, .activity-dot, .newdot, .habadge, .lqi, .device-sections',
|
||||
)) extra.style.display = 'none';
|
||||
marker.querySelector('ha-icon').style.visibility = 'hidden';
|
||||
marker.querySelector('.device-core').style.transition = 'none';
|
||||
marker.querySelector('.device-core').style.boxShadow = 'none';
|
||||
const frame = marker.querySelector('.device-shell-frame');
|
||||
frame.style.transition = 'none';
|
||||
frame.style.boxShadow = 'none';
|
||||
frame.style.border = '0';
|
||||
// A solid diagnostic shell makes the alpha-weighted centroid stable even
|
||||
// when the production stroke is only one sparse physical pixel wide.
|
||||
frame.style.background = 'rgb(255 0 0)';
|
||||
root.querySelector('.stage').style.background = 'rgb(0 0 255)';
|
||||
});
|
||||
|
||||
const dense = await page.evaluate(async () => {
|
||||
const root = window.__card.shadowRoot || window.__card.renderRoot;
|
||||
const marker = root.querySelector('.dev[data-id="d_light1"]');
|
||||
const core = marker.querySelector('.device-core');
|
||||
const frame = marker.querySelector('.device-shell-frame');
|
||||
let maxDx = 0;
|
||||
let maxDy = 0;
|
||||
let maxSizeError = 0;
|
||||
let samples = 0;
|
||||
for (let quarter = 24 * 4; quarter <= 112 * 4; quarter++) {
|
||||
const size = quarter / 4;
|
||||
marker.style.setProperty('--device-base-size', `${size}px`);
|
||||
// A style/layout read is intentional: this smoke enumerates browser
|
||||
// quantisation, production has no per-marker measurement loop.
|
||||
const a = core.getBoundingClientRect();
|
||||
const b = frame.getBoundingClientRect();
|
||||
maxDx = Math.max(maxDx, Math.abs((a.left + a.width / 2) - (b.left + b.width / 2)));
|
||||
maxDy = Math.max(maxDy, Math.abs((a.top + a.height / 2) - (b.top + b.height / 2)));
|
||||
maxSizeError = Math.max(maxSizeError, Math.abs(a.width - size), Math.abs(a.height - size));
|
||||
samples++;
|
||||
}
|
||||
return { samples, maxDx, maxDy, maxSizeError };
|
||||
});
|
||||
|
||||
const painted = [];
|
||||
for (const size of paintedSizes) {
|
||||
const clip = await page.evaluate(async (nextSize) => {
|
||||
const root = window.__card.shadowRoot || window.__card.renderRoot;
|
||||
const marker = root.querySelector('.dev[data-id="d_light1"]');
|
||||
marker.style.setProperty('--device-base-size', `${nextSize}px`);
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const r = marker.querySelector('.device-core').getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2;
|
||||
const cy = r.top + r.height / 2;
|
||||
// Fixed integer clip bounds remove screenshot-crop quantisation from the
|
||||
// centroid comparison; all tested shells fit with a generous margin.
|
||||
return { x: Math.floor(cx - 85), y: Math.floor(cy - 85), width: 170, height: 170 };
|
||||
}, size);
|
||||
await page.evaluate(() => {
|
||||
const marker = (window.__card.shadowRoot || window.__card.renderRoot)
|
||||
.querySelector('.dev[data-id="d_light1"]');
|
||||
marker.querySelector('.device-core').style.visibility = 'hidden';
|
||||
marker.querySelector('.device-shell-frame').style.visibility = 'visible';
|
||||
});
|
||||
const shellPng = await page.screenshot({ type: 'png', clip });
|
||||
await page.evaluate(() => {
|
||||
const marker = (window.__card.shadowRoot || window.__card.renderRoot)
|
||||
.querySelector('.dev[data-id="d_light1"]');
|
||||
marker.querySelector('.device-core').style.visibility = 'visible';
|
||||
marker.querySelector('.device-shell-frame').style.visibility = 'hidden';
|
||||
});
|
||||
const corePng = await page.screenshot({ type: 'png', clip });
|
||||
await page.evaluate(() => {
|
||||
const marker = (window.__card.shadowRoot || window.__card.renderRoot)
|
||||
.querySelector('.dev[data-id="d_light1"]');
|
||||
marker.querySelector('.device-shell-frame').style.visibility = 'visible';
|
||||
});
|
||||
const centroid = await page.evaluate(async ({ shellBase64, coreBase64 }) => {
|
||||
const point = async (base64, channel) => {
|
||||
const raw = atob(base64);
|
||||
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0));
|
||||
const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data;
|
||||
let weight = 0, weightedX = 0, weightedY = 0;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (let y = 0; y < bitmap.height; y++) for (let x = 0; x < bitmap.width; x++) {
|
||||
const i = (y * bitmap.width + x) * 4;
|
||||
const [r, g, b, a] = [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]];
|
||||
const primary = channel === 'red' ? r : g;
|
||||
const other = channel === 'red' ? Math.max(g, b) : Math.max(r, b);
|
||||
const sample = Math.max(0, primary - other) * a / 255;
|
||||
weight += sample; weightedX += x * sample; weightedY += y * sample;
|
||||
if (sample > 0) {
|
||||
minX = Math.min(minX, x); minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: weightedX / weight, y: weightedY / weight, weight,
|
||||
boundsX: (minX + maxX) / 2, boundsY: (minY + maxY) / 2,
|
||||
};
|
||||
};
|
||||
// Capture each paint layer in isolation. Subtracting the large core from
|
||||
// a one-pixel annulus amplifies harmless edge AA into a false centroid.
|
||||
const red = await point(shellBase64, 'red');
|
||||
const green = await point(coreBase64, 'green');
|
||||
return {
|
||||
dx: Math.abs(red.x - green.x),
|
||||
dy: Math.abs(red.y - green.y),
|
||||
boundsDx: Math.abs(red.boundsX - green.boundsX),
|
||||
boundsDy: Math.abs(red.boundsY - green.boundsY),
|
||||
redWeight: red.weight,
|
||||
greenWeight: green.weight,
|
||||
};
|
||||
}, { shellBase64: shellPng.toString('base64'), coreBase64: corePng.toString('base64') });
|
||||
painted.push({ size, ...centroid });
|
||||
}
|
||||
|
||||
const mutant = await page.evaluate(async () => {
|
||||
const root = window.__card.shadowRoot || window.__card.renderRoot;
|
||||
const marker = root.querySelector('.dev[data-id="d_light1"]');
|
||||
marker.style.setProperty('--device-base-size', '50.4px');
|
||||
const frame = marker.querySelector('.device-shell-frame');
|
||||
frame.style.transform = 'translateX(1px)';
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const core = marker.querySelector('.device-core').getBoundingClientRect();
|
||||
const shell = frame.getBoundingClientRect();
|
||||
return Math.abs((core.left + core.width / 2) - (shell.left + shell.width / 2));
|
||||
});
|
||||
|
||||
evidence.push({ dpr, dense, painted, mutant });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const result = {
|
||||
denseQuarterPixelMatrixCovers24To112: evidence.every((row) => row.dense.samples === 353),
|
||||
domCentresShareOneLayoutQuantum: evidence.every((row) =>
|
||||
row.dense.maxDx <= 0.02 && row.dense.maxDy <= 0.02 && row.dense.maxSizeError <= 0.02),
|
||||
paintedCentroidsStayWithinCalibratedHalfCssPixel: evidence.every((row) => row.painted.every((sample) =>
|
||||
sample.redWeight > 0 && sample.greenWeight > 0
|
||||
&& sample.dx <= row.dpr / 2 + 0.1 && sample.dy <= row.dpr / 2 + 0.1)),
|
||||
paintedSupportStaysWithinOneRasterPixel: evidence.every((row) => row.painted.every((sample) =>
|
||||
sample.boundsDx <= 1.01 && sample.boundsDy <= 1.01)),
|
||||
onePixelNudgeMutantIsDetected: evidence.every((row) => row.mutant >= 0.98),
|
||||
};
|
||||
|
||||
if (Object.values(result).some((value) => value !== true)) {
|
||||
console.error(`pixel alignment evidence: ${JSON.stringify(evidence, null, 2)}`);
|
||||
}
|
||||
checkAll(result);
|
||||
await finish(null, result);
|
||||
@@ -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 || [])]
|
||||
@@ -37,6 +37,11 @@ const res = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
const preview = sr().querySelector('hp-device-preview');
|
||||
await preview?.updateComplete;
|
||||
preview?.dispatchEvent(new PointerEvent('pointerover', {
|
||||
pointerType: 'mouse', bubbles: true, composed: true,
|
||||
}));
|
||||
const previewHoverGatePropagates = c.hasAttribute('data-pointer-hover')
|
||||
&& preview?.hasAttribute('data-pointer-hover');
|
||||
const previewFace = face(preview?.renderRoot?.querySelector('.dev'));
|
||||
const providerShown = /demo/i.test(preview?.renderRoot?.querySelector('.previewfacts')?.textContent || '');
|
||||
|
||||
@@ -160,9 +165,17 @@ const res = await page.evaluate(async () => {
|
||||
const staticNode = card.renderRoot.querySelector('.dev[data-id="d_light1"]');
|
||||
const staticFace = face(staticNode);
|
||||
const staticBadgeFace = face(card.renderRoot.querySelector('.dev[data-id="d_temp"]'));
|
||||
const resolvedBaseContract = [
|
||||
sr().querySelector('.dev[data-id="d_light1"]'),
|
||||
persistedPreview?.renderRoot?.querySelector('.dev'),
|
||||
staticNode,
|
||||
].every((node) => node
|
||||
&& getComputedStyle(node).getPropertyValue('--device-visual-factor').trim() === ''
|
||||
&& parseFloat(getComputedStyle(node).getPropertyValue('--device-base-size')) > 0);
|
||||
|
||||
return {
|
||||
allFacesPresent: !!planFace && !!previewFace && !!staticFace,
|
||||
previewHoverGatePropagates,
|
||||
planPreviewEqual: JSON.stringify(planFace) === JSON.stringify(previewFace),
|
||||
planStaticEqual: JSON.stringify(planFace) === JSON.stringify(staticFace),
|
||||
providerShown,
|
||||
@@ -175,6 +188,7 @@ const res = await page.evaluate(async () => {
|
||||
badgeBounds,
|
||||
valueBadgePlanPreviewParity: JSON.stringify(planBadgeFace) === JSON.stringify(persistedPreviewFace),
|
||||
valueBadgePlanStaticParity: JSON.stringify(planBadgeFace) === JSON.stringify(staticBadgeFace),
|
||||
resolvedBaseContract,
|
||||
bottomBadgeLqiStacked,
|
||||
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -38,12 +38,26 @@ const res = await page.evaluate(async () => {
|
||||
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
|
||||
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
|
||||
} else out.metricsRatio = 'no-metrics';
|
||||
// 4) касание помечает сессию как тач и гасит тултип
|
||||
// 4) modality lives on this card: touch clears transient hover, a synthetic
|
||||
// compatibility mouse cannot restore it, and a real mouse can.
|
||||
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
|
||||
c._hoverRoom = { space: c._space, room: c._curSpaceCfg.rooms[0] };
|
||||
c._notePointer(new PointerEvent('pointerdown', { pointerType: 'touch' }));
|
||||
out.touchClearsTip = c._tip === null;
|
||||
c._showTip(new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), 'x', 'y');
|
||||
out.noTipAfterTouch = !c._tip;
|
||||
out.touchClearsTransientHover = c._tip === null && c._hoverRoom === null
|
||||
&& !c.hasAttribute('data-pointer-hover');
|
||||
const compatibilityMouse = new PointerEvent('pointermove', {
|
||||
pointerType: 'mouse', clientX: 5, clientY: 5,
|
||||
});
|
||||
Object.defineProperty(compatibilityMouse, 'sourceCapabilities', {
|
||||
value: { firesTouchEvents: true },
|
||||
});
|
||||
c._showTip(compatibilityMouse, 'compatibility', 'ignored');
|
||||
out.compatibilityMouseIgnored = !c._tip && !c.hasAttribute('data-pointer-hover');
|
||||
c._showTip(new PointerEvent('pointermove', {
|
||||
pointerType: 'mouse', clientX: 7, clientY: 7,
|
||||
}), 'real mouse', 'restored');
|
||||
out.realMouseRestoresHover = c._tip?.title === 'real mouse'
|
||||
&& c.hasAttribute('data-pointer-hover');
|
||||
return out;
|
||||
});
|
||||
check('metricsRatio 0.75', res.metricsRatio, 0.75);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const base = window.__card;
|
||||
const ids = base._model.map((space) => space.id);
|
||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const mount = async (config) => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', ...config });
|
||||
card.hass = base.hass;
|
||||
document.body.appendChild(card);
|
||||
await wait(450);
|
||||
card.hass = { ...base.hass };
|
||||
await card.updateComplete;
|
||||
return card;
|
||||
};
|
||||
const root = (card) => card.shadowRoot || card.renderRoot;
|
||||
|
||||
localStorage.setItem('houseplan_card_nav_v1', JSON.stringify({ space: ids[1] }));
|
||||
location.hash = `#space=${ids[1]}`;
|
||||
|
||||
const fixedId = await mount({ floor: ids[0] });
|
||||
out.idWinsEveryInitialSource = fixedId._space === ids[0]
|
||||
&& fixedId._fixedFloorState().kind === 'valid';
|
||||
out.onlyFixedTab = root(fixedId).querySelectorAll('[data-hp="space-tab"]').length === 1
|
||||
&& root(fixedId).querySelector('[data-hp="space-tab"]')?.dataset.id === ids[0]
|
||||
&& !root(fixedId).querySelector('.tabadd');
|
||||
fixedId._pickSpace(ids[1]);
|
||||
await fixedId.updateComplete;
|
||||
out.tabAndTransitionBlocked = fixedId._space === ids[0]
|
||||
&& fixedId._commitSpace(ids[1]) === false;
|
||||
fixedId._saveNav();
|
||||
out.fixedDoesNotWriteNav = JSON.parse(localStorage.getItem('houseplan_card_nav_v1')).space === ids[1];
|
||||
|
||||
location.hash = `#space=${ids[1]}`;
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await fixedId.updateComplete;
|
||||
out.hashIgnored = fixedId._space === ids[0];
|
||||
|
||||
const fixedIndex = await mount({ floor: 1, kiosk: true, cycle: 1 });
|
||||
out.indexUsesServerOrder = fixedIndex._loadOk === true && fixedIndex._space === ids[1];
|
||||
const indexStart = fixedIndex._space;
|
||||
fixedIndex._cyclePausedUntil = 0;
|
||||
fixedIndex._cycleTick();
|
||||
await fixedIndex.updateComplete;
|
||||
out.fixedKioskCycleDisabled = fixedIndex._space === indexStart && fixedIndex._cycleTimer == null;
|
||||
out.fixedKioskDotsHidden = !root(fixedIndex).querySelector('.kioskdots');
|
||||
const stage = root(fixedIndex).querySelector('.stage');
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, pointerId: 41, clientX: 600, clientY: 300,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
bubbles: true, composed: true, pointerId: 41, clientX: 420, clientY: 305,
|
||||
}));
|
||||
await fixedIndex.updateComplete;
|
||||
out.fixedKioskSwipeDisabled = fixedIndex._space === indexStart;
|
||||
|
||||
const remount = await mount({ floor: ids[0] });
|
||||
out.remountStillFixed = remount._space === ids[0];
|
||||
|
||||
const invalid = await mount({ floor: '__missing_fixed_floor__' });
|
||||
const invalidRoot = root(invalid);
|
||||
out.invalidFailsVisibly = invalidRoot.querySelector('[data-fixed-floor-state="invalid"]')
|
||||
?.dataset.fixedFloorReason === 'unknown-id'
|
||||
&& !!invalidRoot.querySelector('[role="alert"]')
|
||||
&& !invalidRoot.querySelector('.stage');
|
||||
|
||||
location.hash = '';
|
||||
const ordinary = await mount({});
|
||||
out.legacyCardStillRestoresNav = ordinary._space === ids[1]
|
||||
&& ordinary._fixedFloorState().kind === 'absent';
|
||||
|
||||
const editor = document.createElement('houseplan-card-editor');
|
||||
editor.hass = base.hass;
|
||||
editor.setConfig({ type: 'custom:houseplan-card', floor: 1, title: 'Fixed' });
|
||||
document.body.appendChild(editor);
|
||||
await wait(150);
|
||||
let changed = null;
|
||||
editor.addEventListener('config-changed', (event) => { changed = event.detail.config; });
|
||||
editor._valueChanged(new CustomEvent('value-changed', {
|
||||
detail: { value: { ...editor._formData, title: 'Changed' } },
|
||||
}));
|
||||
out.editorPreservesYamlIndex = changed?.floor === 1 && changed?.title === 'Changed';
|
||||
editor._valueChanged(new CustomEvent('value-changed', {
|
||||
detail: { value: { ...editor._formData, floor: '' } },
|
||||
}));
|
||||
out.editorClearDeletesKey = changed && !Object.hasOwn(changed, 'floor');
|
||||
|
||||
for (const card of [fixedId, fixedIndex, remount, invalid, ordinary, editor]) card.remove();
|
||||
location.hash = '';
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -52,12 +52,28 @@ const out = await page.evaluate(async () => {
|
||||
// Keep the smoke local: editor transactions still mutate the real config,
|
||||
// invalidate derived models and history, but do not need a websocket write.
|
||||
c._saveConfig = () => { c._cfgEpoch++; c._modelCache = null; c.requestUpdate(); };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
c._tool = 'partition';
|
||||
c._drawWallField = '15';
|
||||
c._partitionClick([100, 100], false);
|
||||
c._partitionClick([300, 100], false);
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const clickAt = (x, y) => {
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const view = c._viewOr(c._baseVb());
|
||||
return new MouseEvent('click', {
|
||||
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
|
||||
bubbles: true,
|
||||
});
|
||||
};
|
||||
const drawAndFinish = (a, b, cm) => {
|
||||
c._activateMarkupTool('draw');
|
||||
c._drawWallField = String(cm);
|
||||
c._markupClick(clickAt(...a));
|
||||
c._markupClick(clickAt(...b));
|
||||
c._activateMarkupTool('select');
|
||||
};
|
||||
|
||||
drawAndFinish([100, 100], [300, 100], 15);
|
||||
let sp = c._serverCfg.spaces[0];
|
||||
o.partitionCreated = sp.partitions?.length === 1 && sp.partitions[0].cm === 15;
|
||||
|
||||
@@ -174,10 +190,7 @@ const out = await page.evaluate(async () => {
|
||||
c._deletePhysicalSelection();
|
||||
o.deleteOnDraftRemovesWholeOutline = !c._serverCfg.spaces[0].room_drafts;
|
||||
|
||||
c._tool = 'partition';
|
||||
c._drawWallField = '120';
|
||||
c._partitionClick([400, 100], false);
|
||||
c._partitionClick([500, 100], false);
|
||||
drawAndFinish([400, 100], [500, 100], 120);
|
||||
o.invalidThicknessCreatesNothing = !c._serverCfg.spaces[0].partitions;
|
||||
|
||||
return o;
|
||||
|
||||
@@ -239,7 +239,7 @@ const res = await page.evaluate(async () => {
|
||||
const poolBeforeHover = sr().querySelector('.glow-pool');
|
||||
const gradientBeforeHover = sr().querySelector('radialGradient[id^="hp-glow-"]');
|
||||
const roomEl = sr().querySelector('.room');
|
||||
roomEl?.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
roomEl?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
|
||||
await c.updateComplete;
|
||||
const hoverFill = sr().querySelector('.room-hover-fill');
|
||||
const hoverFillLayer = sr().querySelector('.room-hover-fill-layer');
|
||||
@@ -263,7 +263,7 @@ const res = await page.evaluate(async () => {
|
||||
// late outline must follow it; absence is not a layer-order failure.
|
||||
&& (!wallLayer
|
||||
|| !!(wallLayer.compareDocumentPosition(hoverOutlineLayer) & Node.DOCUMENT_POSITION_FOLLOWING));
|
||||
roomEl?.dispatchEvent(new MouseEvent('mouseleave'));
|
||||
roomEl?.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' }));
|
||||
await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -47,6 +47,14 @@ const res = await page.evaluate(async () => {
|
||||
return help?.shadowRoot?.querySelector('.tooltip')
|
||||
|| root().querySelector('hp-dialog')?.shadowRoot?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
|
||||
};
|
||||
const enterWithRealMouse = (target) => {
|
||||
target?.dispatchEvent(new PointerEvent('pointerover', {
|
||||
pointerType: 'mouse', bubbles: true, composed: true,
|
||||
}));
|
||||
target?.dispatchEvent(new PointerEvent('pointerenter', {
|
||||
pointerType: 'mouse', bubbles: false, composed: true,
|
||||
}));
|
||||
};
|
||||
|
||||
// Empty or incomplete content must not leave a dead focus target behind.
|
||||
const incompleteHelp = document.createElement('hp-help');
|
||||
@@ -103,7 +111,7 @@ const res = await page.evaluate(async () => {
|
||||
&& !roleButton.hasAttribute('aria-describedby');
|
||||
|
||||
const beforeHoverGeometry = scrollGeometry();
|
||||
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
|
||||
enterWithRealMouse(roleButton);
|
||||
await wait(330);
|
||||
out.mouseHover = roleButton?.getAttribute('aria-expanded') === 'true'
|
||||
&& roleButton.getAttribute('aria-describedby') === roleDescription?.id
|
||||
@@ -118,7 +126,7 @@ const res = await page.evaluate(async () => {
|
||||
// away from the form control the user was editing.
|
||||
const nameInput = root().querySelector('hp-dialog .namein');
|
||||
nameInput?.focus();
|
||||
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
|
||||
enterWithRealMouse(roleButton);
|
||||
await wait(330);
|
||||
nameInput?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
|
||||
@@ -44,8 +44,9 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
|
||||
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
|
||||
// и он синий, а не тёмный — отличим от недоступного устройства
|
||||
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
|
||||
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
|
||||
const ghostShell = g?.querySelector('.device-shell-frame');
|
||||
o.ghostIsBlue = !!ghostShell && getComputedStyle(ghostShell).borderStyle.includes('dashed')
|
||||
&& getComputedStyle(ghostShell).borderColor !== 'rgb(255, 255, 255)';
|
||||
// тумблер локальный: конфиг не трогается
|
||||
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// #203: `show_names:false` means no permanent room labels in every read-only
|
||||
// renderer. Plan keeps its editor-only card and saving never discards layout.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = (node = card) => node.renderRoot || node.shadowRoot;
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const settle = async () => { await card.updateComplete; await frame(); };
|
||||
const waitMode = async () => {
|
||||
await settle();
|
||||
while (card._modeTransitionBusy) await frame();
|
||||
};
|
||||
const labels = (node = card) => root(node).querySelectorAll('[data-hp="room-label"]');
|
||||
const htmlLabels = (node = card) => root(node).querySelectorAll('div.roomlabel[data-hp="room-label"]');
|
||||
const svgLabels = (node = card) => root(node).querySelectorAll('text.rlabel[data-hp="room-label"]');
|
||||
|
||||
card._setMode('view');
|
||||
await waitMode();
|
||||
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
|
||||
delete space.plan_url;
|
||||
delete space.plan_aspect;
|
||||
const room = space.rooms.find((item) => item.name && item.id && item.area)
|
||||
|| space.rooms.find((item) => item.name && item.id);
|
||||
if (!room) throw new Error('fixture has no named room');
|
||||
space.settings = {
|
||||
...(space.settings || {}), show_borders: true, show_names: true,
|
||||
label_temp: true, label_hum: true, label_lqi: true, label_light: true,
|
||||
};
|
||||
const layoutKey = `rl_${room.id}`;
|
||||
card._layout[layoutKey] = { s: space.id, x: 0.23, y: 0.31, k: 1.6 };
|
||||
card._cfgEpoch++;
|
||||
card._regSignature = '';
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
|
||||
const first = root().querySelector(`[data-hp="room-label"][data-id="${room.id}"]`);
|
||||
const savedLayout = JSON.stringify(card._layout[layoutKey]);
|
||||
const trueState = {
|
||||
oneHtmlPerNamedRoom: htmlLabels().length === space.rooms.filter((item) => item.name).length,
|
||||
noLegacySvg: svgLabels().length === 0,
|
||||
savedScaleApplied: first?.getAttribute('style')?.includes('--rl-scale:1.6') === true,
|
||||
areaIconKept: !room.area || !!first?.querySelector('.rlgo'),
|
||||
};
|
||||
|
||||
// Cancel: the live preview follows the pending false, but persisted config
|
||||
// and the room-label layout return untouched when the dialog is dismissed.
|
||||
card._openSpaceDialog('edit', space.id);
|
||||
card._spaceDialog = { ...card._spaceDialog, showNames: false };
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
const livePreviewHidesAll = labels().length === 0;
|
||||
card._spaceDialog = null;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
const cancelRestoresTrue = space.settings.show_names === true && htmlLabels().length > 0
|
||||
&& JSON.stringify(card._layout[layoutKey]) === savedLayout;
|
||||
|
||||
// Save + reopen: exercise the real existing-space persistence path.
|
||||
card._openSpaceDialog('edit', space.id);
|
||||
card._spaceDialog = { ...card._spaceDialog, showNames: false };
|
||||
await card._saveSpaceDialog();
|
||||
await settle();
|
||||
card._openSpaceDialog('edit', space.id);
|
||||
const reopenReadsFalse = card._spaceDialog?.showNames === false
|
||||
&& space.settings.show_names === false;
|
||||
card._spaceDialog = null;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
|
||||
const flatFalseHasNoLabels = labels().length === 0
|
||||
&& htmlLabels().length === 0 && svgLabels().length === 0;
|
||||
|
||||
card._setMode('plan');
|
||||
await waitMode();
|
||||
const planFalseKeepsEditorCard = htmlLabels().length > 0
|
||||
&& svgLabels().length === 0 && !!root().querySelector('.rlgearbtn');
|
||||
card._setMode('view');
|
||||
await waitMode();
|
||||
const viewAfterPlanStillHidden = labels().length === 0;
|
||||
|
||||
// The Labs registry flag is intentionally expired; use the same dormant
|
||||
// renderer fixture hook as the canonical Stage 2 smokes.
|
||||
const active = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active, space: '' });
|
||||
window.__hpLabs = active;
|
||||
await settle();
|
||||
card._setProjection('iso');
|
||||
await settle();
|
||||
const isoFalseHasNoLabels = !!root().querySelector('[data-hp="iso-walls"]')
|
||||
&& labels().length === 0;
|
||||
card._setProjection('flat');
|
||||
await settle();
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfgFalse = structuredClone(card._serverCfg);
|
||||
const layout = structuredClone(card._layout);
|
||||
const staticHass = {
|
||||
...card.hass,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return { config: cfgFalse, rev: 1 };
|
||||
if (message.type === 'houseplan/layout/get') return { layout, rev: 1 };
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const compact = document.createElement('houseplan-space-card');
|
||||
compact.setConfig({ type: 'custom:houseplan-space-card', space: space.id, show_button: false });
|
||||
compact.hass = staticHass;
|
||||
host.appendChild(compact);
|
||||
const deadline = Date.now() + 6000;
|
||||
while (!root(compact)?.querySelector('.hp-static-stage') && Date.now() < deadline) {
|
||||
await new Promise((done) => setTimeout(done, 60));
|
||||
}
|
||||
await compact.updateComplete;
|
||||
const compactFalseHasNoLabels = labels(compact).length === 0
|
||||
&& htmlLabels(compact).length === 0 && svgLabels(compact).length === 0;
|
||||
|
||||
// Turning names back on restores the existing HTML card and saved layout.
|
||||
space.settings.show_names = true;
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
const restored = root().querySelector(`[data-hp="room-label"][data-id="${room.id}"]`);
|
||||
const trueRestoresExistingCard = !!restored
|
||||
&& restored.tagName.toLowerCase() === 'div'
|
||||
&& restored.getAttribute('style')?.includes('--rl-scale:1.6') === true
|
||||
&& JSON.stringify(card._layout[layoutKey]) === savedLayout;
|
||||
|
||||
host.remove();
|
||||
card._onLabsSnapshot({ active: Object.freeze([]), space: '' });
|
||||
window.__hpLabs = Object.freeze([]);
|
||||
await settle();
|
||||
|
||||
return {
|
||||
...trueState,
|
||||
livePreviewHidesAll,
|
||||
cancelRestoresTrue,
|
||||
reopenReadsFalse,
|
||||
flatFalseHasNoLabels,
|
||||
planFalseKeepsEditorCard,
|
||||
viewAfterPlanStillHidden,
|
||||
isoFalseHasNoLabels,
|
||||
compactFalseHasNoLabels,
|
||||
trueRestoresExistingCard,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -57,10 +57,10 @@ const out2 = await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
};
|
||||
const fs = (el) => parseFloat(getComputedStyle(el).fontSize);
|
||||
// базовый --icon-size в px: ширина немасштабированного бейджа минус рамка 1px×2
|
||||
// Базовый --icon-size в px — фактический package core без shell stroke.
|
||||
const plain = [...sr().querySelectorAll('.dev')].find(
|
||||
(e) => !e.classList.contains('ghost') && !e.classList.contains('valonly') && !(e.getAttribute('style') || '').includes('--dev-scale'));
|
||||
const iconPx = plain.getBoundingClientRect().width - 2;
|
||||
const iconPx = plain.querySelector('.device-core').getBoundingClientRect().width;
|
||||
|
||||
// --- бейдж значения (valonly) ---
|
||||
await setMarker({ display: 'value', size: 1 });
|
||||
@@ -72,19 +72,19 @@ const out2 = await page.evaluate(async () => {
|
||||
const v2 = devEl().getBoundingClientRect();
|
||||
const vf2 = fs(devEl().querySelector('.valtext'));
|
||||
o.valFontScales = vf2 / vf1 > 1.8 && vf2 / vf1 < 2.2;
|
||||
o.valPlateScales = (v2.height - 2) / (v1.height - 2) > 1.8 && (v2.height - 2) / (v1.height - 2) < 2.2
|
||||
o.valPlateScales = v2.height / v1.height > 1.8 && v2.height / v1.height < 2.2
|
||||
&& v2.width / v1.width > 1.6 && v2.width / v1.width < 2.4;
|
||||
|
||||
// --- temp-плашка (.tval) рядом со значком ---
|
||||
await setMarker({ size: 1 });
|
||||
const t1 = devEl().querySelector('.value-badge').getBoundingClientRect();
|
||||
const tf1 = fs(devEl().querySelector('.value-badge'));
|
||||
// дефолт: line-height 0.68 * icon-size + рамка 1px×2
|
||||
o.tvalDefaultKept = Math.abs(t1.height - (iconPx * 0.68 + 2)) < 1;
|
||||
// Package Double section height is 0.7875 of the icon core.
|
||||
o.tvalDefaultKept = Math.abs(t1.height - iconPx * 0.7875) < 1;
|
||||
await setMarker({ size: 2 });
|
||||
const t2 = devEl().querySelector('.value-badge').getBoundingClientRect();
|
||||
const tf2 = fs(devEl().querySelector('.value-badge'));
|
||||
o.tvalScales = (t2.height - 2) / (t1.height - 2) > 1.8 && (t2.height - 2) / (t1.height - 2) < 2.2
|
||||
o.tvalScales = t2.height / t1.height > 1.8 && t2.height / t1.height < 2.2
|
||||
&& tf2 / tf1 > 1.8 && tf2 / tf1 < 2.2;
|
||||
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== 'd_temp');
|
||||
|
||||
@@ -248,11 +248,11 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
const farIcon = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
// the badge is --dev-size PLUS a 1px border on each side, and the border
|
||||
// is not part of the scaled geometry (same correction as smoke_icon_scale)
|
||||
// Measure the package core: shell stroke/shadow are decoration, while the
|
||||
// saved icon geometry must still scale with the plan.
|
||||
const size = () => {
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
|
||||
const el = sr.querySelector('.devlayer .dev .device-core');
|
||||
return Math.round(el.getBoundingClientRect().width * 100) / 100;
|
||||
};
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
|
||||
@@ -118,7 +118,7 @@ const res = await page.evaluate(async () => {
|
||||
const holdRing = holdEl?.querySelector('.device-pulse.continuous.presence i:first-child');
|
||||
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
|
||||
out.holdAnimated = !!hAfter && hAfter.animationName === 'hp-pulse-continuous';
|
||||
out.holdAnimationCalm = !!hAfter && hAfter.animationDuration === '2.4s';
|
||||
out.holdAnimationCalm = !!hAfter && hAfter.animationDuration === '3.6s';
|
||||
out.holdBgNeutral = !!holdEl && !!neutral
|
||||
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
|
||||
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('activity-event');
|
||||
|
||||
@@ -43,6 +43,33 @@ const out = await page.evaluate(async () => {
|
||||
const contactValues = c._contactCandidates().map((item) => item.value);
|
||||
const lockValues = c._lockCandidates().map((item) => item.value);
|
||||
const openings = c._openingsR;
|
||||
const lockBadge = sr().querySelector('.oplock');
|
||||
const originalLockClass = lockBadge?.className || '';
|
||||
const lockVisual = (theme, state) => {
|
||||
lockBadge.className = `oplock theme-${theme} ${state}`;
|
||||
const shell = lockBadge.querySelector('.oplock-shell');
|
||||
const core = lockBadge.querySelector('.oplock-core');
|
||||
const icon = lockBadge.querySelector('ha-icon');
|
||||
const shellStyle = getComputedStyle(shell);
|
||||
const coreStyle = getComputedStyle(core);
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
const coreRect = core.getBoundingClientRect();
|
||||
const iconRect = icon.getBoundingClientRect();
|
||||
return {
|
||||
shell: shellStyle.borderColor,
|
||||
core: coreStyle.backgroundColor,
|
||||
glyph: coreStyle.color,
|
||||
shellCoreRatio: shellRect.width / coreRect.width,
|
||||
glyphCoreRatio: iconRect.width / coreRect.width,
|
||||
footprint: shellRect.width,
|
||||
};
|
||||
};
|
||||
const lockVisualMatrix = Object.fromEntries(['light', 'dark'].map((theme) => [theme, {
|
||||
locked: lockVisual(theme, 'locked'),
|
||||
unlocked: lockVisual(theme, 'unlocked'),
|
||||
unknown: lockVisual(theme, 'unknown'),
|
||||
}]));
|
||||
if (lockBadge) lockBadge.className = originalLockClass;
|
||||
const result = {
|
||||
entityTombstoneCandidate: contactValues.includes('binary_sensor.window'),
|
||||
deviceTombstoneCandidate: lockValues.includes('lock.front_door'),
|
||||
@@ -55,6 +82,27 @@ const out = await page.evaluate(async () => {
|
||||
sharedContactDrivesBoth: openings.length === 2
|
||||
&& openings.every((opening) => c._openingAmt(opening) === 1),
|
||||
sharedLockRendersTwice: sr().querySelectorAll('.oplock').length === 2,
|
||||
openingLockUsesPackageGeometryAndStates:
|
||||
Object.values(lockVisualMatrix).every((theme) => Object.values(theme).every((state) =>
|
||||
Math.abs(state.shellCoreRatio - 1.26875) < 0.03
|
||||
&& Math.abs(state.glyphCoreRatio - 0.55) < 0.03
|
||||
&& state.footprint > 0))
|
||||
&& lockVisualMatrix.light.locked.core === 'rgb(0, 0, 0)'
|
||||
&& lockVisualMatrix.light.locked.shell === 'rgb(0, 0, 0)'
|
||||
&& lockVisualMatrix.light.locked.glyph === 'rgb(255, 255, 255)'
|
||||
&& lockVisualMatrix.dark.locked.core === 'rgb(37, 37, 37)'
|
||||
&& lockVisualMatrix.dark.locked.shell === 'rgb(37, 37, 37)'
|
||||
&& lockVisualMatrix.dark.locked.glyph === 'rgb(255, 255, 255)'
|
||||
&& lockVisualMatrix.light.unlocked.core === 'rgb(240, 160, 12)'
|
||||
&& lockVisualMatrix.light.unlocked.shell === 'rgb(240, 160, 12)'
|
||||
&& lockVisualMatrix.light.unlocked.glyph === 'rgb(255, 255, 255)'
|
||||
&& lockVisualMatrix.dark.unlocked.core === 'rgb(240, 160, 12)'
|
||||
&& lockVisualMatrix.dark.unlocked.shell === 'rgb(240, 160, 12)'
|
||||
&& lockVisualMatrix.dark.unlocked.glyph === 'rgb(37, 37, 37)'
|
||||
&& lockVisualMatrix.light.unknown.core === 'rgb(255, 255, 255)'
|
||||
&& lockVisualMatrix.light.unknown.shell === 'rgb(188, 188, 188)'
|
||||
&& lockVisualMatrix.dark.unknown.core === 'rgb(37, 37, 37)'
|
||||
&& lockVisualMatrix.dark.unknown.shell === 'rgba(37, 37, 37, 0.75)',
|
||||
deletionKeepsOpeningFields: JSON.stringify(openingRefs()) === refsBefore,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// #198: explicit Optimize removes only a proven isolated wall-thickness
|
||||
// micro-interval. Exercise the production bundle's Preview → Apply → Undo flow.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const S = 1 / 240;
|
||||
const wallKey = (a, b) => {
|
||||
const q = (value) => Math.round(value / S) * S;
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
dx /= length; dy /= length;
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const bucket = Math.round(angle * 1800) / 1800;
|
||||
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
const entry = (a, b, cm) => ({ key: wallKey(a, b), a, b, cm });
|
||||
const y = 0.2, split = 0.5, microEnd = split + S / 3;
|
||||
const original = {
|
||||
model_version: 6,
|
||||
spaces: [{
|
||||
id: 'micro', title: 'Micro wall', view_box: [0, 0, 1, 1], cell_cm: 5,
|
||||
rooms: [{ id: 'room', name: 'Room', poly: [[0.2, y], [0.8, y], [0.8, 0.8], [0.2, 0.8]] }],
|
||||
walls: [
|
||||
entry([0.2, y], [split, y], 22),
|
||||
entry([split, y], [microEnd, y], 15),
|
||||
entry([microEnd, y], [0.8, y], 22),
|
||||
],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
let serverConfig = clone(original), serverLayout = {}, backup = null;
|
||||
const sent = [];
|
||||
const baseCall = card.hass.callWS.bind(card.hass);
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/plan/optimize') {
|
||||
sent.push(message.type);
|
||||
backup = { config: clone(serverConfig), layout: clone(serverLayout) };
|
||||
serverConfig = clone(message.config); serverLayout = clone(message.layout);
|
||||
return { ok: true, config_rev: 2, layout_rev: 2, can_undo: true };
|
||||
}
|
||||
if (message.type === 'houseplan/plan/optimize_undo') {
|
||||
sent.push(message.type);
|
||||
serverConfig = clone(backup.config); serverLayout = clone(backup.layout);
|
||||
return { ok: true, config_rev: 3, layout_rev: 3, can_undo: false };
|
||||
}
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: clone(serverConfig), rev: 3, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: clone(serverLayout), rev: 3 };
|
||||
return baseCall(message);
|
||||
},
|
||||
};
|
||||
card._serverCfg = clone(original); card._layout = {};
|
||||
card._modelCache = null; card._frame = null; card._space = 'micro';
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
|
||||
card._openAlignDialog(); await card.updateComplete;
|
||||
const preview = card._alignDialog;
|
||||
result.previewOffersChange = !!preview?.changed
|
||||
&& preview.report.canonicalized === 1 && preview.report.wallsMerged === 2
|
||||
&& preview.config.spaces[0].walls.length === 1
|
||||
&& preview.config.spaces[0].walls[0].cm === 22;
|
||||
result.previewDoesNotWrite = sent.length === 0
|
||||
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
|
||||
result.previewIsVisible = !!card.renderRoot.querySelector('hp-dialog .alignmsg');
|
||||
|
||||
card._alignDialog = null; await card.updateComplete;
|
||||
result.cancelDoesNotWrite = sent.length === 0
|
||||
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
|
||||
|
||||
card._openAlignDialog(); await card.updateComplete;
|
||||
await card._runAlignToGrid(); await card.updateComplete;
|
||||
const appliedWalls = card._serverCfg.spaces[0].walls;
|
||||
result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
|
||||
result.applyStoresOneUniformRun = appliedWalls.length === 1 && appliedWalls[0].cm === 22
|
||||
&& JSON.stringify(appliedWalls[0].a) === JSON.stringify([0.2, 0.2])
|
||||
&& JSON.stringify(appliedWalls[0].b) === JSON.stringify([0.8, 0.2]);
|
||||
result.applyEnablesUndo = card._canOptimizeUndo === true;
|
||||
|
||||
await card._undoPlanOptimization(); await card.updateComplete;
|
||||
result.undoUsesServerSnapshot = sent.filter((type) => type === 'houseplan/plan/optimize_undo').length === 1;
|
||||
result.undoRestoresExactEntries = JSON.stringify(card._serverCfg.spaces[0].walls)
|
||||
=== JSON.stringify(original.spaces[0].walls);
|
||||
result.undoIsOneDeep = card._canOptimizeUndo === false;
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Issues #132/#185: independent-wall host lifecycle and structural wall continuity. */
|
||||
/** Issues #132/#185/#186: hosted-opening lifecycle, continuity and jamb margin. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 960, height: 820 }, 1);
|
||||
@@ -35,7 +35,9 @@ const out = await page.evaluate(async () => {
|
||||
card._space = 'partition-openings';
|
||||
card._modelCache = null;
|
||||
card._cfgEpoch++;
|
||||
let writes = 0;
|
||||
card._saveConfig = () => {
|
||||
writes++;
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._physicalBodiesCache = null;
|
||||
@@ -61,6 +63,67 @@ const out = await page.evaluate(async () => {
|
||||
&& Math.abs(saved.y - 0.5) < 1e-9
|
||||
&& !!root().querySelector(`[data-hp="opening"][data-id="${saved.id}"]`);
|
||||
|
||||
const endpointCandidate = card._resolveOpeningPlacement([250, 500]);
|
||||
const endpointJamb = endpointCandidate?.target?.physicalHalfWidth || 0;
|
||||
const endpointX = 250 + (endpointCandidate?.renderedLength || 0) / 2 + endpointJamb;
|
||||
result.placementClampsToHalfThicknessJamb = !!endpointCandidate
|
||||
&& Math.abs(endpointCandidate.x - endpointX) < 1e-6
|
||||
&& Math.abs(endpointCandidate.host.t - (endpointX - 250) / 500) < 1e-9
|
||||
&& !!endpointCandidate.measure.labels[0].text;
|
||||
|
||||
const originalSvgPoint = card._svgPoint;
|
||||
card._opDrag = {
|
||||
id: saved.id, moved: false, sx: 0, sy: 0, dirty: false,
|
||||
before: card._geometrySnapshot(),
|
||||
};
|
||||
card._svgPoint = () => [250, 500];
|
||||
card._opPointerMove(new PointerEvent('pointermove', {
|
||||
pointerId: 186, clientX: 20, clientY: 0, bubbles: true,
|
||||
}), saved);
|
||||
card._svgPoint = originalSvgPoint;
|
||||
card._opPointerUp(new PointerEvent('pointerup', { pointerId: 186, bubbles: true }), saved);
|
||||
await update();
|
||||
result.directDragStopsAtSameJambBoundary = Math.abs(saved.host.t - (endpointX - 250) / 500) < 1e-9
|
||||
&& Math.abs(saved.x - endpointX / 1000) < 1e-9;
|
||||
|
||||
card._editOpening(card._openingsR.find((opening) => opening.id === saved.id));
|
||||
card._openingDialog = { ...card._openingDialog, lengthCm: 600, lengthTouched: true };
|
||||
const beforeRejectedDialog = JSON.stringify(space.openings);
|
||||
const writesBeforeRejectedDialog = writes;
|
||||
const historyBeforeRejectedDialog = card._geometryHistory.size;
|
||||
await update();
|
||||
const dialogGuidance = root().querySelector('.habindingbanner')?.textContent || '';
|
||||
result.dialogShowsJambSpecificGuidance = dialogGuidance.includes('Leave at least')
|
||||
|| dialogGuidance.includes('Оставьте');
|
||||
card._saveOpening();
|
||||
result.invalidLengthWritesNeitherConfigNorHistory = JSON.stringify(space.openings)
|
||||
=== beforeRejectedDialog
|
||||
&& writes === writesBeforeRejectedDialog
|
||||
&& card._geometryHistory.size === historyBeforeRejectedDialog;
|
||||
card._openingDialog = null;
|
||||
|
||||
saved.host.t = 0.5;
|
||||
saved.x = 0.5;
|
||||
saved.y = 0.5;
|
||||
card._saveConfig();
|
||||
space.partitions.push({ id: 'short', a: [0.2, 0.7], b: [0.27, 0.7], cm: 15 });
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._openingPlacementIntervalsCache = null;
|
||||
const previousToast = card._showToast;
|
||||
let jambToast = '';
|
||||
card._showToast = (message) => { jambToast = message; };
|
||||
const shortPoint = [235, 700];
|
||||
const shortCandidate = card._resolveOpeningPlacement(shortPoint);
|
||||
card._openingClick(shortPoint);
|
||||
card._showToast = previousToast;
|
||||
result.tooShortPartitionHasNoPreviewAndExplainsWhy = shortCandidate === null
|
||||
&& (jambToast.includes('Leave at least') || jambToast.includes('Оставьте'));
|
||||
space.partitions = space.partitions.filter((partition) => partition.id !== 'short');
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._openingPlacementIntervalsCache = null;
|
||||
|
||||
card._physicalBodiesCache = null;
|
||||
const bodies = card._physicalBodiesR();
|
||||
result.hostBodyHasFullDepthOpeningGap = !bodies.some((body) => pointIn(centre, body));
|
||||
|
||||
@@ -182,9 +182,11 @@ const out = await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
result.currentAnchorDoesNotCreateZeroSegment = !active();
|
||||
card._cancelPath();
|
||||
card._tool = 'partition';
|
||||
card._activateMarkupTool('draw');
|
||||
await update();
|
||||
card._markupClick(eventAt(735, 606, 'click'));
|
||||
// Use a fresh point on the same physical line: the earlier assertion left
|
||||
// 735,600 as a saved-draft endpoint, and public Walls correctly resumes it.
|
||||
card._markupClick(eventAt(775, 606, 'click'));
|
||||
await card.updateComplete;
|
||||
result.tapWithoutHoverSnapsFirstPoint = card._path.length === 1
|
||||
&& close(card._path[0][1], 600)
|
||||
@@ -192,7 +194,9 @@ const out = await page.evaluate(async () => {
|
||||
Math.round((card._path[0][0] - 600) / card._gridPitch));
|
||||
card._markupClick(eventAt(896, 496, 'click'));
|
||||
await card.updateComplete;
|
||||
result.secondPartitionClickSnapsEndpoint = card._path.length === 0
|
||||
card._activateMarkupTool('select');
|
||||
await update();
|
||||
result.secondWallsClickFinishesSnappedPartition = card._path.length === 0
|
||||
&& card._curSpaceCfg.partitions.length === 2
|
||||
&& close(card._curSpaceCfg.partitions[1].b[0] * 1000, 900)
|
||||
&& close(card._curSpaceCfg.partitions[1].b[1] * 1000, 500);
|
||||
@@ -202,6 +206,8 @@ const out = await page.evaluate(async () => {
|
||||
drafts: card._curSpaceCfg.room_drafts,
|
||||
partitions: card._curSpaceCfg.partitions,
|
||||
});
|
||||
card._activateMarkupTool('draw');
|
||||
await update();
|
||||
card._suppressClick = true;
|
||||
card._markupClick(eventAt(700, 600, 'click'));
|
||||
result.suppressedClickDoesNotCommit = card._path.length === 0;
|
||||
@@ -222,7 +228,7 @@ const out = await page.evaluate(async () => {
|
||||
partitions: card._curSpaceCfg.partitions,
|
||||
}) === gestureGeometry;
|
||||
|
||||
card._tool = 'select';
|
||||
card._activateMarkupTool('select');
|
||||
await update();
|
||||
result.otherPlanToolsHaveNoOverlay = !overlay();
|
||||
card._setMode('view');
|
||||
|
||||
@@ -195,6 +195,40 @@ const res = await page.evaluate(async () => {
|
||||
out.virtualTJunctionMitred = values.some((x, i) => i % 2 === 0
|
||||
&& Math.abs(x - (500 + half)) < 1e-6
|
||||
&& Math.abs(values[i + 1] - (500 - half)) < 1e-6);
|
||||
|
||||
// #201: a fully virtual stretch has no own cm to restore. Its nearest
|
||||
// solid continuation is one exact 22 cm parent run, atomised at x=600 by a
|
||||
// third room. Close must read the parent through either child instead of
|
||||
// falling back to the 15 cm draw default.
|
||||
sp().rooms = [
|
||||
{ id: 'parent', name: 'Parent', poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.5], [0.1, 0.5]], open_to: ['left'] },
|
||||
{ id: 'left', name: 'Left', poly: [[0.1, 0.5], [0.3, 0.5], [0.3, 0.9], [0.1, 0.9]], open_to: ['parent'] },
|
||||
{ id: 'middle', name: 'Middle', poly: [[0.3, 0.5], [0.6, 0.5], [0.6, 0.9], [0.3, 0.9]] },
|
||||
{ id: 'right', name: 'Right', poly: [[0.6, 0.5], [0.9, 0.5], [0.9, 0.9], [0.6, 0.9]] },
|
||||
];
|
||||
sp().walls = [{
|
||||
key: '0.600000,0.500000@0.0000', cm: 22,
|
||||
a: [0.3, 0.5], b: [0.9, 0.5],
|
||||
}];
|
||||
sp().open_spans = [{ a: [0.1, 0.5], b: [0.3, 0.5] }];
|
||||
delete sp().openings;
|
||||
c._tool = 'boundary';
|
||||
await upd();
|
||||
const atomicParentBefore = JSON.stringify({
|
||||
rooms: sp().rooms, walls: sp().walls, open_spans: sp().open_spans,
|
||||
});
|
||||
const atomicParentCut = c._openCuts()[0];
|
||||
const atomicParentPlan = atomicParentCut && c._planClosedOpenSpan(atomicParentCut);
|
||||
out.atomicParentClosePreviewInherits = atomicParentPlan?.cm === 22;
|
||||
if (atomicParentCut) c._closeOpenSpan(atomicParentCut);
|
||||
await upd();
|
||||
out.atomicParentClosePersistsNeighbourCm = !sp().open_spans
|
||||
&& c._intervalCm([100, 500, 300, 500]) === 22;
|
||||
c._undoGeometry();
|
||||
await upd();
|
||||
out.atomicParentCloseUndo = JSON.stringify({
|
||||
rooms: sp().rooms, walls: sp().walls, open_spans: sp().open_spans,
|
||||
}) === atomicParentBefore;
|
||||
return out;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,38 +1,185 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// включить имена комнат
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true } })) };
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
// 1) комната не кликабельна: cursor default, обработчика клика нет
|
||||
const room = sr().querySelector('.room');
|
||||
out.roomCursor = getComputedStyle(room).cursor === 'default';
|
||||
// 2) значок ссылки у комнат с зоной, кликабелен, ведёт в зону
|
||||
const links = sr().querySelectorAll('.rlgo');
|
||||
out.linksShown = links.length > 0;
|
||||
const withArea = c._spaceModel().rooms.filter((r) => r.name && r.area).length;
|
||||
out.linkPerArearoom = links.length === withArea;
|
||||
const icon = links[0];
|
||||
out.linkCursor = getComputedStyle(icon).cursor === 'pointer';
|
||||
out.linkClickable = getComputedStyle(icon).pointerEvents === 'auto';
|
||||
// навигация: перехват history
|
||||
let navTo = null;
|
||||
const orig = history.pushState.bind(history);
|
||||
history.pushState = (a, b, url) => { navTo = url; };
|
||||
icon.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
history.pushState = orig;
|
||||
out.navigates = typeof navTo === 'string' && navTo.includes('/config/areas/area/');
|
||||
// 3) комнаты без зоны — без значка (проверено соотношением выше); в разметке значков нет
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.noneInPlan = sr().querySelectorAll('.rlgo').length === 0;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
const checks = {};
|
||||
const geometry = [];
|
||||
|
||||
for (const scale of [1, 2]) {
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, scale);
|
||||
for (const dark of [false, true]) {
|
||||
const key = `dpr${scale}-${dark ? 'dark' : 'light'}`;
|
||||
const setup = await page.evaluate(async ({ dark }) => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const settle = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const deadline = performance.now() + 2000;
|
||||
while (c._modeTransitionBusy && performance.now() < deadline)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const labelById = (id) => [...sr().querySelectorAll('.roomlabel')]
|
||||
.find((label) => label.dataset.id === id);
|
||||
const relativeBox = (label, selector) => {
|
||||
const anchorBox = label.getBoundingClientRect();
|
||||
const box = label.querySelector(selector)?.getBoundingClientRect();
|
||||
if (!box) return null;
|
||||
const anchorX = anchorBox.left + anchorBox.width / 2;
|
||||
const anchorY = anchorBox.top + anchorBox.height / 2;
|
||||
return {
|
||||
left: box.left - anchorX,
|
||||
top: box.top - anchorY,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
};
|
||||
};
|
||||
const snapshot = (label) => ({
|
||||
name: relativeBox(label, '.rlname'),
|
||||
metrics: relativeBox(label, '.rlmetrics'),
|
||||
metricsText: label.querySelector('.rlmetrics')?.textContent?.replace(/\s+/g, ' ').trim() || '',
|
||||
});
|
||||
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
themes: { ...(c.hass.themes || {}), darkMode: dark },
|
||||
};
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
spaces: c._serverCfg.spaces.map((space) => space.id !== c._space ? space : ({
|
||||
...space,
|
||||
settings: {
|
||||
...(space.settings || {}),
|
||||
show_names: true,
|
||||
label_temp: true,
|
||||
label_hum: true,
|
||||
label_lqi: true,
|
||||
label_light: true,
|
||||
},
|
||||
})),
|
||||
};
|
||||
const targetRoom = c._spaceModel().rooms.find((room) => room.name && room.area);
|
||||
if (targetRoom?.id) {
|
||||
c._layout = {
|
||||
...c._layout,
|
||||
[`rl_${targetRoom.id}`]: { s: c._space, x: 0.16, y: 0.22 },
|
||||
};
|
||||
}
|
||||
c._setMode('view');
|
||||
await settle();
|
||||
|
||||
const labels = [...sr().querySelectorAll('.roomlabel')];
|
||||
const label = labels.find((candidate) => candidate.querySelector('.rlgo')
|
||||
&& candidate.querySelector('.rlmetrics'));
|
||||
if (!label) return { missingFixture: true };
|
||||
const roomId = label.dataset.id;
|
||||
const view = snapshot(label);
|
||||
const links = sr().querySelectorAll('.rlgo');
|
||||
const withArea = c._spaceModel().rooms.filter((room) => room.name && room.area).length;
|
||||
const room = sr().querySelector('.room');
|
||||
const viewIcon = label.querySelector('.rlgo');
|
||||
const viewCursor = getComputedStyle(viewIcon).cursor;
|
||||
const viewPointerEvents = getComputedStyle(viewIcon).pointerEvents;
|
||||
|
||||
let viewNav = null;
|
||||
const viewPushState = history.pushState;
|
||||
history.pushState = (state, title, url) => { viewNav = url; };
|
||||
viewIcon.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
history.pushState = viewPushState;
|
||||
|
||||
c._setMode('plan');
|
||||
await settle();
|
||||
const planLabel = labelById(roomId);
|
||||
const planIcon = planLabel?.querySelector('.rlgo');
|
||||
const plan = planLabel ? snapshot(planLabel) : null;
|
||||
|
||||
let planNav = null;
|
||||
const planPushState = history.pushState;
|
||||
history.pushState = (state, title, url) => { planNav = url; };
|
||||
planIcon?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
history.pushState = planPushState;
|
||||
|
||||
const iconBox = planIcon?.getBoundingClientRect();
|
||||
window.__roomLinkOriginalPushState = history.pushState;
|
||||
window.__roomLinkDragNav = null;
|
||||
history.pushState = (state, title, url) => { window.__roomLinkDragNav = url; };
|
||||
return {
|
||||
missingFixture: false,
|
||||
roomId,
|
||||
view,
|
||||
plan,
|
||||
roomCursor: getComputedStyle(room).cursor,
|
||||
viewLinkCount: links.length,
|
||||
withArea,
|
||||
viewCursor,
|
||||
viewPointerEvents,
|
||||
viewNav,
|
||||
planLinkCount: sr().querySelectorAll('.rlgo').length,
|
||||
planCursor: planIcon ? getComputedStyle(planIcon).cursor : '',
|
||||
planTitle: planIcon?.getAttribute('title') || '',
|
||||
planNav,
|
||||
planHandles: planLabel?.querySelectorAll('.rlhandle').length || 0,
|
||||
planSettingsButtons: sr().querySelectorAll('.rlgearbtn').length,
|
||||
beforeLayout: c._layout[`rl_${roomId}`] || null,
|
||||
dragTarget: iconBox ? { x: iconBox.left + iconBox.width / 2, y: iconBox.top + iconBox.height / 2 } : null,
|
||||
themeApplied: c.hass.themes.darkMode === dark,
|
||||
};
|
||||
}, { dark });
|
||||
|
||||
if (setup.missingFixture) {
|
||||
checks[`${key}:fixture`] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let drag = { changed: false, dragNav: 'not-run', stayedInPlan: false };
|
||||
if (setup.dragTarget) {
|
||||
await page.mouse.move(setup.dragTarget.x, setup.dragTarget.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(setup.dragTarget.x + 24, setup.dragTarget.y + 16, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
drag = await page.evaluate(async ({ roomId, beforeLayout }) => {
|
||||
const c = window.__card;
|
||||
await c.updateComplete;
|
||||
const after = c._layout[`rl_${roomId}`] || null;
|
||||
const changed = !!after && (!beforeLayout
|
||||
|| Math.abs(after.x - beforeLayout.x) > 1e-6
|
||||
|| Math.abs(after.y - beforeLayout.y) > 1e-6);
|
||||
const dragNav = window.__roomLinkDragNav;
|
||||
history.pushState = window.__roomLinkOriginalPushState;
|
||||
delete window.__roomLinkOriginalPushState;
|
||||
delete window.__roomLinkDragNav;
|
||||
return { changed, dragNav, stayedInPlan: c._mode === 'plan' };
|
||||
}, { roomId: setup.roomId, beforeLayout: setup.beforeLayout });
|
||||
}
|
||||
|
||||
const values = ['left', 'top', 'width', 'height'];
|
||||
const nameDeltas = values.map((field) => Math.abs(setup.view.name[field] - setup.plan.name[field]));
|
||||
const metricDeltas = values.map((field) => Math.abs(setup.view.metrics[field] - setup.plan.metrics[field]));
|
||||
geometry.push({ key, nameDeltas, metricDeltas });
|
||||
checks[`${key}:fixture`] = !setup.missingFixture;
|
||||
checks[`${key}:room-cursor`] = setup.roomCursor === 'default';
|
||||
checks[`${key}:view-link-per-area-room`] = setup.viewLinkCount === setup.withArea;
|
||||
checks[`${key}:view-link-interactive`] = setup.viewCursor === 'pointer'
|
||||
&& setup.viewPointerEvents === 'auto';
|
||||
checks[`${key}:view-navigates`] = typeof setup.viewNav === 'string'
|
||||
&& setup.viewNav.includes('/config/areas/area/');
|
||||
checks[`${key}:plan-keeps-links`] = setup.planLinkCount === setup.withArea;
|
||||
checks[`${key}:plan-link-is-drag-affordance`] = setup.planCursor === 'grab'
|
||||
&& setup.planTitle === '';
|
||||
checks[`${key}:plan-does-not-navigate`] = setup.planNav == null
|
||||
&& drag.dragNav == null && drag.stayedInPlan;
|
||||
checks[`${key}:plan-icon-drags-label`] = drag.changed;
|
||||
checks[`${key}:core-name-parity`] = nameDeltas.every((delta) => delta <= 0.5);
|
||||
checks[`${key}:core-metrics-parity`] = metricDeltas.every((delta) => delta <= 0.5)
|
||||
&& setup.view.metricsText === setup.plan.metricsText;
|
||||
checks[`${key}:plan-controls-preserved`] = setup.planHandles === 4
|
||||
&& setup.planSettingsButtons > 0;
|
||||
checks[`${key}:theme-applied`] = setup.themeApplied;
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
checkAll(checks);
|
||||
await finish(null, { checks, geometry });
|
||||
|
||||
@@ -45,15 +45,15 @@ const res = await page.evaluate(async () => {
|
||||
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(model.name));
|
||||
out.cardShowsSource = lbl ? lbl.textContent.includes('30.2°') : false;
|
||||
const modelIndex = c._spaceModel().rooms.findIndex((r) => r.id === editedId);
|
||||
[...sr().querySelectorAll('.room')][modelIndex].dispatchEvent(new MouseEvent('mousemove', {
|
||||
bubbles: true, composed: true, clientX: 200, clientY: 200,
|
||||
[...sr().querySelectorAll('.room')][modelIndex].dispatchEvent(new PointerEvent('pointermove', {
|
||||
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.tooltipShowsHumiditySource = c._tip?.hum === 47
|
||||
&& (sr().querySelector('.tip')?.textContent || '').includes(`${c._t('tip.hum_avg')} 47%`);
|
||||
model.settings = { ...model.settings, hum_source: 'entity:sensor.missing_room_hum' };
|
||||
[...sr().querySelectorAll('.room')][modelIndex].dispatchEvent(new MouseEvent('mousemove', {
|
||||
bubbles: true, composed: true, clientX: 210, clientY: 210,
|
||||
[...sr().querySelectorAll('.room')][modelIndex].dispatchEvent(new PointerEvent('pointermove', {
|
||||
pointerType: 'mouse', bubbles: true, composed: true, clientX: 210, clientY: 210,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.invalidHumiditySourceIsOmitted = c._tip?.hum == null
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const state = () => ({
|
||||
source: c._spaceDialog?.source,
|
||||
showBorders: c._spaceDialog?.showBorders,
|
||||
showNames: c._spaceDialog?.showNames,
|
||||
displayTouched: c._spaceDialog?.displayTouched,
|
||||
});
|
||||
const choose = async (source) => {
|
||||
const radios = [...sr().querySelectorAll('hp-dialog input[name="plansrc"]')];
|
||||
radios[source === 'file' ? 0 : 1].click();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const toggle = async (key) => {
|
||||
const label = [...sr().querySelectorAll('hp-dialog label.srcrow')]
|
||||
.find((row) => row.textContent.includes(c._t(key)));
|
||||
label.querySelector('input[type="checkbox"], ha-switch').click();
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
out.freshFile = state();
|
||||
await choose('draw');
|
||||
out.untouchedDraw = state();
|
||||
await choose('file');
|
||||
out.untouchedFileAgain = state();
|
||||
await choose('draw');
|
||||
await toggle('space.show_borders');
|
||||
out.mixedTouched = state();
|
||||
await choose('file');
|
||||
out.mixedFile = state();
|
||||
await choose('draw');
|
||||
out.mixedDrawAgain = state();
|
||||
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Mixed display defaults' };
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const mixed = c._serverCfg.spaces.find((s) => s.title === 'Mixed display defaults');
|
||||
out.savedMixed = [mixed?.settings?.show_borders, mixed?.settings?.show_names];
|
||||
c._openSpaceDialog('edit', mixed.id); await c.updateComplete;
|
||||
out.reopenedMixed = state();
|
||||
await choose('file');
|
||||
out.editSourcePreservesPair = state();
|
||||
|
||||
const beforeCancel = JSON.stringify(c._serverCfg);
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
await choose('draw');
|
||||
await toggle('space.show_names');
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
out.cancelDidNotWrite = JSON.stringify(c._serverCfg) === beforeCancel;
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
out.freshAfterCancel = state();
|
||||
|
||||
c._spaceDialog = null;
|
||||
c._importQueue = ['Floor one', 'Floor two'];
|
||||
c._importTotal = 2;
|
||||
c._openNextImport(); await c.updateComplete;
|
||||
out.onboardingFirst = { title: c._spaceDialog.title, ...state() };
|
||||
await choose('draw');
|
||||
await toggle('space.show_borders');
|
||||
c._skipImport(); await c.updateComplete;
|
||||
out.onboardingSecond = { title: c._spaceDialog.title, ...state() };
|
||||
|
||||
c._importQueue = [];
|
||||
c._importTotal = 0;
|
||||
c._spaceDialog = null;
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
const existingPlan = c._serverCfg.spaces.find((s) => s.plan_url)?.plan_url;
|
||||
c._spaceDialog = {
|
||||
...c._spaceDialog, title: 'File display defaults', planUrl: existingPlan || '/local/houseplan/plan.svg',
|
||||
};
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const file = c._serverCfg.spaces.find((s) => s.title === 'File display defaults');
|
||||
out.savedFileDefaults = [file?.settings?.show_borders, file?.settings?.show_names];
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res, {
|
||||
freshFile: { source: 'file', showBorders: false, showNames: false, displayTouched: false },
|
||||
untouchedDraw: { source: 'draw', showBorders: true, showNames: true, displayTouched: false },
|
||||
untouchedFileAgain: { source: 'file', showBorders: false, showNames: false, displayTouched: false },
|
||||
mixedTouched: { source: 'draw', showBorders: false, showNames: true, displayTouched: true },
|
||||
mixedFile: { source: 'file', showBorders: false, showNames: true, displayTouched: true },
|
||||
mixedDrawAgain: { source: 'draw', showBorders: false, showNames: true, displayTouched: true },
|
||||
savedMixed: [false, true],
|
||||
reopenedMixed: { source: 'draw', showBorders: false, showNames: true, displayTouched: true },
|
||||
editSourcePreservesPair: { source: 'file', showBorders: false, showNames: true, displayTouched: true },
|
||||
freshAfterCancel: { source: 'file', showBorders: false, showNames: false, displayTouched: false },
|
||||
onboardingFirst: {
|
||||
title: 'Floor one', source: 'file', showBorders: false, showNames: false, displayTouched: false,
|
||||
},
|
||||
onboardingSecond: {
|
||||
title: 'Floor two', source: 'file', showBorders: false, showNames: false, displayTouched: false,
|
||||
},
|
||||
savedFileDefaults: [false, false],
|
||||
});
|
||||
await finish(browser, res);
|
||||
@@ -62,7 +62,9 @@ const res = await page.evaluate(async () => {
|
||||
out.labelSaved = !!c._layout['rl_r1'];
|
||||
// 6) диалог: create + draw
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
|
||||
[...sr().querySelectorAll('input[name="plansrc"]')][1].click();
|
||||
await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', orientation: 'square' };
|
||||
await c.updateComplete;
|
||||
out.saveEnabled = !sr().querySelector('hp-dialog .btn.on[disabled]');
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
|
||||
@@ -18,10 +18,10 @@ const out = await page.evaluate(async () => {
|
||||
'static-icon', 'on', 'open', 'alarm', 'unavail', 'valonly',
|
||||
'activity-running', 'activity-event', 'activity-presence', 'activity-transition',
|
||||
].includes(name)),
|
||||
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
|
||||
icon: node.querySelector('.device-core > ha-icon')?.getAttribute('icon') || '',
|
||||
satellites: node.querySelectorAll('.tval,.hval,.lqi,.valtext').length,
|
||||
scale: node.style.getPropertyValue('--dev-scale'),
|
||||
angle: node.querySelector(':scope > ha-icon')?.style.transform || '',
|
||||
angle: node.querySelector('.device-core > ha-icon')?.style.transform || '',
|
||||
});
|
||||
const show = (device, states) => {
|
||||
const visibleSnapshot = card._visibleDeviceSnapshot;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// НАХОДЯТСЯ через querySelector — ровно так, как их будет искать чужой CSS.
|
||||
// 1) устройство: data-hp="device" + data-id + data-entity + data-area;
|
||||
// 2) комната: data-hp="room" + data-id + data-area;
|
||||
// 3) подпись комнаты: data-hp="room-label" + data-id (и в SVG, и в HTML-карточке);
|
||||
// 3) видимая подпись комнаты: data-hp="room-label" + data-id на HTML-карточке;
|
||||
// 4) проём: data-hp="opening" + data-id + data-kind (door/window/gate);
|
||||
// 5) декор: data-hp="decor" + data-id + data-kind (line/rect/ellipse/text);
|
||||
// 6) вкладка пространства: data-hp="space-tab" + data-id;
|
||||
@@ -50,8 +50,8 @@ const res = await page.evaluate(async () => {
|
||||
out.roomIsAnSvgShape = !!kitchen && ['polygon', 'rect', 'path'].includes(kitchen.tagName.toLowerCase());
|
||||
|
||||
// ============ 3. подписи комнат ============
|
||||
// одно имя — две разные реализации: <text> в SVG на плане без подложки и
|
||||
// HTML-карточка, когда включены «имена на плане». Хук на обеих один и тот же.
|
||||
// #203: false означает отсутствие подписи, а не вторую SVG-реализацию.
|
||||
// При true единственный публичный hook живёт на HTML-карточке.
|
||||
const disp = c._curSpaceCfg.settings || (c._curSpaceCfg.settings = {});
|
||||
const wasNames = disp.show_names;
|
||||
const planUrl = c._curSpaceCfg.plan_url;
|
||||
@@ -60,11 +60,8 @@ const res = await page.evaluate(async () => {
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
};
|
||||
delete c._curSpaceCfg.plan_url; disp.show_names = false; await redraw();
|
||||
const svgLabels = qa('text.rlabel[data-hp="room-label"]');
|
||||
out.svgRoomLabelHooked = svgLabels.length > 0
|
||||
&& svgLabels.every((e) => !!e.getAttribute('data-id'));
|
||||
out.svgRoomLabelFoundById = !!q('text[data-hp="room-label"][data-id="r1"]');
|
||||
out.svgRoomLabelHasArea = q('text[data-hp="room-label"][data-id="r1"]')?.getAttribute('data-area') === 'living_room';
|
||||
out.hiddenRoomLabelsAreAbsent = qa('[data-hp="room-label"]').length === 0
|
||||
&& qa('text.rlabel').length === 0;
|
||||
c._curSpaceCfg.plan_url = planUrl; disp.show_names = true; await redraw();
|
||||
const htmlLabel = q('div.roomlabel[data-hp="room-label"][data-id="r2"]');
|
||||
out.htmlRoomLabelHooked = !!htmlLabel;
|
||||
|
||||
@@ -76,7 +76,7 @@ const res = await page.evaluate(async () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
await c.updateComplete;
|
||||
out.visibleRestartsClock = c._dayCycleTimer !== 0;
|
||||
out.visibilityKeepsHover = c._hoverRoom?.room === room;
|
||||
out.visibilityClearsHover = c._hoverRoom === null;
|
||||
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
|
||||
else delete document.visibilityState;
|
||||
|
||||
|
||||
@@ -1,22 +1,53 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
window.matchMedia = (q) => q.includes('hover: none')
|
||||
? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, onchange: null, dispatchEvent: () => false }
|
||||
: orig(q);
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => window.__card && window.__card._devices?.length, null, { timeout: 15000 });
|
||||
const { page, browser } = await launch(
|
||||
{ width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true },
|
||||
);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// "тап" по комнате → mousemove-эмуляция → тултип НЕ должен появиться
|
||||
c._showTip(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }), 'Room', 'meta');
|
||||
out.touchHardwareHasNoFineHover = !matchMedia(
|
||||
'(any-hover: hover) and (any-pointer: fine)',
|
||||
).matches;
|
||||
c._tip = { x: 1, y: 1, title: 'stale', meta: 'stale' };
|
||||
c._hoverRoom = { space: c._space, room: c._curSpaceCfg.rooms[0] };
|
||||
c._notePointer(new PointerEvent('pointerdown', {
|
||||
pointerType: 'touch', pointerId: 9, clientX: 100, clientY: 100,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
|
||||
out.touchClearsAllTransientHover = !c._tip && !c._hoverRoom
|
||||
&& !c.hasAttribute('data-pointer-hover');
|
||||
c._showTip(new PointerEvent('pointermove', {
|
||||
pointerType: 'touch', pointerId: 9, clientX: 100, clientY: 100,
|
||||
}), 'Room', 'meta');
|
||||
out.noTipOnTouch = !c._tip;
|
||||
c._notePointer(new PointerEvent('pointermove', {
|
||||
pointerType: 'mouse', clientX: 110, clientY: 110,
|
||||
}));
|
||||
out.touchOnlyHardwareRejectsMouseHover = !c.hasAttribute('data-pointer-hover');
|
||||
out.nestedHoverGateIsOff = [...c.renderRoot.querySelectorAll(
|
||||
'hp-dialog, hp-help, hp-color-opacity',
|
||||
)].every((node) => !node.hasAttribute('data-pointer-hover'));
|
||||
|
||||
const marker = c.renderRoot.querySelector('.dev[data-id="d_light1"]');
|
||||
const shell = marker?.querySelector('.device-shell');
|
||||
const originalCallService = c.hass.callService;
|
||||
let dispatches = 0;
|
||||
c.hass.callService = async () => { dispatches++; };
|
||||
marker?.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
pointerType: 'touch', pointerId: 10, bubbles: true, composed: true,
|
||||
}));
|
||||
marker?.dispatchEvent(new PointerEvent('pointerup', {
|
||||
pointerType: 'touch', pointerId: 10, bubbles: true, composed: true,
|
||||
}));
|
||||
marker?.click();
|
||||
await Promise.resolve();
|
||||
const touchFeedback = shell?.getAnimations().find((animation) =>
|
||||
animation.effect?.getTiming().duration === 200);
|
||||
out.touchDispatchGetsFeedback = dispatches === 1
|
||||
&& touchFeedback?.effect?.getKeyframes?.().some((frame) => frame.scale === '0.95');
|
||||
c._cancelDevicePressFeedback();
|
||||
c.hass.callService = originalCallService;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -41,7 +41,9 @@ const res = await page.evaluate(async () => {
|
||||
// тултип комнаты: площадь и агрегаты температуры/влажности в стабильном порядке
|
||||
const rooms = [...sr().querySelectorAll('.room')];
|
||||
const room = rooms[0];
|
||||
room.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 200, clientY: 200 }));
|
||||
room.dispatchEvent(new PointerEvent('pointermove', {
|
||||
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.tipTemp = c._tip?.temp;
|
||||
out.tipHum = c._tip?.hum;
|
||||
@@ -60,26 +62,37 @@ const res = await page.evaluate(async () => {
|
||||
out.tipHasAreaLine = (sr().querySelector('.tip')?.textContent || '').includes(expectedArea);
|
||||
|
||||
// Комната без источника влажности не получает пустую строку.
|
||||
rooms[1].dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 210, clientY: 210 }));
|
||||
rooms[1].dispatchEvent(new PointerEvent('pointermove', {
|
||||
pointerType: 'mouse', bubbles: true, composed: true, clientX: 210, clientY: 210,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.noHumidityOmitted = c._tip?.hum == null
|
||||
&& !(sr().querySelector('.tip')?.textContent || '').includes('средняя влажность');
|
||||
|
||||
// Общий device tooltip остаётся без room humidity.
|
||||
sr().querySelector('.dev').dispatchEvent(new MouseEvent('mousemove', {
|
||||
bubbles: true, composed: true, clientX: 220, clientY: 220,
|
||||
sr().querySelector('.dev').dispatchEvent(new PointerEvent('pointermove', {
|
||||
pointerType: 'mouse', bubbles: true, composed: true, clientX: 220, clientY: 220,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.deviceTooltipHasNoHumidity = c._tip?.hum == null
|
||||
&& !(sr().querySelector('.tip')?.textContent || '').includes('средняя влажность');
|
||||
|
||||
// После первого touch/pen hover-подсказки по-прежнему не создаются.
|
||||
c.constructor._touchSeen = true;
|
||||
// Touch/pen input never creates hover-only tooltips.
|
||||
room.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
pointerType: 'touch', pointerId: 91, bubbles: true, composed: true,
|
||||
clientX: 230, clientY: 230,
|
||||
}));
|
||||
c._tip = null;
|
||||
room.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 230, clientY: 230 }));
|
||||
room.dispatchEvent(new PointerEvent('pointermove', {
|
||||
pointerType: 'touch', pointerId: 91, bubbles: true, composed: true,
|
||||
clientX: 230, clientY: 230,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.noHoverSuppressesTooltip = c._tip === null;
|
||||
c.constructor._touchSeen = false;
|
||||
room.dispatchEvent(new PointerEvent('pointerup', {
|
||||
pointerType: 'touch', pointerId: 91, bubbles: true, composed: true,
|
||||
clientX: 230, clientY: 230,
|
||||
}));
|
||||
// диалог: радио заливки, компактные поля, ширина
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
|
||||
|
||||
@@ -39,11 +39,18 @@ const out = await page.evaluate(async () => {
|
||||
// owner 2026-07-31: the base badge, but round and 20% smaller — same plate
|
||||
// vs a NEUTRAL badge: the robot's own base is yellow while cleaning
|
||||
const devEl = sr().querySelector('.dev:not(.on)');
|
||||
const pcs = getComputedStyle(puck), dcs = getComputedStyle(devEl);
|
||||
o.puckPlateMatchesDev = pcs.backgroundColor === dcs.backgroundColor
|
||||
&& pcs.borderTopColor === dcs.borderTopColor;
|
||||
o.puck80pct = Math.abs(puck.getBoundingClientRect().width
|
||||
- devEl.getBoundingClientRect().width * 0.8) < 2;
|
||||
const devCore = devEl.querySelector('.device-core');
|
||||
const devShell = devEl.querySelector('.device-shell-frame');
|
||||
const pcs = getComputedStyle(puck);
|
||||
o.puckPlateMatchesDev = pcs.backgroundColor === getComputedStyle(devCore).backgroundColor
|
||||
&& pcs.borderTopColor === getComputedStyle(devShell).borderTopColor;
|
||||
const originalScale = devEl.style.getPropertyValue('--dev-scale');
|
||||
devEl.style.setProperty('--dev-scale', '1');
|
||||
const puckWidth = parseFloat(getComputedStyle(puck).width);
|
||||
const deviceCoreWidth = devCore.getBoundingClientRect().width;
|
||||
if (originalScale) devEl.style.setProperty('--dev-scale', originalScale);
|
||||
else devEl.style.removeProperty('--dev-scale');
|
||||
o.puck80pct = Math.abs(puckWidth - deviceCoreWidth * 0.8) < 0.5;
|
||||
o.noWedge = !sr().querySelector('.vacwedge'); // owner 2026-07-31: no heading arrow
|
||||
// the glyph is DEAD centre (owner report: it floated on the text baseline)
|
||||
const pr = puck.getBoundingClientRect();
|
||||
@@ -109,6 +116,31 @@ const out = await page.evaluate(async () => {
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
|
||||
|
||||
// ---- resumed server run: dock hides it, same current returns whole ----
|
||||
// Backend owns the 30-minute decision. The card must neither paint an ended
|
||||
// current in default mode nor lose its pre-dock points after ended is cleared.
|
||||
c._vacSrvTrails = { e_vacuum_robo: {
|
||||
current: { map_id: 'm1', started: 10, ended: 20,
|
||||
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
|
||||
} };
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(950, 1150) } } };
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.srvEndedCurrentHiddenDefault = !sr().querySelector('.vactrail path, .vactrail polyline');
|
||||
|
||||
c._vacSrvTrails = { e_vacuum_robo: {
|
||||
current: { map_id: 'm1', started: 10, ended: null,
|
||||
points: [[700, 500], [900, 500], [1100, 640], [950, 1150], [1200, 1200]] },
|
||||
} };
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(1200, 1200) } } };
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
|
||||
const resumedD = sr().querySelector('.vactrail > path.case')?.getAttribute('d') || '';
|
||||
o.srvResumedCurrentKeepsEarlierPoints = (resumedD.match(/[ML]/g) || []).length === 4;
|
||||
|
||||
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'always';
|
||||
c._vacSrvTrails = { e_vacuum_robo: {
|
||||
@@ -247,6 +279,7 @@ checkAll(out, {
|
||||
trailCasing: true, trailToggleOff: true,
|
||||
puckGoneWhenDocked: true, trailHiddenAfterDock: true, hiddenNoPuck: true,
|
||||
unknownMapNoPuck: true,
|
||||
srvEndedCurrentHiddenDefault: true, srvResumedCurrentKeepsEarlierPoints: true,
|
||||
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
|
||||
multiSubpathNoBridge: true, integrationPathAlwaysAtRest: true,
|
||||
tipExists: true, tipGluedToPuck: true, neverHidesAll: true,
|
||||
|
||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |