Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b46a62503 | ||
|
|
cd3c7752a8 | ||
|
|
9f2c5f5ff4 | ||
|
|
20d7883699 | ||
|
|
6ebf12af1e | ||
|
|
09143e23a6 | ||
|
|
0e6cb7570b | ||
|
|
321d153c22 | ||
|
|
7f70b64f48 | ||
|
|
6c37cd5f05 | ||
|
|
7642c484d2 | ||
|
|
ec7408f3d5 | ||
|
|
0f8d35f516 | ||
|
|
295257240d | ||
|
|
f2fcf0d594 | ||
|
|
3e4e549b56 | ||
|
|
875b09cd8d | ||
|
|
84cd5f9331 | ||
|
|
debb13baa2 | ||
|
|
ab2a014568 | ||
|
|
558dae95cd | ||
|
|
1937c32572 | ||
|
|
5ed2821161 | ||
|
|
f66cf8ad4d | ||
|
|
d66cd2ebab | ||
|
|
f0e7700805 | ||
|
|
aad625a84d | ||
|
|
b57ea94cb8 | ||
|
|
737e7b62aa | ||
|
|
f11a4e1085 | ||
|
|
548677a99c | ||
|
|
087f7cf381 | ||
|
|
b860ef4c43 | ||
|
|
3b0b9eea50 | ||
|
|
0cf10613f2 | ||
|
|
e894ce2986 | ||
|
|
ac30f8913d | ||
|
|
de46db3343 | ||
|
|
782ff54e0f | ||
|
|
b989c84b71 | ||
|
|
400ca7043e | ||
|
|
9f5d729538 | ||
|
|
8eb4bab7c6 | ||
|
|
6c48c6d5c6 | ||
|
|
5bebc26aeb | ||
|
|
f5c36da648 | ||
|
|
e9a148315a | ||
|
|
fb4096f67b | ||
|
|
e83da25085 | ||
|
|
ae10b2861b | ||
|
|
eef3634f23 | ||
|
|
6ecbedfb85 | ||
|
|
e6366b6548 | ||
|
|
159094cfec | ||
|
|
188a386cd8 | ||
|
|
5df8b723e7 | ||
|
|
de0171dd02 | ||
|
|
f1e6cca3db | ||
|
|
1079cdfab2 | ||
|
|
b7b28ee579 | ||
|
|
af851cda85 | ||
|
|
0af095a6c4 | ||
|
|
9ad2b3b4ef | ||
|
|
fea0d55c67 | ||
|
|
bc98116a31 | ||
|
|
328ed7afc0 | ||
|
|
0e69c4a183 | ||
|
|
ef3cc98d1c | ||
|
|
e13215c02f | ||
|
|
42b3f44c4a | ||
|
|
4c73e2ccdb | ||
|
|
76ce755742 | ||
|
|
50099acc75 | ||
|
|
a282f850af | ||
|
|
d7f3bb8119 | ||
|
|
5c6ab8ea9b | ||
|
|
fda4893f0c | ||
|
|
888e90450a | ||
|
|
b2263a6551 | ||
|
|
565f518dcd | ||
|
|
02e0c9801d | ||
|
|
e93c405b13 | ||
|
|
955de3e69c | ||
|
|
7af4146614 | ||
|
|
a43602934c | ||
|
|
516257e322 | ||
|
|
9177c9a944 | ||
|
|
8a3f6efa0a | ||
|
|
be7d6b9706 | ||
|
|
2fd042a7de | ||
|
|
d7e2c4d4f0 | ||
|
|
d1be6891b2 | ||
|
|
9be81c1413 | ||
|
|
fafeca4540 | ||
|
|
3ade633538 | ||
|
|
68596a75a0 | ||
|
|
65a86db122 | ||
|
|
a29df12e0b | ||
|
|
9146b4c357 | ||
|
|
9ad01be813 |
@@ -58,7 +58,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
|
||||
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
|
||||
# shellcheck disable=SC2086
|
||||
if ! node "$gate" --range "${base}..${local_sha}" $issues_flag >&2; then
|
||||
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Mutation gate
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
|
||||
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
|
||||
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
|
||||
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
|
||||
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
|
||||
- cron: '20 5 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: mutation-gate
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mutants:
|
||||
runs-on: ubuntu-latest
|
||||
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
|
||||
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Реестр применим к текущему коду
|
||||
run: node scripts/mutation-gate.mjs --check
|
||||
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs
|
||||
@@ -150,6 +150,8 @@ jobs:
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
@@ -164,6 +166,7 @@ jobs:
|
||||
run: |
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||||
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
|
||||
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
@@ -58,8 +59,9 @@ jobs:
|
||||
*) echo "метка $LABEL конвейер не запускает" ;;
|
||||
esac
|
||||
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком треке (PROCESS.md §4).
|
||||
limit=4; [ "$SMALL" = "true" ] && limit=2
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
|
||||
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
|
||||
@@ -134,8 +136,15 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
|
||||
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
|
||||
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
|
||||
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
|
||||
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
|
||||
# тяжёлых job, здесь его не было.
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||||
@@ -156,6 +165,24 @@ jobs:
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
@@ -205,10 +232,38 @@ jobs:
|
||||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||||
умеет падать, либо разобран по коду с явной записью «проверено
|
||||
чтением, не исполнением». «Verified» без названной команды и её
|
||||
результата доказательством не является. Зависимостей в рабочей
|
||||
копии нет: перед гейтами выполни `npm ci`. Проверь трейлеры Issue и
|
||||
User-Visible, при User-Visible: yes — правки в оба changelog в том же
|
||||
коммите.
|
||||
результата доказательством не является. Зависимости уже установлены
|
||||
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
|
||||
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
|
||||
changelog в том же коммите.
|
||||
|
||||
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
Всегда, они дешёвые:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла.
|
||||
|
||||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
|
||||
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
|
||||
только когда задача действительно задевает всё;
|
||||
- `npm run golden:verify` — если diff может изменить видимый
|
||||
результат: рендер, геометрия, стили, слои;
|
||||
- `python -m pytest tests_backend -q` — если тронут
|
||||
`custom_components/**/*.py`;
|
||||
- performance-профили — если названы в AC либо тронуты
|
||||
чувствительные к перфу пути.
|
||||
|
||||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||||
тем тестам, которые ты прогонял.
|
||||
|
||||
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
|
||||
почему.** Это условие честности такого сужения: непрогнанный гейт
|
||||
становится видимым решением, а не молчаливым пропуском. Раздел «чего
|
||||
не проверял» в документе ревью — не формальность, а главный его
|
||||
раздел на коротких задачах.
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
@@ -249,14 +304,21 @@ jobs:
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
|
||||
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
|
||||
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
|
||||
target="${BRANCH:-dev}"
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "ветки задачи нет — документ некуда класть"; exit 0
|
||||
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
|
||||
fi
|
||||
git checkout -- . 2>/dev/null || true
|
||||
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
|
||||
git add docs/reviews 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
echo "документ ревью не создан"; exit 0
|
||||
echo "::warning::документ ревью не создан"
|
||||
exit 0
|
||||
fi
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
@@ -266,9 +328,23 @@ jobs:
|
||||
Issue: #$NUM
|
||||
User-Visible: no
|
||||
EOF
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$BRANCH"
|
||||
echo "документ опубликован в $BRANCH"
|
||||
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
|
||||
# либо dev, поэтому push нужен с явным перебазированием при гонке:
|
||||
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"; then
|
||||
git fetch -q origin "$target"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase "origin/$target"; then
|
||||
git rebase --abort || true
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
|
||||
exit 0
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"
|
||||
fi
|
||||
echo "документ опубликован в $target"
|
||||
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
|
||||
@@ -51,12 +51,63 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TARGET_REF: ${{ github.ref }}
|
||||
# Публичный репозиторий: штатного токена хватает на чтение issue.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/process-gate.mjs --github-range --issues
|
||||
|
||||
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
|
||||
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
|
||||
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
|
||||
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
|
||||
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
|
||||
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
|
||||
# там делает код-ревью, которое гоняет гейты само (#127).
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
echo "dev: без фильтров, всё true"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
zero=$(printf '%040d' 0)
|
||||
base="$BEFORE_SHA"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
|
||||
# иначе классифицировалась бы вся история.
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||||
git fetch -q origin dev
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
|
||||
{
|
||||
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
|
||||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
hacs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -66,6 +117,8 @@ jobs:
|
||||
category: integration
|
||||
|
||||
hassfest:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -73,6 +126,8 @@ jobs:
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
frontend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -193,6 +248,8 @@ jobs:
|
||||
path: artifacts/performance-smoke
|
||||
|
||||
backend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -38,8 +38,16 @@ task records: problem, scope, acceptance criteria and discussion.
|
||||
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
|
||||
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
|
||||
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
|
||||
issue. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
|
||||
human-facing view synchronised from the labels, not the source of truth.
|
||||
issue. Labels are the whole of it: GitHub Projects is no longer used.
|
||||
|
||||
Two shortcuts exist for small work. `small` — the light track: the spec lives in
|
||||
the issue body and its review is a comment. `trivial` — the short track: no spec
|
||||
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
|
||||
issue body first. `trivial` requires a bug confined to one surface with no new UX
|
||||
contract, no migration, no i18n, no perf or touch impact, at most three checkable
|
||||
AC, **and expected behaviour already on record** — nothing left to decide. Code
|
||||
review is never skipped on either track; it is what stands in for testing.
|
||||
`PROCESS.md` §5 and §5.1 hold the criteria.
|
||||
|
||||
An issue filed by an outsider is worked exactly like one of the owner's own, once
|
||||
the owner has decided to take it. The check sits **at the entrance**, not on every
|
||||
@@ -146,6 +154,28 @@ arrive. Cycles are counted per stage, so a code review spends its own budget.
|
||||
Everything else still requires the owner's explicit command: pushing `main`,
|
||||
creating tags, publishing betas and releases, closing issues.
|
||||
|
||||
## Working trees (#115)
|
||||
|
||||
One checkout, one `HEAD`: two agents sharing a directory inherit each other's
|
||||
branch, and twice in one hour a commit landed on someone else's task branch that
|
||||
way. The layout is therefore fixed:
|
||||
|
||||
- **`houseplan-card-src/houseplan-card`** — the author's tree. Task branches live
|
||||
here; nobody else commits in it. Unfamiliar local changes belong to the author
|
||||
or the owner — never reset or clean them away.
|
||||
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
|
||||
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
|
||||
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
|
||||
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
|
||||
and publishes through the GitHub API; it makes no local commits at all, so it
|
||||
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
|
||||
pruned after use.
|
||||
|
||||
A worktree is only usable on the machine that created it: the `.git` file records
|
||||
an absolute path in that machine's format. One created from a Linux sandbox is
|
||||
dead on Windows and vice versa — create worktrees on the machine that will use
|
||||
them, which for `hp-dev` means the owner's.
|
||||
|
||||
## Two-agent workflow
|
||||
|
||||
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
|
||||
@@ -290,9 +320,19 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
```
|
||||
|
||||
During the implementation cycle only the fast gates run. `smoke`, `golden` and
|
||||
`performance_smoke` spin up Chromium and belong to the pre-beta run — which is then
|
||||
mandatory and complete.
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
|
||||
environment, which changes one thing (#151): **before moving an issue to
|
||||
`S7-code-review`, run the smokes named in its AC locally** — `node
|
||||
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
|
||||
locally it costs a minute. Precedent: on #89 a fixture error lived through a
|
||||
whole review round that a local run would have caught immediately.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
|
||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
|
||||
@@ -18,13 +18,12 @@ requests still belong in [issues](https://github.com/Matysh/houseplan-card/issue
|
||||
|
||||
## Backlog and work status
|
||||
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
|
||||
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
|
||||
only active project backlog. Issues own scope and acceptance criteria; Project
|
||||
v2 owns prioritization and workflow status. Before starting planned work, link
|
||||
it to an existing issue or create one, add it to the Project, and keep both
|
||||
surfaces current until the verified result is closed. Design specs and ADRs may
|
||||
support an issue, but they do not replace it or maintain a separate checklist.
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the only
|
||||
active backlog. An issue owns scope and acceptance criteria; its **labels** own
|
||||
priority and workflow status — `PROCESS.md` §9 holds the vocabulary. Before
|
||||
starting planned work, link it to an existing issue or create one, and keep it
|
||||
current until the verified result is closed. Design specs and ADRs may support an
|
||||
issue, but they do not replace it or maintain a separate checklist.
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
> его не содержал вовсе.
|
||||
>
|
||||
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
|
||||
> метках, Project v2 остаётся человеческим представлением. При расхождении
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> документации с GitHub побеждает GitHub. При расхождении этого документа с
|
||||
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
|
||||
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
|
||||
@@ -70,7 +70,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
|
||||
|
||||
служебные: blocked (поверх статуса) rejected (закрыт)
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком треке 2
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
|
||||
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
```
|
||||
|
||||
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
|
||||
@@ -85,9 +86,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
### 2.2 Аналитика и оценка
|
||||
|
||||
Задача разбирается, продуктовое «да» ещё не дано.
|
||||
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
|
||||
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
|
||||
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
|
||||
однозначно.
|
||||
|
||||
- **Кто:** агент-аналитик готовит, владелец решает.
|
||||
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
|
||||
- **Чек-лист**, результат — комментарием в issue:
|
||||
1. дубликаты проверены (ссылки на похожие issue);
|
||||
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
|
||||
@@ -97,10 +101,21 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. лёгкий трек — да/нет по критериям §5.
|
||||
- **Приоритет и ценность — поля владельца.** Агент предлагает, владелец
|
||||
утверждает; иначе агенты приоритизируют сами и P1 разрастается.
|
||||
- **Выход:** «ТЗ в работе» либо «Отклонено» с записанной причиной.
|
||||
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
|
||||
владельцем на любой стадии.
|
||||
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
|
||||
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
|
||||
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
|
||||
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
|
||||
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
|
||||
него в ТЗ пишется блок принятых предположений.
|
||||
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
|
||||
это единственный случай, когда аналитика останавливается и ждёт владельца.
|
||||
|
||||
### 2.3 ТЗ в работе — написание ТЗ
|
||||
|
||||
@@ -294,6 +309,42 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
|
||||
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
|
||||
|
||||
### 5.1 Короткий трек (метка `trivial`)
|
||||
|
||||
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
|
||||
обходится без него совсем.
|
||||
|
||||
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
|
||||
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
|
||||
|
||||
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
|
||||
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
|
||||
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
|
||||
|
||||
**Критерии, все обязательны:**
|
||||
|
||||
- тип `bug`;
|
||||
- правка ограничена одной поверхностью, нового UX-контракта нет;
|
||||
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
|
||||
- AC выражаются тремя проверяемыми утверждениями или меньше;
|
||||
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
|
||||
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
|
||||
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
|
||||
|
||||
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
|
||||
где владелец утверждает и приоритет.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
|
||||
Лимит циклов код-ревью — 2, как на лёгком треке.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
|
||||
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
|
||||
диагностика.
|
||||
|
||||
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
|
||||
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
|
||||
|
||||
---
|
||||
|
||||
## 6. Роли
|
||||
@@ -431,6 +482,17 @@ npm run golden:verify # если менялся визуал
|
||||
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
|
||||
```
|
||||
|
||||
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
|
||||
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
|
||||
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
|
||||
результата, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
|
||||
|
||||
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
|
||||
@@ -444,9 +506,12 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
|
||||
## 9. Метки — канонический статус
|
||||
|
||||
Статус читается из меток: их видно в списке issue и их читает любой токен с
|
||||
доступом к Issues, в отличие от Project v2, который требует отдельного скоупа.
|
||||
Project v2 остаётся человеческим представлением и синхронизируется по меткам.
|
||||
Статус читается из меток: их видно в списке issue, их читает любой токен с
|
||||
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
|
||||
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
|
||||
представление статуса рядом с метками требовало отдельного скоупа токена,
|
||||
синхронизации и внимания, а давало вид доски. Два источника одного факта
|
||||
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
|
||||
|
||||
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
|
||||
документе были только на бумаге; репозиторий с самого начала жил на английских.
|
||||
@@ -464,8 +529,8 @@ Project v2 остаётся человеческим представление
|
||||
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
|
||||
| `rejected` | Отклонено, issue закрыт |
|
||||
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `hotfix`, `process`,
|
||||
`review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
|
||||
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
|
||||
ортогональны процессу.
|
||||
|
||||
@@ -523,6 +588,13 @@ Project v2 остаётся человеческим представление
|
||||
считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него
|
||||
попали бы все нарушения, совершённые до появления гейта.
|
||||
|
||||
При возврате `main` в `dev` диапазон merge-коммита содержит второй родитель —
|
||||
уже опубликованные в `main` коммиты с закрытыми issue. Для destination `dev`
|
||||
общий скрипт pre-push/CI исключает только SHA, доказанно достижимые из
|
||||
`origin/main`; сам merge и новые post-merge коммиты остаются под всеми
|
||||
проверками. На `main`, beta/issue-ветки и обычный push в `dev` это исключение
|
||||
не распространяется (issue #155).
|
||||
|
||||
Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает
|
||||
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
|
||||
который не работает в самолёте, отключают целиком, а строгий проход всё равно
|
||||
@@ -560,7 +632,7 @@ Project v2 остаётся человеческим представление
|
||||
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
|
||||
недоступный или помеченный `blocked` — отказ (**fail closed**).
|
||||
|
||||
Две оговорки к проверке 8, обе выяснились при реализации.
|
||||
Три оговорки к проверке 8 выяснились при реализации.
|
||||
|
||||
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
|
||||
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
|
||||
@@ -573,6 +645,13 @@ Validate стартует от этого push и успевает прочит
|
||||
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
|
||||
`S7-code-review`, то есть заведомо вне рабочего множества.
|
||||
|
||||
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
|
||||
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
|
||||
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
|
||||
Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через
|
||||
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
|
||||
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
|
||||
|
||||
Не реализовано и остаётся долгом:
|
||||
|
||||
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
|
||||
|
||||
@@ -312,6 +312,17 @@ Always source is deliberately constant-on. With several own `light.*`/`switch.*`
|
||||
entities, Always also offers a leading-entity selector; a missing saved choice
|
||||
is warned about and retained while a deterministic fallback is used.
|
||||
|
||||
To make that virtual lamp manually switchable without creating a Home
|
||||
Assistant helper, also choose **Tap action → Toggle state** on the lamp itself.
|
||||
This exact combination — virtual binding, **Light source → Always**, and
|
||||
**Toggle state** — stores a shared on/off state in the House Plan integration.
|
||||
It survives page reloads and Home Assistant restarts and updates Glow, room
|
||||
fill/statistics, full cards and `houseplan-space-card` together. Any signed-in
|
||||
dashboard viewer may toggle it. While this manual mode is active, saved
|
||||
**Controls other light sources** remain intact but are not called; changing the
|
||||
role, binding or tap action restores their normal behaviour. This operational
|
||||
state is deliberately not part of plan exports or Home Assistant entities.
|
||||
|
||||
The same dialog controls how the device looks on the plan. **Display** switches between the
|
||||
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
|
||||
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
|
||||
|
||||
@@ -24,7 +24,12 @@ from .const import (
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
|
||||
from .store import (
|
||||
HouseplanConfigEntry,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
create_data,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,6 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
await data.config_store.async_load()
|
||||
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
|
||||
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
||||
try:
|
||||
await data.virtual_light_store.async_load()
|
||||
except Exception: # noqa: BLE001 — operational state fails safe to default on
|
||||
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
|
||||
entry.runtime_data = data
|
||||
|
||||
# server-side vacuum trails: the integration records the path itself
|
||||
@@ -152,7 +161,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
rev = int(stored.get("rev", 0))
|
||||
if cfg and migrate_config(cfg): # 2. the config half
|
||||
rev += 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
await async_save_config_state(data, cfg, rev, previous_rev=rev - 1)
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await async_save_layout_state(
|
||||
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
|
||||
@@ -186,11 +195,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
|
||||
))
|
||||
if stored.get("config") != target_config or config_rev < target_config_rev:
|
||||
previous_config_rev = config_rev
|
||||
config_rev = max(config_rev, target_config_rev)
|
||||
await data.config_store.async_save({
|
||||
"config": target_config,
|
||||
"rev": config_rev,
|
||||
})
|
||||
await async_save_config_state(
|
||||
data,
|
||||
target_config,
|
||||
config_rev,
|
||||
previous_rev=previous_config_rev,
|
||||
)
|
||||
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
|
||||
layout_rev = max(layout_rev, target_layout_rev)
|
||||
exact_metadata = pending.get("final_metadata")
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
DOMAIN = "houseplan"
|
||||
STORAGE_KEY = f"{DOMAIN}.layout"
|
||||
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_MINOR_VERSION = 1
|
||||
STORAGE_MINOR_VERSION = 2
|
||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
@@ -45,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.63.0-beta.1"
|
||||
VERSION = "1.64.0"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
@@ -64,5 +65,5 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
"markers": [],
|
||||
"settings": {},
|
||||
"settings": {"bg_mode": "daynight"},
|
||||
}
|
||||
|
||||
@@ -73,6 +73,35 @@ def _json_copy(value: Any) -> Any:
|
||||
return json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
|
||||
|
||||
|
||||
def _background_mode(settings: Any) -> str | None:
|
||||
if not isinstance(settings, dict):
|
||||
return None
|
||||
mode = settings.get("bg_mode")
|
||||
return mode if mode in ("static", "daynight") else None
|
||||
|
||||
|
||||
def _materialize_global_background(config: dict[str, Any], fallback: str = "static") -> str:
|
||||
"""Make the global mode portable instead of relying on a code default."""
|
||||
settings = config.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
config["settings"] = settings
|
||||
mode = _background_mode(settings) or fallback
|
||||
settings["bg_mode"] = mode
|
||||
return mode
|
||||
|
||||
|
||||
def _materialize_space_background(space: dict[str, Any], fallback: str) -> str:
|
||||
"""Make one space independent from the target installation's default."""
|
||||
settings = space.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
space["settings"] = settings
|
||||
mode = _background_mode(settings) or fallback
|
||||
settings["bg_mode"] = mode
|
||||
return mode
|
||||
|
||||
|
||||
def _stored_model_version(config: dict[str, Any]) -> int:
|
||||
"""Return the model actually stored without silently upgrading it."""
|
||||
value = config.get("model_version", 0)
|
||||
@@ -239,6 +268,7 @@ def create_export(
|
||||
# payload would either duplicate it or tempt an exporter to silently stamp
|
||||
# the current version over an older/future stored model.
|
||||
config.pop("model_version", None)
|
||||
global_background = _materialize_global_background(config)
|
||||
layout = live_layout(config, LAYOUT_SCHEMA(_json_copy(layout_data.get("layout") or {})))
|
||||
stamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
title = ""
|
||||
@@ -247,6 +277,7 @@ def create_export(
|
||||
space = next((sp for sp in config.get("spaces") or [] if str(sp.get("id")) == space_id), None)
|
||||
if not space:
|
||||
raise ImportFailure("space_not_found", "Space was not found")
|
||||
_materialize_space_background(space, global_background)
|
||||
title = str(space.get("title") or space.get("id") or "space")
|
||||
selected_layout = {
|
||||
key: pos for key, pos in layout.items()
|
||||
@@ -358,6 +389,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
if document["kind"] == "full":
|
||||
_materialize_global_background(config)
|
||||
else:
|
||||
for space in config.get("spaces") or []:
|
||||
if isinstance(space, dict):
|
||||
_materialize_space_background(space, "static")
|
||||
try:
|
||||
layout = LAYOUT_SCHEMA(_json_copy(payload.get("layout") or {}))
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
@@ -560,6 +597,7 @@ def build_space_merge(
|
||||
if len(spaces) != 1:
|
||||
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
|
||||
space = spaces[0]
|
||||
_materialize_space_background(space, "static")
|
||||
used = {
|
||||
str(value)
|
||||
for sp in current_config.get("spaces") or []
|
||||
@@ -1039,6 +1077,7 @@ def prepare_apply(
|
||||
_detach_missing(imported_config, candidate.get("content") or [])
|
||||
if document["kind"] == "full":
|
||||
config = imported_config
|
||||
_materialize_global_background(config)
|
||||
# Full exports keep the data-model version in the portable envelope so
|
||||
# the payload can be validated independently. Restore it before the
|
||||
# configuration is persisted; otherwise every full round-trip silently
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.63.0-beta.1"
|
||||
"version": "1.64.0"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -10,7 +12,43 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN, STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_MINOR_VERSION, STORAGE_VERSION
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
STORAGE_CONFIG_KEY,
|
||||
STORAGE_KEY,
|
||||
STORAGE_MINOR_VERSION,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_BG_MODES = frozenset({"static", "daynight"})
|
||||
|
||||
|
||||
def migrate_config_background_mode(old_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Materialize the legacy implicit background mode without changing its view.
|
||||
|
||||
Only the config-store document has a top-level ``config`` object. Layout
|
||||
and virtual-light stores pass through this helper unchanged even though
|
||||
they share the same Store subclass and minor version.
|
||||
"""
|
||||
config = old_data.get("config")
|
||||
if not isinstance(config, dict):
|
||||
return old_data
|
||||
settings = config.get("settings")
|
||||
mode = settings.get("bg_mode") if isinstance(settings, dict) else None
|
||||
if mode in _BG_MODES:
|
||||
return old_data
|
||||
|
||||
data = copy.deepcopy(old_data)
|
||||
migrated_config = data["config"]
|
||||
migrated_settings = migrated_config.get("settings")
|
||||
if not isinstance(migrated_settings, dict):
|
||||
migrated_settings = {}
|
||||
migrated_config["settings"] = migrated_settings
|
||||
migrated_settings["bg_mode"] = "static"
|
||||
return data
|
||||
|
||||
|
||||
class HouseplanStore(Store):
|
||||
@@ -28,10 +66,9 @@ class HouseplanStore(Store):
|
||||
old_minor_version: int,
|
||||
old_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
data = old_data
|
||||
# if old_major_version == 1 and old_minor_version < 2:
|
||||
# ...migrate...
|
||||
return data
|
||||
if old_major_version == 1 and old_minor_version < 2:
|
||||
return migrate_config_background_mode(old_data)
|
||||
return old_data
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,6 +77,7 @@ class HouseplanData:
|
||||
|
||||
store: HouseplanStore
|
||||
config_store: HouseplanStore
|
||||
virtual_light_store: HouseplanStore
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
@@ -71,6 +109,12 @@ def create_data(hass: HomeAssistant) -> HouseplanData:
|
||||
config_store=HouseplanStore(
|
||||
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
|
||||
),
|
||||
virtual_light_store=HouseplanStore(
|
||||
hass,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||
minor_version=STORAGE_MINOR_VERSION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -139,3 +183,43 @@ async def async_save_layout_state(
|
||||
)
|
||||
await runtime.store.async_save(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def async_save_config_state(
|
||||
runtime: HouseplanData,
|
||||
config: dict[str, Any],
|
||||
rev: int,
|
||||
*,
|
||||
previous_rev: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist configuration and reconcile dependent operational state.
|
||||
|
||||
Callers already hold ``runtime.write_lock``. Reading the previous
|
||||
revision here keeps less common writers (import recovery and undo) on the
|
||||
same path as ordinary editor saves without duplicating lifecycle rules.
|
||||
"""
|
||||
if previous_rev is None:
|
||||
previous = await runtime.config_store.async_load() or {}
|
||||
try:
|
||||
previous_rev = int(previous.get("rev", 0))
|
||||
except (TypeError, ValueError):
|
||||
previous_rev = 0
|
||||
|
||||
payload = {"config": config, "rev": rev}
|
||||
await runtime.config_store.async_save(payload)
|
||||
|
||||
# The config is already durable at this point. Reconciliation remains a
|
||||
# separate Store write; an interrupted pair is detected from config_rev on
|
||||
# the next read and fails safe to the compatibility default (all on).
|
||||
from .virtual_lights import async_reconcile_virtual_lights
|
||||
|
||||
try:
|
||||
await async_reconcile_virtual_lights(
|
||||
runtime.virtual_light_store,
|
||||
config,
|
||||
rev,
|
||||
previous_config_rev=previous_rev,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - config commit already stands
|
||||
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
|
||||
return payload
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Persistent operational state for manual virtual lights."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .store import HouseplanStore
|
||||
|
||||
|
||||
EVENT_VIRTUAL_LIGHT_UPDATED = "houseplan_virtual_light_updated"
|
||||
|
||||
|
||||
def is_manual_virtual_light(marker: Any) -> bool:
|
||||
"""Return whether a marker uses the exact persistent manual-light mode."""
|
||||
return (
|
||||
isinstance(marker, dict)
|
||||
and isinstance(marker.get("id"), str)
|
||||
and bool(marker["id"])
|
||||
and marker.get("binding") == "virtual"
|
||||
and marker.get("is_light") is True
|
||||
and marker.get("tap_action") == "toggle"
|
||||
and marker.get("removed") is not True
|
||||
)
|
||||
|
||||
|
||||
def eligible_virtual_light_ids(config: Any) -> set[str]:
|
||||
"""Collect live marker ids eligible for persistent manual state."""
|
||||
if not isinstance(config, dict):
|
||||
return set()
|
||||
markers = config.get("markers")
|
||||
if not isinstance(markers, list):
|
||||
return set()
|
||||
return {marker["id"] for marker in markers if is_manual_virtual_light(marker)}
|
||||
|
||||
|
||||
def _integer(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0, parsed)
|
||||
|
||||
|
||||
def _read_state(stored: Any) -> tuple[int, int, set[str]]:
|
||||
if not isinstance(stored, dict):
|
||||
return 0, 0, set()
|
||||
raw_off = stored.get("off")
|
||||
off = (
|
||||
{item for item in raw_off if isinstance(item, str) and item}
|
||||
if isinstance(raw_off, list)
|
||||
else set()
|
||||
)
|
||||
return _integer(stored.get("rev")), _integer(stored.get("config_rev")), off
|
||||
|
||||
|
||||
def _wire(rev: int, config_rev: int, off: set[str]) -> dict[str, Any]:
|
||||
return {"rev": rev, "config_rev": config_rev, "off": sorted(off)}
|
||||
|
||||
|
||||
async def async_virtual_light_snapshot(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a coherent snapshot, repairing stale or interrupted state.
|
||||
|
||||
A revision gap means an older writer may have changed eligibility without
|
||||
knowing about this Store. Clearing every manual-off bit is conservative:
|
||||
it restores the pre-feature/default-on behaviour and cannot resurrect an
|
||||
old off state for a marker whose role changed in the meantime.
|
||||
"""
|
||||
stored = await store.async_load() or {}
|
||||
rev, state_config_rev, stored_off = _read_state(stored)
|
||||
eligible = eligible_virtual_light_ids(config)
|
||||
off = stored_off & eligible if state_config_rev == config_rev else set()
|
||||
if off != stored_off:
|
||||
rev += 1
|
||||
payload = _wire(rev, config_rev, off)
|
||||
if payload != stored:
|
||||
await store.async_save(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def async_reconcile_virtual_lights(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
*,
|
||||
previous_config_rev: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Carry eligible state across one known configuration transition."""
|
||||
stored = await store.async_load() or {}
|
||||
rev, state_config_rev, stored_off = _read_state(stored)
|
||||
eligible = eligible_virtual_light_ids(config)
|
||||
off = stored_off & eligible if state_config_rev == previous_config_rev else set()
|
||||
if off != stored_off:
|
||||
rev += 1
|
||||
payload = _wire(rev, config_rev, off)
|
||||
if payload != stored:
|
||||
await store.async_save(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def async_toggle_virtual_light(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
marker_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Atomically invert one eligible marker and persist before returning."""
|
||||
if marker_id not in eligible_virtual_light_ids(config):
|
||||
return None
|
||||
snapshot = await async_virtual_light_snapshot(store, config, config_rev)
|
||||
off = set(snapshot["off"])
|
||||
if marker_id in off:
|
||||
off.remove(marker_id)
|
||||
else:
|
||||
off.add(marker_id)
|
||||
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
|
||||
await store.async_save(payload)
|
||||
return {
|
||||
"marker_id": marker_id,
|
||||
"on": marker_id not in off,
|
||||
"rev": payload["rev"],
|
||||
}
|
||||
@@ -44,10 +44,16 @@ from .store import (
|
||||
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
HouseplanData,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
get_data,
|
||||
get_entry,
|
||||
)
|
||||
from .virtual_lights import (
|
||||
EVENT_VIRTUAL_LIGHT_UPDATED,
|
||||
async_toggle_virtual_light,
|
||||
async_virtual_light_snapshot,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
@@ -126,6 +132,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_virtual_light_toggle)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
|
||||
@@ -194,10 +201,11 @@ async def _persist_pair_intent(
|
||||
|
||||
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
|
||||
"""Write both target halves and remove the durable intent last."""
|
||||
await rt.config_store.async_save({
|
||||
"config": pending["config"],
|
||||
"rev": int(pending["config_rev"]),
|
||||
})
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
pending["config"],
|
||||
int(pending["config_rev"]),
|
||||
)
|
||||
stored = await rt.store.async_load() or {}
|
||||
await async_save_layout_state(
|
||||
rt,
|
||||
@@ -1058,14 +1066,28 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
config_rev = int(data.get("rev", 0))
|
||||
try:
|
||||
virtual_lights = await async_virtual_light_snapshot(
|
||||
rt.virtual_light_store,
|
||||
config,
|
||||
config_rev,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - config remains independently readable
|
||||
_LOGGER.exception("House Plan: reading virtual-light state failed")
|
||||
# Never expose a stale off bit after an unreadable/revision-gap
|
||||
# operational store. Compatibility/default on is the safe frame.
|
||||
virtual_lights = {"rev": 0, "config_rev": config_rev, "off": []}
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"config": config,
|
||||
"rev": data.get("rev", 0),
|
||||
"rev": config_rev,
|
||||
"virtual_lights": virtual_lights,
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
|
||||
"undo_kind": _undo_kind(data, layout_data),
|
||||
@@ -1073,6 +1095,42 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/virtual_light/toggle",
|
||||
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=500)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_virtual_light_toggle(
|
||||
hass: HomeAssistant, connection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Atomically toggle one eligible virtual light for any signed-in user."""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
result = await async_toggle_virtual_light(
|
||||
rt.virtual_light_store,
|
||||
config,
|
||||
int(data.get("rev", 0)),
|
||||
msg["marker_id"],
|
||||
)
|
||||
if result is None:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"not_toggleable",
|
||||
"Marker is not an active virtual light with tap_action=toggle",
|
||||
)
|
||||
return
|
||||
# Both the reply and event follow the durable Store write. There is no
|
||||
# optimistic client state, so all cards converge on this revision.
|
||||
connection.send_result(msg["id"], result)
|
||||
hass.bus.async_fire(EVENT_VIRTUAL_LIGHT_UPDATED, result)
|
||||
|
||||
|
||||
|
||||
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
|
||||
"""Plan file names a configuration names through OUR urls.
|
||||
@@ -1215,7 +1273,12 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
msg["config"],
|
||||
new_rev,
|
||||
previous_rev=int(current_rev),
|
||||
)
|
||||
try:
|
||||
await _discard_optimizer_snapshot(rt)
|
||||
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
|
||||
@@ -1340,7 +1403,12 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
msg["config"],
|
||||
new_config_rev,
|
||||
previous_rev=config_rev,
|
||||
)
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, msg["layout"], new_layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup},
|
||||
@@ -1410,7 +1478,12 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
restored_config,
|
||||
new_config_rev,
|
||||
previous_rev=config_rev,
|
||||
)
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, restored_layout, new_layout_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
|
||||
|
||||
@@ -14,11 +14,26 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1'].includes(profile))
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
space.room_drafts = [0, 1].map((draft) => {
|
||||
const y = 0.985 + draft * 0.025;
|
||||
return {
|
||||
id: `perf-draft-${floor}-${draft}`,
|
||||
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
|
||||
segments: [{ cm: 15 }, { cm: 20 }],
|
||||
};
|
||||
});
|
||||
}
|
||||
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
|
||||
}
|
||||
const viewport = { width: 1440, height: 1000 };
|
||||
|
||||
const { page, browser } = await launch(
|
||||
@@ -48,7 +63,9 @@ const rows = [];
|
||||
try {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric }) => {
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
@@ -104,6 +121,7 @@ try {
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
});
|
||||
|
||||
window.__card?.remove?.();
|
||||
@@ -120,6 +138,7 @@ try {
|
||||
card.setConfig({
|
||||
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
|
||||
});
|
||||
let wsCalls = 0;
|
||||
const connection = {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
@@ -134,6 +153,7 @@ try {
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
},
|
||||
callWS: async (message) => {
|
||||
wsCalls++;
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
@@ -196,6 +216,85 @@ try {
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
let planSnapDiagnostics = null;
|
||||
const planSnapPointer = planSnap ? await duration(async () => {
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
card._path = [];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
|
||||
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
|
||||
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
|
||||
const cacheValue = card._planSnapGeometryCache?.value ?? null;
|
||||
const configBefore = JSON.stringify(card._serverCfg);
|
||||
const callsBefore = wsCalls;
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const fromPlan = (x, y) => ({
|
||||
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
|
||||
});
|
||||
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
|
||||
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
|
||||
.map((line) => ({
|
||||
line,
|
||||
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
|
||||
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
|
||||
}))
|
||||
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
|
||||
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
|
||||
const points = [
|
||||
firstEndpoint
|
||||
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
|
||||
: [40, 40],
|
||||
longLine
|
||||
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
|
||||
: [120, 40],
|
||||
[10, 10],
|
||||
];
|
||||
const seenKinds = new Set();
|
||||
for (let index = 0; index < 120; index++) {
|
||||
const point = points[index % points.length];
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
...fromPlan(point[0], point[1]),
|
||||
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const active = card.renderRoot.querySelector(
|
||||
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
|
||||
);
|
||||
if (active) seenKinds.add(active.getAttribute('data-kind'));
|
||||
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
|
||||
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
|
||||
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
|
||||
}
|
||||
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
planSnapDiagnostics = {
|
||||
supported: requiresPlanSnap,
|
||||
staticLines,
|
||||
staticNodes,
|
||||
activeKinds: [...seenKinds].sort(),
|
||||
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
|
||||
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
|
||||
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
|
||||
=== staticNodes,
|
||||
configStable: JSON.stringify(card._serverCfg) === configBefore,
|
||||
wsWrites: wsCalls - callsBefore,
|
||||
};
|
||||
if (requiresPlanSnap && (
|
||||
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|
||||
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|
||||
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|
||||
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
|
||||
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
|
||||
const resizePreview = await duration(async () => {
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
@@ -279,6 +378,10 @@ try {
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
...(planSnapPointer ? {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -288,6 +391,7 @@ try {
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -306,7 +410,7 @@ try {
|
||||
return result;
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -319,6 +423,7 @@ const metricNames = [
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,29 +1,42 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 17,
|
||||
"acceptedAt": "2026-08-13T14:30:05.929Z",
|
||||
"sourceFingerprint": "66f31850eafc963848acdc4e56e363a364bbb9b189bf4ade73d3c111ae2fc375",
|
||||
"matrixVersion": 22,
|
||||
"acceptedAt": "2026-08-14T16:03:22.017Z",
|
||||
"sourceFingerprint": "24117bcfb4c94d5421e0304612089743615233eab3911f065e8cfa003f48fa7d",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"isometric-geometry-view-dark": "6db601f322fe55e33a1a89fe62f5d3a7d53f12bac4a68c7911b12f6382ef4fca",
|
||||
"isometric-geometry-view-light": "c4c005fa55f64280abf9fccccdf7dc8efc3497bd626572bc11f45cc3cddaa159",
|
||||
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
|
||||
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
|
||||
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "36c83bbe39809f346ebb5a0f4ed633c938e2fed028defaa48097c281db8e23a3",
|
||||
"isometric-large-warm-remount-dark": "798d312671dffebf59034a39f2865a65ece27a84b150e77bc59038bb2567d07c",
|
||||
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
|
||||
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
|
||||
"geometry-plan-editor-dark": "6b06213324c5ff50c7176451307a33d8ac31ee636c96698f8efe61e8db763bae",
|
||||
"opening-placement-door-thick-wall-dark": "24f472818f2246eeaa6568648ca4a6c42d9d84723fbcdd879435253399e2995d",
|
||||
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
|
||||
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
|
||||
"tray-wide-selection-en": "468ac6acfd7fcdcbfa947e032843ff117a32ae01b4ec30937a6cddecff534504",
|
||||
"tray-wide-tool-ru": "388e03d7bf7a2391d0581e8446d0692048b9792e80bf3b9dd9bca86222fe9418",
|
||||
"tray-medium-group-en": "190d5c356461ad2aa8b63b132416e8958a285047f8dea92301213678c7ac5a92",
|
||||
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
|
||||
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
|
||||
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
|
||||
"geometry-diagonal-45-opening-dark": "3736a75163d47a71f60c45cd554d604f27cfbc81119b5b55e8134da61a2a0f0a",
|
||||
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
|
||||
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
|
||||
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
|
||||
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
|
||||
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
|
||||
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
|
||||
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
|
||||
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
|
||||
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
|
||||
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
|
||||
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
|
||||
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
|
||||
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
|
||||
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
|
||||
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
|
||||
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
|
||||
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
|
||||
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
|
||||
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
|
||||
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
|
||||
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
|
||||
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
|
||||
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
|
||||
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
|
||||
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
@@ -42,15 +55,15 @@
|
||||
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
|
||||
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
|
||||
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
|
||||
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
|
||||
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
|
||||
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
|
||||
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
|
||||
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
|
||||
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
|
||||
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
|
||||
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
|
||||
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
|
||||
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
|
||||
"backup-space-preview-mobile-ru": "16d06859ea6aac6cbed586f0d6918da972e9c95206fffd771fe43b6084f79877"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -1,5 +1,5 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
|
||||
|
||||
@@ -51,6 +51,64 @@ async function stableEnvironment(page, scenario) {
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
export function prepareGoldenFixture(scenario) {
|
||||
const fixture = fixtureFor(scenario.fixture);
|
||||
if (scenario.cornerSplitWall) {
|
||||
const stage = scenario.cornerSplitWall;
|
||||
if (!['before', 'thin', 'thick'].includes(stage))
|
||||
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.50];
|
||||
const br = [0.90, 0.90], bl = [0.10, 0.90];
|
||||
const entry = (from, to, cm) => ({
|
||||
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
|
||||
});
|
||||
const before = stage === 'before';
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
name: 'Corner Split',
|
||||
rooms: before
|
||||
? [{ id: 'corner-source', name: 'Before Split', area: null, poly: [a, tr, br, bl] }]
|
||||
: [
|
||||
{ id: 'corner-source', name: 'Main room', area: null, poly: [a, tr, split] },
|
||||
{ id: 'corner-fresh', name: 'New room', area: null, poly: [split, br, bl, a] },
|
||||
],
|
||||
walls: before
|
||||
? [entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15)]
|
||||
: [
|
||||
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
|
||||
entry(br, bl, 15), entry(bl, a, 15), entry(a, split, stage === 'thin' ? 15 : 100),
|
||||
],
|
||||
settings: { show_borders: true, fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 } },
|
||||
});
|
||||
}
|
||||
if (scenario.wallJunctions) {
|
||||
const a = [0.06, 0.06], tr = [0.94, 0.06], br = [0.94, 0.94], bl = [0.06, 0.94];
|
||||
const entry = (from, to, cm) => ({
|
||||
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
|
||||
});
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
title: 'Wall junctions',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
rooms: [{ id: 'junction-room', name: 'Room', area: null, poly: [a, tr, br, bl] }],
|
||||
walls: [entry(a, tr, 10), entry(tr, br, 10), entry(br, bl, 10), entry(bl, a, 10)],
|
||||
partitions: [
|
||||
{ id: 'junction-l-a', a: [0.16, 0.25], b: [0.38, 0.25], cm: 18 },
|
||||
{ id: 'junction-l-b', a: [0.38, 0.25], b: [0.38, 0.46], cm: 30 },
|
||||
{ id: 'junction-oblique-a', a: [0.58, 0.22], b: [0.78, 0.38], cm: 22 },
|
||||
{ id: 'junction-oblique-b', a: [0.78, 0.38], b: [0.62, 0.52], cm: 14 },
|
||||
{ id: 'junction-t-through', a: [0.18, 0.70], b: [0.78, 0.70], cm: 24 },
|
||||
{ id: 'junction-t-branch', a: [0.50, 0.54], b: [0.50, 0.70], cm: 16 },
|
||||
{ id: 'junction-room-branch', a: [0.30, 0.82], b: [0.30, 0.94], cm: 18 },
|
||||
],
|
||||
room_drafts: [{
|
||||
id: 'junction-draft', points: [[0.16, 0.54], [0.30, 0.54], [0.30, 0.64]],
|
||||
segments: [{ cm: 12 }, { cm: 20 }],
|
||||
}],
|
||||
wall_columns: [],
|
||||
});
|
||||
}
|
||||
const requireSpace = () => {
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
@@ -61,12 +119,13 @@ export function prepareGoldenFixture(scenario) {
|
||||
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
}
|
||||
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|
||||
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
|
||||
const space = requireSpace();
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
|
||||
...(scenario.bgMode ? { bg_mode: scenario.bgMode } : {}),
|
||||
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
|
||||
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
|
||||
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
|
||||
@@ -290,6 +349,60 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.wallJunctionPreview) {
|
||||
const { path, pointer, cms, cm } = scenario.wallJunctionPreview;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!Array.isArray(path) || !path.length || !path.every(validPoint)
|
||||
|| !validPoint(pointer) || !Array.isArray(cms) || !cms.every(Number.isFinite)
|
||||
|| !(Number(cm) > 0)) {
|
||||
throw new Error(`invalid golden wallJunctionPreview contract: ${scenario.id}`);
|
||||
}
|
||||
card._tool = 'draw';
|
||||
card._activeDraftId = null;
|
||||
card._path = path.map((point) => [point[0] * 1000, point[1] * card._spaceH]);
|
||||
card._draftSegmentCms = [...cms];
|
||||
card._drawWallField = String(cm);
|
||||
card._cursorPt = [pointer[0] * 1000, pointer[1] * card._spaceH];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (!card.renderRoot.querySelector('.drawwall-preview'))
|
||||
throw new Error(`golden wall junction preview did not render: ${scenario.id}`);
|
||||
}
|
||||
if (scenario.planSnap) {
|
||||
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)
|
||||
|| (anchor != null && !validPoint(anchor))
|
||||
|| !['endpoint', 'line'].includes(expectedKind)) {
|
||||
throw new Error(`invalid golden planSnap contract: ${scenario.id}`);
|
||||
}
|
||||
card._tool = tool;
|
||||
card._activeDraftId = null;
|
||||
card._path = anchor ? [[anchor[0] * 1000, anchor[1] * card._spaceH]] : [];
|
||||
card._clearPlanSnapHover();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const svgRoot = card.renderRoot.querySelector('.stage svg');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const screen = new DOMPoint(pointer[0] * 1000, pointer[1] * card._spaceH)
|
||||
.matrixTransform(svgRoot.getScreenCTM());
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerId: 992, pointerType: 'mouse',
|
||||
clientX: screen.x, clientY: screen.y,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
const active = overlay?.querySelector('.plan-snap-node[data-active="true"]');
|
||||
if (!overlay || active?.getAttribute('data-kind') !== expectedKind
|
||||
|| overlay.querySelectorAll('.plan-snap-node[data-active="true"]').length !== 1) {
|
||||
throw new Error(`golden plan snap candidate did not render: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.openingPreview) {
|
||||
const { type, pointer } = scenario.openingPreview;
|
||||
if (!['window', 'door', 'gate'].includes(type)
|
||||
@@ -482,6 +595,17 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
}
|
||||
if (scenario.dayCycle) {
|
||||
const environment = card.renderRoot.querySelector('.hp-day-cycle-env');
|
||||
const active = card.renderRoot.querySelector('.hp-day-cycle-bg.active');
|
||||
const paper = card.renderRoot.querySelector('.hp-paperg');
|
||||
if (environment?.dataset.dayCyclePhase !== scenario.dayCycle.phase
|
||||
|| active?.dataset.dayCycleLayer !== scenario.dayCycle.phase
|
||||
|| !active?.getAttribute('style')?.includes(scenario.dayCycle.top)
|
||||
|| !paper) {
|
||||
throw new Error(`golden day-cycle contract did not render: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
await document.fonts?.ready;
|
||||
await frame();
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
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 = 17;
|
||||
export const GOLDEN_MATRIX_VERSION = 22;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-corner-wall-thin-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'thin', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -28,8 +35,50 @@ 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: 'day-cycle-dawn-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'dawn', top: '#aabdd1' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 95, elevation: -2, rising: true } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-day-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'day', top: '#dce9ef' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 180, elevation: 42, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-dusk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'dusk', top: '#48536c' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-night-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'night', top: '#111a27' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'plan-snap-endpoint-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' },
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'plan-snap-line-gaps-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
planSnap: {
|
||||
tool: 'draw', anchor: [0.70, 0.455], pointer: [0.482, 0.455], expectedKind: 'line',
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-plan-preview-light', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.16, 0.15], [0.38, 0.15]], pointer: [0.38, 0.25], cms: [12], cm: 24,
|
||||
},
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-plan-t-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.50, 0.54]], pointer: [0.50, 0.70], cms: [], cm: 24,
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-view-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
// complete 90 cm door preset while still proving rotation, inner-face
|
||||
|
||||
@@ -12,6 +12,16 @@ flat while reporting the same profile. The dedicated
|
||||
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
|
||||
evidence; a local report is diagnostic.
|
||||
|
||||
Issue #137 adds `large-house-plan-snap-v1` without changing the meaning or
|
||||
budgets of the original profile. The same 60-room/60-partition fixture gains
|
||||
six saved open outlines, renders the Plan snap overlay, and sends 120 real
|
||||
pointer moves across endpoint, line and miss targets. Candidate bundles fail
|
||||
inside the runner if the static DOM or geometry cache grows, more than one
|
||||
active node appears, endpoint/line paths are not both exercised, or config and
|
||||
websocket traffic change. Its dedicated budget retains every original timing,
|
||||
heap and cache ceiling and adds the measured pointer series plus a one-entry
|
||||
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
|
||||
|
||||
The runner records seven measured samples after one discarded warm-up. With
|
||||
this intentionally small CI sample, the nearest-rank `p95` is the observed
|
||||
maximum; reports keep the conventional field name but should be read as a
|
||||
@@ -110,6 +120,7 @@ Build and copy a fresh demo bundle first, then run:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
|
||||
```
|
||||
|
||||
A local report is diagnostic only; it cannot replace the CI comparison.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-plan-snap-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 200,
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"planSnapPointerMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 5000
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.4,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 2000
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.35,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 7000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxSingleRegressionRatio": 0.3,
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.35,
|
||||
"countNoiseAllowance": 3,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 150
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.75,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 160,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -33,13 +33,15 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_gridPitch',
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_path',
|
||||
'_rszDrag',
|
||||
'_serverCfg',
|
||||
'_settingsDialog',
|
||||
'_tool',
|
||||
]),
|
||||
// A comparison SHA before #89 is intentionally flat; the isometric runner
|
||||
// checks these two members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze(['_isoGeometryCache', '_setProjection']),
|
||||
optionalFields: Object.freeze(['_isoGeometryCache', '_planSnapGeometryCache', '_setProjection']),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_cleanFloorCache: 'map',
|
||||
@@ -48,8 +50,11 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
_gridPitch: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_model: 'array',
|
||||
_path: 'array',
|
||||
_isoGeometryCache: 'map',
|
||||
_planSnapGeometryCache: 'object',
|
||||
_setProjection: 'function',
|
||||
_serverCfg: 'object',
|
||||
_tool: 'string',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
|
||||
// Four stills of the #146 day-cycle environment.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
|
||||
@@ -16,21 +16,22 @@ await page.evaluate(async () => {
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
});
|
||||
const shot = async (az, el, name) => {
|
||||
await page.evaluate(async ([az, el]) => {
|
||||
const shot = async (az, el, rising, name) => {
|
||||
await page.evaluate(async ([az, el, rising]) => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
attributes: { azimuth: az, elevation: el, rising },
|
||||
} } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
}, [az, el]);
|
||||
}, [az, el, rising]);
|
||||
await page.waitForTimeout(300);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
|
||||
};
|
||||
await shot(180, 60, 'dn_noon');
|
||||
await shot(265, 3, 'dn_sunset');
|
||||
await shot(0, -20, 'dn_night');
|
||||
await shot(95, -2, true, 'dn_dawn');
|
||||
await shot(180, 60, false, 'dn_noon');
|
||||
await shot(265, -2, false, 'dn_sunset');
|
||||
await shot(0, -20, false, 'dn_night');
|
||||
await browser.close();
|
||||
console.log('shots done');
|
||||
|
||||
@@ -257,7 +257,12 @@ const layers = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const m = c._spaceModel();
|
||||
const svgEl = c.renderRoot.querySelector('.stage svg');
|
||||
const kids = [...svgEl.children];
|
||||
// Stage 2 keeps the flat floor DOM byte-for-byte equivalent but places it
|
||||
// in one transformable scene group shared with Iso. Ordering is therefore
|
||||
// measured inside the group that actually owns paper/image/rooms.
|
||||
const layerRoot = [...svgEl.children].find((node) =>
|
||||
node.querySelector?.('.hp-paperg') && node.querySelector?.('.room')) || svgEl;
|
||||
const kids = [...layerRoot.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
const papers = [...svgEl.querySelectorAll('.hp-paper')];
|
||||
return {
|
||||
|
||||
@@ -103,6 +103,25 @@ const res = await page.evaluate(async () => {
|
||||
await settleMode();
|
||||
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
|
||||
|
||||
// The environment, plan and zoom badge own explicit stacking levels. DOM
|
||||
// order alone let an opaque cross-fade layer cover the plan, while raising
|
||||
// only the plan hid the later percentage badge behind it.
|
||||
const previousBgMode = c._serverCfg.settings.bg_mode;
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._zoom = 2.5;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const zoomWrap = sr().querySelector('.zoomwrap');
|
||||
const zoomBadge = sr().querySelector('.zoombadge');
|
||||
const environment = sr().querySelector('.hp-day-cycle-env');
|
||||
out.dayCycleStaysBehindPlan = getComputedStyle(environment).zIndex === '0'
|
||||
&& getComputedStyle(zoomWrap).zIndex === '1';
|
||||
out.zoomBadgeStaysAbovePlan = !!zoomBadge
|
||||
&& getComputedStyle(zoomBadge).zIndex === '12';
|
||||
if (previousBgMode === undefined) delete c._serverCfg.settings.bg_mode;
|
||||
else c._serverCfg.settings.bg_mode = previousBgMode;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// 9) kiosk instance respects the setting
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
@@ -112,19 +131,27 @@ const res = await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
// the kiosk instance re-reads the pristine demo config — inject the color
|
||||
// into ITS copy; what matters here is the kiosk rendering path
|
||||
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
|
||||
k.hass = { ...c.hass };
|
||||
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a', bg_mode: 'daynight' };
|
||||
k.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 42, rising: false },
|
||||
} } };
|
||||
k.requestUpdate();
|
||||
await k.updateComplete;
|
||||
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
|
||||
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
|
||||
out.kioskSharesDay = kStage?.classList.contains('phase-day')
|
||||
&& kStage.querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'day';
|
||||
k.remove();
|
||||
|
||||
// 10) the static space-card paints the same background
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
|
||||
const hass2 = { ...c.hass, callWS: async (m) => {
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456', bg_mode: 'daynight' };
|
||||
const hass2 = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 95, elevation: -2, rising: true },
|
||||
} }, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return origWS.call(c.hass, m);
|
||||
@@ -140,6 +167,11 @@ const res = await page.evaluate(async () => {
|
||||
await sc.updateComplete;
|
||||
const st = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
|
||||
out.staticCardSharesDawn = st?.classList.contains('phase-dawn')
|
||||
&& st.querySelector('.hp-day-cycle-env')?.dataset.dayCycleSource === 'sun'
|
||||
&& st.querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'dawn';
|
||||
out.staticCardLayersStayOrdered = getComputedStyle(st.querySelector('svg')).zIndex === '1'
|
||||
&& getComputedStyle(st.querySelector('.devlayer')).zIndex === '2';
|
||||
// ...and the space override beats the global there too
|
||||
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
|
||||
sc.hass = { ...hass2 };
|
||||
@@ -166,8 +198,8 @@ checkAll(res);
|
||||
// bounding box (section 12), and never the backdrop image rect either
|
||||
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
|
||||
// asserts the new rule on exactly the case that used to be the exception.
|
||||
// The scene colour is visible ONLY around the paper; the 'daynight' night
|
||||
// dims the paper via brightness only.
|
||||
// The scene colour is visible ONLY around the paper. The four-phase
|
||||
// environment changes only outside it and adds an alpha-aware outer outline.
|
||||
const paper = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
@@ -197,7 +229,10 @@ const paper = await page.evaluate(async () => {
|
||||
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
|
||||
}
|
||||
// …and the picture is drawn ON that paper: image after paper, before walls
|
||||
const kids = [...sr().querySelector('.stage svg').children];
|
||||
const svgEl = sr().querySelector('.stage svg');
|
||||
const layerRoot = [...svgEl.children].find((node) =>
|
||||
node.querySelector?.('.hp-paperg') && node.querySelector?.('.room')) || svgEl;
|
||||
const kids = [...layerRoot.children];
|
||||
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
|
||||
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
|
||||
@@ -207,16 +242,18 @@ const paper = await page.evaluate(async () => {
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
|
||||
c._setMode('view');
|
||||
await upd(); await settleMode();
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
// daynight night: the paper stays pixel-identical and opaque; only its
|
||||
// grouped outer outline and the environment may change.
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._serverCfg.settings.north_deg = 0;
|
||||
c._cfgRev++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 180, elevation: -20 },
|
||||
attributes: { azimuth: 180, elevation: -20, rising: false },
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
out.nightDoesNotDimPlan = !(sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.');
|
||||
out.nightEnvironment = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'night';
|
||||
const pn = sr().querySelector('.stage svg .hp-paper');
|
||||
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
|
||||
delete c._serverCfg.settings.bg_mode;
|
||||
@@ -265,8 +302,10 @@ const drawn = await page.evaluate(async () => {
|
||||
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
|
||||
});
|
||||
// paper first, everything else on top of it (one .hp-paperg group wraps
|
||||
// all paper shapes so the daynight drop shadow composites without seams)
|
||||
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
|
||||
// all paper shapes so the day-cycle outline composites without seams)
|
||||
const noplanSvg = sr().querySelector('.stage.noplan svg');
|
||||
const noplanLayers = [...noplanSvg.children].find((node) => node.querySelector?.('.hp-paperg')) || noplanSvg;
|
||||
const first = [...noplanLayers.children].find((node) => node.tagName.toLowerCase() !== 'defs');
|
||||
out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
|
||||
&& !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
|
||||
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// #140: opening and physical-object properties share the existing medium
|
||||
// dialog shell. Measure real localized buttons rather than merely checking
|
||||
// the `wide` attribute, because the contract is usable spare width plus safe
|
||||
// wrapping when the viewport becomes narrow.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const DESKTOP_WIDTH = 1000;
|
||||
const NARROW_WIDTH = 320;
|
||||
const { page, browser } = await launch({ width: DESKTOP_WIDTH, height: 820 }, 1);
|
||||
|
||||
const desktop = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
card._serverStorage = true;
|
||||
card._serverCfg = {
|
||||
spaces: [{
|
||||
id: 'dialog-layout', title: 'Dialog layout', cell_cm: 5,
|
||||
view_box: [0, 0, 1, 1], rooms: [], settings: {},
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._space = 'dialog-layout';
|
||||
card._mode = 'plan';
|
||||
card._modelCache = null;
|
||||
card._saveConfig = () => {};
|
||||
await settle();
|
||||
|
||||
const layout = (kind, language) => {
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
const dialog = root.querySelector('hp-dialog');
|
||||
const surface = dialog?.shadowRoot?.querySelector('.surface');
|
||||
const footer = dialog?.querySelector('.dialog-action-footer');
|
||||
const danger = footer?.querySelector('.dialog-action-danger');
|
||||
const commit = footer?.querySelector('.dialog-action-commit');
|
||||
const surfaceRect = surface?.getBoundingClientRect();
|
||||
const footerRect = footer?.getBoundingClientRect();
|
||||
const footerStyle = footer ? getComputedStyle(footer) : null;
|
||||
const dangerRect = danger?.getBoundingClientRect();
|
||||
const commitRect = commit?.getBoundingClientRect();
|
||||
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
||||
const innerWidth = footerRect && footerStyle
|
||||
? footerRect.width - parseFloat(footerStyle.paddingLeft) - parseFloat(footerStyle.paddingRight)
|
||||
: 0;
|
||||
const contentWidth = (dangerRect?.width || 0) + (commitRect?.width || 0);
|
||||
return {
|
||||
kind,
|
||||
language,
|
||||
widePreset: dialog?.wide === true && dialog.hasAttribute('wide'),
|
||||
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
|
||||
innerWidth: Number(innerWidth.toFixed(2)),
|
||||
contentWidth: Number(contentWidth.toFixed(2)),
|
||||
spareWidth: Number((innerWidth - contentWidth).toFixed(2)),
|
||||
buttons: buttons.length,
|
||||
oneRow: !!dangerRect && !!commitRect && Math.abs(dangerRect.top - commitRect.top) <= 1,
|
||||
noHorizontalOverflow: !!surface && !!footer
|
||||
&& surface.scrollWidth <= surface.clientWidth + 1
|
||||
&& footer.scrollWidth <= footer.clientWidth + 1,
|
||||
};
|
||||
};
|
||||
|
||||
const open = async (kind, language) => {
|
||||
card._config = { ...(card._config || {}), language };
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
if (kind === 'opening') {
|
||||
card._openingDialog = {
|
||||
id: 'opening-1', type: 'door', lengthCm: 90,
|
||||
contact: '', lock: '', invert: false, flipH: false, flipV: false,
|
||||
x: 200, y: 200, angle: 0,
|
||||
};
|
||||
} else if (kind === 'physical') {
|
||||
card._physicalDialog = {
|
||||
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
|
||||
};
|
||||
} else {
|
||||
card._openSpaceDialog('edit', 'dialog-layout');
|
||||
}
|
||||
await settle();
|
||||
const result = layout(kind, language);
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
await settle();
|
||||
return result;
|
||||
};
|
||||
|
||||
const metrics = [];
|
||||
for (const language of ['en', 'ru']) {
|
||||
metrics.push(await open('opening', language));
|
||||
metrics.push(await open('physical', language));
|
||||
}
|
||||
const space = await open('space', 'ru');
|
||||
return { metrics, space };
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: NARROW_WIDTH, height: 820 });
|
||||
|
||||
const narrow = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const measure = async (kind) => {
|
||||
card._config = { ...(card._config || {}), language: 'ru' };
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
if (kind === 'opening') {
|
||||
card._openingDialog = {
|
||||
id: 'opening-1', type: 'door', lengthCm: 90,
|
||||
contact: '', lock: '', invert: false, flipH: false, flipV: false,
|
||||
x: 200, y: 200, angle: 0,
|
||||
};
|
||||
} else if (kind === 'physical') {
|
||||
card._physicalDialog = {
|
||||
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
|
||||
};
|
||||
} else if (kind === 'draft') {
|
||||
card._physicalDialog = {
|
||||
kind: 'draft', id: 'draft-1', segment: 0, cm: '12', length: '1 m',
|
||||
};
|
||||
} else {
|
||||
card._openSpaceDialog('edit', 'dialog-layout');
|
||||
}
|
||||
await settle();
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
const dialog = root.querySelector('hp-dialog');
|
||||
const surface = dialog?.shadowRoot?.querySelector('.surface');
|
||||
const footer = dialog?.querySelector('.dialog-action-footer');
|
||||
const danger = footer?.querySelector('.dialog-action-danger');
|
||||
const commit = footer?.querySelector('.dialog-action-commit');
|
||||
const surfaceRect = surface?.getBoundingClientRect();
|
||||
const footerRect = footer?.getBoundingClientRect();
|
||||
const footerStyle = footer ? getComputedStyle(footer) : null;
|
||||
const dangerRect = danger?.getBoundingClientRect();
|
||||
const commitRect = commit?.getBoundingClientRect();
|
||||
const innerLeft = footerRect && footerStyle
|
||||
? footerRect.left + parseFloat(footerStyle.paddingLeft) : 0;
|
||||
const innerRight = footerRect && footerStyle
|
||||
? footerRect.right - parseFloat(footerStyle.paddingRight) : 0;
|
||||
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
||||
const result = {
|
||||
kind,
|
||||
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
|
||||
insideViewport: !!surfaceRect && surfaceRect.left >= -1
|
||||
&& surfaceRect.right <= innerWidth + 1,
|
||||
noHorizontalOverflow: !!surface && !!footer
|
||||
&& surface.scrollWidth <= surface.clientWidth + 1
|
||||
&& footer.scrollWidth <= footer.clientWidth + 1,
|
||||
buttonsContained: !!footerRect && buttons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.left >= innerLeft - 1 && rect.right <= innerRight + 1
|
||||
&& rect.top >= footerRect.top - 1 && rect.bottom <= footerRect.bottom + 1;
|
||||
}),
|
||||
wrapped: !!dangerRect && !!commitRect && commitRect.top >= dangerRect.bottom - 1,
|
||||
buttons: buttons.length,
|
||||
};
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
await settle();
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
opening: await measure('opening'),
|
||||
physical: await measure('physical'),
|
||||
draft: await measure('draft'),
|
||||
space: await measure('space'),
|
||||
};
|
||||
});
|
||||
|
||||
const checks = {};
|
||||
for (const metric of desktop.metrics) {
|
||||
const key = `${metric.kind}_${metric.language}`;
|
||||
checks[`${key}_medium_shell`] = metric.widePreset
|
||||
&& metric.surfaceWidth >= 499 && metric.surfaceWidth <= 501;
|
||||
checks[`${key}_three_actions_one_row`] = metric.buttons === 3 && metric.oneRow;
|
||||
checks[`${key}_positive_localization_headroom`] = metric.spareWidth > 0;
|
||||
checks[`${key}_no_horizontal_overflow`] = metric.noHorizontalOverflow;
|
||||
}
|
||||
checks.space_dialog_still_uses_medium_shell = desktop.space.widePreset
|
||||
&& desktop.space.surfaceWidth >= 499 && desktop.space.surfaceWidth <= 501;
|
||||
checks.space_dialog_footer_not_regressed = desktop.space.buttons === 3
|
||||
&& desktop.space.oneRow && desktop.space.noHorizontalOverflow;
|
||||
|
||||
for (const kind of ['opening', 'physical']) {
|
||||
const metric = narrow[kind];
|
||||
checks[`${kind}_narrow_fits_viewport`] = metric.surfaceWidth <= NARROW_WIDTH * 0.94 + 1
|
||||
&& metric.insideViewport && metric.noHorizontalOverflow && metric.buttonsContained;
|
||||
checks[`${kind}_narrow_keeps_responsive_wrap`] = metric.buttons === 3 && metric.wrapped;
|
||||
}
|
||||
checks.draft_four_actions_remain_contained = narrow.draft.buttons === 4
|
||||
&& narrow.draft.insideViewport && narrow.draft.noHorizontalOverflow
|
||||
&& narrow.draft.buttonsContained;
|
||||
checks.space_narrow_not_regressed = narrow.space.buttons === 3
|
||||
&& narrow.space.insideViewport && narrow.space.noHorizontalOverflow
|
||||
&& narrow.space.buttonsContained;
|
||||
|
||||
checkAll(checks);
|
||||
await finish(browser, {
|
||||
checks,
|
||||
desktop: {
|
||||
dialogs: desktop.metrics,
|
||||
longest: desktop.metrics.reduce((longest, metric) => (
|
||||
metric.contentWidth > longest.contentWidth ? metric : longest
|
||||
)),
|
||||
space: desktop.space,
|
||||
},
|
||||
narrow,
|
||||
});
|
||||
@@ -5,6 +5,13 @@ const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._openSpaceDialog('create');
|
||||
out.newSpaceUsesDaynight = c._spaceDialog?.bgMode === 'daynight';
|
||||
c._spaceDialog = null;
|
||||
c._importQueue = ['Imported floor'];
|
||||
c._openNextImport();
|
||||
out.floorImportUsesDaynight = c._spaceDialog?.bgMode === 'daynight';
|
||||
c._spaceDialog = null;
|
||||
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.rows = sr().querySelectorAll('.gsrow').length;
|
||||
@@ -67,6 +74,8 @@ checkAll(res, {
|
||||
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
|
||||
],
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"newSpaceUsesDaynight": true,
|
||||
"floorImportUsesDaynight": true,
|
||||
"lqiBefore": 7,
|
||||
"lqiAfter": 0,
|
||||
});
|
||||
|
||||
@@ -197,7 +197,13 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
},
|
||||
// модульный кэш конфига инвалидируется только через это событие — стаб
|
||||
// сохраняет колбэк, чтобы следующий тест мог сбросить кэш
|
||||
connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
|
||||
connection: { subscribeEvents: async (cb, type) => {
|
||||
// #107 added a third, virtual-light subscription. Keep the config event
|
||||
// callback specifically; otherwise the next fixture calls the unrelated
|
||||
// virtual-light listener and reuses this stale shared config snapshot.
|
||||
if (type === 'houseplan_config_updated') window.__hpInvalidate = cb;
|
||||
return () => {};
|
||||
} } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// #89 Stage 1: Labs lifecycle, toggle, projection parity and flat editor boundary.
|
||||
// #122 Stage 2: Labs lifecycle, structural composition and flat editor boundary.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 });
|
||||
@@ -44,7 +44,12 @@ const out = await page.evaluate(async () => {
|
||||
const device = root().querySelector('[data-hp="device"]');
|
||||
const roomLabel = root().querySelector('[data-hp="room-label"]');
|
||||
result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true'
|
||||
&& !!root().querySelector('[data-hp="iso-underlay"] .iso-floor-side')
|
||||
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
|
||||
result.sharedProjectionSnapshot = ['.iso-underlay-svg', '.plan-svg', '.iso-shadows-svg', '.iso-walls-svg']
|
||||
.map((selector) => root().querySelector(selector)?.getAttribute('viewBox'))
|
||||
.every((value, _index, values) => !!value && value === values[0]);
|
||||
result.stage2DefinitionsBounded = root().querySelectorAll('[id^="hp-iso-"]').length <= 5;
|
||||
result.preferenceStored = JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
|
||||
result.anchorsFinite = [device, roomLabel].every((node) => node
|
||||
&& center(node).every((value) => Number.isFinite(value)));
|
||||
@@ -79,7 +84,7 @@ const out = await page.evaluate(async () => {
|
||||
build: () => {
|
||||
attempts++;
|
||||
if (shouldFail) throw new Error('injected isometric failure');
|
||||
return [];
|
||||
return { walls: [], floor: [], openings: [] };
|
||||
},
|
||||
});
|
||||
card.requestUpdate();
|
||||
@@ -108,7 +113,8 @@ const out = await page.evaluate(async () => {
|
||||
await frame();
|
||||
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]')
|
||||
&& !root().querySelector('[data-hp="iso-walls"]');
|
||||
&& !root().querySelector('[data-hp="iso-walls"]')
|
||||
&& !root().querySelector('[id^="hp-iso-"]');
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// #89 Stage 1: Q6 live-floor preservation plus touch, kiosk and remount paths.
|
||||
// #122 Stage 2: live-floor preservation, vertical openings, touch and remount paths.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch(
|
||||
@@ -26,6 +26,8 @@ const out = await page.evaluate(async () => {
|
||||
contact: 'binary_sensor.window' },
|
||||
{ id: 'iso-door', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.12,
|
||||
contact: 'binary_sensor.window', lock: 'lock.front_door' },
|
||||
{ id: 'iso-gate', type: 'gate', x: 0.72, y: 0.14, angle: 0, length: 0.16,
|
||||
contact: 'binary_sensor.gate', lock: 'lock.front_door' },
|
||||
];
|
||||
configSpace.partitions = [{
|
||||
id: 'iso-live-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
|
||||
@@ -86,6 +88,7 @@ const out = await page.evaluate(async () => {
|
||||
glowSources: count('.glowlayer [data-glow-source]'),
|
||||
sun: count('.sunlayer'),
|
||||
openings: count('[data-hp="opening"]'),
|
||||
verticalOpenings: count('[data-hp="iso-openings"] .iso-opening-panel'),
|
||||
devices: count('[data-hp="device"]'),
|
||||
hover: count('.room-hover-fill'),
|
||||
vacuumPucks: count('.vacpuck'),
|
||||
@@ -104,6 +107,8 @@ const out = await page.evaluate(async () => {
|
||||
const wallFingerprint = root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint;
|
||||
const cachedGeometry = original._isoGeometryCache.get(wallFingerprint)?.geometry;
|
||||
const light = original.hass.states['light.ceiling'];
|
||||
const openingPanelBefore = root(original)
|
||||
.querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d');
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
states: {
|
||||
@@ -111,6 +116,9 @@ const out = await page.evaluate(async () => {
|
||||
'light.ceiling': {
|
||||
...light, attributes: { ...light.attributes, rgb_color: [255, 64, 32] },
|
||||
},
|
||||
'binary_sensor.window': {
|
||||
...original.hass.states['binary_sensor.window'], state: 'off',
|
||||
},
|
||||
},
|
||||
};
|
||||
await original.updateComplete;
|
||||
@@ -118,22 +126,26 @@ const out = await page.evaluate(async () => {
|
||||
const afterSpillParts = [...root(original).querySelectorAll('.glow-pool')]
|
||||
.map((node) => node.getAttribute('data-lit-parts'));
|
||||
const ordered = [
|
||||
root(original).querySelector('.iso-underlay-svg'),
|
||||
root(original).querySelector('.hp-backdrop'),
|
||||
root(original).querySelector('.decorlayer'),
|
||||
root(original).querySelector('[data-hp="room"]'),
|
||||
root(original).querySelector('.glow-base-layer'),
|
||||
root(original).querySelector('.glowlayer'),
|
||||
root(original).querySelector('.sunlayer'),
|
||||
root(original).querySelector('.iso-shadows-svg'),
|
||||
root(original).querySelector('.iso-walls-svg'),
|
||||
root(original).querySelector('.vacpuck'),
|
||||
];
|
||||
const result = {
|
||||
isoOnTouch: !!root(original).querySelector('[data-hp="iso-walls"]'),
|
||||
flatIsoLayerParity: JSON.stringify(flat) === JSON.stringify(before)
|
||||
flatIsoLayerParity: JSON.stringify({ ...flat, openings: 0, verticalOpenings: before.verticalOpenings })
|
||||
=== JSON.stringify(before)
|
||||
&& JSON.stringify(flatSpillParts) === JSON.stringify(beforeSpillParts),
|
||||
liveLayersPresent: before.rooms > 0 && before.roomFills > 0 && before.decor >= 2
|
||||
&& before.furniture > 0 && before.backdrop > 0 && before.glowBase > 0
|
||||
&& before.glowSources >= 2 && before.sun > 0 && before.openings >= 2
|
||||
&& before.glowSources >= 2 && before.sun > 0 && before.openings === 0
|
||||
&& before.verticalOpenings === 5
|
||||
&& before.devices > 0 && before.hover > 0 && before.vacuumPucks > 0
|
||||
&& before.vacuumTrails >= 2,
|
||||
liveLayersStable: JSON.stringify(after) === JSON.stringify(before),
|
||||
@@ -147,9 +159,50 @@ const out = await page.evaluate(async () => {
|
||||
.some((stop) => stop.getAttribute('stop-color') === '#ff4020'),
|
||||
sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint,
|
||||
haUpdateReusesGeometry: original._isoGeometryCache.get(wallFingerprint)?.geometry === cachedGeometry,
|
||||
noStage1VerticalOpenings: !root(original).querySelector('.iso-window,.window-light,.vertical-door'),
|
||||
contactUpdateMovesOnlyLivePanel: !!openingPanelBefore
|
||||
&& root(original).querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d')
|
||||
!== openingPanelBefore
|
||||
&& original._isoGeometryCache.size === 1,
|
||||
oneLightModel: !root(original).querySelector('.window-light,.iso-window-light,.iso-glow,.iso-sun'),
|
||||
oldFloorSymbolsNotDuplicated: before.openings === 0,
|
||||
};
|
||||
|
||||
configSpace.settings.hide_openings = true;
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.hideOpeningsKeepsStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& !root(original).querySelector('[data-hp="opening"]')
|
||||
&& root(original).querySelectorAll('.glowlayer [data-glow-source]').length >= 2;
|
||||
|
||||
configSpace.settings.hide_openings = false;
|
||||
configSpace.settings.show_borders = false;
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.noBordersUsesFloorSymbols = !root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !root(original).querySelector('[data-hp="iso-underlay"]')
|
||||
&& root(original).querySelectorAll('[data-hp="opening"]').length === 3;
|
||||
|
||||
configSpace.settings.show_borders = true;
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.visibleBordersRestoreStage2 = !!root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& original._isoGeometryCache.size === 1;
|
||||
|
||||
const supports = CSS.supports;
|
||||
CSS.supports = () => false;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.unsupportedDecorationKeepsIsoStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !!root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& !root(original).querySelector('[data-hp="iso-shadows"]');
|
||||
CSS.supports = supports;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
|
||||
const calls = [];
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/** Issue #137: architectural endpoint/line overlay and exact snap in Plan. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => { card.requestUpdate(); await card.updateComplete; };
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'snap', title: 'Snap', cell_cm: 5, view_box: [0, 0, 1, 0.7],
|
||||
rooms: [
|
||||
{ id: 'left', name: 'Left', area: null,
|
||||
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
|
||||
{ id: 'right', name: 'Right', area: null,
|
||||
poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
|
||||
],
|
||||
openings: [{ id: 'door', type: 'door', x: 0.3, y: 0.1, angle: 0, length: 0.1 }],
|
||||
open_spans: [{ a: [0.5, 0.2], b: [0.5, 0.3] }],
|
||||
room_drafts: [{ id: 'saved', points: [[0.1, 0.6], [0.3, 0.6], [0.3, 0.7]],
|
||||
segments: [{ cm: 15 }, { cm: 15 }] }],
|
||||
partitions: [{ id: 'base-partition', a: [0.6, 0.6], b: [0.9, 0.6], cm: 15 }],
|
||||
wall_columns: [{ id: 'ignored-column', shape: 'square', center: [0.8, 0.35], cm: 25 }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._serverCfg = JSON.parse(JSON.stringify(cfg));
|
||||
card._layout = {};
|
||||
card._space = 'snap';
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._cfgEpoch++;
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
card._path = [];
|
||||
card._activeDraftId = null;
|
||||
card._clearPlanSnapHover();
|
||||
await update();
|
||||
|
||||
const stage = root().querySelector('.stage');
|
||||
const eventAt = (x, y, type = 'pointermove', extra = {}) => {
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const EventType = type.startsWith('pointer') ? PointerEvent : MouseEvent;
|
||||
return new EventType(type, {
|
||||
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
|
||||
bubbles: true,
|
||||
pointerId: 41,
|
||||
pointerType: 'mouse',
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
const overlay = () => root().querySelector('[data-hp="plan-snap-overlay"]');
|
||||
const active = () => root().querySelector('.plan-snap-node[data-active="true"]');
|
||||
const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon;
|
||||
|
||||
result.overlayBeforeFirstClick = !!overlay();
|
||||
result.overlayIsPointerTransparent = overlay()?.getAttribute('pointer-events') === 'none'
|
||||
&& getComputedStyle(overlay()).pointerEvents === 'none';
|
||||
const wallBodies = root().querySelector('.wallbodies');
|
||||
result.overlayAfterWallBodies = !!wallBodies && !!overlay()
|
||||
&& !!(wallBodies.compareDocumentPosition(overlay()) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
result.oneLinePerSolidInterval = overlay()?.querySelectorAll('.plan-snap-line').length === 12;
|
||||
result.uniqueSourceEndpointsOnly = overlay()?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length === 11;
|
||||
result.columnIsNotACandidate = ![...overlay().querySelectorAll('.plan-snap-node')].some((node) =>
|
||||
close(+node.getAttribute('cx'), 800) && close(+node.getAttribute('cy'), 350));
|
||||
|
||||
const crosses = (x1, y1, x2, y2, px, py) => {
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const length2 = dx * dx + dy * dy;
|
||||
if (!length2) return false;
|
||||
const t = ((px - x1) * dx + (py - y1) * dy) / length2;
|
||||
if (t <= 0 || t >= 1) return false;
|
||||
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) < 0.01;
|
||||
};
|
||||
const lines = () => [...overlay().querySelectorAll('.plan-snap-line')].map((line) => [
|
||||
+line.getAttribute('x1'), +line.getAttribute('y1'),
|
||||
+line.getAttribute('x2'), +line.getAttribute('y2'),
|
||||
]);
|
||||
result.openingGapHasNoLine = !lines().some((line) => crosses(...line, 300, 100));
|
||||
result.openSpanHasNoLine = !lines().some((line) => crosses(...line, 500, 250));
|
||||
result.cutBoundariesAreNotEndpoints = ![...overlay().querySelectorAll('[data-kind="endpoint"]')].some((node) => {
|
||||
const x = +node.getAttribute('cx'), y = +node.getAttribute('cy');
|
||||
return (close(y, 100) && (close(x, 250) || close(x, 350)))
|
||||
|| (close(x, 500) && (close(y, 200) || close(y, 300)));
|
||||
});
|
||||
const cachedGeometry = card._planSnapGeometryCache?.value;
|
||||
const staticNodesBefore = [...overlay().querySelectorAll(
|
||||
'.plan-snap-line, .plan-snap-node[data-kind="endpoint"]',
|
||||
)];
|
||||
const originalRequestUpdate = card.requestUpdate;
|
||||
let initialHoverUpdates = 0;
|
||||
card.requestUpdate = function (...args) {
|
||||
initialHoverUpdates++;
|
||||
return originalRequestUpdate.apply(this, args);
|
||||
};
|
||||
|
||||
stage.dispatchEvent(eventAt(104, 104));
|
||||
await card.updateComplete;
|
||||
result.endpointHoverIsActive = active()?.getAttribute('data-kind') === 'endpoint'
|
||||
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 100)
|
||||
&& close(+active().getAttribute('r'), card._cmToUnits(10));
|
||||
stage.dispatchEvent(eventAt(735, 606));
|
||||
await card.updateComplete;
|
||||
const initialLineActive = active()?.getAttribute('data-kind') === 'line';
|
||||
stage.dispatchEvent(eventAt(300, 100));
|
||||
await card.updateComplete;
|
||||
const initialMissInactive = !active();
|
||||
stage.dispatchEvent(eventAt(104, 104));
|
||||
await card.updateComplete;
|
||||
card.requestUpdate = originalRequestUpdate;
|
||||
result.initialHoverAvoidsFullCardUpdate = initialHoverUpdates === 0;
|
||||
result.initialHoverCyclesCandidateKinds = initialLineActive && initialMissInactive
|
||||
&& active()?.getAttribute('data-kind') === 'endpoint';
|
||||
result.initialHoverKeepsStaticNodeIdentity = staticNodesBefore.every(
|
||||
(node, index) => node === overlay().querySelectorAll(
|
||||
'.plan-snap-line, .plan-snap-node[data-kind="endpoint"]',
|
||||
)[index],
|
||||
);
|
||||
card._markupClick(eventAt(104, 104, 'click', { shiftKey: true }));
|
||||
await card.updateComplete;
|
||||
result.endpointOverridesGridAndShift = card._path.length === 1
|
||||
&& close(card._path[0][0], 100) && close(card._path[0][1], 100);
|
||||
|
||||
stage.dispatchEvent(eventAt(735, 606));
|
||||
await card.updateComplete;
|
||||
const lineNode = active();
|
||||
result.lineHoverShowsOneDynamicNode = !!lineNode
|
||||
&& lineNode.getAttribute('data-kind') === 'line'
|
||||
&& root().querySelectorAll('.plan-snap-node.dynamic[data-active="true"]').length === 1;
|
||||
const linePoint = lineNode ? [+lineNode.getAttribute('cx'), +lineNode.getAttribute('cy')] : [NaN, NaN];
|
||||
result.lineNodeStaysWallBound = close(linePoint[1], 600);
|
||||
result.lineNodeQuantizesAlongWall = close((linePoint[0] - 600) / card._gridPitch,
|
||||
Math.round((linePoint[0] - 600) / card._gridPitch));
|
||||
result.hoverKeepsStaticGeometryCache = !!cachedGeometry
|
||||
&& card._planSnapGeometryCache?.value === cachedGeometry;
|
||||
const originalPartition = JSON.stringify(card._curSpaceCfg.partitions[0]);
|
||||
card._markupClick(eventAt(735, 606, 'click'));
|
||||
await card.updateComplete;
|
||||
result.drawCommitUsesExactLineNode = card._path.length === 2
|
||||
&& close(card._path[1][0], linePoint[0]) && close(card._path[1][1], linePoint[1]);
|
||||
result.existingPartitionWasNotSplit = card._curSpaceCfg.partitions.length === 1
|
||||
&& JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
|
||||
|
||||
stage.dispatchEvent(eventAt(300, 100));
|
||||
await card.updateComplete;
|
||||
result.openingGapDoesNotActivateSnap = !active();
|
||||
stage.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true, pointerId: 41 }));
|
||||
await card.updateComplete;
|
||||
result.pointerLeaveClearsActiveNode = !active();
|
||||
|
||||
card._cancelPath();
|
||||
card._activeDraftId = 'saved';
|
||||
card._path = [[100, 600], [300, 600], [300, 700]];
|
||||
await update();
|
||||
result.activeDraftExcluded = !lines().some((line) => (
|
||||
close(line[1], 600) && close(line[3], 600)
|
||||
&& Math.min(line[0], line[2]) < 300 && Math.max(line[0], line[2]) > 100
|
||||
) || (
|
||||
close(line[0], 300) && close(line[2], 300)
|
||||
&& Math.min(line[1], line[3]) < 700 && Math.max(line[1], line[3]) > 600
|
||||
));
|
||||
stage.dispatchEvent(eventAt(302, 602));
|
||||
await card.updateComplete;
|
||||
result.intermediateCurrentPointDoesNotSelfSnap = !active();
|
||||
stage.dispatchEvent(eventAt(102, 602));
|
||||
await card.updateComplete;
|
||||
result.firstCurrentPointRemainsClosureTarget = active()?.getAttribute('data-kind') === 'endpoint'
|
||||
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 600);
|
||||
stage.dispatchEvent(eventAt(302, 698));
|
||||
await card.updateComplete;
|
||||
result.currentAnchorDoesNotCreateZeroSegment = !active();
|
||||
card._cancelPath();
|
||||
card._tool = 'partition';
|
||||
await update();
|
||||
card._markupClick(eventAt(735, 606, 'click'));
|
||||
await card.updateComplete;
|
||||
result.tapWithoutHoverSnapsFirstPoint = card._path.length === 1
|
||||
&& close(card._path[0][1], 600)
|
||||
&& close((card._path[0][0] - 600) / card._gridPitch,
|
||||
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._curSpaceCfg.partitions.length === 2
|
||||
&& close(card._curSpaceCfg.partitions[1].b[0] * 1000, 900)
|
||||
&& close(card._curSpaceCfg.partitions[1].b[1] * 1000, 500);
|
||||
result.originalSegmentStillUnchanged = JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
|
||||
|
||||
const gestureGeometry = JSON.stringify({
|
||||
drafts: card._curSpaceCfg.room_drafts,
|
||||
partitions: card._curSpaceCfg.partitions,
|
||||
});
|
||||
card._suppressClick = true;
|
||||
card._markupClick(eventAt(700, 600, 'click'));
|
||||
result.suppressedClickDoesNotCommit = card._path.length === 0;
|
||||
card._suppressClick = false;
|
||||
stage.dispatchEvent(eventAt(700, 600, 'pointerdown', { pointerId: 51, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(760, 640, 'pointermove', { pointerId: 51, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(760, 640, 'pointerup', { pointerId: 51 }));
|
||||
stage.dispatchEvent(eventAt(650, 560, 'pointerdown', { pointerId: 61, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(800, 650, 'pointerdown', { pointerId: 62, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(620, 540, 'pointermove', { pointerId: 61, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(830, 670, 'pointermove', { pointerId: 62, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(620, 540, 'pointercancel', { pointerId: 61 }));
|
||||
stage.dispatchEvent(eventAt(830, 670, 'pointerup', { pointerId: 62 }));
|
||||
await card.updateComplete;
|
||||
result.panPinchCancelDoNotCommit = card._path.length === 0
|
||||
&& JSON.stringify({
|
||||
drafts: card._curSpaceCfg.room_drafts,
|
||||
partitions: card._curSpaceCfg.partitions,
|
||||
}) === gestureGeometry;
|
||||
|
||||
card._tool = 'select';
|
||||
await update();
|
||||
result.otherPlanToolsHaveNoOverlay = !overlay();
|
||||
card._setMode('view');
|
||||
await update();
|
||||
result.viewHasNoOverlay = !overlay();
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await page.emulateMedia({ forcedColors: 'active' });
|
||||
out.forcedColorsStayReadable = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
card._path = [];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
const line = overlay?.querySelector('.plan-snap-line');
|
||||
const node = overlay?.querySelector('.plan-snap-node');
|
||||
const lineStyle = line ? getComputedStyle(line) : null;
|
||||
const nodeStyle = node ? getComputedStyle(node) : null;
|
||||
return matchMedia('(forced-colors: active)').matches
|
||||
&& lineStyle?.stroke !== 'none' && nodeStyle?.stroke !== 'none'
|
||||
&& nodeStyle?.fill !== 'none';
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,187 @@
|
||||
// #131: a read-only HA session may read the House Plan snapshot while HA
|
||||
// rejects event subscriptions. The initial frame must still select one exact
|
||||
// space and render every raw-space layer before any user click.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { makeVisualMatrixFixture } from './fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixture = makeVisualMatrixFixture();
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const result = await page.evaluate(async (rawFixture) => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const HP = customElements.get('houseplan-card');
|
||||
const host = document.getElementById('host');
|
||||
window.__card.remove();
|
||||
HP._warmBootReset?.();
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (key.startsWith('houseplan_card_')) localStorage.removeItem(key);
|
||||
}
|
||||
history.replaceState(null, '', '/demo.html');
|
||||
|
||||
const fixture = structuredClone(rawFixture);
|
||||
const lighting = fixture.config.spaces.find((space) => space.id === 'golden-lighting');
|
||||
const geometry = fixture.config.spaces.find((space) => space.id === 'golden-geometry');
|
||||
lighting.id = 'home';
|
||||
lighting.title = 'Home';
|
||||
lighting.settings = {
|
||||
...lighting.settings,
|
||||
fill_mode: 'custom',
|
||||
custom_fill: { c: '#cdbb96', a: 0.42 },
|
||||
glow_enabled: true,
|
||||
show_borders: true,
|
||||
};
|
||||
lighting.decor = [{
|
||||
id: 'readonly-furniture', kind: 'rect', x: 0.16, y: 0.18, w: 0.12, h: 0.08,
|
||||
color: '#8d6e63', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#bcaaa4', fill_opacity: 1,
|
||||
}];
|
||||
geometry.id = 'upstairs';
|
||||
geometry.title = 'Upstairs';
|
||||
fixture.config.spaces = [lighting, geometry];
|
||||
fixture.config.markers.push({
|
||||
id: 'golden-light-one', binding: 'device:golden-light-one', display: 'value',
|
||||
});
|
||||
fixture.layout = Object.fromEntries(Object.entries(fixture.layout).map(([id, pos]) => [
|
||||
id,
|
||||
{ ...pos, s: pos.s === 'golden-lighting' ? 'home' : pos.s === 'golden-geometry' ? 'upstairs' : pos.s },
|
||||
]));
|
||||
|
||||
const base = window.__mkHass();
|
||||
const makeHass = (subscriptionMode = 'partial') => {
|
||||
const calls = { configGets: 0, events: {}, unsubscribed: [] };
|
||||
const connection = {
|
||||
subscribeEvents: async (callback, event) => {
|
||||
calls.events[event] = (calls.events[event] || 0) + 1;
|
||||
if (String(event).startsWith('houseplan_')
|
||||
&& (subscriptionMode === 'reject-all'
|
||||
|| event === 'houseplan_config_updated' || event === 'houseplan_trail_updated')) {
|
||||
throw new Error('unauthorized');
|
||||
}
|
||||
return () => { calls.unsubscribed.push(event); };
|
||||
},
|
||||
subscribeMessage: async () => () => {},
|
||||
};
|
||||
const hass = {
|
||||
...base,
|
||||
user: { id: 'readonly', name: 'Readonly', is_admin: false },
|
||||
devices: fixture.devices,
|
||||
entities: fixture.entities,
|
||||
areas: fixture.areas,
|
||||
states: fixture.states,
|
||||
connection,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
calls.configGets += 1;
|
||||
return { config: structuredClone(fixture.config), rev: 131, can_write: false };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') {
|
||||
return { layout: structuredClone(fixture.layout), rev: 131 };
|
||||
}
|
||||
if (message.type === 'houseplan/trail/get') return { trails: {} };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
|
||||
if (message.type === 'config_entries/get') {
|
||||
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden' }];
|
||||
}
|
||||
if (message.type === 'manifest/list') {
|
||||
return [{ domain: 'houseplan_golden', name: 'Golden' }];
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
return { hass, calls };
|
||||
};
|
||||
|
||||
const layerSnapshot = (card) => {
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
return {
|
||||
exactSpace: card._curSpaceCfg?.id || null,
|
||||
activeSpace: root.querySelector('[data-hp="space-tab"].active')?.getAttribute('data-id') || null,
|
||||
rooms: root.querySelectorAll('[data-hp="room"]').length,
|
||||
decor: root.querySelectorAll('[data-hp="decor"]').length,
|
||||
walls: root.querySelectorAll('.wallbodies .wallbody-fill').length,
|
||||
glow: root.querySelectorAll('.glow-spot').length,
|
||||
devices: root.querySelectorAll('[data-hp="device"]').length,
|
||||
values: root.querySelectorAll('[data-hp="device"] .valtext').length,
|
||||
};
|
||||
};
|
||||
const complete = (snapshot) => snapshot.exactSpace === 'home'
|
||||
&& snapshot.activeSpace === 'home' && snapshot.rooms > 0 && snapshot.decor > 0
|
||||
&& snapshot.walls > 0 && snapshot.glow > 0 && snapshot.devices > 0 && snapshot.values > 0;
|
||||
const waitForCard = async (card, calls, kiosk = false) => {
|
||||
const until = performance.now() + 9000;
|
||||
while (performance.now() < until) {
|
||||
const snapshot = layerSnapshot(card);
|
||||
const allSubscriptionsTried = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
|
||||
.every((event) => calls.events[event] === 1);
|
||||
if (card._loadOk && !card._loading && card._booting === false
|
||||
&& allSubscriptionsTried && complete({
|
||||
...snapshot,
|
||||
activeSpace: kiosk ? 'home' : snapshot.activeSpace,
|
||||
})) return snapshot;
|
||||
await sleep(25);
|
||||
}
|
||||
throw new Error(`readonly card did not settle: ${JSON.stringify({
|
||||
state: layerSnapshot(card), calls, space: card._space, loadOk: card._loadOk,
|
||||
})}`);
|
||||
};
|
||||
const mount = async (title, options = {}) => {
|
||||
const runtime = makeHass(options.subscriptionMode);
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title, kiosk: !!options.kiosk });
|
||||
host.appendChild(card);
|
||||
card.hass = runtime.hass;
|
||||
const snapshot = await waitForCard(card, runtime.calls, !!options.kiosk);
|
||||
return { card, snapshot, ...runtime };
|
||||
};
|
||||
|
||||
const cold = await mount('Readonly cold start');
|
||||
out.coldSelectsExactSpace = cold.card._space === 'home'
|
||||
&& cold.snapshot.exactSpace === 'home' && cold.snapshot.activeSpace === 'home';
|
||||
out.coldRendersAllSpatialLayers = complete(cold.snapshot);
|
||||
out.readOnlyStaysReadOnly = cold.card._canEdit === false
|
||||
&& !(cold.card.shadowRoot || cold.card.renderRoot).querySelector('.modetab');
|
||||
out.allSubscriptionsAttempted = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
|
||||
.every((event) => cold.calls.events[event] === 1);
|
||||
out.allowedSubscriptionAdopted = typeof cold.card._unsubLayout === 'function'
|
||||
&& !cold.card._unsubCfg && !cold.card._unsubTrail;
|
||||
out.cacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
|
||||
const beforeClick = layerSnapshot(cold.card);
|
||||
cold.card._pickSpace('home');
|
||||
await cold.card.updateComplete;
|
||||
out.activeTabClickIsNoop = JSON.stringify(layerSnapshot(cold.card)) === JSON.stringify(beforeClick);
|
||||
await sleep(700);
|
||||
out.optionalFailureNoFullRetry = cold.calls.configGets === 1;
|
||||
cold.card.remove();
|
||||
await sleep(20);
|
||||
out.successfulSubscriptionCleanedUp = cold.calls.unsubscribed.includes('houseplan_layout_updated');
|
||||
|
||||
const warm = await mount('Readonly cold start');
|
||||
out.warmRemountKeepsCompleteSpace = complete(warm.snapshot) && warm.card._space === 'home';
|
||||
warm.card.remove();
|
||||
await sleep(20);
|
||||
|
||||
HP._warmBootReset?.();
|
||||
const reload = await mount('Readonly simulated reload');
|
||||
out.cachedReloadKeepsCompleteSpace = complete(reload.snapshot) && reload.card._space === 'home';
|
||||
reload.card.remove();
|
||||
await sleep(20);
|
||||
|
||||
HP._warmBootReset?.();
|
||||
localStorage.removeItem('houseplan_card_cfg_v1');
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
const kiosk = await mount('Readonly cold kiosk', { kiosk: true, subscriptionMode: 'reject-all' });
|
||||
const kioskRoot = kiosk.card.shadowRoot || kiosk.card.renderRoot;
|
||||
out.coldKioskCompleteWithoutHeaderAction = kiosk.card._space === 'home'
|
||||
&& kiosk.snapshot.exactSpace === 'home' && kiosk.snapshot.rooms > 0
|
||||
&& kiosk.snapshot.decor > 0 && kiosk.snapshot.walls > 0 && kiosk.snapshot.glow > 0
|
||||
&& kiosk.snapshot.devices > 0 && getComputedStyle(kioskRoot.querySelector('.hdr')).display === 'none';
|
||||
out.kioskCacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
|
||||
kiosk.card.remove();
|
||||
|
||||
return out;
|
||||
}, fixture);
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -17,12 +17,41 @@ const res = await page.evaluate(async () => {
|
||||
r1.open_to = [r2.id]; r2.open_to = [r1.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
pairsCalls = 0; buildCalls = 0;
|
||||
const devicesBeforeHassTicks = c._devices;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } };
|
||||
await c.updateComplete;
|
||||
}
|
||||
out.pairsBounded = pairsCalls <= 20;
|
||||
out.modelBuildsPer10Renders = buildCalls;
|
||||
out.devicesStableAcrossHassTicks = c._devices === devicesBeforeHassTicks;
|
||||
|
||||
// #146: the browser-clock fallback repaints only the environment. A 30 s
|
||||
// tick must not rebuild the plan, devices, wall union, or sun-ray geometry.
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: { ...(c._serverCfg?.settings || {}), bg_mode: 'daynight' },
|
||||
};
|
||||
const statesWithoutSun = { ...c.hass.states };
|
||||
delete statesWithoutSun['sun.sun'];
|
||||
c.hass = { ...c.hass, states: statesWithoutSun };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clockModel = c._model;
|
||||
const clockDevices = c._devices;
|
||||
const clockWallUnion = c._wallUnionCache;
|
||||
const clockSunRays = c._sunRaysCache;
|
||||
buildCalls = 0;
|
||||
c._dayCycleClockKey = 'force-next-clock-tick';
|
||||
c._dayCycleTick();
|
||||
await c.updateComplete;
|
||||
out.fallbackClockActive = (c.shadowRoot || c.renderRoot)
|
||||
.querySelector('.hp-day-cycle-env')?.dataset.dayCycleSource === 'clock';
|
||||
out.clockTickModelBuilds = buildCalls;
|
||||
out.clockTickKeepsModel = c._model === clockModel;
|
||||
out.clockTickKeepsDevices = c._devices === clockDevices;
|
||||
out.clockTickKeepsWallUnion = c._wallUnionCache === clockWallUnion;
|
||||
out.clockTickKeepsSunRays = c._sunRaysCache === clockSunRays;
|
||||
|
||||
const before = pairsCalls;
|
||||
const r3 = sp.rooms[2] || sp.rooms[0];
|
||||
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
|
||||
@@ -35,5 +64,6 @@ const res = await page.evaluate(async () => {
|
||||
});
|
||||
checkAll(res, {
|
||||
modelBuildsPer10Renders: 0,
|
||||
clockTickModelBuilds: 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/** Issue #138: a new room may close along one existing continuous solid wall interval. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => { card.requestUpdate(); await card.updateComplete; };
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
const baseSpace = {
|
||||
id: 'autoclose', title: 'Autoclose', cell_cm: 5, view_box: [0, 0, 1, 0.7],
|
||||
rooms: [{
|
||||
id: 'existing', name: 'Existing', area: null,
|
||||
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]],
|
||||
}],
|
||||
walls: [{
|
||||
key: '0.500000,0.300000@1.5708',
|
||||
a: [0.5, 0.1], b: [0.5, 0.5], cm: 40,
|
||||
}],
|
||||
};
|
||||
const reset = async (spacePatch = {}) => {
|
||||
card._serverCfg = { spaces: [{ ...clone(baseSpace), ...clone(spacePatch) }], markers: [], settings: {} };
|
||||
card._layout = {};
|
||||
card._space = 'autoclose';
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._cfgEpoch++;
|
||||
card._planSnapGeometryCache = null;
|
||||
card._path = [];
|
||||
card._activeDraftId = null;
|
||||
card._draftSegmentCms = [];
|
||||
card._closingWallCm = null;
|
||||
card._roomDialog = false;
|
||||
card._roomEditId = null;
|
||||
card._pendingSplit = null;
|
||||
card._toast = '';
|
||||
card._drawWallField = '15';
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
card._clearPlanSnapHover();
|
||||
await update();
|
||||
};
|
||||
|
||||
await reset();
|
||||
const stage = root().querySelector('.stage');
|
||||
const eventAt = (x, y, extra = {}) => {
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const view = card._viewOr(card._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,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
const click = async (x, y, extra = {}) => {
|
||||
card._markupClick(eventAt(x, y, extra));
|
||||
await update();
|
||||
};
|
||||
const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon;
|
||||
const currentDraft = () => card._curSpaceCfg.room_drafts?.find((draft) =>
|
||||
draft.id === card._activeDraftId);
|
||||
|
||||
// r1 regression guard: two points on the common wall are still only an open line.
|
||||
await click(500, 100);
|
||||
await click(500, 500);
|
||||
result.secondCommonWallPointStaysOpen = card._path.length === 2
|
||||
&& !card._roomDialog && !card._toast
|
||||
&& currentDraft()?.points.length === 2
|
||||
&& currentDraft()?.segments.length === 1;
|
||||
|
||||
// Endpoint-to-endpoint auto-close after the new room has enough vertices.
|
||||
await reset();
|
||||
await click(500, 100);
|
||||
await click(800, 100);
|
||||
await click(800, 500);
|
||||
await click(500, 500);
|
||||
const successfulDraft = clone(currentDraft());
|
||||
result.endpointAutoCloseOpensDialog = card._roomDialog
|
||||
&& card._path.length === 5
|
||||
&& close(card._path[0][0], card._path.at(-1)[0])
|
||||
&& close(card._path[0][1], card._path.at(-1)[1]);
|
||||
result.terminalSegmentPersistsBeforeDialog = successfulDraft?.points.length === 4
|
||||
&& successfulDraft?.segments.length === 3
|
||||
&& close(successfulDraft.points.at(-1)[0], 0.5)
|
||||
&& close(successfulDraft.points.at(-1)[1], 0.5)
|
||||
&& successfulDraft.segments.every((segment) => close(segment.cm, 15));
|
||||
result.roomIsNotCommittedBeforeSave = card._curSpaceCfg.rooms.length === 1;
|
||||
|
||||
card._roomDialogCancel();
|
||||
await update();
|
||||
result.cancelKeepsTerminalOpenDraft = !card._roomDialog
|
||||
&& card._path.length === 4
|
||||
&& !card._contourClosed
|
||||
&& JSON.stringify(currentDraft()) === JSON.stringify(successfulDraft)
|
||||
&& close(card._path.at(-1)[0], 500) && close(card._path.at(-1)[1], 500);
|
||||
|
||||
// Save promotes the same draft, keeps the existing shared thickness and creates no partition.
|
||||
await reset();
|
||||
await click(500, 100);
|
||||
await click(800, 100);
|
||||
await click(800, 500);
|
||||
await click(500, 500);
|
||||
card._nameSel = 'Adjacent';
|
||||
card._saveRoom();
|
||||
await update();
|
||||
const sharedWall = (card._curSpaceCfg.walls || []).find((wall) => wall.cm === 40
|
||||
&& wall.a && wall.b
|
||||
&& close(wall.a[0], 0.5) && close(wall.b[0], 0.5)
|
||||
&& close(Math.min(wall.a[1], wall.b[1]), 0.1)
|
||||
&& close(Math.max(wall.a[1], wall.b[1]), 0.5));
|
||||
result.savePromotesDraftWithoutPartition = card._curSpaceCfg.rooms.length === 2
|
||||
&& card._curSpaceCfg.rooms.some((room) => room.name === 'Adjacent')
|
||||
&& !card._curSpaceCfg.room_drafts?.length
|
||||
&& !card._curSpaceCfg.partitions?.length
|
||||
&& card._path.length === 0;
|
||||
result.sharedWallKeepsNeighbourThickness = !!sharedWall
|
||||
&& (card._curSpaceCfg.walls || []).filter((wall) => wall.cm === 15).length >= 3;
|
||||
|
||||
// A canonical cut splits the room wall, so the same endpoint pair no longer closes.
|
||||
await reset({ openings: [{
|
||||
id: 'door', type: 'door', x: 0.5, y: 0.3, angle: 90, length: 0.1,
|
||||
}] });
|
||||
await click(500, 100);
|
||||
await click(800, 100);
|
||||
await click(800, 500);
|
||||
await click(500, 500);
|
||||
result.openingCutPreventsAutoClose = !card._roomDialog
|
||||
&& card._path.length === 4
|
||||
&& currentDraft()?.points.length === 4;
|
||||
|
||||
// An eligible but self-intersecting prospective ring is consumed without partial write.
|
||||
await reset();
|
||||
await click(500, 100);
|
||||
await click(400, 300);
|
||||
await click(700, 300);
|
||||
const invalidDraftBefore = clone(currentDraft());
|
||||
await click(500, 500);
|
||||
result.invalidCloseHasNoPartialWrite = !card._roomDialog
|
||||
&& card._path.length === 3
|
||||
&& JSON.stringify(currentDraft()) === JSON.stringify(invalidDraftBefore)
|
||||
&& card._toast === card._t('toast.contour_cannot_close');
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -84,6 +84,6 @@ checkAll(res, {
|
||||
"atticSquare": true,
|
||||
// New spaces keep the product default (Glow on), but store it independently
|
||||
// from the ordinary data fill.
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "custom", "custom_fill": {"c": "#607d8b", "a": 0}, "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "bg_mode": "daynight", "fill_mode": "custom", "custom_fill": {"c": "#607d8b", "a": 0}, "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/** Issue #123: a Split ending at a room vertex must not reshape the facade. */
|
||||
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 sp = () => c._serverCfg.spaces.find((space) => space.id === c._space);
|
||||
const pitch = 1 / 240;
|
||||
const wallKey = (a, b) => {
|
||||
const q = (value) => Math.round(value / pitch) * pitch;
|
||||
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;
|
||||
angle = Math.round(angle * 1800) / 1800;
|
||||
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
|
||||
};
|
||||
const entry = (a, b, cm) => ({ key: wallKey(a, b), a: [...a], b: [...b], cm });
|
||||
const update = async () => {
|
||||
c._cfgEpoch++;
|
||||
c._wallUnionCache = null;
|
||||
c._lightBarrierCache = null;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const bbox = (element) => {
|
||||
const box = element?.getBBox?.();
|
||||
return box ? [box.x, box.y, box.width, box.height] : null;
|
||||
};
|
||||
const sameBox = (a, b, epsilon = 0.01) => !!a && !!b
|
||||
&& a.every((value, index) => Math.abs(value - b[index]) <= epsilon);
|
||||
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], b = [0.90, 0.50];
|
||||
const br = [0.90, 0.90], bl = [0.10, 0.90];
|
||||
const original = { id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, br, bl] };
|
||||
const outerBefore = [
|
||||
entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15),
|
||||
];
|
||||
const outerAfter = [
|
||||
entry(a, tr, 15), entry(tr, b, 15), entry(b, br, 15),
|
||||
entry(br, bl, 15), entry(bl, a, 15),
|
||||
];
|
||||
const splitRooms = [
|
||||
{ id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, b] },
|
||||
{ id: 'corner-fresh', name: 'Corner fresh', area: null, poly: [b, br, bl, a] },
|
||||
];
|
||||
|
||||
const space = sp();
|
||||
space.settings = { ...(space.settings || {}), show_borders: true };
|
||||
space.rooms = [structuredClone(original)];
|
||||
space.walls = structuredClone(outerBefore);
|
||||
delete space.open_spans;
|
||||
delete space.openings;
|
||||
delete space.partitions;
|
||||
delete space.room_drafts;
|
||||
delete space.wall_columns;
|
||||
c._setMode('plan');
|
||||
await update();
|
||||
const beforeWall = bbox(sr().querySelector('.wallbody'));
|
||||
const beforePaper = bbox(sr().querySelector('.hp-paperg'));
|
||||
out.beforeDrawn = !!beforeWall && !!beforePaper;
|
||||
|
||||
space.rooms = structuredClone(splitRooms);
|
||||
const pathByThickness = {};
|
||||
for (const cm of [0, 15, 100]) {
|
||||
space.walls = [
|
||||
...structuredClone(outerAfter),
|
||||
...(cm ? [entry(a, b, cm)] : []),
|
||||
];
|
||||
await update();
|
||||
const wall = sr().querySelector('.wallbody');
|
||||
const paper = sr().querySelector('.hp-paperg');
|
||||
out[`wall${cm}KeepsFacade`] = sameBox(bbox(wall), beforeWall);
|
||||
out[`paper${cm}KeepsFacade`] = sameBox(bbox(paper), beforePaper);
|
||||
pathByThickness[cm] = wall?.getAttribute('d') || '';
|
||||
}
|
||||
out.dividerChangesInterior = pathByThickness[15] !== pathByThickness[100]
|
||||
&& pathByThickness[0] !== pathByThickness[15];
|
||||
|
||||
const persisted = JSON.stringify({ rooms: space.rooms, walls: space.walls });
|
||||
const planD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
|
||||
const lightSpace = c._spaceModel();
|
||||
const lightPolys = lightSpace.rooms
|
||||
.filter((room) => Array.isArray(room.poly))
|
||||
.map((room) => ({ r: room, poly: room.poly }));
|
||||
const lightPhysical = c._physicalBodiesR(lightSpace);
|
||||
const lightGeom = c._lightBarriers(lightSpace, lightPolys, lightPhysical).masonryGeometry;
|
||||
const lightPoints = lightGeom.flat(2);
|
||||
const lightBox = lightPoints.length ? [
|
||||
Math.min(...lightPoints.map((point) => point[0])),
|
||||
Math.min(...lightPoints.map((point) => point[1])),
|
||||
Math.max(...lightPoints.map((point) => point[0])) - Math.min(...lightPoints.map((point) => point[0])),
|
||||
Math.max(...lightPoints.map((point) => point[1])) - Math.min(...lightPoints.map((point) => point[1])),
|
||||
] : null;
|
||||
out.lightUsesFacade = sameBox(lightBox, beforeWall);
|
||||
|
||||
c._setMode('view');
|
||||
await update();
|
||||
const viewD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
|
||||
out.planViewParity = !!planD && viewD === planD;
|
||||
|
||||
const kioskBefore = c._config.kiosk;
|
||||
c._config.kiosk = true;
|
||||
await update();
|
||||
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
c._config.kiosk = kioskBefore;
|
||||
|
||||
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await update();
|
||||
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
|
||||
&& c._isoSource().build().walls.flat(2).length > 0;
|
||||
c._setProjection('flat');
|
||||
await update();
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = structuredClone(c._serverCfg);
|
||||
const baseCall = c.hass.callWS.bind(c.hass);
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
|
||||
staticCard.hass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (message.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
|
||||
return baseCall(message);
|
||||
} };
|
||||
document.body.appendChild(staticCard);
|
||||
const started = Date.now();
|
||||
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await staticCard.updateComplete;
|
||||
out.staticParity = staticCard.renderRoot?.querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
staticCard.remove();
|
||||
|
||||
out.renderDoesNotRewriteConfig = JSON.stringify({ rooms: space.rooms, walls: space.walls }) === persisted;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
|
||||
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
|
||||
// day/night background vs static, per-space inheritance, weather independence,
|
||||
// four-phase background vs static, per-space inheritance, weather independence,
|
||||
// memoisation across hass ticks, editors stay clean.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
@@ -19,7 +19,7 @@ const res = await page.evaluate(async () => {
|
||||
const setSun = async (azimuth, elevation) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation },
|
||||
attributes: { azimuth, elevation, rising: azimuth <= 180 },
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
@@ -51,12 +51,12 @@ const res = await page.evaluate(async () => {
|
||||
];
|
||||
touchCfg();
|
||||
|
||||
// 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
|
||||
// 1) sun present + rays enabled, but NO north_deg anywhere → no window rays
|
||||
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.silentWithoutNorth = domPolys().length === 0;
|
||||
out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
|
||||
out.noBackgroundWhenStatic = !sr().querySelector('.stage.daycycle');
|
||||
|
||||
// 2) compass set → morning east sun lights ONLY the east window
|
||||
cfg().settings.north_deg = 0;
|
||||
@@ -99,21 +99,21 @@ const res = await page.evaluate(async () => {
|
||||
await setSun(91, 5);
|
||||
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
|
||||
|
||||
// 8) day/night background: daynight breathes, static does not
|
||||
// 8) four-phase background: daynight paints environment, static does not
|
||||
await setSun(180, 60);
|
||||
out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
await setSun(180, 60);
|
||||
out.daynightNoonBg = stageStyle().includes('background:#ffffff');
|
||||
out.daynightClass = !!sr().querySelector('.stage.daynight');
|
||||
out.daynightNoonBg = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'day';
|
||||
out.daynightClass = !!sr().querySelector('.stage.daycycle');
|
||||
await setSun(180, -20);
|
||||
out.daynightNightBg = stageStyle().includes('background:#070c14');
|
||||
out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
out.daynightNightBg = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'night';
|
||||
out.nightPlanUnchanged = !(sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.');
|
||||
cfg().settings.bg_mode = 'static';
|
||||
touchCfg();
|
||||
await setSun(180, -20);
|
||||
out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
|
||||
out.staticNightKeepsTheme = !sr().querySelector('.hp-day-cycle-env');
|
||||
delete cfg().settings.bg_mode;
|
||||
touchCfg();
|
||||
|
||||
@@ -153,13 +153,13 @@ const res = await page.evaluate(async () => {
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
|
||||
// 12) editors stay clean: no wedges, no day/night canvas
|
||||
// 12) editors stay clean: no wedges, no day-cycle environment
|
||||
await setSun(90, 5);
|
||||
c._setMode('plan');
|
||||
await upd();
|
||||
await settleMode();
|
||||
out.editorNoRays = domPolys().length === 0;
|
||||
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
|
||||
out.editorNoDaynight = !sr().querySelector('.stage.daycycle');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
await settleMode();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
|
||||
// после обновления страницы». The day/night sky must follow sun.sun on a plain
|
||||
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
|
||||
// background of the stage (what the eye sees, not the style attribute) must
|
||||
// actually arrive at the sky colour (docs/SUN.md).
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
// Four-phase Sun background (#146): plain hass ticks update one constant
|
||||
// environment tree, never dim the plan, and clock fallback owns a visible-only
|
||||
// 30-second lifecycle. Window rays retain their separate north/sun gates.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
@@ -11,82 +9,77 @@ const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
|
||||
cfg.spaces.find((s) => s.id === 'f1').openings = [
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_mode: 'daynight', sun_rays: true };
|
||||
cfg.spaces.find((space) => space.id === 'f1').openings = [
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
c._cfgEpoch++;
|
||||
// NO requestUpdate anywhere below: production only ever assigns `hass`.
|
||||
const setSun = async (az, el) => {
|
||||
|
||||
// No external requestUpdate: production advances through the hass setter.
|
||||
const setSun = async (azimuth, elevation, rising) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation, rising },
|
||||
} } };
|
||||
await c.updateComplete;
|
||||
// one paint so the sky lands on screen, not only in the attribute
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
};
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const comp = () => getComputedStyle(stage()).backgroundColor;
|
||||
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
|
||||
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
|
||||
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
|
||||
const env = () => sr().querySelector('.hp-day-cycle-env');
|
||||
const active = () => sr().querySelector('.hp-day-cycle-bg.active');
|
||||
const zoomStyle = () => sr().querySelector('.zoomwrap').getAttribute('style') || '';
|
||||
const rayCount = () => sr().querySelectorAll('.sunlayer polygon').length;
|
||||
|
||||
await setSun(180, 40);
|
||||
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
|
||||
out.dayNoDim = !dim().includes('brightness(') || dim().includes('brightness(1.000)');
|
||||
out.dayRays = polys() > 0;
|
||||
// Background needs neither compass nor window-ray geometry.
|
||||
delete cfg.settings.north_deg;
|
||||
await setSun(90, -2, true);
|
||||
out.dawnOnPlainTick = stage().classList.contains('phase-dawn')
|
||||
&& env()?.dataset.dayCycleSource === 'sun'
|
||||
&& active()?.dataset.dayCycleLayer === 'dawn';
|
||||
out.constantFourLayers = sr().querySelectorAll('.hp-day-cycle-bg').length === 4;
|
||||
out.backgroundIndependentOfNorth = !!stage().classList.contains('daycycle');
|
||||
out.raysStillNeedNorth = rayCount() === 0;
|
||||
|
||||
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
|
||||
await setSun(180, -10);
|
||||
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
|
||||
out.nightDimmed = dim().includes('brightness(0.900');
|
||||
out.nightNoRays = polys() === 0;
|
||||
cfg.settings.north_deg = 0;
|
||||
c._cfgEpoch++;
|
||||
await setSun(180, 40, false);
|
||||
out.dayOnPlainTick = stage().classList.contains('phase-day')
|
||||
&& active()?.getAttribute('style').includes('#dce9ef');
|
||||
out.dayRays = rayCount() > 0;
|
||||
out.dayPlanUnfiltered = !zoomStyle().includes('brightness(0.');
|
||||
|
||||
// ...and back, again on a plain tick
|
||||
await setSun(180, 40);
|
||||
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
|
||||
out.backToDayRays = polys() > 0;
|
||||
await setSun(180, 0, false);
|
||||
out.duskOnPlainTick = stage().classList.contains('phase-dusk')
|
||||
&& active()?.dataset.dayCycleLayer === 'dusk';
|
||||
await setSun(180, -6, false);
|
||||
out.exactMinusSixIsNight = stage().classList.contains('phase-night');
|
||||
out.nightLightHidden = env()?.style.getPropertyValue('--hp-day-cycle-sun-opacity') === '0.000';
|
||||
out.nightPlanUnfiltered = !zoomStyle().includes('brightness(0.');
|
||||
out.nightNoRays = rayCount() === 0;
|
||||
|
||||
// a small step still moves the sky (0.1° granularity of the model)...
|
||||
await setSun(180, 20);
|
||||
const twenty = comp();
|
||||
await setSun(180, 12);
|
||||
out.smallStepMovesSky = comp() !== twenty;
|
||||
await setSun(180, 6, true);
|
||||
out.exactPlusSixIsDay = stage().classList.contains('phase-day');
|
||||
out.phaseTransitionIs1100ms = getComputedStyle(active()).transitionDuration === '1.1s';
|
||||
|
||||
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
|
||||
// GLIDE: the catch-up jump is for a card that was not watching, never for the
|
||||
// day/night breathing itself (docs/SUN.md).
|
||||
await setSun(180, 12.6);
|
||||
const target = stage().getAttribute('style').split('background:')[1];
|
||||
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
|
||||
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
|
||||
// One invalid required field switches the whole environment atomically to
|
||||
// browser-local time and arms the fallback timer only while visible.
|
||||
await setSun(180, 6, undefined);
|
||||
out.invalidSampleUsesClock = env()?.dataset.dayCycleSource === 'clock';
|
||||
out.clockTimerArmed = c._dayCycleTimer !== 0;
|
||||
|
||||
// A quick tab switch is not a suspended renderer: keep the current painted
|
||||
// sky and hover in place. The old visibility handler cleared both on every
|
||||
// return, producing a one-frame whole-plan flash even after two seconds.
|
||||
const ownVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
const room = c._spaceModel().rooms[0];
|
||||
c._hoverRoom = { space: c._space, room };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const skyBeforeQuickReturn = c._skyElev;
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
out.hiddenStopsClock = c._dayCycleTimer === 0;
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
await c.updateComplete;
|
||||
out.quickReturnKeepsSky = c._skyElev === skyBeforeQuickReturn;
|
||||
out.quickReturnKeepsHover = c._hoverRoom?.room === room;
|
||||
out.quickReturnHasNoSnapFrame = !stage().classList.contains('skysnap')
|
||||
&& !stage().classList.contains('hpresume');
|
||||
out.visibleRestartsClock = c._dayCycleTimer !== 0;
|
||||
out.visibilityKeepsHover = c._hoverRoom?.room === room;
|
||||
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
|
||||
else delete document.visibilityState;
|
||||
|
||||
// the tab came back from the background: the sky catches up at once
|
||||
await setSun(180, 25); // 12° of sun happened while we were not painting
|
||||
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
|
||||
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// #107: persistent manual virtual light, shared by two full cards and one static card.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1200, height: 900 });
|
||||
const result = await page.evaluate(async () => {
|
||||
const first = window.__card;
|
||||
const markerId = 'smoke_manual_virtual_light';
|
||||
const space = first._model[0];
|
||||
const room = space.rooms.find((candidate) => candidate.id && candidate.area);
|
||||
const serverConfig = structuredClone(first._serverCfg);
|
||||
serverConfig.markers = [...(serverConfig.markers || []), {
|
||||
id: markerId,
|
||||
name: 'Manual virtual lamp',
|
||||
binding: 'virtual',
|
||||
is_light: true,
|
||||
tap_action: 'toggle',
|
||||
tap_confirm: false,
|
||||
controls: ['light.ceiling'],
|
||||
space: space.id,
|
||||
area: room.area,
|
||||
room_id: room.id,
|
||||
}];
|
||||
const serverLayout = {
|
||||
...first._layout,
|
||||
[markerId]: { s: space.id, x: 0.32, y: 0.32 },
|
||||
};
|
||||
const virtual = { rev: 0, config_rev: first._cfgRev, off: [] };
|
||||
const subscriptions = new Map();
|
||||
const serviceCalls = [];
|
||||
let toggleCalls = 0;
|
||||
const emit = (event, data) => {
|
||||
for (const listener of subscriptions.get(event) || []) listener({ data });
|
||||
};
|
||||
const baseHass = first.hass;
|
||||
const baseCallWS = baseHass.callWS.bind(baseHass);
|
||||
const connection = {
|
||||
...baseHass.connection,
|
||||
addEventListener: baseHass.connection?.addEventListener?.bind(baseHass.connection),
|
||||
removeEventListener: baseHass.connection?.removeEventListener?.bind(baseHass.connection),
|
||||
subscribeMessage: baseHass.connection?.subscribeMessage?.bind(baseHass.connection),
|
||||
subscribeEvents: async (callback, event) => {
|
||||
const listeners = subscriptions.get(event) || new Set();
|
||||
listeners.add(callback);
|
||||
subscriptions.set(event, listeners);
|
||||
return () => listeners.delete(callback);
|
||||
},
|
||||
};
|
||||
const hass = {
|
||||
...baseHass,
|
||||
connection,
|
||||
callService: async (domain, service, data) => {
|
||||
serviceCalls.push({ domain, service, data });
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return {
|
||||
config: serverConfig,
|
||||
rev: virtual.config_rev,
|
||||
can_write: true,
|
||||
virtual_lights: structuredClone(virtual),
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get') return {
|
||||
layout: serverLayout,
|
||||
rev: first._layoutRev,
|
||||
};
|
||||
if (message.type === 'houseplan/virtual_light/toggle') {
|
||||
toggleCalls++;
|
||||
const off = new Set(virtual.off);
|
||||
if (off.has(message.marker_id)) off.delete(message.marker_id);
|
||||
else off.add(message.marker_id);
|
||||
virtual.off = [...off].sort();
|
||||
virtual.rev++;
|
||||
const reply = {
|
||||
marker_id: message.marker_id,
|
||||
on: !off.has(message.marker_id),
|
||||
rev: virtual.rev,
|
||||
};
|
||||
queueMicrotask(() => emit('houseplan_virtual_light_updated', reply));
|
||||
return reply;
|
||||
}
|
||||
return baseCallWS(message);
|
||||
},
|
||||
};
|
||||
|
||||
// Reconnect the already mounted card to the fake server authority.
|
||||
first._unsubVirtual?.();
|
||||
first._unsubVirtual = null;
|
||||
first.hass = hass;
|
||||
await first._reloadConfigOnly(true);
|
||||
first._layout = serverLayout;
|
||||
first._regSignature = '';
|
||||
first._maybeRebuildDevices();
|
||||
first._ensureLiveSyncSubscriptions();
|
||||
await first.updateComplete;
|
||||
|
||||
const second = document.createElement('houseplan-card');
|
||||
second.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
|
||||
document.body.append(second);
|
||||
second.hass = hass;
|
||||
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: space.id });
|
||||
document.body.append(staticCard);
|
||||
staticCard.hass = hass;
|
||||
|
||||
const waitFor = async (predicate, timeout = 6000) => {
|
||||
const started = Date.now();
|
||||
while (!predicate() && Date.now() - started < timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
return !!predicate();
|
||||
};
|
||||
await waitFor(() => second._devices?.some((device) => device.id === markerId));
|
||||
await waitFor(() => staticCard.renderRoot?.querySelector(`[data-id="${markerId}"]`));
|
||||
await second.updateComplete;
|
||||
await staticCard.updateComplete;
|
||||
|
||||
const deviceOf = (card) => card._devices.find((device) => device.id === markerId);
|
||||
const on = (card) => card._stateClass(deviceOf(card)).includes('on');
|
||||
const staticOn = () => staticCard.renderRoot
|
||||
.querySelector(`[data-id="${markerId}"]`)?.classList.contains('on');
|
||||
const initialEverywhereOn = on(first) && on(second) && staticOn();
|
||||
|
||||
const markerNode = first.renderRoot.querySelector(`.dev[data-id="${markerId}"]`);
|
||||
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await waitFor(() => virtual.rev === 1 && !on(second) && staticOn() === false);
|
||||
await first.updateComplete;
|
||||
const clickEverywhereOff = !on(first) && !on(second) && staticOn() === false;
|
||||
|
||||
// A second real click returns to on; the saved outgoing control never calls HA.
|
||||
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await waitFor(() => virtual.rev === 2 && on(second) && staticOn() === true);
|
||||
const secondClickEverywhereOn = on(first) && on(second) && staticOn();
|
||||
|
||||
const beforeTouch = toggleCalls;
|
||||
markerNode.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
|
||||
clientX: 100, clientY: 100,
|
||||
}));
|
||||
markerNode.dispatchEvent(new PointerEvent('pointerup', {
|
||||
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
|
||||
clientX: 100, clientY: 100,
|
||||
}));
|
||||
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await waitFor(() => virtual.rev === 3 && !on(second) && staticOn() === false);
|
||||
const touchSingleToggle = toggleCalls === beforeTouch + 1;
|
||||
|
||||
// A fresh card must paint the server's off snapshot, not wait for an event.
|
||||
const reloaded = document.createElement('houseplan-card');
|
||||
reloaded.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
|
||||
document.body.append(reloaded);
|
||||
reloaded.hass = hass;
|
||||
await waitFor(() => reloaded._devices?.some((device) => device.id === markerId));
|
||||
await reloaded.updateComplete;
|
||||
const reloadFirstStateOff = !on(reloaded);
|
||||
|
||||
second.remove();
|
||||
staticCard.remove();
|
||||
reloaded.remove();
|
||||
return {
|
||||
initialEverywhereOn,
|
||||
clickEverywhereOff,
|
||||
secondClickEverywhereOn,
|
||||
touchSingleToggle,
|
||||
reloadFirstStateOff,
|
||||
oneServerTogglePerGesture: toggleCalls === 3,
|
||||
noHaServiceCalls: serviceCalls.length === 0,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -42,6 +42,11 @@ const result = await page.evaluate(async (visualFixture) => {
|
||||
card._serverCfg = visualFixture.config;
|
||||
card._layout = visualFixture.layout;
|
||||
card._space = 'golden-lighting';
|
||||
// This fixture starts after a same-route space choice. #131 deliberately
|
||||
// refuses to treat the class initializer/current id as cold-start intent;
|
||||
// mark this injected selection as already adopted navigation so the reload
|
||||
// exercises continuity instead of cold-start precedence.
|
||||
card._navApplied = true;
|
||||
card._regSignature = '';
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/** Issue #141: joined independent walls share one production geometry surface. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => {
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._wallUnionCache = null;
|
||||
card._lightBarrierCache = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
};
|
||||
const entry = (a, b, cm) => ({
|
||||
// The smoke needs an exact persisted span; the renderer resolves the key
|
||||
// from these endpoints before it falls back to the legacy bucket.
|
||||
key: `smoke-${a.join('-')}-${b.join('-')}`, a: [...a], b: [...b], cm,
|
||||
});
|
||||
const a = [0.05, 0.05], tr = [0.95, 0.05], br = [0.95, 0.95], bl = [0.05, 0.95];
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'junctions', title: 'Junctions', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'room', name: 'Room', area: null, poly: [a, tr, br, bl] }],
|
||||
walls: [entry(a, tr, 0), entry(tr, br, 0), entry(br, bl, 0), entry(bl, a, 0)],
|
||||
partitions: [
|
||||
{ id: 'l-horizontal', a: [0.10, 0.30], b: [0.40, 0.30], cm: 10 },
|
||||
{ id: 'l-vertical', a: [0.40, 0.30], b: [0.40, 0.60], cm: 20 },
|
||||
{ id: 'oblique-a', a: [0.58, 0.22], b: [0.76, 0.38], cm: 22 },
|
||||
{ id: 'oblique-b', a: [0.76, 0.38], b: [0.62, 0.53], cm: 12 },
|
||||
{ id: 't-through', a: [0.18, 0.70], b: [0.78, 0.70], cm: 24 },
|
||||
{ id: 't-branch', a: [0.50, 0.54], b: [0.50, 0.70], cm: 16 },
|
||||
{ id: 'room-branch', a: [0.30, 0.82], b: [0.30, 0.95], cm: 18 },
|
||||
],
|
||||
room_drafts: [{
|
||||
id: 'saved-draft', points: [[0.12, 0.56], [0.28, 0.56], [0.28, 0.64]],
|
||||
segments: [{ cm: 12 }, { cm: 18 }],
|
||||
}],
|
||||
wall_columns: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._serverCfg = structuredClone(cfg);
|
||||
card._layout = {};
|
||||
card._space = 'junctions';
|
||||
card._setMode('plan');
|
||||
card._tool = 'select';
|
||||
await update();
|
||||
|
||||
const sourceBefore = JSON.stringify(card._serverCfg.spaces[0]);
|
||||
const space = card._spaceModel();
|
||||
const bodies = card._physicalBodiesR(space);
|
||||
const raw = card._rawPhysicalBodiesR(space);
|
||||
const frame = card._physicalBodiesCache;
|
||||
result.computedPatchesExist = frame?.patches.length >= 4 && bodies.length > raw.length;
|
||||
result.rawIdentityCountStaysPerSegment = raw.length === 9
|
||||
&& card._curSpaceCfg.partitions.length === 7
|
||||
&& card._curSpaceCfg.room_drafts[0].segments.length === 2;
|
||||
|
||||
const wallBody = root().querySelector('.wallbody');
|
||||
const missingCorner = new DOMPoint(404, 298);
|
||||
result.savedRightAngleToothIsFilled = !!wallBody?.isPointInFill(missingCorner);
|
||||
const planD = wallBody?.getAttribute('d') || '';
|
||||
|
||||
const insideRing = (point, ring) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const a0 = ring[i], b0 = ring[j];
|
||||
const crosses = ((a0[1] > point[1]) !== (b0[1] > point[1]))
|
||||
&& point[0] < ((b0[0] - a0[0]) * (point[1] - a0[1]))
|
||||
/ ((b0[1] - a0[1]) || 1e-12) + a0[0];
|
||||
if (crosses) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const insideGeometry = (point, geometry) => (geometry || []).some((polygon) =>
|
||||
polygon?.length && insideRing(point, polygon[0])
|
||||
&& !polygon.slice(1).some((hole) => insideRing(point, hole)));
|
||||
const room = space.rooms[0];
|
||||
const clean = card._cleanFloor(room, room.poly, space);
|
||||
result.cleanFloorUsesJoinedCorner = !insideGeometry([404, 298], clean.geom);
|
||||
const light = card._lightBarriers(
|
||||
space, [{ r: room, poly: room.poly }], card._physicalBodiesR(space),
|
||||
);
|
||||
result.lightUsesJoinedCorner = insideGeometry([404, 298], light.masonryGeometry);
|
||||
|
||||
card._tool = 'draw';
|
||||
card._activeDraftId = null;
|
||||
card._path = [[120, 780], [380, 780]];
|
||||
card._draftSegmentCms = [12];
|
||||
card._drawWallField = '24';
|
||||
card._cursorPt = [380, 600];
|
||||
await update();
|
||||
const liveD = root().querySelector('.drawwall-preview')?.getAttribute('d') || '';
|
||||
card._path = [[120, 780], [380, 780], [380, 600]];
|
||||
card._draftSegmentCms = [12, 24];
|
||||
card._cursorPt = null;
|
||||
await update();
|
||||
const committedPreviewD = root().querySelector('.drawwall-preview')?.getAttribute('d') || '';
|
||||
result.rubberBandAndCommittedPreviewMatch = !!liveD && committedPreviewD === liveD;
|
||||
|
||||
card._path = [[500, 540]];
|
||||
card._draftSegmentCms = [];
|
||||
card._drawWallField = '20';
|
||||
card._cursorPt = [500, 700];
|
||||
await update();
|
||||
result.lineTargetGetsLocalJoinPatch = root().querySelectorAll('.drawwall-preview').length === 2;
|
||||
result.previewDoesNotWriteOrSplitTarget = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
|
||||
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
card._tool = 'select';
|
||||
card._setMode('view');
|
||||
await update();
|
||||
result.planViewParity = !!planD && root().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
|
||||
history.replaceState(null, '', '?hp-labs=iso#space=junctions');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await update();
|
||||
result.isoUsesJoinedFootprint = !!root().querySelector('.iso-walls .iso-wall-top')
|
||||
&& card._isoSource().build().walls.flat(2).length > 0;
|
||||
card._setProjection('flat');
|
||||
await update();
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
const baseCall = card.hass.callWS.bind(card.hass);
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'junctions', show_button: false });
|
||||
staticCard.hass = { ...card.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return { config: structuredClone(cfg), rev: 1 };
|
||||
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
return baseCall(message);
|
||||
} };
|
||||
document.body.appendChild(staticCard);
|
||||
const started = Date.now();
|
||||
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await staticCard.updateComplete;
|
||||
result.staticUsesSameJoinedPath = staticCard.renderRoot
|
||||
?.querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
staticCard.remove();
|
||||
|
||||
result.renderNeverRewritesConfig = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -104,6 +104,18 @@ the same profiler available between stable promotions.
|
||||
affordance exists only when both its localized body and complete accessible
|
||||
label are non-empty; the card factory and `hp-help` enforce this independently,
|
||||
so incomplete content cannot leave a dead focus target or a layout gap.
|
||||
7. **One four-phase environment resolver.** `resolveDayCycle()` in `src/sun.ts`
|
||||
atomically chooses a strict real `sun.sun` sample or browser-local clock
|
||||
fallback and returns only phase/source/light tokens. `src/day-cycle-render.ts`
|
||||
owns the constant four-layer DOM and exact palette used by full View, kiosk,
|
||||
and `houseplan-space-card`; no surface copies thresholds or formulas. The
|
||||
environment is a pointer-inert sibling behind the plan. The only
|
||||
phase-dependent effect touching the SVG is a zero-offset filter on the one
|
||||
grouped `.hp-paperg` footprint; the content tree has no brightness, tint,
|
||||
opacity, or blend changes. Full and static card lifecycles arm a 30-second
|
||||
timer only during clock fallback while visible, catch up on visibility return,
|
||||
and dispose it on disconnect. Window-ray geometry remains a separate
|
||||
north-gated consumer of `sun.sun`.
|
||||
|
||||
## Coordinate system
|
||||
|
||||
@@ -318,6 +330,16 @@ physical bodies are unioned with room walls for rendering and light occlusion,
|
||||
and subtracted from clean room floor area. Openings (doors, windows and gates)
|
||||
still belong only to derived room walls and never cut an independent object.
|
||||
|
||||
Independent linear objects have two deliberate projections. Raw flat-capped
|
||||
quads preserve source identity for hit/selection/drag/properties/delete/history
|
||||
and furniture magnet behaviour. `physicalBodySet()` also derives exact
|
||||
endpoint↔endpoint and endpoint↔line topology, adds bounded mitre/bevel patches
|
||||
without persisted nodes or segment splits, and exposes the joined geometry to
|
||||
presentation and physics. Degree-one caps remain flat; an interior X crossing
|
||||
is only a boolean overlap. The full card caches this structural frame by
|
||||
space/config geometry, while static cards use a weak server-snapshot cache;
|
||||
cursor and HA state updates do not repeat the saved O(N²) node search.
|
||||
|
||||
Rooms may not overlap
|
||||
(`pointStrictlyInside` + `roomsOverlap`; being ON a shared wall is legal — real neighbouring
|
||||
walls overlap collinearly rather than match exactly). **Merge/Split** use boolean geometry from
|
||||
@@ -326,6 +348,48 @@ its types declare named exports — breaking either tsc or the runtime): merge a
|
||||
when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the
|
||||
bigger part keeps the room identity (name/area/devices).
|
||||
|
||||
`wallBodiesGeometry()` is the canonical physical masonry for flat full/static
|
||||
rendering, hidden isometric projection and Glow/sun occlusion. Its exterior
|
||||
shell is derived from the union of room centrelines plus the surviving `outer`
|
||||
atomic intervals; internal/shared interval bodies are clipped to that union
|
||||
before the shell is restored. Consequently a Split edge ending at an exterior
|
||||
vertex cannot contribute a child-room mitre to the facade. Per-room rings remain
|
||||
an interior join/nested-room representation, and atomic quads provide a safe
|
||||
physical interval when an acute child ring cannot be subtracted. Paper and
|
||||
masonry paths are emitted by that same geometry pass. Computed independent
|
||||
junction patches enter as extras only after room opening cuts, so an opening
|
||||
cannot cut a coincident partition and room exterior authority remains intact.
|
||||
The full card retains the
|
||||
pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot
|
||||
cache guarded by a structural geometry fingerprint. This is computed render
|
||||
state only: it never rewrites rooms or wall entries, and an HA state tick does
|
||||
not rebuild topology.
|
||||
|
||||
### Hidden Isometric Stage 2 composition (#122)
|
||||
|
||||
The hidden `iso` View reuses that masonry but has one bounded structural scene,
|
||||
not a second house model. `_isoGeometryCache` remains an eight-entry LRU keyed
|
||||
by room/wall/opening geometry (including opening flips), scale/camera, fixed
|
||||
wall/floor-edge heights and an algorithm revision. Each value holds wall faces,
|
||||
the room/exterior slab edge, immutable opening jamb bases and the projected
|
||||
frame. HA state, theme, hover and filter support are presentation inputs and
|
||||
never enter this key.
|
||||
|
||||
`floorFootprintGeometry()` derives only the union of room floors and exterior
|
||||
masonry; unlike wall volume, it has no independent partition/column input.
|
||||
`buildIsoFloorGeometry()` emits visible low faces for outer component rings,
|
||||
not internal edges or holes. `src/iso-openings.ts` stores jamb/axis topology and
|
||||
applies `openingAmount()` only during live projection, keeping contact updates
|
||||
out of the boolean geometry path.
|
||||
|
||||
Composition is shared-viewBox SVG: ambient shadow/floor edge → the existing
|
||||
affine-projected floor/live scene → contact/leaf shadows → wall material and
|
||||
vertical panels → existing screen-facing HTML overlays. A constant set of
|
||||
gradients/filters serves every face. Unsupported decoration or forced colours
|
||||
remove nuance/shadows without changing projection; only structural failure
|
||||
uses the Stage 1 latched Flat fallback. Details and fixed ratios are recorded in
|
||||
`docs/adr/122-isometric-stage2-composition.md`.
|
||||
|
||||
## Markup editor (v1.4.0+)
|
||||
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
|
||||
@@ -362,6 +426,16 @@ field. Double click edits physical-object properties, pointer drag moves the
|
||||
whole object on the grid, and Delete removes only the selected object. The
|
||||
legacy root `space.segments` array is still stripped on every save.
|
||||
|
||||
Adjacent-room auto-close reuses the immutable architectural snap snapshot and
|
||||
requires the first and prospective terminal points to belong to one completed
|
||||
room's same solid segment after opening/open-span cuts. It runs only after the
|
||||
draft has two edges and after explicit first-point/Ctrl closure handling. The
|
||||
prospective polygon is checked for range, self-intersection and room overlap
|
||||
before `_path` or `room_drafts` changes; success persists the terminal segment
|
||||
and then opens the ordinary room dialog. Cancel therefore returns to the open
|
||||
draft at that terminal point, while Save lets the normal room/wall
|
||||
normalization preserve an existing shared wall's thickness.
|
||||
|
||||
While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/
|
||||
`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is
|
||||
per-space `cell_cm` — cm represented by one grid cell (default 5, so 240 cells ≈ 12 m).
|
||||
@@ -450,7 +524,8 @@ spans are clipped per atomic body.
|
||||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
|
||||
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
|
||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/config/get` | — | `{config, rev, virtual_lights:{rev,config_rev,off[]}}` (`virtual_lights` optional for rolling compatibility) |
|
||||
| `houseplan/virtual_light/toggle` | `marker_id` | `{marker_id,on,rev}` / err `not_toggleable`; event `houseplan_virtual_light_updated` |
|
||||
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
|
||||
| `houseplan/trail/delete` | `marker_id` | `{ok, removed}` — erase current/previous runs after marker deletion |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||
@@ -471,6 +546,23 @@ spans are clipped per atomic body.
|
||||
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
|
||||
the old `houseplan/file/set` was removed in v1.10.0.
|
||||
|
||||
Manual virtual-light state is operational data, not plan configuration. The
|
||||
integration owns a separate versioned `houseplan.virtual_lights` Store whose
|
||||
bounded payload contains only `{rev, config_rev, off[]}`. The existing shared
|
||||
write lock serializes config reconciliation and atomic toggles. Eligibility is
|
||||
always recalculated from server config; the toggle command accepts no desired
|
||||
state, entity id or service. It is intentionally available to every
|
||||
authenticated View user, while config writers remain governed by `may_write`.
|
||||
The durable save precedes both reply and event. A config-revision gap from an
|
||||
older writer clears manual off bits to the compatibility default `on`.
|
||||
|
||||
The first `config/get` frame carries the coherent operational snapshot. Full
|
||||
cards subscribe directly to the update event; all `houseplan-space-card`
|
||||
instances share the module-level config cache and one subscription. Local
|
||||
storage may retain the last snapshot for continuity, but never authorizes an
|
||||
optimistic toggle. The data is excluded from marker/layout schemas, portable
|
||||
export/import and the HA entity registry.
|
||||
|
||||
Portable import preview uses authenticated
|
||||
`POST /api/houseplan/import/preview`. The endpoint streams at most 8 MiB,
|
||||
strictly rejects duplicate/prototype keys, non-finite numbers and future model
|
||||
@@ -637,6 +729,24 @@ localized opaque recovery overlay, after a 150 ms delay. The overlay never
|
||||
steals initial focus; while visible it alone is interactive and the scene is
|
||||
`inert`.
|
||||
|
||||
**The initial snapshot does not depend on live-sync subscriptions** (#131).
|
||||
`houseplan-card` first accepts config and layout, builds the model, chooses one
|
||||
exact space, caches the accepted snapshot and restores its viewport. Only then
|
||||
is the mandatory load complete. Config, trail and layout event subscriptions
|
||||
start together as independent best-effort enrichments: one rejected channel
|
||||
does not prevent the others from subscribing, does not erase the usable
|
||||
snapshot and does not schedule a full-load retry solely for that rejection.
|
||||
Missing channels get another attempt on the next normal load or reconnect.
|
||||
|
||||
`src/initial-load.ts` is the shared authority for the exact space used by a
|
||||
cached snapshot, a live snapshot and its protected-backdrop candidate. A cold
|
||||
load considers only valid ids in this order: URL hash, saved navigation,
|
||||
`default_floor`, first live space. Once an initial URL hash has been consumed,
|
||||
a valid same-route current selection is preserved instead of repeatedly
|
||||
snapping back to that hash. The legacy field initializer is never a cold-start
|
||||
choice by itself. A plan with no spaces keeps `null` authority and does not
|
||||
invent an id.
|
||||
|
||||
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
|
||||
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
|
||||
card memoizes it on `hass` identity, which Home Assistant replaces on every
|
||||
|
||||
@@ -542,3 +542,40 @@ the ±`CANVAS_LIMIT` guard. Rigid partition drag clamps one shared delta against
|
||||
both endpoints, so it cannot deform the segment or let its far endpoint cross
|
||||
the backend boundary. Hit areas and drag thresholds are expressed in CSS
|
||||
pixels, therefore selection remains usable at every zoom.
|
||||
|
||||
## Architectural connection overlay
|
||||
|
||||
When **Room outline** or **Partition** is active in the Plan editor, a derived
|
||||
pointer-transparent SVG layer exposes the centre axes of completed room walls,
|
||||
saved inactive outlines and independent partitions. It is painted after their
|
||||
physical wall bodies, but before interactive editor chrome. Columns, decor,
|
||||
devices, the active outline and its live preview are not candidates. Door,
|
||||
window, gate and intentionally open-span intervals are cut from room axes; a
|
||||
cut boundary does not become a new endpoint.
|
||||
|
||||
The layer and hit resolver share one immutable geometry snapshot. Original
|
||||
segment endpoints are deduplicated and drawn at a physical radius of 5 cm.
|
||||
Inside a 12 CSS px hit zone, an endpoint wins over every line and grows to
|
||||
10 cm. Otherwise the nearest solid line receives one 10 cm dynamic node: the
|
||||
raw pointer is projected onto that line, then quantized by the grid step along
|
||||
the line from its stable start. This keeps diagonal connections wall-bound even
|
||||
when neither resulting coordinate is a global grid multiple. The same resolver
|
||||
runs again on click, so hover is only a preview and never authoritative.
|
||||
|
||||
Endpoint and line candidates override the normal grid and Shift/45° result.
|
||||
Outside the hit zone, §9.3–9.4 remain unchanged. A line connection adds only the
|
||||
new segment endpoint; it does not split or rewrite the existing wall. The
|
||||
current anchor is excluded to prevent zero-length segments, while the first
|
||||
point of a valid room outline remains an explicit closure target. The static
|
||||
geometry is cached by structural editor state; pointer movement changes at
|
||||
most the single active candidate and never writes config, layout or storage.
|
||||
|
||||
For **Room outline**, the same completed-room provenance also enables adjacent
|
||||
auto-close. Once the draft already has two edges, a click that places its next
|
||||
point on the same uninterrupted solid room-wall segment as the first point is
|
||||
validated as the prospective polygon before any mutation. A valid click
|
||||
persists that terminal draft segment and opens the standard room dialog with
|
||||
the existing wall as the closing edge. A cut, a different edge, a saved draft
|
||||
or an independent partition is ineligible; an eligible but invalid polygon is
|
||||
reported without appending a point or segment. Explicit first-point and
|
||||
Ctrl/Cmd closure retain priority.
|
||||
|
||||
@@ -2,6 +2,113 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.64.0-beta.3 — 2026-08-14
|
||||
|
||||
- Large plans no longer recompute an unused physical-wall union on every floor
|
||||
switch, and the Plan snap target before the first click updates without
|
||||
rerendering the complete card, removing the `v1.64.0` release performance
|
||||
regressions while preserving the same geometry and snap behaviour
|
||||
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.64.0 — 2026-08-14
|
||||
|
||||
- **Follows the Sun** now gives the plan a four-phase dawn/day/dusk/night
|
||||
environment with moving soft light while preserving the plan's live fills,
|
||||
Glow, devices, decor, vacuum, hover and window rays
|
||||
([#146](https://github.com/Matysh/houseplan-card/issues/146)).
|
||||
- Room outline and Partition drawing now exposes exact wall snap nodes and
|
||||
centre lines, closes valid contours along an existing room wall, and forms
|
||||
seamless thick corner and T-junctions in flat, static and hidden isometric
|
||||
geometry ([#137](https://github.com/Matysh/houseplan-card/issues/137),
|
||||
[#138](https://github.com/Matysh/houseplan-card/issues/138),
|
||||
[#141](https://github.com/Matysh/houseplan-card/issues/141)).
|
||||
- Read-only View and kiosk cards now paint a complete first frame even when
|
||||
Home Assistant refuses live-sync event subscriptions
|
||||
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
|
||||
- Virtual markers configured as an always-on light source can now be toggled
|
||||
without a Home Assistant helper, with persistent shared state across full
|
||||
and static cards ([#107](https://github.com/Matysh/houseplan-card/issues/107)).
|
||||
- Opening and physical-object property dialogs keep their footer actions on one
|
||||
desktop line while retaining responsive narrow-screen wrapping
|
||||
([#140](https://github.com/Matysh/houseplan-card/issues/140)).
|
||||
- Large plans no longer recompute an unused physical-wall union on every floor
|
||||
switch, and the Plan snap target before the first click updates without
|
||||
rerendering the complete card
|
||||
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.64.0-beta.2 — 2026-08-14
|
||||
|
||||
- **Follows the Sun** now gives the plan a four-phase dawn/day/dusk/night
|
||||
environment with a moving soft light and alpha-aware outer outline. It uses
|
||||
`sun.sun` when its sample is complete, falls back to local browser time, and
|
||||
no longer needs the compass; the plan, Glow, fills, devices, decor, vacuum,
|
||||
hover and window rays remain visually unchanged across phases. Full View,
|
||||
kiosk and static space cards share the result. New installations and spaces
|
||||
enable it explicitly, while a one-time migration and portable import/export
|
||||
preserve every existing plan's prior background choice. Existing zoom
|
||||
percentage badges remain visible above the plan at magnification
|
||||
([#146](https://github.com/Matysh/houseplan-card/issues/146)).
|
||||
- Opening and physical-object property dialogs now use the existing 500 px
|
||||
desktop shell, keeping Delete, Cancel and Save on one line with localization
|
||||
headroom while preserving responsive wrapping on narrow screens
|
||||
([#140](https://github.com/Matysh/houseplan-card/issues/140)).
|
||||
- A new room outline that starts and ends on one uninterrupted solid interval
|
||||
of an existing room wall now closes automatically and opens the normal room
|
||||
dialog. Endpoints and wall-bound T-connection points work alike; openings,
|
||||
intentional gaps, different edges and a second point without enough sides do
|
||||
not trigger the shortcut ([#138](https://github.com/Matysh/houseplan-card/issues/138)).
|
||||
- Connected Room outline and Partition segments now form one seamless thick
|
||||
wall immediately, including the live rubber-band. Right and oblique corners
|
||||
use bounded mitre/bevel joins, T-connections have no visible butt face, each
|
||||
segment keeps its own thickness, and single-segment thickness hover and free
|
||||
ends remain visible and flat consistently in
|
||||
Plan, View, static cards, hidden isometric, clean-floor and light geometry
|
||||
([#141](https://github.com/Matysh/houseplan-card/issues/141)).
|
||||
- Room outline and Partition drawing now shows wall centre lines and exact
|
||||
endpoints above existing walls. The enlarged target previews whether the
|
||||
next click will join an endpoint or create a wall-bound T-connection, while
|
||||
door/window/gate and intentional open-span gaps remain excluded
|
||||
([#137](https://github.com/Matysh/houseplan-card/issues/137)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.64.0-beta.1 — 2026-08-14
|
||||
|
||||
- Read-only View and kiosk cards now paint a complete first frame even when
|
||||
Home Assistant refuses live-sync event subscriptions. The selected space,
|
||||
room fills, decor, walls, Glow and device values no longer require clicking
|
||||
the already active space tab ([#131](https://github.com/Matysh/houseplan-card/issues/131)).
|
||||
- Virtual markers configured as **Light source → Always** with **Toggle state**
|
||||
can now be switched directly without an HA helper. Their shared state
|
||||
survives reloads/restarts and stays consistent across Glow, room light
|
||||
presentation, full cards and static space cards, including markers that retain
|
||||
saved outgoing controls
|
||||
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.63.0 — 2026-08-13
|
||||
|
||||
- Preserved explicit door, window and gate bindings when their standalone
|
||||
sensor or lock marker is removed, and fixed the supported empty state after
|
||||
deleting the last space
|
||||
([#104](https://github.com/Matysh/houseplan-card/issues/104),
|
||||
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
|
||||
- Splitting a room from an existing corner no longer deforms the exterior
|
||||
facade, including with thick dividers. Flat, static and hidden isometric
|
||||
rendering use the same preserved wall geometry
|
||||
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.63.0-beta.2 — 2026-08-13
|
||||
|
||||
- Splitting a room from an existing corner no longer deforms the exterior wall
|
||||
or pulls a thick internal divider through the facade. Plan, View, kiosk,
|
||||
static cards, hidden isometric rendering and light obstacles now use the same
|
||||
preserved exterior geometry, including already saved plans
|
||||
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.63.0-beta.1 — 2026-08-13
|
||||
|
||||
- Deleting a standalone sensor or lock marker no longer breaks its explicit
|
||||
|
||||
@@ -8,6 +8,117 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.64.0-beta.3 — 2026-08-14
|
||||
|
||||
- Большие планы больше не пересчитывают неиспользуемое объединение физических
|
||||
стен при каждом переключении этажа, а цель привязки в Плане до первого клика
|
||||
обновляется без полной перерисовки карточки. Регрессии производительности
|
||||
релизной линии `v1.64.0` устранены без изменения геометрии и поведения snap
|
||||
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.64.0 — 2026-08-14
|
||||
|
||||
- **«Следует за Солнцем»** теперь создаёт вокруг плана четырёхфазное окружение
|
||||
рассвета, дня, заката и ночи с движущимся мягким светом, сохраняя без изменений
|
||||
живые заливки плана, Glow, устройства, декор, пылесос, hover и оконные лучи
|
||||
([#146](https://github.com/Matysh/houseplan-card/issues/146)).
|
||||
- При рисовании «Контура комнаты» и «Перегородки» теперь видны точные узлы и
|
||||
осевые линии привязки стен; допустимый контур замыкается по существующей стене,
|
||||
а толстые угловые и T-стыки выглядят бесшовно в плоской, статичной и скрытой
|
||||
изометрической геометрии
|
||||
([#137](https://github.com/Matysh/houseplan-card/issues/137),
|
||||
[#138](https://github.com/Matysh/houseplan-card/issues/138),
|
||||
[#141](https://github.com/Matysh/houseplan-card/issues/141)).
|
||||
- Read-only карточки View и киоска теперь сразу показывают полный первый кадр,
|
||||
даже если Home Assistant запрещает подписки на события live-sync
|
||||
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
|
||||
- Виртуальные маркеры, настроенные как постоянно активный источник света, теперь
|
||||
можно переключать без helper Home Assistant; общее состояние сохраняется и
|
||||
согласовано между полной и статичной карточками
|
||||
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
|
||||
- Диалоги свойств проёма и физического объекта сохраняют кнопки футера в одной
|
||||
строке на настольном экране и адаптивный перенос на узком
|
||||
([#140](https://github.com/Matysh/houseplan-card/issues/140)).
|
||||
- Большие планы больше не пересчитывают неиспользуемое объединение физических
|
||||
стен при каждом переключении этажа, а цель привязки в Плане до первого клика
|
||||
обновляется без полной перерисовки карточки
|
||||
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.64.0-beta.2 — 2026-08-14
|
||||
|
||||
- **«Следует за Солнцем»** теперь создаёт вокруг плана четырёхфазное окружение:
|
||||
рассвет, день, закат и ночь, с движущимся мягким светом и полупрозрачным
|
||||
внешним контуром. Полный снимок `sun.sun` используется при наличии, иначе
|
||||
включается локальное время браузера; компас фону больше не нужен. Сам план,
|
||||
Glow, заливки, устройства, декор, пылесос, hover и оконные лучи между фазами
|
||||
не меняются. Полный View, киоск и статическая карточка показывают один
|
||||
результат. Новые установки и пространства включают режим явно, а однократная
|
||||
миграция и перенос full/space сохраняют прежний выбор фона существующих
|
||||
планов. Существующий индикатор масштаба с процентами остаётся видимым поверх
|
||||
плана при увеличении ([#146](https://github.com/Matysh/houseplan-card/issues/146)).
|
||||
- Диалоги свойств проёма и физического объекта теперь используют существующую
|
||||
настольную ширину 500 px: «Удалить», «Отмена» и «Сохранить» помещаются в одну
|
||||
строку с запасом для локализации, а на узком экране сохраняется адаптивный
|
||||
перенос ([#140](https://github.com/Matysh/houseplan-card/issues/140)).
|
||||
- Новый контур комнаты, начатый и законченный на одном непрерывном сплошном
|
||||
участке стены существующей комнаты, теперь замыкается автоматически и
|
||||
открывает обычный диалог комнаты. Одинаково работают конечные узлы и точки
|
||||
T-соединения на линии; проёмы, намеренные разрывы, разные грани и второй клик
|
||||
без достаточного числа сторон быстрым замыканием не считаются
|
||||
([#138](https://github.com/Matysh/houseplan-card/issues/138)).
|
||||
- Соединённые отрезки «Контура комнаты» и «Перегородки» теперь сразу, включая
|
||||
активный rubber-band, образуют одну бесшовную толстую стену. Прямые и косые
|
||||
углы получают ограниченный mitre/bevel, T-соединение не показывает торец,
|
||||
каждый отрезок сохраняет свою толщину, а hover толщины одиночного сегмента и
|
||||
свободные концы остаются видимыми и плоскими в
|
||||
Plan, View, статичной карточке, скрытой изометрии, чистой площади и световой
|
||||
геометрии ([#141](https://github.com/Matysh/houseplan-card/issues/141)).
|
||||
- При рисовании инструментами «Контур комнаты» и «Перегородка» поверх
|
||||
существующих стен теперь видны их осевые линии и точные конечные узлы.
|
||||
Увеличенная точка заранее показывает соединение с концом стены или создание
|
||||
T-соединения на её линии; разрывы дверей, окон, ворот и намеренно открытых
|
||||
участков исключены ([#137](https://github.com/Matysh/houseplan-card/issues/137)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.64.0-beta.1 — 2026-08-14
|
||||
|
||||
- Read-only карточки в режимах View и киоска теперь сразу показывают полный
|
||||
первый кадр, даже если Home Assistant запрещает подписки на события live-sync.
|
||||
Выбранное пространство, заливки комнат, декор, стены, Glow и значения
|
||||
устройств больше не требуют нажатия на уже активную вкладку пространства
|
||||
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
|
||||
- Виртуальные маркеры с сочетанием **Источник света → Всегда** и
|
||||
**Переключить состояние** теперь можно включать и выключать без helper/entity
|
||||
Home Assistant. Общее состояние переживает перезагрузку страницы и HA и
|
||||
одинаково отображается в Glow, свете комнаты, полной и статической карточках,
|
||||
в том числе при сохранённых исходящих связях управления
|
||||
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.63.0 — 2026-08-13
|
||||
|
||||
- Сохранены явные привязки дверей, окон и ворот после удаления отдельного
|
||||
маркера датчика или замка; исправлено предусмотренное пустое состояние после
|
||||
удаления последнего пространства
|
||||
([#104](https://github.com/Matysh/houseplan-card/issues/104),
|
||||
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
|
||||
- Split из существующего угла комнаты больше не деформирует наружный фасад,
|
||||
в том числе с толстым разделителем. Плоский, статичный и скрытый
|
||||
изометрический рендер используют одну сохранённую геометрию стен
|
||||
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.63.0-beta.2 — 2026-08-13
|
||||
|
||||
- Split из существующего угла комнаты больше не деформирует наружную стену и
|
||||
не вытягивает толстый внутренний разделитель сквозь фасад. Редактор плана,
|
||||
View, киоск, статичная карточка, скрытая изометрия и световые препятствия
|
||||
используют одну сохранённую наружную геометрию, в том числе для уже
|
||||
сохранённых планов ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.63.0-beta.1 — 2026-08-13
|
||||
|
||||
- Удаление самостоятельного маркера датчика или замка больше не разрывает его
|
||||
|
||||
@@ -48,6 +48,27 @@ Unknown future fields remain outside this report and continue to follow the
|
||||
backend's forward-compatibility policy. Absence from the report is therefore
|
||||
not permission to delete a field.
|
||||
|
||||
## Four-phase background default and transfer (#146)
|
||||
|
||||
The schema remains `settings.bg_mode: static | daynight` globally and per
|
||||
space; absence is still accepted for older files and the runtime's final
|
||||
fallback remains `static`. New semantics are materialized instead of changing
|
||||
that fallback:
|
||||
|
||||
- new integration config uses explicit global `daynight`;
|
||||
- manual and Floors/Areas space creation writes explicit per-space `daynight`;
|
||||
- storage minor v1.2 migrates a missing or invalid legacy global token to
|
||||
`static` once, while preserving valid global/per-space values, unknown
|
||||
siblings, revisions, and the other stores;
|
||||
- full export/import always carries an explicit global mode, with legacy
|
||||
missing mode becoming `static` before preview/apply;
|
||||
- a space export copies its effective mode into the exported space; a legacy
|
||||
space import without a mode becomes `static` before merge, regardless of the
|
||||
target installation's global setting.
|
||||
|
||||
Import preview and apply therefore operate on the same normalized candidate.
|
||||
Explicit `static` and `daynight` survive same-instance and foreign transfer.
|
||||
|
||||
## Legacy device tap action
|
||||
|
||||
The historical marker token `tap_action: cover` remains accepted indefinitely.
|
||||
@@ -142,3 +163,29 @@ while an untouched old broken source remains readable and round-trippable.
|
||||
space transfer remaps an internal target and disables/counts a link whose
|
||||
target is outside the transfer. Older clients ignore the field and may erase
|
||||
it if they reconstruct the same marker after a downgrade.
|
||||
|
||||
## Persistent manual virtual-light state
|
||||
|
||||
The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a
|
||||
shared operational on/off state. It is not a Marker/ServerConfig field: the
|
||||
integration stores `{rev, config_rev, off[]}` under a separate versioned Store
|
||||
key and exposes an optional `virtual_lights` snapshot in `houseplan/config/get`.
|
||||
It is excluded from layout, full/space export, import and HA entities. Missing
|
||||
Store data or a missing wire field projects to `on`.
|
||||
|
||||
Compatibility matrix:
|
||||
|
||||
| Frontend | Backend | Behaviour |
|
||||
|---|---|---|
|
||||
| old | new | Extra snapshot/event are ignored; the marker keeps historical #84/#94 behaviour and config remains intact |
|
||||
| new | old | Missing snapshot starts `on`; an unsupported toggle command reports an error and creates no optimistic local state |
|
||||
| new | new | Server snapshot is authoritative; revisioned events synchronize full and static cards |
|
||||
|
||||
Every current config writer reconciles the Store. Rename, move, hide and
|
||||
unrelated edits preserve off bits for still-eligible stable marker ids;
|
||||
binding/role/action changes, tombstones and deletion prune them. Re-entering the
|
||||
triple therefore starts on. If `config_rev` skips the revision recorded by the
|
||||
operational Store — for example after downgrade, an old writer or an interrupted
|
||||
pair — all off bits are cleared rather than resurrected against unknown marker
|
||||
lifecycle history. Older integrations safely ignore the separate Store on
|
||||
downgrade.
|
||||
|
||||
@@ -263,7 +263,7 @@ stages a draft prerelease and uploads both assets. Only then does it make the
|
||||
release public. It locates the Release, HACS-zip and Telegram runs by workflow
|
||||
file plus exact tag/SHA, verifies the downloaded public asset contents and
|
||||
paginated HACS prerelease order, and finally closes only the explicitly supplied
|
||||
issues and moves their Project v2 items to Done. Re-running the same command
|
||||
issues and strips their status label. Re-running the same command
|
||||
after a partial failure is safe when local/remote tags still resolve to the same
|
||||
SHA: stale public assets are replaced and verified rather than accepted or left
|
||||
for manual deletion. ZIP inspection is implemented in Node and does not depend
|
||||
@@ -278,9 +278,9 @@ draft-first publication entirely on GitHub, including both assets. Prereleases
|
||||
are intentionally silent in Telegram; only stable releases are announced.
|
||||
GitHub exposes a `workflow_dispatch` button only after
|
||||
the workflow file exists on the default branch; until the next promotion to
|
||||
`main`, use the local command. The button deliberately does not close Issues or
|
||||
edit the user-owned Project because the repository `GITHUB_TOKEN` has no safe
|
||||
authority over that Project.
|
||||
`main`, use the local command. The button deliberately does not close Issues:
|
||||
closing them is the release manager's call, and the `close-merged` job does it
|
||||
from the beta itself (#120).
|
||||
|
||||
The older release-event workflows remain supported as a recovery/manual
|
||||
fallback. They still gate assets on the exact tagged SHA, so adopting the new
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Isometric Stage 1 internals
|
||||
# Hidden Isometric View internals
|
||||
|
||||
Issue [#89](https://github.com/Matysh/houseplan-card/issues/89) implements a
|
||||
hidden, presentation-only volumetric View experiment. The normative contract is
|
||||
@@ -53,6 +53,11 @@ physical bodies have been resolved. It normalizes outer/hole winding, builds one
|
||||
evenodd top path and at most one visible side per ring edge, then uses a stable
|
||||
depth/order tie-break. Complexity is O(E) in canonical ring edges.
|
||||
|
||||
For connected drafts and partitions those extras already contain computed
|
||||
bounded junction patches. Isometric wall tops/sides therefore use the same
|
||||
seamless L/T footprint as flat full/static cards; raw per-record rectangles are
|
||||
reserved for editor identity and never projected as competing wall faces.
|
||||
|
||||
The content fingerprint includes rooms, wall geometry/thickness, open cuts,
|
||||
openings, partitions, drafts, columns, scale/grid inputs, camera, wall height and
|
||||
algorithm version. It deliberately excludes `_cfgEpoch`, HA state, hover and
|
||||
@@ -90,3 +95,79 @@ the rollback path and does not depend on the iso cache.
|
||||
Golden references are accepted only from the complete reviewed Linux artifact.
|
||||
The full `large-house-isometric-v1` performance comparison is also canonical on
|
||||
the exact Linux CI SHA.
|
||||
|
||||
## Stage 2 composition (#122)
|
||||
|
||||
Stage 2 evolves the same hidden `iso` experiment; it does not add a flag,
|
||||
setting or public activation path. The accepted implementation contract is
|
||||
`docs/specs/122-isometric-stage2.md` and the fixed composition decisions are in
|
||||
`docs/adr/122-isometric-stage2-composition.md`. The Labs `since: 1.62.0` and
|
||||
exclusive `expires: 1.65.0` boundary are unchanged.
|
||||
|
||||
### One structural scene, live opening leaves
|
||||
|
||||
The per-card LRU remains capped at eight entries. Its Stage 2 value contains:
|
||||
|
||||
- canonical wall top/sides and the physical-wall contact path;
|
||||
- a room/exterior floor footprint and its low visible outer faces;
|
||||
- immutable opening jamb/axis bases, including type, flips and selected wall
|
||||
face;
|
||||
- the shared projected frame, including wall/opening tops and the low floor
|
||||
edge.
|
||||
|
||||
The key includes rooms, masonry/opening geometry, flips, scale/camera, wall and
|
||||
edge heights and algorithm revision. It excludes HA state, theme, hover,
|
||||
day/night and filter capability. `openingAmount()` is applied only after an LRU
|
||||
hit by `projectIsoOpening()`, so a contact update projects O(O) leaves without
|
||||
repeating a wall or floor boolean operation.
|
||||
|
||||
`floorFootprintGeometry()` deliberately accepts no independent physical-body
|
||||
input. The slab is the union of room floors and derived exterior masonry:
|
||||
internal room boundaries and nested holes make no decorative step, detached
|
||||
room components keep separate outside edges, while partitions and columns do
|
||||
not enlarge it.
|
||||
|
||||
### Layer order and materials
|
||||
|
||||
All geometry roots use one scene `viewBox`. The existing floor/live nodes are
|
||||
grouped under the Stage 1 affine matrix; HTML anchors still use
|
||||
`projectPlanPoint()`.
|
||||
|
||||
```text
|
||||
stage background
|
||||
→ shared ambient shadow + low exterior floor edge
|
||||
→ existing floor SVG (paper/image, room fills/hover, decor, Glow, sun)
|
||||
→ shared contact and live leaf shadows
|
||||
→ canonical wall sides/top + inert vertical opening panels
|
||||
→ existing HTML devices, labels/cards, locks and vacuum overlays
|
||||
```
|
||||
|
||||
Wall top and side use two shared matte gradients. Ambient, contact and leaf
|
||||
shadows use three shared filters; definition count is constant per card, never
|
||||
per face or opening. Forced colours use solid `Canvas`/`CanvasText` faces and
|
||||
omit decoration. A runtime without the required filter paint keeps solid
|
||||
structure, floor edge and vertical panels but emits no Stage 2 shadows; this
|
||||
does not enter the structural fallback latch.
|
||||
|
||||
### Vertical openings and display settings
|
||||
|
||||
`src/iso-openings.ts` mirrors the existing opening-symbol transform algebra:
|
||||
door has one jamb-hinged leaf, gate has two leaves with the established
|
||||
0–10° exterior-face turn, and window has two light neutral casements. Heights
|
||||
are fixed presentation ratios of `ISO_WALL_HEIGHT`; there is no schema field.
|
||||
Panels/shadows are pointer- and ARIA-inert. Existing lock badges/cards and HA
|
||||
actions remain the only interactive opening surface.
|
||||
|
||||
- borders visible: vertical panels replace the floor-plane symbols;
|
||||
- `hide_openings: true`: panels and leaf shadows disappear, while masonry
|
||||
cuts, Glow/sun and contact/lock meaning remain;
|
||||
- `show_borders: false`: Stage 2 roots are absent and the established floor
|
||||
symbols and Stage 1 projected frame return (subject to `hide_openings`),
|
||||
avoiding floating panels or an invisible Stage 2 bound that reframes them;
|
||||
- Flat, editors and `houseplan-space-card` retain their old symbols and DOM.
|
||||
|
||||
Stage 2 adds no window beam, Glow source, sun renderer, material config,
|
||||
network request or HA service path. Structural topology/projection exceptions
|
||||
still use the Stage 1 latched Flat fallback. The known independent exact-SHA
|
||||
view-toggle performance debt remains tracked in #124; #122 neither weakens its
|
||||
budget nor treats fallback as benchmark success.
|
||||
|
||||
@@ -26,7 +26,9 @@ plan geometry and shares it between every lamp in the space.
|
||||
|
||||
- the wall bodies exactly as the plan draws them (`wallBodiesGeometry`), with
|
||||
their real thickness and mitred junctions;
|
||||
- independent bodies: partitions, columns, room drafts;
|
||||
- independent bodies: partitions, columns and room drafts. Exact connected
|
||||
draft/partition segments enter as one joined volume, not as raw rectangles
|
||||
whose former butt faces could become false barriers;
|
||||
- the bare outline of any room edge that carries no thickness — a wall is still
|
||||
a wall when it is drawn as a line.
|
||||
|
||||
@@ -107,6 +109,12 @@ set is invisible — the plan simply keeps lighting through a wall that now
|
||||
exists. The same fingerprint, plus position and radius, keys the per-source
|
||||
region cache (`_glowClipCache`).
|
||||
|
||||
The masonry boolean receives room walls after passage cuts plus the cached
|
||||
joined independent body set. Its outer/hole rings are the authoritative
|
||||
barriers for both visibility and the fail-dark source guard. A boolean failure
|
||||
falls back to the raw independent bodies as opaque obstacles; it never turns a
|
||||
malformed wall transparent.
|
||||
|
||||
## Source, state and service identity
|
||||
|
||||
The geometry above consumes `resolvedLightSources()`; it never discovers light
|
||||
@@ -134,6 +142,18 @@ source even when the marker has no controllable HA entity. That last case is a
|
||||
- its position, room, colour, brightness and radius belong to the target lamp,
|
||||
not to the switch which drives it.
|
||||
|
||||
There is one explicit manual-authority exception. An active marker with a
|
||||
`virtual` binding, `is_light: true` (Always) and `tap_action: toggle` reads its
|
||||
on/off value from the integration's revisioned operational store. Absence is
|
||||
`on`. While the triple remains active, that value overrides incoming controller
|
||||
OR for the marker source and therefore reaches every consumer of
|
||||
`resolvedLightSources()` — Glow, room fill/counts, device presentation, preview
|
||||
and both card types. Saved controls remain lossless but a tap on this exact
|
||||
marker performs the operational toggle, never an HA service call. Leaving the
|
||||
triple restores the normal controller rules and clears any stored off bit;
|
||||
hiding alone does neither. The operational revision is part of the resolver
|
||||
cache key, so an event changes the projection without an HA state tick.
|
||||
|
||||
The controller picker can therefore link a smart relay to a virtual marker for
|
||||
a dumb physical lamp. Multiple controllers use OR. A direct entity reference
|
||||
and a marker reference resolving to the same stateful source are deduplicated.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Документация House Plan
|
||||
|
||||
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.60.0**.
|
||||
Этот каталог — точка входа в русскоязычную документацию House Plan.
|
||||
|
||||
> **Сверено с версией v1.60.0.** Продукт с тех пор ушёл вперёд (линия 1.64);
|
||||
> при расхождении этого каталога с `docs/USER-GUIDE.ru.md` и changelog верить им.
|
||||
> Пересверка каталога — отдельная документационная задача.
|
||||
|
||||
| Документ | Для кого | Что внутри |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<!-- release: v1.63.0-beta.1 -->
|
||||
<!-- release: v1.64.0 -->
|
||||
|
||||
## Основное
|
||||
|
||||
- Удаление маркера датчика или замка больше не разрывает его привязку к двери, окну или воротам.
|
||||
- Исправлено падение после удаления последнего пространства; из пустого состояния снова можно добавить пространство.
|
||||
- Режим фона «Следует за Солнцем» теперь создаёт четырёхфазное окружение рассвета, дня, заката и ночи с мягким движущимся светом.
|
||||
- Рисование комнат и перегородок получило видимые узлы и линии привязки, автоматическое замыкание по существующей стене и бесшовные угловые и T-стыки без задержек на больших планах.
|
||||
- View и киоск надёжно показывают первый кадр, а постоянные виртуальные источники света переключаются без helper Home Assistant.
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Deleting a sensor or lock marker no longer breaks its door, window or gate binding.
|
||||
- Fixed the crash after deleting the last space; a space can again be added from the empty state.
|
||||
- Follows the Sun now creates a four-phase dawn, day, dusk and night environment with a moving soft light.
|
||||
- Room and partition drawing now has visible snap nodes and lines, automatic closure along an existing wall, and seamless corner and T-junctions without large-plan delays.
|
||||
- View and kiosk now render their first frame reliably, while always-on virtual light sources can be toggled without a Home Assistant helper.
|
||||
- Small fixes and improvements.
|
||||
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.md)
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.64.0/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.64.0/docs/CHANGELOG.md)
|
||||
|
||||
@@ -5,9 +5,8 @@ not a hardcoded feature. Universal (any house, any language, any plan format),
|
||||
flexible (options instead of assumptions), and measured against the official
|
||||
**Integration Quality Scale** even though custom integrations are not formally graded.
|
||||
Current tasks and priorities live only in
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
|
||||
[Project v2](https://github.com/users/Matysh/projects/1); this file keeps
|
||||
historical engineering direction. The original market rationale is archived at
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and their labels
|
||||
(`PROCESS.md` §9); this file keeps historical engineering direction. The original market rationale is archived at
|
||||
`legacy/docs/PRODUCT-2026-07-05.md`.
|
||||
|
||||
Phases 0–6 of the original plan (server-side config, room markup editor, device
|
||||
|
||||
@@ -10,22 +10,22 @@
|
||||
> v1.42.0 — the user base is largely Russian-speaking, see the Telegram chat)**, STATUS.md for state changes
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes. Work scope and status live in GitHub
|
||||
> Issues + Project v2, not in a parallel backlog document.
|
||||
> Issues and their labels, not in a parallel backlog document.
|
||||
|
||||
**Promotion rule (2026-08-08):** every new feature or material behaviour
|
||||
change must pass through a published beta/RC before stable. Stable release
|
||||
commits are promotion-only (versions, generated bundles and release/changelog
|
||||
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
## Snapshot (2026-08-13)
|
||||
## Snapshot (2026-08-14)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.63.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
|
||||
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). It also carries the reviewed process automation work #105 and #118–#121. |
|
||||
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1 as a hidden, expiring `iso` Labs experiment: a fixed near-top orthographic volumetric View. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a public feature. |
|
||||
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| Version | **v1.64.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion after the published performance-repair beta.3 |
|
||||
| Current local cycle | v1.64.0-beta.3 is published and #156 is closed after exact-SHA Validate, release and HACS-discovery success. Stable v1.64.0 promotes those identical product changes without product-code edits: runtime consumers no longer build an unused physical-wall union on every floor switch, and initial Plan snap hover updates only its active marker. The owner explicitly authorized #156 implementation and self-review in one Codex session while Claude/status automation was unavailable. The independent #124 view-toggle debt remains open under the owner's accepted exception. |
|
||||
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
|
||||
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
|
||||
@@ -239,10 +239,9 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
## Open items / watchlist
|
||||
|
||||
0. **Canonical backlog** — [GitHub Issues](https://github.com/Matysh/houseplan-card/issues)
|
||||
contain task scope and acceptance criteria; the linked
|
||||
[Project v2](https://github.com/users/Matysh/projects/1) contains the
|
||||
current priority and workflow status. Every open in-scope issue belongs in
|
||||
the Project. The former local product plan is preserved only as a snapshot at
|
||||
contain task scope and acceptance criteria; their **labels** carry priority
|
||||
and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used.
|
||||
The former local product plan is preserved only as a snapshot at
|
||||
[`legacy/docs/PRODUCT-IMPROVEMENT-PLAN.ru.md`](../legacy/docs/PRODUCT-IMPROVEMENT-PLAN.ru.md)
|
||||
and must not be updated or used as a backlog.
|
||||
1. **hacs/default PR #9004** — accepted by the bot into the review queue ('New default
|
||||
|
||||
@@ -162,9 +162,9 @@ On a room shape: `.filled` (a fill mode is painting it), `.styled`,
|
||||
These exist in the DOM and **will** change without notice. Do not build on
|
||||
them:
|
||||
|
||||
- **Boot and transition classes** — `.hpboot`, `.hpsettle`, `.skysnap`,
|
||||
`.mode-transition`, `.editorchrome.transitioning`,
|
||||
`.daynight` on `.stage`. They exist for one animation frame each and their
|
||||
- **Boot and transition classes** — `.hpboot`, `.hpsettle`,
|
||||
`.mode-transition`, `.editorchrome.transitioning`, and the four-phase
|
||||
`.daycycle` / `.phase-*` environment classes. Their
|
||||
whole job is to be replaced.
|
||||
- **Editor chrome and previews** — `.dtframe`, `.dthandle`, `.dtknob`,
|
||||
`.dtbox`, `.dtstem`, `.bdhandle`, `.rszhandle`, `.rszicon`, `.rszframe`,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Sun on the plan — the spec (source of truth)
|
||||
|
||||
Status: approved by the owner 2026-08-03. Shipped in v1.56.0.
|
||||
Status: window rays approved 2026-08-03 and shipped in v1.56.0; the
|
||||
four-phase background was replaced by the owner-approved #146 contract in
|
||||
2026-08. The current background contract below supersedes the retained legacy
|
||||
notes explicitly marked as historical.
|
||||
Scope decisions final: the compass lives in the GENERAL settings with a
|
||||
per-space override, the feature is silent until `north_deg` is set
|
||||
anywhere, wedges ship for the FULL card only in v1, and mutual shading
|
||||
@@ -8,19 +11,18 @@ of the building's wings is explicitly NOT computed.
|
||||
|
||||
## Principle
|
||||
|
||||
The plan learns where north is, and from that single number plus HA's
|
||||
own `sun.sun` the card knows where the sun stands relative to every
|
||||
wall. Two visuals follow: the stage background can breathe with the
|
||||
day (day → golden hour → dusk → night), and windows on exterior walls
|
||||
cast soft wedges of light into their rooms. Everything is display
|
||||
only — no entities are created, no services are called.
|
||||
Two independent display-only visuals use solar data. The four-phase stage
|
||||
background follows a valid `sun.sun` sample or browser-local clock fallback and
|
||||
does not need a compass. Window rays use `sun.sun` plus the plan's north to cast
|
||||
soft wedges from exterior windows. Neither creates entities nor calls services.
|
||||
|
||||
## Data
|
||||
|
||||
- Source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north,
|
||||
- Real source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north,
|
||||
clockwise; `attributes.elevation` in degrees, negative below the
|
||||
horizon). No `sun.sun` in the install → the whole feature stays
|
||||
silent and the settings dialog says why.
|
||||
horizon; boolean `attributes.rising`). Missing/invalid data sends the
|
||||
background to local-clock fallback; window rays stay off and the settings
|
||||
dialog says why.
|
||||
- `sun.sun` is consumed from the live HA state machine. It is valid for
|
||||
this core/runtime entity to have no Entity Registry row; registry
|
||||
projection must preserve its live state while still excluding rows
|
||||
@@ -42,12 +44,57 @@ only — no entities are created, no services are called.
|
||||
plain number input sits next to it for accessibility and precision.
|
||||
- Per-space override in the space settings (empty = inherit), the same
|
||||
pattern as `show_lqi` / `fill_mode`.
|
||||
- While `north_deg` is null at BOTH levels the whole sun feature is
|
||||
inert: static background, no wedges, nothing computed. The settings
|
||||
dialogs show a hint.
|
||||
- While `north_deg` is null at BOTH levels window rays are inert and the
|
||||
settings dialogs show a hint. The four-phase background remains active.
|
||||
- Backend validation: integer in 0–359 at both levels.
|
||||
|
||||
## Plan background — `settings.bg_mode: 'static' | 'daynight'`
|
||||
## Current four-phase background — `settings.bg_mode: 'static' | 'daynight'`
|
||||
|
||||
The public setting remains a two-value selector. `static` uses `bg_color`.
|
||||
`daynight` paints a decorative environment behind the unchanged plan:
|
||||
`dawn`, `day`, `dusk`, or `night`.
|
||||
|
||||
- A valid real sample is atomic: finite `sun.sun` azimuth and elevation plus a
|
||||
boolean `rising`. Elevation `<= -6°` is night, `>= +6°` is day, and the band
|
||||
between them is dawn while rising or dusk while falling. Invalid or missing
|
||||
required data switches the whole resolver to browser-local clock time; real
|
||||
and fallback fields are never mixed.
|
||||
- Clock fallback uses dawn 05:00–08:00, day 08:00–18:00, dusk 18:00–21:00,
|
||||
and night otherwise. A visible card checks it every 30 seconds, catches up on
|
||||
`pageshow`/visibility return, pauses while hidden, and removes the timer on
|
||||
disconnect.
|
||||
- The soft decorative light follows real azimuth/elevation, or the fixed
|
||||
05:00→13:00→21:00 fallback arc. It has zero visible opacity at night.
|
||||
- Four constant environment layers cross-fade for exactly 1100 ms with
|
||||
`cubic-bezier(.22,.61,.36,1)`. Reduced motion disables the transition.
|
||||
- Only the environment and the zero-offset alpha-aware outline outside the
|
||||
grouped plan-paper footprint change. The plan, paper, floors, room fills,
|
||||
Glow/spill, devices, labels, decor/backdrop, vacuum, hover, and window rays
|
||||
are never dimmed, tinted, faded, blended, or otherwise phase-filtered.
|
||||
- Full View, kiosk, and `houseplan-space-card` share the same pure resolver,
|
||||
palette, outline, and clock lifecycle. Plan/Devices/Decor editors remain
|
||||
static.
|
||||
- The background is independent of `north_deg`. North remains required only
|
||||
for direction-dependent window rays.
|
||||
|
||||
New installations materialize global `daynight`; every manually or
|
||||
Floors/Areas-created space materializes its own `daynight`. The storage v1.2
|
||||
migration materializes a missing/invalid legacy global token as `static`, so an
|
||||
upgrade cannot change an existing plan's appearance. Full and per-space
|
||||
export/import likewise materialize compatibility modes; a per-space export
|
||||
carries its effective mode and a legacy space import without one becomes
|
||||
`static` before merge. Runtime's last-resort missing-token fallback remains
|
||||
`static`.
|
||||
|
||||
The exact palette, positioning formulas, migration matrix, lifecycle, and
|
||||
acceptance criteria are in `docs/specs/146-four-phase-sun-background.md`.
|
||||
|
||||
## Historical continuous background (removed by #146)
|
||||
|
||||
The remainder of this section documents the pre-#146 elevation-interpolated
|
||||
implementation for archaeology only. It is not a current runtime or UX
|
||||
contract: the 45-second sky glide, `skysnap`, compass gate, and night
|
||||
brightness filter described below were removed.
|
||||
|
||||
- Global default in the general settings, per-space override (null =
|
||||
inherit). Default `'static'`.
|
||||
@@ -313,15 +360,17 @@ saving General settings removes it.
|
||||
|
||||
## Edge cases and limits
|
||||
|
||||
- No `sun.sun` → silent feature + a hint in the settings dialog.
|
||||
- `north_deg` unset everywhere → silent feature + a hint.
|
||||
- No valid `sun.sun` sample → the background follows browser-local time;
|
||||
window rays stay off and the settings dialog explains the fallback.
|
||||
- `north_deg` unset everywhere → the background still works; window rays stay
|
||||
off and the settings dialog shows the compass hint.
|
||||
- Any weather state, including rain and snow → no effect on rays.
|
||||
- `prefers-reduced-motion` → no transitions; colors and wedges render
|
||||
statically for the current sun position.
|
||||
- Kiosk mode → works (same view path).
|
||||
- Static `houseplan-space-card` → the background honours the effective
|
||||
`bg_mode`/color; wedges are v1 FULL-CARD ONLY (documented limit).
|
||||
- Editors (plan/devices/decor) → no wedges and no day/night: the
|
||||
four-phase `bg_mode`/color and fallback lifecycle; wedges are FULL-CARD ONLY.
|
||||
- Editors (plan/devices/decor) → no wedges and no four-phase environment: the
|
||||
editor canvases render exactly as before.
|
||||
- Mutual shading of the building's own wings (an L-shaped house
|
||||
shadowing its inner corner) is NOT computed — a lit window casts its
|
||||
@@ -335,9 +384,12 @@ saving General settings removes it.
|
||||
- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge
|
||||
quads + clipping, the rim edges and its stops, settings
|
||||
inheritance); unit-tested in `test/sun.test.mjs`.
|
||||
- `src/houseplan-card.ts` — the memoised wedge layer, the day/night
|
||||
stage background, both settings dialogs (compass dial included).
|
||||
- `src/space-render.ts` — the static card's background only.
|
||||
- `src/day-cycle-render.ts` — the shared constant environment layers and
|
||||
plan-outline variables for full and static cards.
|
||||
- `src/houseplan-card.ts` — the memoised wedge layer, four-phase background
|
||||
lifecycle, and both settings dialogs (compass dial included).
|
||||
- `src/space-render.ts` / `src/space-card.ts` — static-card environment and
|
||||
visible-only clock lifecycle.
|
||||
- `custom_components/houseplan/validation.py` — the three active sun
|
||||
settings plus the accepted legacy global weather field; tests in
|
||||
`tests_backend/test_validation.py`.
|
||||
@@ -359,9 +411,10 @@ saving General settings removes it.
|
||||
editor, unaffected by legacy weather settings — and a pixel probe on
|
||||
WHITE paper proving the side of the wedge is measurably darker than the paper on either hand
|
||||
(the point of the whole change).
|
||||
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
|
||||
`hass` tick with no reload, asserted on the COMPUTED background of the
|
||||
stage; small steps still glide, big ones catch up at once.
|
||||
- `demo/smoke_sun_live_bg.mjs` — phases follow real samples on plain `hass`
|
||||
ticks, the plan remains unfiltered, compass independence and atomic clock
|
||||
fallback are preserved, and the fallback timer follows visibility.
|
||||
- `demo/golden/matrix.mjs` — deterministic dawn/day/dusk/night visual scenes.
|
||||
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
|
||||
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
|
||||
- `demo/shot_sun_rim.mjs` — the rim on white paper and on the dark glow
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Manual testing checklist
|
||||
|
||||
## Правила для новых тестов (issue #85) — обязательны
|
||||
|
||||
Зелёный тест в этом проекте несколько раз означал «ничего не проверено»:
|
||||
смок непрерывности не заметил удаления механизма, который защищает; golden-сцена,
|
||||
заведённая под #71, была пустой; смок теней был зелёным, пока тени физически не
|
||||
рисовались. Общее у всех случаев — **тест ни разу не проверяли на способность
|
||||
падать**. Отсюда правила.
|
||||
|
||||
1. **Наличие атрибута, класса или узла — не проверка поведения.** Такой ассерт
|
||||
допустим только рядом с пиксельным либо поведенческим: атрибут доказывает,
|
||||
что код выполнился, а не что механизм сработал.
|
||||
2. **Golden-сцена, заведённая под конкретную задачу, несёт семантический
|
||||
ассерт** (`warmPixelRegion` и родственные в `demo/golden/run.mjs`): сцена
|
||||
обязана падать, если перестала показывать то, ради чего заведена. Пиксельный
|
||||
дифф с эталоном этого не заменяет — пустая сцена совпадает со своим пустым
|
||||
эталоном идеально.
|
||||
3. **Фикстура содержит слои, которые тест защищает.** Смок непрерывности без
|
||||
подложки, Glow и декора проверяет пустую страницу; солнце с азимутом, при
|
||||
котором луч не достигает единственного окна (#89), — та же ошибка в
|
||||
геометрии.
|
||||
4. **Тест, охраняющий механизм, сопровождается мутантом** в
|
||||
`scripts/mutation-gate.mjs`: 2–5 строк патча, воспроизводящего поломку,
|
||||
против которой тест заведён, и тест обязан на ней краснеть. Чистым функциям
|
||||
с обычными юнитами мутант не нужен.
|
||||
5. **Тавтологический ассерт — читающий то же свойство, которое код только что
|
||||
выставил, — не пишется вовсе.** Он может упасть только при удалении строки,
|
||||
но не при её неработоспособности.
|
||||
|
||||
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
|
||||
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
|
||||
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
|
||||
|
||||
|
||||
## Device value badge (#90)
|
||||
|
||||
- [ ] An untouched legacy thermometer/humidity marker remains pixel-identical;
|
||||
@@ -329,6 +362,18 @@ separately promised workflows:
|
||||
- [ ] Ctrl/Cmd+click closes the current endpoint back to the first point without
|
||||
adding another vertex. It requires at least two existing edges and refuses
|
||||
degenerate or self-intersecting closure [auto: smoke_editor_tabs]
|
||||
- [ ] Adjacent-room auto-close: after two drawn edges, ending on the same
|
||||
uninterrupted solid existing-room wall interval as the first point adds
|
||||
the terminal segment and opens the normal room dialog. Endpoints and
|
||||
wall-interior snap points both work; Save creates one room without an
|
||||
independent partition and preserves the shared wall thickness. Cancel
|
||||
restores the open draft ending at that point [auto: smoke_room_autoclose;
|
||||
unit: plan-snap-overlay.test.mjs]
|
||||
- [ ] Adjacent-room auto-close safety: a second point alone stays open; different
|
||||
room edges, draft/partition-only axes and points separated by an opening
|
||||
or open-span cut do not trigger it. An eligible self-intersecting contour
|
||||
shows the existing error and writes neither the terminal point nor segment
|
||||
[auto: smoke_room_autoclose; unit: plan-snap-overlay.test.mjs]
|
||||
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
|
||||
- [ ] Room dialog uses the medium width and its body has no horizontal overflow;
|
||||
long options stay inside it at desktop and narrow widths [auto:
|
||||
@@ -1363,16 +1408,24 @@ require hands on real hardware — they remain for the human pass.
|
||||
|
||||
## Sun on the plan (docs/SUN.md)
|
||||
|
||||
- [ ] Everything below is INERT until `north_deg` is set (general ⚙ or the
|
||||
space settings) AND the install has `sun.sun`; both dialogs hint at
|
||||
whichever is missing
|
||||
- [ ] Four-phase background resolves strict real `sun.sun` data atomically:
|
||||
`<= -6°` night, `>= +6°` day, the middle band dawn while rising and dusk
|
||||
while falling. Missing/garbage elevation, azimuth, or rising switches the
|
||||
whole sample to local-clock fallback at exact 05:00/08:00/18:00/21:00
|
||||
boundaries [auto: `test/sun.test.mjs`, `smoke_sun_live_bg`]
|
||||
- [ ] Background works without `north_deg` and without valid `sun.sun`; the
|
||||
latter arms one visible-only 30-second fallback timer, stops it while
|
||||
hidden/disconnected, and catches up on pageshow/visible return. Window
|
||||
rays independently remain gated by valid sun + compass
|
||||
[auto: `smoke_sun_live_bg`]
|
||||
- [ ] The ⚙ compass: dragging the «N» arrow turns it in 1° steps (15° with
|
||||
Shift); the number field mirrors the dial and accepts 0–359; «Clear»
|
||||
returns the unset state
|
||||
- [ ] «Plan background» selector: `static` keeps the existing color picker
|
||||
and behaviour byte-for-byte; `daynight` hides the picker and the stage
|
||||
follows the sun — neutral day, warm golden hour, dark night — with a
|
||||
slow (tens of seconds) transition; the PLAN dims only ~10% at night
|
||||
and behaviour byte-for-byte; `daynight` hides the picker and shows exact
|
||||
dawn/day/dusk/night environment tokens. Only environment and outer
|
||||
plan-paper outline cross-fade for 1100 ms; reduced motion is instant
|
||||
[auto: `smoke_sun_live_bg`, golden `day-cycle-*`]
|
||||
- [ ] Opaque plan paper (2026-08-03, owner): the scene background —
|
||||
`bg_color` or the daynight sky — is visible ONLY around the plan and
|
||||
NEVER bleeds through it, in view/kiosk/editors and on the static
|
||||
@@ -1385,8 +1438,8 @@ require hands on real hardware — they remain for the human pass.
|
||||
empty drawn space has no paper at all. A live resize preview
|
||||
(`_rszPreview`) moves the paper together with the dragged wall.
|
||||
Colours: historical white for drawn plans, the theme card background
|
||||
under an image. At night (`daynight`) the paper dims via the zoomwrap
|
||||
`brightness` filter only — its alpha stays 1. Pixel-proofed against
|
||||
under an image. Across all four phases the paper and every plan pixel
|
||||
remain unchanged: no brightness/tint/opacity/blend filter. Pixel-proofed against
|
||||
an acid `#ff00ff` background [auto: smoke_bg_color]
|
||||
- [ ] Per-space overrides (background mode, north, sun-in-windows) inherit
|
||||
when empty, exactly like show_lqi/fill_mode
|
||||
@@ -1413,10 +1466,14 @@ require hands on real hardware — they remain for the human pass.
|
||||
wedge geometry and peak opacity; a legacy `weather_entity` value is ignored
|
||||
- [ ] Sun geometry recomputes ONLY when the sun attributes or the config
|
||||
change — an unrelated `hass` tick reuses the memo
|
||||
- [ ] Editors (plan/devices/decor) render with NO wedges and NO day/night;
|
||||
kiosk works; the static space-card honours the background mode
|
||||
(wedges are full-card-only in v1)
|
||||
- [ ] `prefers-reduced-motion` → no transitions, static colors
|
||||
- [ ] Editors (plan/devices/decor) render with NO wedges and NO four-phase
|
||||
environment; kiosk and static space-card share phase/palette/fallback
|
||||
(wedges remain full-card-only)
|
||||
- [ ] New install and new manual/Floors spaces materialize `daynight`; legacy
|
||||
missing global mode migrates once to `static`; full/space transfer
|
||||
materializes source semantics before preview/apply
|
||||
[auto: `test_ha_import_export.py`]
|
||||
- [ ] `prefers-reduced-motion` → no transitions, current phase renders directly
|
||||
- [ ] Smoke: `node demo/smoke_sun.mjs`; units: `test/sun.test.mjs`;
|
||||
backend: `tests_backend/test_validation.py` (sun settings)
|
||||
|
||||
@@ -2056,6 +2113,16 @@ require hands on real hardware — they remain for the human pass.
|
||||
source inside masonry lights nothing. Window rays are blocked by the same
|
||||
bodies. `show_borders: false` changes paint only [auto:
|
||||
physical-geometry.test; manual visual].
|
||||
- [ ] **Seamless junctions**: saved draft/partition L corners (right, acute and
|
||||
obtuse), unequal thickness, endpoint-on-line T and a branch touching a
|
||||
room wall use one bounded joined body; near-miss, X crossing, malformed
|
||||
segments and flat free caps keep their documented semantics. The active
|
||||
rubber-band has the same contour before and after commit, target records
|
||||
are not split, Plan/View/static/hidden-iso paths agree, clean floor and
|
||||
light use the joined corner, and preview/render never writes config
|
||||
[auto: wall-thickness.test, physical-geometry.test,
|
||||
smoke_wall_junctions, wall-junctions golden scenarios; manual golden
|
||||
artifact review].
|
||||
- [ ] **Lifecycle/performance**: an external config revision cancels live
|
||||
move/rotate state before replacing geometry. Drag preview performs no
|
||||
polygon boolean work; clean floor and Glow clips are reused until the
|
||||
|
||||
@@ -33,7 +33,7 @@ House Plan — локальная интеграция и две Lovelace-кар
|
||||
12. [Визуальные состояния устройств](#12-визуальные-состояния-устройств)
|
||||
13. [Заливки комнат и свет](#13-заливки-комнат-и-свет)
|
||||
14. [Редактор подложки](#14-редактор-подложки)
|
||||
15. [Солнце и фон день/ночь](#15-солнце-и-фон-деньночь)
|
||||
15. [Солнце: фон и оконные лучи](#15-солнце-фон-и-оконные-лучи)
|
||||
16. [Роботы-пылесосы](#16-роботы-пылесосы)
|
||||
17. [Киоск-режим](#17-киоск-режим)
|
||||
18. [Статическая карточка пространства](#18-статическая-карточка-пространства)
|
||||
@@ -263,10 +263,31 @@ desktop: для точного рисования, Resize, модификато
|
||||
5. Замкните контур кликом по первой точке или `Ctrl` + кликом в любом месте.
|
||||
Быстрое замыкание доступно после двух нарисованных граней и не сработает,
|
||||
если замыкающая стена создаёт самопересечение.
|
||||
Если первая и текущая точки лежат на одном непрерывном сплошном участке
|
||||
стены существующей комнаты, текущий клик сам добавит последнюю грань,
|
||||
замкнёт контур общей стеной и откроет тот же диалог. Можно попадать как в
|
||||
конечный узел, так и в точку на линии. Проём или намеренный разрыв разделяет
|
||||
участок, разные грани не объединяются, а второй клик после единственной
|
||||
первой точки остаётся обычным незамкнутым отрезком.
|
||||
6. В диалоге задайте название, выберите свободную HA-зону либо оставьте в списке «— без зоны —», затем нажмите «Сохранить».
|
||||
|
||||
Во время рисования показываются длина сегмента и угол. Углы, кратные 45°, выделяются. Удерживайте `Shift`, чтобы зафиксировать текущий отрезок по ближайшему такому направлению; предпросмотр и поставленная точка останутся на сетке. Новая комната не может частично перекрывать другую, но полностью вложенная «островная» комната поддерживается.
|
||||
|
||||
При работе инструментами **Контур комнаты** и **Перегородка** поверх уже
|
||||
нарисованных стен видны тонкие осевые линии и точки их концов. Увеличенная
|
||||
точка показывает точное место следующего соединения: конец стены имеет
|
||||
приоритет, а увеличенная точка посреди линии создаёт T-соединение, не разрезая
|
||||
существующую стену. В проёмах соединительной линии нет. Для точного наведения и
|
||||
предпросмотра рекомендуется редактор на компьютере; tap на сенсорном экране
|
||||
тоже выполняет привязку, но отдельный hover до касания не показывается.
|
||||
|
||||
Толстые отрезки, соединённые в такой точке, сразу показываются как одна стена:
|
||||
прямые и косые углы получают ограниченный mitre/bevel без зуба или щели, а
|
||||
T-соединение входит в проходящую стену без видимого торца. Это действует уже у
|
||||
активного rubber-band до клика и сохраняется после клика, в View и на статичной
|
||||
карточке. Толщина каждого ранее поставленного отрезка остаётся своей; свободный
|
||||
конец незамкнутой стены остаётся плоским.
|
||||
|
||||
Каждый законченный отрезок незамкнутого контура сохраняется сразу. В той же
|
||||
сессии инструмент продолжит его автоматически; после перезагрузки выберите
|
||||
кликом один из концов. Замкнутый контур можно сохранить как комнату или выбрать
|
||||
@@ -304,6 +325,9 @@ desktop: для точного рисования, Resize, модификато
|
||||
- Промежуточные точки должны идти внутри комнаты и не пересекать контур или сам разрез.
|
||||
- Большая по площади часть сохраняет исходную комнату и устройства.
|
||||
- Для меньшей части открывается диалог новой комнаты.
|
||||
- Разрез можно начать или закончить точно в существующем углу. Наружная форма
|
||||
дома при этом не меняется: общая стена двух новых комнат примыкает к фасаду
|
||||
только изнутри, даже если она толще наружной стены.
|
||||
|
||||
### Resize
|
||||
|
||||
@@ -334,6 +358,7 @@ Glow и солнечные лучи, но при Resize комнаты оста
|
||||
| Общая стена | Одна физическая стена между комнатами |
|
||||
| Чистая площадь | Считается по внутренней грани стен |
|
||||
| Углы | Соседние тела стен соединяются; на малом масштабе штриховка скрывается, тело остаётся |
|
||||
| Независимые стыки | Контуры и перегородки с точным общим узлом образуют один ограниченный mitre/bevel; T-стык не дробит исходную стену |
|
||||
| Соседние одинаковые участки | Нормализуются в один участок при редактировании/оптимизации |
|
||||
| Виртуальный участок | Не имеет тела и толщины; реальные остатки сохраняют свою толщину |
|
||||
|
||||
@@ -492,6 +517,22 @@ House Plan читает реестры устройств, сущностей и
|
||||
`marker:*` является только внутренней ссылкой плана и никогда не отправляется
|
||||
в Home Assistant как entity ID.
|
||||
|
||||
Если у самой виртуальной лампы одновременно выбраны **Является источником
|
||||
света → Всегда** и **Действие по нажатию → Переключить состояние**, нажатие
|
||||
переключает её собственное ручное состояние. Новая лампа начинается
|
||||
включённой; состояние общее для всех полных и статических карточек, сохраняется
|
||||
после перезагрузки страницы и Home Assistant и одинаково меняет Glow, заливку
|
||||
«Свет» и статистику комнаты. Переключать может любой вошедший пользователь
|
||||
дашборда — права редактора плана для этого не требуются.
|
||||
|
||||
Это точное исключение из обычной схемы `controls`. Пока три условия активны,
|
||||
сохранённые внешние `controls` не вызывают сервисы HA и не переопределяют
|
||||
ручное состояние, но остаются в настройках без потерь. Смена роли, привязки
|
||||
или действия возвращает обычное управление; при повторном включении ручного
|
||||
режима лампа снова начинается включённой. Скрытие сохраняет состояние, удаление
|
||||
маркера очищает его. Ручное состояние не является entity/helper HA и не входит
|
||||
в экспорт или импорт плана.
|
||||
|
||||
Удаление и скрытие — разные операции. Скрытый маркер можно показать через
|
||||
**Скрытые и деактивированные**, и он продолжает участвовать в предусмотренных для скрытых
|
||||
устройств данных. Удалённый маркер там не показывается, не участвует в LQI,
|
||||
@@ -853,15 +894,20 @@ marker на плане при этом не меняются.
|
||||
- угловыми ручками по умолчанию сохраняет пропорции, а с `Shift` меняет ширину и глубину независимо;
|
||||
- поворачивается с шагом 5°, а с `Shift` — свободно.
|
||||
|
||||
## 15. Солнце и фон день/ночь
|
||||
## 15. Солнце: фон и оконные лучи
|
||||
|
||||
Для солнечных функций нужны `sun.sun` и направление севера на плане. Общие значения можно переопределить в конкретном пространстве.
|
||||
Это две независимые функции. Фон **Следует за Солнцем** работает без компаса:
|
||||
он использует корректные данные `sun.sun`, а при их отсутствии автоматически
|
||||
переходит на локальные часы браузера. Направление севера и `sun.sun` обязательны
|
||||
только для оконных лучей. Общие значения можно переопределить в конкретном
|
||||
пространстве.
|
||||
|
||||
### Настройка
|
||||
|
||||
1. В общих настройках выберите фон **Следует за солнцем** при необходимости.
|
||||
2. Укажите север числом 0–359° или поверните стрелку компаса.
|
||||
3. Включите **Солнечный свет через окна**.
|
||||
2. Если нужны оконные лучи, укажите север числом 0–359° или поверните стрелку
|
||||
компаса.
|
||||
3. При необходимости включите **Солнечный свет через окна**.
|
||||
4. Для отдельного этажа задайте локальный север/режим/включение либо оставьте наследование.
|
||||
|
||||
### Поведение
|
||||
@@ -880,7 +926,22 @@ marker на плане при этом не меняются.
|
||||
|
||||
Луч обрезается чистым контуром комнаты, имеет чёткие боковые границы и плавно растворяется по длине.
|
||||
|
||||
Фон день/ночь меняет оттенок вокруг плана по высоте солнца: нейтральный день, тёплый горизонт и тёмная ночь. Сам план ночью затемняется не более чем примерно на 10%. Обычные изменения плавные; после сна вкладки большой разрыв корректируется сразу.
|
||||
Фон проходит четыре состояния: рассвет, день, закат и ночь. При корректном
|
||||
`sun.sun` фаза определяется высотой и направлением движения Солнца; fallback по
|
||||
локальным часам использует интервалы 05:00–08:00, 08:00–18:00, 18:00–21:00 и
|
||||
21:00–05:00. Переход окружения занимает 1,1 секунды, а при системном уменьшении
|
||||
движения происходит сразу. Мягкий свет движется по небу и полностью исчезает
|
||||
ночью.
|
||||
|
||||
Меняется только окружение вокруг плана и его внешний полупрозрачный контур.
|
||||
Сам план, заливки, Glow, устройства, подписи, декор, подложка, пылесос,
|
||||
подсветка при наведении и оконные лучи не затемняются и не перекрашиваются.
|
||||
Полная карточка, киоск и статическая карточка пространства показывают одну и
|
||||
ту же фазу; редакторы остаются со своим обычным фоном.
|
||||
|
||||
У новых установок и новых пространств режим включён по умолчанию. Обновление
|
||||
старой установки сохраняет её прежний статический фон; перенос полного плана
|
||||
или отдельного пространства также сохраняет выбранную семантику.
|
||||
|
||||
Тени от других крыльев здания и внешних объектов не моделируются.
|
||||
|
||||
|
||||
@@ -42,6 +42,17 @@ Every thick wall grows **half outward and half inward** from the polygon edge
|
||||
(outer and shared alike). Silhouette is wider than the polygon by `cm/2` on
|
||||
outer walls. Paper and the content frame grow under that outer half.
|
||||
|
||||
The exterior silhouette is generated from the boolean union of room
|
||||
centrelines and its surviving `outer` atomic intervals. A shared Split edge
|
||||
therefore disappears before exterior mitres are built. When Split ends at an
|
||||
existing corner, its divider is clipped to the interior side of this envelope:
|
||||
the real exterior mitre/bevel and unequal arm depths stay unchanged, while any
|
||||
divider thickness remains inside the facade. The same computed geometry is
|
||||
used for the full/static/hidden-isometric renderers and light occlusion. The
|
||||
paper and masonry paths come from one cached structural pass in flat renderers;
|
||||
live HA state ticks do not repeat the boolean topology. Saved room and wall data
|
||||
is not migrated or rewritten.
|
||||
|
||||
## 3. Body render
|
||||
|
||||
Production body is the **ring** `outset(poly, half) − inset(poly, half)` per
|
||||
@@ -54,6 +65,15 @@ thinner than 3 CSS px on screen, the shared full/static render policy suppresses
|
||||
only the hatch so it does not collapse into noise; the solid fill remains. Mitre
|
||||
joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
|
||||
|
||||
Independent draft/partition segments keep flat raw quads for editor identity,
|
||||
but exact endpoint↔endpoint and endpoint↔line nodes add computed join patches
|
||||
before the presentation union. Each incident ray keeps its own half-depth;
|
||||
ordinary corners use the same bounded `MITRE_LIMIT = 4` rule and excessive
|
||||
spikes become bevels. A degree-one endpoint receives no patch and therefore
|
||||
keeps its flat cap. This topology is render-only: a T does not split or rewrite
|
||||
the saved target segment. The live open-outline/rubber-band preview calls the
|
||||
same primitive with saved per-segment thicknesses plus the current field value.
|
||||
|
||||
Openings cut the body full-depth; jambs cap the cut; window glass mid-tunnel;
|
||||
door swing from the **inner face**. Association uses wall direction ≈ opening
|
||||
angle (mod 180°), then nearest span — never a perpendicular neighbour at a T.
|
||||
@@ -132,14 +152,17 @@ virtual-T mitre; angle-aware opening; 45° wall; T-junction; detached parallel
|
||||
room; nested-room tie; partially out-of-span legacy opening; overlapping
|
||||
opening de-duplication; shared symbol/cut/tunnel rejection; thick-door tunnel
|
||||
clipping and room-side colour ownership; whole and
|
||||
atomic rekey after edge/scale.
|
||||
atomic rekey after edge/scale; corner Split exterior equality across
|
||||
0/1/15/100 cm, unequal arms, both windings and convex/concave endpoints.
|
||||
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial
|
||||
virtual stretch, its solid thick remainders and Undo move as one real resize;
|
||||
the virtual rubber band paints above the real body; sun starts at the room-side
|
||||
opening corners; nav mode restores after `can_write`; a 1 cm body uses
|
||||
solid-only in both full and static cards while a 20 cm body keeps its hatch;
|
||||
door/window/gate tunnels repeat outer/shared room fills without an axis seam
|
||||
(`demo/smoke_opening_tunnel_fill.mjs`).
|
||||
(`demo/smoke_opening_tunnel_fill.mjs`); corner Split keeps the same facade in
|
||||
Plan/View/kiosk/static/isometric surfaces and the light barrier
|
||||
(`demo/smoke_split_corner_wall.mjs`).
|
||||
|
||||
## 9. Independent partitions, drafts and columns
|
||||
|
||||
@@ -153,3 +176,13 @@ occlusion even when borders are hidden. A source inside/on a physical body is
|
||||
fully occluded instead of leaking around its own masonry. The same fail-dark
|
||||
placement rule applies to window tunnels and exterior door/gate openings;
|
||||
interior passages remain valid source positions (#92).
|
||||
|
||||
`physicalBodySet()` separates raw draft/partition/column bodies from computed
|
||||
junction patches and their joined geometry. Raw bodies remain authoritative for
|
||||
hit testing, selection, drag, properties, deletion, history and furniture
|
||||
magnet semantics. Flat full/static render, hidden isometric, clean floor, Glow,
|
||||
sun and source placement consume the joined set through the canonical masonry
|
||||
pass, so an old butt face cannot become a visible seam or a false light barrier.
|
||||
Exact near-misses remain separate, an interior↔interior X crossing keeps normal
|
||||
boolean-union semantics, and malformed legacy segments fall back opaque without
|
||||
writing configuration.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADR #122 — Stage 2 isometric composition
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/122
|
||||
- Status: accepted for implementation
|
||||
- Date: 2026-08-14
|
||||
- Normative spec: `docs/specs/122-isometric-stage2.md`, revision 1
|
||||
- Predecessor: `docs/adr/089-isometric-stage1-renderer.md`
|
||||
|
||||
## Context
|
||||
|
||||
Stage 1 established one fixed affine floor projection, canonical wall volume,
|
||||
screen-facing HTML anchors and a bounded structural LRU. Stage 2 must add matte
|
||||
depth, an exterior floor edge, grounding shadows and vertical openings without
|
||||
creating another plan/light model or making HA state rebuild topology.
|
||||
|
||||
The owner explicitly allowed implementation to begin with #124's known
|
||||
view-toggle performance debt still open. That arbitration removes the DoR
|
||||
blocker only: the unchanged exact-SHA performance budget remains a gate and
|
||||
fallback may not be forced to make it green.
|
||||
|
||||
## Decision
|
||||
|
||||
### Structural/live boundary
|
||||
|
||||
The existing eight-entry Iso LRU becomes a structural scene cache. Each entry
|
||||
contains wall faces, floor footprint/edge, immutable opening bases and one
|
||||
projected frame. The fingerprint includes opening geometry and flips plus the
|
||||
fixed wall/edge heights and algorithm revision. It excludes HA state and every
|
||||
decorative capability.
|
||||
|
||||
An opening basis stores its resolved wall face, jamb hinge, closed vector,
|
||||
quarter-turn vector and fixed height bounds. Rendering applies the existing
|
||||
`openingAmount()` after the cache lookup. Door/window/gate live changes are
|
||||
therefore O(O) vector projection and cannot invoke polygon boolean operations.
|
||||
|
||||
### Floor footprint
|
||||
|
||||
The slab source is the canonical union of rooms plus derived exterior masonry.
|
||||
Independent partitions, drafts and columns remain canonical wall volume and
|
||||
light occluders but are not accepted as floor-footprint input. Stage 2 projects
|
||||
only outer rings down by ten plan units; holes and internal/shared boundaries
|
||||
cannot become steps. Disconnected outer polygons remain disconnected.
|
||||
|
||||
### Composition
|
||||
|
||||
Four absolute SVG roots share the effective scene `viewBox`: underlay, the
|
||||
existing floor scene, shadows and wall/opening volume. The old floor nodes are
|
||||
not copied; they live under the same affine matrix Stage 1 used. HTML overlays
|
||||
stay above every SVG root.
|
||||
|
||||
The stable order is ambient shadow, floor edge, current floor/live layers,
|
||||
contact/leaf shadows, wall sides/top, vertical panels, then HTML overlays.
|
||||
All new geometry is pointer/focus/ARIA inert.
|
||||
|
||||
### Material and degradation
|
||||
|
||||
Two shared SVG gradients distinguish matte wall top and side. Three shared
|
||||
filters provide ambient/contact/leaf softness. Definitions are O(1) per card;
|
||||
there are no per-face filters, raster textures, data URLs or runtime
|
||||
dependencies.
|
||||
|
||||
Filter-paint failure removes shadows only. Forced colours also remove gradient
|
||||
nuance and use solid system colours. Structural wall/opening/edge geometry and
|
||||
all existing live layers remain Iso. Only a structural topology/projection
|
||||
exception enters the established `space|fingerprint` Flat fallback latch.
|
||||
|
||||
### Opening presentation and settings
|
||||
|
||||
- door: one full jamb-hinged leaf, 92% of wall height;
|
||||
- gate: two half leaves, 88%, preserving the existing 0–10° face-aware turn;
|
||||
- window: two light inserts between 27% and 78% of wall height.
|
||||
|
||||
These are presentation constants, not persisted fields. With visible borders,
|
||||
vertical panels replace the old floor symbols. `hide_openings` hides panels and
|
||||
leaf shadows. With borders disabled, every Stage 2 root is absent and the
|
||||
existing floor symbols return, so there are no floating vertical leaves. The
|
||||
no-borders branch also keeps the accepted Stage 1 projected frame (floor plus
|
||||
the fixed wall-height allowance): absent Stage 2 floor/opening geometry cannot
|
||||
contribute invisible bounds and reframe the live floor. With borders visible,
|
||||
fit includes every Stage 2 structural bound; blur capability never changes it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Flat, editors, static card, schema, storage keys, i18n, backend and HA actions
|
||||
are unchanged.
|
||||
- Glow/spill and sunlight remain the only room/window light models.
|
||||
- Theme, hover, filter fallback and HA-only updates do not grow the structural
|
||||
LRU.
|
||||
- Fit includes structural opening/wall tops and the low floor edge, never blur.
|
||||
- Golden and browser evidence must review new Iso pixels; Flat and the existing
|
||||
no-borders baseline remain unchanged.
|
||||
- Stage 2 stays hidden and expires with the existing `iso` Labs entry unless a
|
||||
separate owner-reviewed rollout/graduation issue changes that contract.
|
||||
@@ -0,0 +1,307 @@
|
||||
# Код-ревью issue #107 — переключение виртуального источника света «Всегда» (r1)
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
|
||||
- **ТЗ:** [`docs/specs/107-virtual-light-toggle.md`](../specs/107-virtual-light-toggle.md), ревью
|
||||
[`SPEC-REVIEW-107-r1.md`](SPEC-REVIEW-107-r1.md) — зелёное, High 0 / Medium 0.
|
||||
- **Диапазон:** `origin/dev...HEAD`, коммит `1079cdfab25617df924b8c3592631aa40e078d87`
|
||||
("feat: add persistent virtual light toggles"), ветка `issue/107-virtual-light-toggle`.
|
||||
- **Ревьюер:** Claude (код-ревью ≠ ревью ТЗ, свежая сессия, без контекста реализации).
|
||||
- **Цикл:** r1/4.
|
||||
|
||||
## 1. Скоуп изменения
|
||||
|
||||
Backend: новый Store `houseplan.virtual_lights` (`virtual_lights.py`, `const.py`,
|
||||
`store.py`), новая WS-команда `houseplan/virtual_light/toggle`, дополнение
|
||||
`houseplan/config/get` полем `virtual_lights`, согласование состояния во всех
|
||||
писателях конфигурации (`websocket_api.py`, `__init__.py` — миграция при
|
||||
setup).
|
||||
|
||||
Frontend: новый модуль `src/virtual-light-state.ts` (нормализация wire-снапшота,
|
||||
монотонное применение событий, reconciliation при смене config revision);
|
||||
интеграция в `resolveToggleIntent`/`resolvedLightSources`
|
||||
(`device-toggle.ts`, `devices.ts`), в live-sync и localStorage-кэш
|
||||
(`config-store.ts`, `houseplan-card.ts`), в статическую карточку
|
||||
(`space-card.ts`, `space-render.ts`); i18n EN/RU; unit-тесты
|
||||
(`test/virtual-light-state.test.mjs`, дополнения `device-toggle.test.mjs`,
|
||||
`devices.test.mjs`); backend-тесты (`test_virtual_lights.py`,
|
||||
`test_ha_virtual_lights.py`, дополнение `test_ha_setup.py`); целевой browser
|
||||
smoke `demo/smoke_virtual_light_toggle.mjs`; документация (`docs/LIGHT.md`,
|
||||
`docs/CONFIG-COMPATIBILITY.md`, `docs/ARCHITECTURE.md`,
|
||||
`docs/USER-GUIDE.ru.md`, `README.md`, оба changelog).
|
||||
|
||||
Соответствует заявленной поверхности ТЗ. Продуктовое соответствие
|
||||
`docs/SCOPE.md`: J1/J3, узкое исключение к замороженным virtual devices, не
|
||||
пересмотрено ревьюером ТЗ повторно (не входит в код-ревью).
|
||||
|
||||
## 2. Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| Unit (frontend) | `npm test` | 781/781 pass |
|
||||
| Build + сверка бандлов | `npm run build && sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js` | один и тот же SHA-256 `caffc463…d23bbc89` для всех трёх копий; рабочее дерево осталось чистым (`git status --short` пусто) — сборка воспроизводима и совпадает с закоммиченным бандлом |
|
||||
| Backend pytest (чистое подмножество, без HA) | `python3 -m venv /tmp/venv-review && /tmp/venv-review/bin/pip install pytest voluptuous && PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q` | **упал сбором** — `tests_backend/test_virtual_lights.py` не собирается без `homeassistant` (см. находку M1). Полный HA-харнесс (`pytest-homeassistant-custom-component`) в этом окружении не установлен и не входит в его canonical Linux CI набор по факту — только офлайн-подмножество, которое теперь не запускается вовсе. `test_ha_virtual_lights.py`/`test_ha_setup.py` разобраны чтением (см. §4) |
|
||||
| Целевой browser smoke | `node demo/smoke_virtual_light_toggle.mjs` (после `npm run build` и синхронизации трёх копий бандла, Chromium/Playwright уже установлены в среде) | **FAILED (2)**: `clickEverywhereOff: expected true, got false`; `reloadFirstStateOff: expected true, got false`. Остальные 5 проверок (`initialEverywhereOn`, `secondClickEverywhereOn`, `touchSingleToggle`, `oneServerTogglePerGesture`, `noHaServiceCalls`) — true. См. находку H1 |
|
||||
|
||||
Не прогонялись (осознанно, вне гейта код-ревью для этого объёма изменений
|
||||
по PROCESS.md §8): `npm run golden:verify` (изменение не трогает
|
||||
художественный baseline — новая fixture по плану идёт в golden только на
|
||||
pre-beta gate), полный набор `demo/smoke_*.mjs` (127 сценариев — задача не
|
||||
задевает все поверхности, а точечный сценарий уже прогнан выше и провалился),
|
||||
performance-профили (в AC не назван численный бюджет; §15.4 ТЗ явно откладывает
|
||||
его на pre-beta). Прогон полного HA backend harness (`pytest-homeassistant-custom-component`)
|
||||
недоступен в этом окружении — AC2/AC3/AC4/AC8/AC9, доказываемые
|
||||
`test_ha_virtual_lights.py`, разобраны чтением, не исполнением (см. §4).
|
||||
|
||||
## 3. Находки
|
||||
|
||||
### H1 (High, блокирует). Собственная презентация маркера (иконка/CSS-класс `on`/`off`) не отражает ручное состояние, когда у того же маркера есть исходящие `controls` — подтверждено исполнением целевого smoke
|
||||
|
||||
**Файл:** `src/device-presentation.ts` (не тронут этим PR), проявляется через
|
||||
`src/houseplan-card.ts` (`_devicePresentation`/`_stateClass`),
|
||||
`src/space-card.ts`, `src/space-render.ts`.
|
||||
|
||||
**Сценарий воспроизведения:** ровно fixture `demo/smoke_virtual_light_toggle.mjs`
|
||||
— маркер `binding: 'virtual', is_light: true, tap_action: 'toggle', controls:
|
||||
['light.ceiling']` (эта же тройка + исходящий `controls`, то есть ровно сценарий
|
||||
AC12). Запуск `node demo/smoke_virtual_light_toggle.mjs` на чистом собранном
|
||||
бандле даёт:
|
||||
|
||||
```
|
||||
FAILED (2):
|
||||
- clickEverywhereOff: expected true, got false
|
||||
- reloadFirstStateOff: expected true, got false
|
||||
```
|
||||
|
||||
**Причина, подтверждена чтением и точечной инструментацией (debug-копия
|
||||
смока, запущена и удалена, в репозиторий не попала):** `resolvePresentationSources()`
|
||||
(`src/device-presentation.ts:279-280`) выбирает `sourceKind = 'controls'`,
|
||||
как только у устройства есть хоть один источник с `via === 'controls'` —
|
||||
это ветка для «маркер-контроллер показывает лицом состояние того, что
|
||||
включает». Для точной тройки #107 `marker.controls` — это **исходящая**,
|
||||
не относящаяся к ролям Auto/Always связь (AC12 требует, чтобы она сохранялась
|
||||
lossless и не вызывала HA-сервис, но не говорит, что она должна забирать
|
||||
приоритет у собственного ручного состояния маркера). Проверено
|
||||
инструментацией: без поля `controls` та же тройка `_stateClass`/иконка
|
||||
корректно показывает `off` после клика (`sourceKind: 'light'`,
|
||||
`visualSources: [{"eid":"marker:...","state":"off"}]`); с полем `controls`
|
||||
`_devicePresentation()` даёт `sourceKind: 'controls'`,
|
||||
`visualSources: [{"eid":"light.ceiling","state":"on"}, {"eid":"marker:...",
|
||||
"state":"off"}]` — правильное ручное состояние присутствует в списке, но не
|
||||
выбрано для лица маркера, поэтому `_stateClass()`/CSS-класс `on` остаются
|
||||
завязаны на состояние `light.ceiling`, а не на ручной toggle.
|
||||
|
||||
Это касается всех трёх типов потребителей презентации маркера
|
||||
(`_devicePresentation` полной карточки, второй полной карточки, статической —
|
||||
все три вызывают `resolveDevicePresentation` с одним и тем же
|
||||
`resolvePresentationSources`), поэтому и `on(first)`, и `on(second)`, и
|
||||
`staticOn()` остаются залипшими на состоянии `light.ceiling`, а не на ручном
|
||||
toggle — что и даёт `false` в обеих сравнительных проверках смока.
|
||||
|
||||
**Почему это блокирует, а не Low/Medium:** ТЗ §6.2 требует «один и тот же
|
||||
resolved source обязан дать одинаковый результат» для списка потребителей,
|
||||
явно включающего «room card и device presentation»; AC5 требует `source.on=
|
||||
false … во всех canonical light consumers»; AC10/AC12 — это ровно комбинация
|
||||
(манильный маркер + сохранённые исходящие controls), которую тестирует сам
|
||||
смок. Реализация проходит AC1–AC9 (см. §4) и логику toggle/persistence, но
|
||||
для этой явно предусмотренной ТЗ комбинации собственная иконка маркера не
|
||||
меняется — заявленный сценарий («нажатие выключает его … Одновременно
|
||||
меняются его Glow и все room-light consumers») не выполняется полностью:
|
||||
Glow/room fill меняются (подтверждено smoke-проверками `oneServerTogglePerGesture`,
|
||||
`noHaServiceCalls` и раздельно нижеприведённым разбором devices.ts), а лицо
|
||||
самого маркера — нет. Это ровно тот класс дефекта, который должен доказываться
|
||||
исполнением, а не чтением: без прогона smoke эта находка осталась бы
|
||||
незамеченной, потому что unit-тесты `devices.test.mjs`/`device-toggle.test.mjs`
|
||||
не строят презентацию через `resolveDevicePresentation()` и поэтому не видят
|
||||
приоритет `sourceKind`.
|
||||
|
||||
**Что не задето:** сама персистентность, атомарность toggle, авторизация,
|
||||
отсутствие HA-сервисного вызова, поведение без `controls` — всё корректно
|
||||
(unit-тесты и 5/7 smoke-проверок это подтверждают).
|
||||
|
||||
### M1 (Medium, обязан стать отдельным issue). `tests_backend/test_virtual_lights.py` не собирается pytest без установленного `homeassistant`, хотя заявлен как «чистый» тест
|
||||
|
||||
**Файл:** `tests_backend/test_virtual_lights.py:1,4`.
|
||||
|
||||
**Сценарий воспроизведения:**
|
||||
|
||||
```
|
||||
python3 -m venv /tmp/venv-review
|
||||
/tmp/venv-review/bin/pip install pytest voluptuous
|
||||
PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q
|
||||
```
|
||||
|
||||
даёт
|
||||
|
||||
```
|
||||
ERROR tests_backend/test_virtual_lights.py
|
||||
ImportError while importing test module '.../tests_backend/test_virtual_lights.py'
|
||||
tests_backend/test_virtual_lights.py:4: in <module>
|
||||
from custom_components.houseplan.virtual_lights import (...)
|
||||
custom_components/houseplan/__init__.py:9: in <module>
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
E ModuleNotFoundError: No module named 'homeassistant'
|
||||
Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
|
||||
```
|
||||
|
||||
Это полная остановка сбора — ни один тест `tests_backend/` не выполняется,
|
||||
включая ранее рабочие чистые файлы вроде `test_validation.py`.
|
||||
|
||||
**Причина:** файл заявляет в докстринге «Pure operational-store rules
|
||||
independent of the HA WebSocket harness» и импортирует модуль обычным путём
|
||||
`from custom_components.houseplan.virtual_lights import …`. Такой импорт
|
||||
обязан сначала выполнить `custom_components/houseplan/__init__.py` (пакетный
|
||||
`__init__`), который безусловно импортирует `homeassistant.components.frontend`
|
||||
и другие модули HA. Существующий `test_validation.py` (не тронут этим PR)
|
||||
решает ту же задачу иначе и специально: докстринг «validation.py is loaded by
|
||||
path, without importing the HA integration package», реализовано через
|
||||
`importlib.util.spec_from_file_location`, ровно чтобы не тянуть
|
||||
`homeassistant` для чистых тестов.
|
||||
|
||||
`AGENTS.md` фиксирует это же поведение как контракт: «Locally only the pure
|
||||
subset runs; `python -m pytest tests_backend/ -q` without Home Assistant
|
||||
silently skips `test_ha_*.py` … so a green result proves nothing» — то есть
|
||||
не-`test_ha_*` файлы обязаны собираться и выполняться без HA. Этот PR впервые
|
||||
нарушает инвариант: вместо тихого пропуска — полная остановка сбора для всего
|
||||
каталога.
|
||||
|
||||
**Почему не блокирует:** CI-джоб `backend` (`.github/workflows/validate.yml:206`)
|
||||
всегда ставит `pytest-homeassistant-custom-component`, так что зелёный проход
|
||||
в CI не страдает — сами тесты внутри `test_virtual_lights.py` корректны и
|
||||
проверяют то, что заявлено (см. §4). Ломается только локальный/офлайн путь
|
||||
без HA, который прежде «доказывал что-то» для остальных чистых файлов.
|
||||
Соответствует критерию Medium: реальный, воспроизводимый дефект вне
|
||||
заявленных AC, требует отдельного issue со ссылкой на #107 (тип `tech-debt`,
|
||||
поверхность — тестовая инфраструктура backend).
|
||||
|
||||
## 4. Что проверено и корректно
|
||||
|
||||
- **AC1 (eligibility matrix, unit):** `isManualVirtualLightMarker()`/
|
||||
`eligible_virtual_light_ids()` (frontend `virtual-light-state.ts`, backend
|
||||
`virtual_lights.py`) требуют точную тройку `binding==='virtual'` +
|
||||
`is_light===true` + `tap_action==='toggle'` + `removed!==true`; unit-тест
|
||||
`test_eligibility_is_the_exact_triple_and_hidden_is_not_lifecycle` и frontend
|
||||
`devices.test.mjs`/`device-toggle.test.mjs` покрывают Auto/Never/wrong-action/
|
||||
non-virtual/removed — доказано автотестом, тест умеет падать (проверено
|
||||
изменением условия вручную не потребовалось: тест явно перечисляет все
|
||||
отрицательные варианты одним assert на множество).
|
||||
- **AC2/AC3 (persistence, atomic concurrency, backend):** `async_toggle_virtual_light`
|
||||
вызывается из `ws_virtual_light_toggle` **внутри** `rt.write_lock` — тот же
|
||||
lock, что использует `ws_config_get`/`ws_config_set`, поэтому конкурентные
|
||||
toggle-запросы физически сериализуются на уровне WS-хендлера, а не только
|
||||
внутри `virtual_lights.py`. Backend-тест
|
||||
`test_invalid_target_and_concurrent_toggles_are_server_atomic` (файл требует
|
||||
полного HA harness — прочитан, не исполнен: заявленное поведение —
|
||||
две последовательные server revisions `(1,False),(2,True)` — соответствует
|
||||
коду `async_toggle_virtual_light`, который читает текущий snapshot, инвертирует
|
||||
и сохраняет одной операцией под общим локом).
|
||||
- **AC4 (права, ревью кода):** `ws_virtual_light_toggle` не вызывает `_check_write`/
|
||||
`may_write` — команда доступна любому аутентифицированному соединению,
|
||||
соответствует ТЗ §7.4. `not_toggleable` возвращается без изменения store и
|
||||
без события (`if result is None: connection.send_error(...); return` —
|
||||
находится **внутри** `async with rt.write_lock`, до `send_result`/`async_fire`).
|
||||
- **AC5 (canonical `source.on`, unit):** `devices.ts:536-548` — цикл идёт по
|
||||
ВСЕМ passive-источникам (`if (!source.passive) continue`), сначала считает
|
||||
обычный OR по `incoming`-контроллерам, затем **безусловно** переопределяет
|
||||
`source.on` через `virtualLightIsOn()`, если `isManualVirtualLightMarker`
|
||||
— то есть ручное состояние выигрывает и когда `control` есть, и когда его
|
||||
нет. `devices.test.mjs` («manual virtual state is canonical and invalidates
|
||||
the light cache») это явно проверяет для случая с входящим контроллером.
|
||||
Данный путь работает корректно на уровне Glow/room-графа; H1 — это
|
||||
отдельный путь (device-presentation), который **не** использует этот
|
||||
безусловный override напрямую, а получает свой собственный `resolvedLightSources()`
|
||||
вызов без `virtualLights`, скорректированный через `lightSources:
|
||||
planLightSources`, что ломается только при наличии собственных `controls`
|
||||
у того же маркера (см. H1).
|
||||
- **AC6 (typed intent, unit + ревью кода):** `resolveToggleIntent()`
|
||||
(`device-toggle.ts:610-632`) возвращает `operation: {kind:'virtual-light',
|
||||
markerId}` и `command: null` для точной тройки, до общей ветки `controls`
|
||||
— marker ID никогда не подставляется в `ToggleCommand`/`entity_id`;
|
||||
`test/device-toggle.test.mjs` («exact manual virtual light wins over saved
|
||||
HA controls») подтверждает `on.command === null` и стабильность
|
||||
`sameToggleOperationTargets` при смене направления.
|
||||
- **AC7 (i18n, диалог):** новые ключи `marker.virtual_light_target/
|
||||
virtual_light_current/virtual_light_state_on/off` есть в EN и RU
|
||||
(`src/i18n/en.json`, `src/i18n/ru.json`), formatter в `houseplan-card.ts`
|
||||
(`~17025-17046`) ветвится по `target.via === 'virtual-light'` до общего
|
||||
`toggle_hint_current`/`toggle_hint_single` — старый `no_actionable_entity`
|
||||
hint для этой тройки не строится, так как `resolveToggleIntent` возвращает
|
||||
ненулевой intent раньше проверки `no-actionable-entity`.
|
||||
- **AC8 (initial snapshot, backend):** `ws_config_get` вызывает
|
||||
`async_virtual_light_snapshot` **внутри** `rt.write_lock`, тем же
|
||||
`config_rev`, что и возвращаемый `config`/`rev` — снэпшот согласован с той
|
||||
же ревизией конфигурации, на которой backend проверял eligibility (прочитано
|
||||
в коде; `test_default_toggle_event_and_restart_persistence` подтверждает
|
||||
восстановление `off` после `async_reload` — прочитано, не исполнено, требует
|
||||
HA harness).
|
||||
- **AC9 (config writer lifecycle, backend):** все места, где раньше был
|
||||
прямой `rt.config_store.async_save({...})` (`_converge_pair`, `ws_config_set`,
|
||||
`ws_plan_optimize`, `ws_plan_optimize_undo`, миграция в `__init__.py`),
|
||||
заменены на `async_save_config_state`, которая безусловно вызывает
|
||||
`async_reconcile_virtual_lights` с явным `previous_rev`/`previous_config_rev`
|
||||
— единая точка, разрозненных ручных `virtual_light_store.async_save()` не
|
||||
найдено (`grep` по `websocket_api.py`/`__init__.py` подтверждает единственный
|
||||
вызывающий путь). Fail-safe на неизвестный revision gap (`state_config_rev
|
||||
== config_rev` иначе `off = set()`) реализован и в `async_virtual_light_snapshot`,
|
||||
и в `async_reconcile_virtual_lights` — прочитано, соответствует ТЗ §7.5.
|
||||
- **AC11/AC13/AC14 (live-sync, confirmation, cache invalidation):**
|
||||
`config-store.ts`/`houseplan-card.ts` подписываются на
|
||||
`houseplan_virtual_light_updated` рядом с существующими событиями,
|
||||
`applyVirtualLightEvent` монотонна по `rev` (тест «events are monotonic and
|
||||
never optimistic» и «event-before-response ordering never rolls state back»
|
||||
в `test/virtual-light-state.test.mjs» умеют падать — проверено инверсией
|
||||
условия мысленно: `rev <= current.rev` иначе `event.on` не проверялся бы).
|
||||
`virtualLightFingerprint` участвует в ключе `RESOLVED_LIGHT_CACHE`
|
||||
(`devices.ts`), поэтому событие инвалидирует Glow/room-граф без HA state
|
||||
tick — подтверждено smoke-проверкой `oneServerTogglePerGesture` (одно
|
||||
серверное действие на один жест) и фактическим обновлением `staticOn`/`on(second)`
|
||||
в дебаг-прогоне без `controls`.
|
||||
- **AC15 (build/bundle parity):** `npm run build` воспроизводим, три копии
|
||||
бандла побайтно идентичны (см. §2).
|
||||
- **AC16 (документация):** оба changelog, `README.md`, `docs/LIGHT.md`,
|
||||
`docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`,
|
||||
`docs/USER-GUIDE.ru.md` обновлены в том же коммите, ссылаются на #107,
|
||||
описывают точную тройку, persistence/permissions, исключение из экспорта —
|
||||
сверено построчно, расхождений с реализацией не найдено (кроме того, что
|
||||
H1 делает фразу README «updates Glow, room fill/statistics, full cards and
|
||||
`houseplan-space-card` together» неполной для комбинации с `controls` —
|
||||
само по себе не отдельная находка, устранится вместе с H1).
|
||||
- **Трейлеры:** коммит `1079cdf` несёт `Issue: #107`, `User-Visible: yes`,
|
||||
оба changelog правлены в том же коммите — соответствует.
|
||||
|
||||
## 5. Чего не проверял
|
||||
|
||||
- Полный HA backend harness (`pytest-homeassistant-custom-component`) —
|
||||
недоступен в этой среде (не `.venv-backend`, чистый Linux-раннер без
|
||||
предустановленного пакета). AC2/AC3/AC4/AC8/AC9, чьё единственное
|
||||
автотест-доказательство — `test_ha_virtual_lights.py`, разобраны только
|
||||
чтением кода и тестового файла (см. §4), не исполнением. Тесты выглядят
|
||||
корректными и умеющими падать по структуре (явные значения `rev`/`on` на
|
||||
каждом шаге, явный `monkeypatch` для forced-failure сценария), но это не
|
||||
заменяет фактический прогон — Linux CI job `backend` остаётся канонической
|
||||
проверкой для них.
|
||||
- `npm run golden:verify`, полный набор из 127 `demo/smoke_*.mjs`,
|
||||
performance-профили — не прогонялись, обоснование в §2 (объём соразмерен
|
||||
задаче по PROCESS.md §8; они относятся к pre-beta gate).
|
||||
- Продуктовое соответствие `docs/SCOPE.md`/выбор J1/J3 — не пересматривалось
|
||||
повторно, это было предметом ревью ТЗ, а не код-ревью.
|
||||
- Не проверялся весь возможный матрикс `is_light`/`tap_action` смены онлайн
|
||||
(rename/move/hidden/tombstone и т.д., AC9 edge cases) сверх того, что
|
||||
показывает `test_lifecycle_preserves_hidden_and_prunes_when_eligibility_ends`
|
||||
— тест прочитан и логически согласован с кодом, не исполнен.
|
||||
|
||||
## 6. Вердикт
|
||||
|
||||
`H1` — реальный, воспроизведённый прогоном собственного целевого smoke-теста
|
||||
дефект: презентация маркера (иконка/CSS-класс, используемый полной и
|
||||
статической карточками) не отражает ручное состояние виртуального света,
|
||||
когда у того же маркера есть сохранённые исходящие `controls` — то есть ровно
|
||||
в комбинации, которую требует покрывать AC10+AC12 и которую строит сам
|
||||
`demo/smoke_virtual_light_toggle.mjs`. Это High: блокирует.
|
||||
|
||||
`M1` — дефект тестовой инфраструктуры (не блокирует CI, но ломает
|
||||
документированный локальный офлайн-прогон `pytest tests_backend -q` из
|
||||
`AGENTS.md` для всего каталога, а не только для нового файла) — Medium,
|
||||
обязан стать отдельным issue.
|
||||
|
||||
Задача возвращается в `S6-in-progress`.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Код-ревью #122 — Isometric Stage 2: hidden visual polish (r1)
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/122
|
||||
- Спецификация: `docs/specs/122-isometric-stage2.md` (SPEC-REVIEW-122-r1: green)
|
||||
- Диапазон: `git diff origin/dev...HEAD` на коммите `42b3f44` (`feat: add hidden
|
||||
isometric stage 2`), плюс сопутствующие `76ce755` (ТЗ), `4c73e2c` (ревью ТЗ)
|
||||
- Роль: ревьюер кода (свежая сессия, без контекста реализации)
|
||||
- Вердикт: **красный · цикл r1/4 · High: 2 · Medium: 1 → #134**
|
||||
|
||||
## Скоуп
|
||||
|
||||
Класс файлов коммита `42b3f44`: A (`src/houseplan-card.ts`, `src/iso-openings.ts`
|
||||
(новый), `src/iso-projection.ts`, `src/iso-walls.ts`, `src/wall-thickness.ts`,
|
||||
`src/styles.ts`) + B (`test/**`, `demo/smoke_isometric_contract.mjs`,
|
||||
`demo/smoke_isometric_live_touch.mjs`, `demo/golden/matrix.mjs`,
|
||||
`tsconfig.test.json`) + C (`docs/ISOMETRIC.md`, `docs/ARCHITECTURE.md`,
|
||||
`docs/STATUS.md`, `docs/adr/122-isometric-stage2-composition.md`). Класс D
|
||||
(`dist/**`, `custom_components/houseplan/frontend/houseplan-card.js`,
|
||||
`demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**`) **не
|
||||
изменён вовсе** — см. High-1.
|
||||
|
||||
Трейлеры на всех трёх коммитах диапазона корректны: `Issue: #122`,
|
||||
`User-Visible: no` (фича скрытая, публичного changelog не требует — верно).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | green, без вывода |
|
||||
| Unit | `npm test` | green, 766/766 |
|
||||
| Build | `npm run build` | green, `dist/houseplan-card.js` собран |
|
||||
| Синхронизация 3 копий бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и то же для `demo/srv/assets/houseplan-card.js` | **FAIL** — расходятся, byte 45, `git diff --stat` 327 insertions / 209 deletions на файл; см. High-1 |
|
||||
| Целевые браузерные смоки (названы в AC1/AC5/AC6/AC9/AC10/AC11 и напрямую тронуты диффом) | `node demo/smoke_isometric_contract.mjs`, `node demo/smoke_isometric_live_touch.mjs` — **после** `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js`, т.к. коммит не обновил эту копию | оба green, все проверки в JSON-выводе `true` |
|
||||
| `npm run golden:verify` (диф меняет рендер/геометрию/слои — попадает в критерий «по необходимости») | `npm run golden:verify` (после того же build+copy) | 46/53 сценариев `passed`; 7 `different`: 5 ожидаемо-разных iso-сценариев без принятых эталонов (не блокер сам по себе, эталоны Stage 2 умышленно не принимаются на этом этапе — §12.3 ТЗ), но **2 сценария обязаны были остаться пиксель-идентичными и не остались** — см. High-2, Medium-1 |
|
||||
| Дифференциальная проверка причинности (A/B) | те же две команды (`npm run build`, `npm run golden:verify`) в `git worktree add /tmp/dev-check origin/dev` | `isometric-no-borders-dark` и `large-house-zoom-250-dark` оба `passed` на `origin/dev` — регрессия строго локализована в этом диффе, не окружение |
|
||||
| Backend | не прогонялся | правок в `custom_components/**/*.py` нет — не применимо |
|
||||
| Performance (`large-house-isometric-v1`) | не прогонялся | по ТЗ §10/§12.4 и решению владельца это pre-beta гейт, заблокированный отдельно #124; в этом ревью не требуется |
|
||||
|
||||
**Чего не проверял и почему:** полный набор из 127 браузерных смоков (диф не
|
||||
задевает весь продукт — только iso-поверхность и косвенно `wall-thickness.ts`);
|
||||
`python -m pytest tests_backend` (без py-правок); performance-профиль (вне
|
||||
гейта код-ревью, зависит от #124); принятие golden-эталонов (не роль
|
||||
ревьюера и не требуется до pre-beta).
|
||||
|
||||
## Находки
|
||||
|
||||
### High-1 — три копии бандла не пересобраны; CI уже красный на этом SHA
|
||||
|
||||
Коммит `42b3f44` не трогает `dist/houseplan-card.js`,
|
||||
`custom_components/houseplan/frontend/houseplan-card.js` ни
|
||||
`demo/srv/assets/houseplan-card.js` — все три остаются побитово равны
|
||||
промоушен-коммиту `a282f85` (`build: promote v1.63.0`), то есть Stage-1-коду
|
||||
без единой строки Stage 2.
|
||||
|
||||
Воспроизведение:
|
||||
|
||||
```
|
||||
$ npm run build
|
||||
$ cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js differ: byte 45, line 1
|
||||
$ git diff --stat dist/houseplan-card.js
|
||||
dist/houseplan-card.js | 536 ++++++++++++++++++++++++++++++-------------------
|
||||
1 file changed, 327 insertions(+), 209 deletions(-)
|
||||
$ grep -c "invalid isometric opening input" dist/houseplan-card.js # fresh build
|
||||
1
|
||||
$ git show HEAD:custom_components/houseplan/frontend/houseplan-card.js | grep -c "invalid isometric opening input"
|
||||
0
|
||||
$ git show HEAD:demo/srv/assets/houseplan-card.js | grep -c "invalid isometric opening input"
|
||||
0
|
||||
```
|
||||
|
||||
Независимое подтверждение — CI Validate уже прогонялся на этом самом SHA и
|
||||
уже красный ровно на этом шаге:
|
||||
|
||||
```
|
||||
$ gh run view 31746904479 --repo Matysh/houseplan-card
|
||||
X frontend in 36s
|
||||
✓ Build
|
||||
X Card bundle snapshots in sync
|
||||
```
|
||||
|
||||
Хендофф-комментарий исполнителя утверждает «три отслеживаемые bundle-копии
|
||||
byte-identical» — это не так; либо команда не выполнялась, либо результат
|
||||
интерпретирован неверно. Смысл дефекта не формальный: реальный
|
||||
`custom_components/houseplan/frontend/houseplan-card.js` (то, что грузит HA) и
|
||||
`demo/srv/assets/houseplan-card.js` (то, что видит демо-стенд и что реально
|
||||
тестируют браузерные смоки/golden в CI до пересборки) сейчас **не содержат
|
||||
Stage 2 вовсе**. `smoke`/`golden`/`performance_smoke` в CI зависят от
|
||||
`frontend` (`needs: frontend`) и поэтому на этом SHA не запускались вообще —
|
||||
ни один из пре-релизных гейтов не имеет зелёного прогона против настоящего
|
||||
кода задачи.
|
||||
|
||||
Блокирует. AC16 не выполнен.
|
||||
|
||||
### High-2 — `show_borders:false` в Iso не сохраняет пиксель-идентичный Stage-1 «no-volume» сценарий
|
||||
|
||||
ТЗ §6.6 и ADR «Consequences» требуют: при `show_borders:false` в Iso
|
||||
Stage-2-геометрия отсутствует целиком, и AC7/AC15 требуют, чтобы
|
||||
`isometric-no-borders-dark` остался неизменным. Golden показывает
|
||||
значительную регрессию:
|
||||
|
||||
```
|
||||
$ npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
$ npm run golden:verify
|
||||
different isometric-no-borders-dark
|
||||
```
|
||||
|
||||
Из `artifacts/golden/golden-report.json`:
|
||||
`differingPixels: 169919, diffRatio: 0.2194 (≈22%), maxObservedDelta: 206`.
|
||||
Повторный прогон — идентичные числа (детерминированно, не флейк). На
|
||||
`origin/dev` (та же команда, тот же `git worktree add /tmp/dev-check
|
||||
origin/dev`, тот же Chromium 151.0.7922.34) сценарий `passed` чисто.
|
||||
|
||||
Причина по коду (`src/houseplan-card.ts:4503-4522`, `_isoScene()`):
|
||||
рамка проекции теперь всегда строится с
|
||||
`floorDepth: ISO_FLOOR_EDGE_HEIGHT` и объединяется с `isoOpeningBounds()` через
|
||||
`unionRect(flat, openingFrame)` **независимо** от `disp.showBorders` /
|
||||
`resolveIsoDecoration().structural`. В Stage 1 рамка включала только
|
||||
`wallHeight`; Stage 2 добавляет глубину пола и границы проёмов всегда, даже
|
||||
когда сама геометрия стен/проёмов/края пола не рисуется (`structural=false`).
|
||||
Это меняет коэффициент "сжатия" `floorView`, и весь план визуально сдвигается/
|
||||
масштабируется иначе, чем в принятом Stage-1 baseline — то есть нарушение не
|
||||
косметическое, это другой кадр.
|
||||
|
||||
Похоже на конфликт двух требований ТЗ: AC12 хочет единую рамку, не зависящую
|
||||
от переключения `show_borders` (чтобы zoom не менялся), а AC7/AC15 хотят
|
||||
байт-в-байт то же изображение, что и Stage 1, где такой рамки не было. Решить
|
||||
это — работа автора (например, включать `floorDepth`/`openingFrame` в рамку
|
||||
только когда `structural===true`, и объяснить в ADR, почему AC12 всё равно
|
||||
держится). Ревьюер такие продуктовые компромиссы не решает.
|
||||
|
||||
Блокирует. AC7 и AC15 не выполнены буквально.
|
||||
|
||||
### Medium-1 — регрессия пикселей во Flat-сценарии при выключенном Labs → #134
|
||||
|
||||
`large-house-zoom-250-dark` (без `labs`/`projection` в `demo/golden/matrix.mjs`
|
||||
— чистый Flat, Labs неактивен) на `origin/dev` проходит чисто (`passed`), на
|
||||
этой ветке — нет:
|
||||
|
||||
```
|
||||
different large-house-zoom-250-dark
|
||||
```
|
||||
|
||||
`differingPixels: 920, diffRatio: 0.00105, maxObservedDelta: 201` —
|
||||
воспроизведено дважды подряд с идентичными числами (не флейк, не дрейф
|
||||
Chromium: версия в обоих прогонах `151.0.7922.34`).
|
||||
|
||||
Это прямое нарушение AC2 («with Labs inactive... Flat... retain their
|
||||
existing state/action/pixel contract») и инварианта из `docs/ISOMETRIC.md`
|
||||
(«existing Flat DOM and pixels remain the reference»). В рамках бюджета этого
|
||||
ревью не удалось локализовать точный узел DOM/CSS, отвечающий за эту
|
||||
конкретную (небольшую по площади, но не нулевую и стабильную) разницу;
|
||||
кандидат — новая безусловная обёртка `<g class=${iso ? 'iso-floor-scene' :
|
||||
nothing} transform=${iso ? isoFloorMatrixCss() : nothing}>` вокруг содержимого
|
||||
плана в `src/houseplan-card.ts` (добавляется в разметку независимо от того,
|
||||
активен ли `iso`), но это не доказано построчно — отметка «предположение,
|
||||
требует дальнейшего разбора автором», а не факт.
|
||||
|
||||
Площадь и видимое воздействие малы (0.1% пикселей одного сценария на большом
|
||||
зуме), поэтому находка не расширяет цикл #122 сверх уже блокирующих High-1/
|
||||
High-2, но контракт заявлен как безусловный и находка воспроизводима —
|
||||
заведён отдельный issue #134 со ссылкой на #122, а не оставлена как TODO
|
||||
в этом документе.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **AC1** (`unit`+`smoke`+код): `iso` остаётся единственным Labs id;
|
||||
`demo/smoke_isometric_contract.mjs` подтверждает Flat как дефолт,
|
||||
`isometric-contract.test.mjs` подтверждает отсутствие второго флага/ключа —
|
||||
прочитано и прогнано, зелёно.
|
||||
- **AC5/AC6** (открывающиеся элементы): `src/iso-openings.ts` —
|
||||
`buildIsoOpeningBasis`/`projectIsoOpening` алгебраически повторяют
|
||||
существующий floor-symbol (поворот на 0–10° для gate, симметричные leaves
|
||||
для window, один leaf с шарниром для door); `test/iso-openings.test.mjs`
|
||||
проверяет неподвижность базиса при live-изменениях, `flip`-инверсию базиса,
|
||||
совпадение `unavailable`≡no-contact через `openingAmount()`, границы через
|
||||
`isoOpeningBounds`. Панели помечены `aria-hidden="true" pointer-events="none"`
|
||||
(`src/houseplan-card.ts`, рендер `_renderIsoWalls`) — проверено чтением и
|
||||
подтверждено смоком `live_touch` (`touchOpeningIsSafe`, `flatIsoActionParity`).
|
||||
Тесты умеют падать: `assert.deepEqual(gateBasis.leaves.map(l =>
|
||||
Math.abs(l.turnDeg)), [10, 10])` красится при любой правке угла;
|
||||
аналогично для высот leaf.
|
||||
- **AC4** (floor edge): `buildIsoFloorGeometry`/`floorFootprintGeometry` —
|
||||
внутренние/общие границы и вложенные дырки не создают ступень (тест
|
||||
«canonical adjacent room union has no edge on its shared boundary», «floor
|
||||
edge follows outer components without internal or nested steps»),
|
||||
независимые partition/column не расширяют footprint (тест «Stage floor
|
||||
footprint excludes detached independent physical bodies»), стабильность к
|
||||
порядку/winding подтверждена отдельным тестом. Прочитано и прогнано зелёно;
|
||||
тесты специфичны настолько, что удаление одной внешней грани красит их.
|
||||
- **AC8/AC14** (отсутствие второго свето-слоя, отсутствие новых сетевых/
|
||||
конфигурационных путей): `isometric-contract.test.mjs`
|
||||
(«Stage 2 adds no schema, dependency, storage, network or HA action
|
||||
surface») грепает исходники на `localStorage|fetch|XMLHttpRequest|
|
||||
WebSocket|callService|config|schema` и `three|babylon|webgl` — прогнано,
|
||||
зелёно. `src/styles.ts` не содержит нового источника света, только матовые
|
||||
градиенты/тени — прочитано.
|
||||
- **AC9** (структурный кэш не растёт от HA/темы/hover): фингерпринт в
|
||||
`_isoSource()` включает `rooms, walls, openCuts, openings (только
|
||||
геометрия/флипы), partitions, roomDrafts, columns, cellCm, gridPitch,
|
||||
wallKeyPitch, camera, wallHeight, floorEdgeHeight, algorithm: 3` и не
|
||||
включает HA-состояние/тему/hover — подтверждено и тестом
|
||||
`isometric-contract.test.mjs` («structural cache includes opening flips and
|
||||
excludes live HA amount», прямая проверка исходного текста `_isoSource`
|
||||
на отсутствие `_openingAmt|openingAmount|.hass|matchMedia|CSS.supports|
|
||||
theme|hover`) и смоком `live_touch` (`sameWallFingerprint`,
|
||||
`haUpdateReusesGeometry`, `contactUpdateMovesOnlyLivePanel`).
|
||||
- **AC11** (деградация): `resolveIsoDecoration` — чистая функция, тест
|
||||
«decoration degradation never removes structure or creates floating
|
||||
panels» покрывает все 4 комбинации `filtersSupported`/`forcedColors`/
|
||||
`hideOpenings`/`showBorders`; `src/styles.ts` содержит
|
||||
`@media (forced-colors: active)` и `@supports not (filter: blur(1px))`,
|
||||
оба убирают только тени/нюанс материала, не геометрию. Прочитано, логика
|
||||
соответствует ТЗ.
|
||||
- **AC13** — намеренно не проверялось: и по ТЗ (§10/§12.4), и по решению
|
||||
владельца это остаётся pre-beta гейтом, заблокированным #124; хендофф
|
||||
честно пишет «не запускались».
|
||||
- Трейлеры коммитов, `User-Visible: no`, отсутствие изменений в
|
||||
`custom_components/**/*.py`, i18n и schema — проверено чтением диффа,
|
||||
корректно.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полные 127 браузерных смоков — диф касается только iso-поверхности и
|
||||
`wall-thickness.ts`; прогнаны только два целевых, названных в AC и прямо
|
||||
изменённых в этом диффе.
|
||||
- `python -m pytest tests_backend` — нет изменений в Python.
|
||||
- `large-house-isometric-v1` performance — вне гейта код-ревью при открытом
|
||||
#124 (см. ТЗ §10, решение владельца).
|
||||
- Принятие golden-эталонов — не роль ревьюера; данные из
|
||||
`artifacts/golden/golden-report.json` использованы только как
|
||||
диагностика причинности регрессии, не как повод принять/отклонить
|
||||
baseline.
|
||||
- Точная DOM/CSS-причина Medium-1 не изолирована построчно — честно оставлено
|
||||
как открытый вопрос автору в #134.
|
||||
|
||||
## Итог
|
||||
|
||||
Две High-находки блокируют цикл r1: код не собран в те артефакты, которые
|
||||
реально исполняются (High-1, CI уже подтверждает это независимо), и Stage 2
|
||||
в текущем виде ломает пиксельный контракт, который ТЗ объявляет безусловным —
|
||||
`show_borders:false` в Iso (High-2). Третья находка (регрессия чистого Flat)
|
||||
воспроизводима, но мала по воздействию — заведена как Medium #134, а не
|
||||
оставлена как TODO. Работа предметно сильная — покрытие юнит-тестами
|
||||
геометрии открытий и floor edge выдерживает мутации, изоляция
|
||||
структурного/live кэша сделана аккуратно и подтверждена и тестами, и
|
||||
смоками — но AC16, AC7 и AC15 не могут считаться выполненными до
|
||||
пересборки бандла и разбора причины пиксельной регрессии `show_borders:false`.
|
||||