mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2219700d63 | ||
|
|
854a6944a0 | ||
|
|
2a8302f4d6 | ||
|
|
f1537b2108 | ||
|
|
5e1315f61d | ||
|
|
4e29db1afb | ||
|
|
953063b984 | ||
|
|
d48d220a8c | ||
|
|
1c949ae49d | ||
|
|
9956a6cfe6 | ||
|
|
fe5f5b6a24 | ||
|
|
6db9eb0a66 | ||
|
|
3028122016 | ||
|
|
29fb9deb43 | ||
|
|
6a9122f41f | ||
|
|
25f43da1bd | ||
|
|
e7aca9c678 | ||
|
|
5ca4c7e5c5 | ||
|
|
e0f6746d7f | ||
|
|
d2bec266ed | ||
|
|
4868cc0786 | ||
|
|
e188f9d609 | ||
|
|
3ad4e9d803 | ||
|
|
7333223a55 | ||
|
|
de5e8129a1 | ||
|
|
b44d2fb958 | ||
|
|
ee87d3d00e | ||
|
|
a7d956a072 | ||
|
|
309bd59358 | ||
|
|
31cd4142ac | ||
|
|
cb2b064c6a | ||
|
|
e6579f1e55 | ||
|
|
1b37427f1d | ||
|
|
4c260f0ea0 | ||
|
|
159f4f43c0 | ||
|
|
99f7c3a4a9 | ||
|
|
f4e5ce6822 | ||
|
|
0ee80a6a52 | ||
|
|
1397a71f84 | ||
|
|
46f20fe4e1 | ||
|
|
1108b2bc14 | ||
|
|
2fd46493db | ||
|
|
df233905c5 | ||
|
|
85263d520f | ||
|
|
02ae7f9588 | ||
|
|
1dc03e1fb1 | ||
|
|
d21d532f10 | ||
|
|
50ba443492 | ||
|
|
aa01eaef01 | ||
|
|
93b60c5b8e | ||
|
|
eb17396006 | ||
|
|
0af50a74ae | ||
|
|
232c4807fd | ||
|
|
285d569102 | ||
|
|
22b588e116 | ||
|
|
1da1aba625 | ||
|
|
de53d530fa | ||
|
|
ade8daab16 | ||
|
|
05a2a838d6 | ||
|
|
f4ad843619 | ||
|
|
a7d58f0552 | ||
|
|
fd72330549 | ||
|
|
2c947f4f7a | ||
|
|
693601a8e0 | ||
|
|
c7fa9542ba | ||
|
|
478d2042b2 | ||
|
|
47ab60cddd | ||
|
|
79142d9334 | ||
|
|
bb4d4e1f6e | ||
|
|
b6675dc3a4 | ||
|
|
53e1c7163d | ||
|
|
ca66791440 | ||
|
|
db19753bbf | ||
|
|
9be44dab1e | ||
|
|
ba88782ce1 | ||
|
|
a20b73621f | ||
|
|
b70153769a | ||
|
|
5e5c06f126 | ||
|
|
c4a80bcb9f | ||
|
|
9ccc3831d1 | ||
|
|
33a960031e | ||
|
|
3a6a819dec | ||
|
|
c024c6d75a | ||
|
|
a8bb145ffb | ||
|
|
1e0295a370 | ||
|
|
c74ce39eb3 | ||
|
|
ee0ee9a1d3 | ||
|
|
a8d40e4d99 | ||
|
|
63a8274623 | ||
|
|
1a75ee1ddf | ||
|
|
e81e841ed6 | ||
|
|
3e23ff58c3 | ||
|
|
21786ed5a1 | ||
|
|
6414873fb0 | ||
|
|
941d2709fd | ||
|
|
9bcfebe8f4 | ||
|
|
8b439ea0f3 | ||
|
|
b7d5cb8a8f | ||
|
|
84f8bcf0e6 | ||
|
|
10927ffaed | ||
|
|
6ff79106b8 | ||
|
|
f49ac613b5 | ||
|
|
ec58b0602a | ||
|
|
65070c0520 | ||
|
|
fa32afa9f3 | ||
|
|
adaed9be7c | ||
|
|
b508c20ad3 | ||
|
|
ca07579b63 | ||
|
|
29dae45e2a | ||
|
|
88008961e3 | ||
|
|
6d16f69f38 | ||
|
|
92ea8fa03c | ||
|
|
6743b6efc4 | ||
|
|
c9775141c9 | ||
|
|
9c224e7606 | ||
|
|
93d97e1a83 | ||
|
|
fad2e87ab5 | ||
|
|
909bb6fbc7 | ||
|
|
4e3d8f1d53 | ||
|
|
a1f7fb4161 | ||
|
|
257a71123a | ||
|
|
69c5a4c41d | ||
|
|
77327c07d4 | ||
|
|
18b8589438 | ||
|
|
9870b2fb98 | ||
|
|
7de7cfb8c4 | ||
|
|
18ac9b459f | ||
|
|
4e355dcbd2 | ||
|
|
f9b612a389 | ||
|
|
d27864e90e | ||
|
|
f3bc3278e5 | ||
|
|
75524d9d85 | ||
|
|
089c5ef462 | ||
|
|
8dbc7db0b2 | ||
|
|
23daa28cf4 | ||
|
|
d063453670 | ||
|
|
f83577afa7 | ||
|
|
eddc8b41db | ||
|
|
1eabfeeee8 | ||
|
|
11186371a9 | ||
|
|
692bb91e57 | ||
|
|
893ccff94f | ||
|
|
d2faa070b5 | ||
|
|
f5c56d2f8e | ||
|
|
0142d37bec | ||
|
|
40abbccbf0 | ||
|
|
831e694f83 | ||
|
|
b350893448 | ||
|
|
fc8b6f85a4 | ||
|
|
6fc60e3a50 | ||
|
|
dcc0b156af | ||
|
|
9d56708f2d | ||
|
|
6d8ec7b92a | ||
|
|
a9b999b3e0 | ||
|
|
f36d2cddad | ||
|
|
1770ca2960 | ||
|
|
764e023996 | ||
|
|
ce6a11f53f | ||
|
|
3e976562ff | ||
|
|
f56bceef27 | ||
|
|
96a01e1380 | ||
|
|
08a9cc7d27 | ||
|
|
1be79a1427 | ||
|
|
d7c20ff6a5 | ||
|
|
fa59767c69 | ||
|
|
60d6167ecd | ||
|
|
110fabd038 | ||
|
|
3456706ef6 | ||
|
|
1310c84a32 | ||
|
|
94563d2a0c | ||
|
|
5615afa33b | ||
|
|
4083e14247 | ||
|
|
518d72fb74 | ||
|
|
fc95a1f09b | ||
|
|
4e736d49a3 | ||
|
|
aa3c379540 | ||
|
|
fa15598e67 | ||
|
|
9eec856d50 | ||
|
|
0bb9282edd | ||
|
|
09f4dc4115 | ||
|
|
2551b4ea0e | ||
|
|
694e1e9a3b | ||
|
|
996a7442ec | ||
|
|
098a147f87 | ||
|
|
14f7c06bf4 | ||
|
|
9f36b39379 | ||
|
|
9da96abb05 | ||
|
|
df65d25348 | ||
|
|
8db6b2673f | ||
|
|
c9030af900 | ||
|
|
5392dadeaa | ||
|
|
aa53b33dd6 | ||
|
|
a8ce6020f4 | ||
|
|
9282c28830 | ||
|
|
8c5d5ba5c5 | ||
|
|
6c90e03427 | ||
|
|
9b180c5917 | ||
|
|
3084472c75 | ||
|
|
c00048611e | ||
|
|
f5e6c0318d | ||
|
|
e1e730560d | ||
|
|
94b298962a | ||
|
|
f7fe63776a | ||
|
|
01bc4f9711 | ||
|
|
85491d0fea | ||
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c | ||
|
|
7128ab504d | ||
|
|
f953a3c286 | ||
|
|
ef270d11b7 | ||
|
|
dc24390222 | ||
|
|
254354bf56 | ||
|
|
c9a60a110d | ||
|
|
75279308c1 | ||
|
|
b7ae3e7adf | ||
|
|
379fb68db2 | ||
|
|
96a70495d3 | ||
|
|
d3db9e30e6 | ||
|
|
2e731debd9 | ||
|
|
a49b5e6d2e | ||
|
|
4418312b0b | ||
|
|
3f719cc32a | ||
|
|
260615a63f | ||
|
|
e4e300adaa | ||
|
|
96d387ff1d | ||
|
|
8b531db3f5 | ||
|
|
68aa1f04ba | ||
|
|
3d41fe16b8 | ||
|
|
ac734688d4 | ||
|
|
2e2d353b04 | ||
|
|
f8c8cb4eeb | ||
|
|
c749b52a0d | ||
|
|
15e5dd7392 | ||
|
|
f1b501a956 | ||
|
|
5d2dbb1009 | ||
|
|
40cb0302e3 | ||
|
|
14cc4df4bd | ||
|
|
ead56dd9b6 | ||
|
|
018b37940f | ||
|
|
ebeaa5c0c6 | ||
|
|
02ba18dc7b | ||
|
|
715a93ec61 | ||
|
|
09b0ba41a5 | ||
|
|
0467cee98a | ||
|
|
c0653dfc73 | ||
|
|
946e7543ad | ||
|
|
641c61dc19 | ||
|
|
ae9168f6ec | ||
|
|
45c863138a | ||
|
|
e04ef2f2e6 | ||
|
|
a841d17543 | ||
|
|
e63b7882a6 | ||
|
|
c1e3cdb768 | ||
|
|
41b20e1901 | ||
|
|
49b0cb4e05 | ||
|
|
0fd0ba408d | ||
|
|
5c7d1ca8bb | ||
|
|
b4bb732736 | ||
|
|
10d4084d17 | ||
|
|
19e19b5c5f | ||
|
|
88dc0d1de7 | ||
|
|
ad7e946e75 | ||
|
|
9191a94701 | ||
|
|
eea669fa12 | ||
|
|
bd9fabfe99 | ||
|
|
b03ef70794 | ||
|
|
1d6ca968e8 | ||
|
|
82eed100b2 |
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Telegram chat (@ha_houseplan)
|
||||
url: https://t.me/ha_houseplan
|
||||
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
|
||||
- name: 💡 GitHub discussions
|
||||
url: https://github.com/Matysh/houseplan-card/discussions
|
||||
about: Longer-form ideas and show-and-tell.
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Announce release
|
||||
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
|
||||
# Fires on every published release, prereleases included: the workflow file
|
||||
# lives at the TAGGED commit, so it works for beta tags cut from dev as soon
|
||||
# as this file is on dev, and for stable tags once it reaches main.
|
||||
# workflow_dispatch exists purely as a connectivity test button.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: {}
|
||||
permissions: {}
|
||||
jobs:
|
||||
telegram:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send to Telegram
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
NAME: ${{ github.event.release.name }}
|
||||
URL: ${{ github.event.release.html_url }}
|
||||
PRE: ${{ github.event.release.prerelease }}
|
||||
# The body goes through env, never through shell interpolation —
|
||||
# release notes are arbitrary text.
|
||||
BODY: ${{ github.event.release.body }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT" = "workflow_dispatch" ]; then
|
||||
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
|
||||
else
|
||||
KIND=$([ "$PRE" = "true" ] && echo "🧪 Пре-релиз" || echo "🏠 Релиз")
|
||||
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
|
||||
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
|
||||
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
|
||||
fi
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.telegram.org/bot$TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=$TEXT" \
|
||||
-d disable_web_page_preview=true
|
||||
@@ -4,11 +4,43 @@ on:
|
||||
types: [published]
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
jobs:
|
||||
build:
|
||||
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
|
||||
# workflow only built and uploaded, so an asset shipped while both Validate
|
||||
# runs for the very same commit were red. The asset now waits for a green
|
||||
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
|
||||
#
|
||||
# Needs a push with a token that has the `workflow` scope (the ordinary
|
||||
# Personal Access Token used for `git push` refuses workflow file updates).
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Require a green Validate for this exact commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# HEAD is the peeled commit even when TAG is annotated. Do not trust
|
||||
# target_commitish (it may be a branch name) or an event-context SHA.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
echo "release tag: $TAG; exact commit: $SHA"
|
||||
node scripts/release-gate.mjs "$SHA"
|
||||
build:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
@@ -17,3 +49,29 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
hacs-discovery:
|
||||
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
|
||||
# sorting SemVer. A valid asset can therefore be invisible to beta users
|
||||
# (beta.10 appeared after beta.9). Keep the release asset, but
|
||||
# make that distribution failure impossible to miss in the release run.
|
||||
if: ${{ github.event.release.prerelease }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify the published tag is the prerelease HACS will discover
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const first = releases.find((r) => r.prerelease && !r.draft);
|
||||
const expected = context.payload.release.tag_name;
|
||||
if (first?.tag_name !== expected) {
|
||||
core.setFailed(
|
||||
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
|
||||
`Use an rc/new version line or correct the release ordering before announcing the update.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,8 +32,48 @@ jobs:
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Card bundle in sync with integration
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
- name: Card bundle snapshots in sync
|
||||
run: |
|
||||
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
smoke:
|
||||
# audit T2: the end-to-end layer used to run only when a human remembered.
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build a FRESH bundle for the smokes
|
||||
# Never depend on a previous job's artefact; build the exact source
|
||||
# under test again before the browser suite.
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Smoke suite
|
||||
run: |
|
||||
fail=0
|
||||
mkdir -p /tmp/smoke-logs
|
||||
for f in demo/smoke_*.mjs; do
|
||||
name=$(basename "$f" .mjs)
|
||||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
echo "ok $name"
|
||||
else
|
||||
echo "FAIL $name"
|
||||
tail -20 "/tmp/smoke-logs/$name.log"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -4,3 +4,4 @@ test-build/
|
||||
*.log
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# AGENTS.md
|
||||
|
||||
House Plan is one HACS package with two parts plus a demo harness:
|
||||
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
|
||||
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
|
||||
|
||||
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, 424 tests at v1.60.0), `npm run build`. After building, keep both committed snapshots in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/` and `cp dist/houseplan-card.js demo/srv/assets/`. CI enforces both comparisons byte-for-byte.
|
||||
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (150 tests at v1.60.0: 100 pure + 50 HA harness). Running `python3 -m pytest tests_backend` without Home Assistant silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the pure set.
|
||||
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed demo snapshot must remain byte-identical to `dist` (CI checks it); rebuilding first also guarantees the browser suite tests the current source in an uncommitted worktree. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
|
||||
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
|
||||
- **Owner's local Windows checkout is invisible here.** Path
|
||||
`C:\Users\Sergey\Downloads\dev\houseplan-dev` (workflow notes + often
|
||||
unpushed edits) is **not mounted** into managed Cloud Agent VMs. Do not
|
||||
expect to `ls` or diff that folder. To bring local work into the cloud
|
||||
agent: push a branch to GitHub and say its name, or run a **local** Cursor
|
||||
Agent / My Machines worker inside that checkout. Day-to-day source of truth
|
||||
for cloud sessions remains **`origin/dev`** (minors) and **`origin/main`**
|
||||
(releases) — see `docs/STATUS.md`.
|
||||
@@ -3,6 +3,19 @@
|
||||
Thanks for your interest! The project is one HACS package: a storage **integration**
|
||||
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
|
||||
|
||||
## Changelog
|
||||
|
||||
User-visible changes go into **both** changelogs in the same commit:
|
||||
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
|
||||
older than v1.42.0 exist only in the English file — no need to backfill them.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
|
||||
is the quickest route to the author and other users. Bugs and concrete feature
|
||||
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,10 +1,71 @@
|
||||
# 🏠 House Plan — an interactive house plan for Home Assistant
|
||||
# 🏠 House Plan — interactive floor plan card for Home Assistant
|
||||
|
||||
**A live map of your home right inside Home Assistant: floors, rooms and devices on a real floor plan — with live states, temperature and signal strength. Everything is configured with the mouse, without a single line of YAML.**
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](LICENSE)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||

|
||||
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
|
||||
a floor plan, outline the rooms with your mouse — and every smart device appears
|
||||
in its real place: live states, tap-to-toggle lights, temperature and humidity per
|
||||
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
|
||||
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
|
||||
right on your Lovelace dashboard.
|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md)
|
||||
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
|
||||
> on phones and tablets. The editors are designed primarily for a desktop
|
||||
> browser with a mouse and keyboard; individual editing operations on touch
|
||||
> devices may be awkward, limited, or unavailable.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Try it live — no install needed
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
|
||||
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
|
||||
> toggle lights, open the editors, break things. The stand resets itself to a
|
||||
> pristine state every hour.
|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Feature highlights**
|
||||
|
||||
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
|
||||
past: draw and place devices anywhere, pan at any zoom, zoom out to see
|
||||
everything, and let one tap fit the whole plan back on screen.
|
||||
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
|
||||
virtual walls and a visual decor layer, all drawn with clicks; room resize
|
||||
by dragging walls, with live lengths and areas as you drag; smart
|
||||
alignment guides and a live ruler in real meters/feet.
|
||||
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
|
||||
place and pull a corner to size it, with its real size in metres shown as
|
||||
you drag, so the drawing and the photo of your plan finally line up.
|
||||
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
|
||||
whole groups of lights (works for dumb switches and stateless remotes too).
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
|
||||
of its own color that spills through doorways and open zone boundaries.
|
||||
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
|
||||
the day (white noon → golden hour → deep night), while windows on exterior
|
||||
walls cast real wedges of sunlight into the rooms; optional cloud cover
|
||||
from a weather entity.
|
||||
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
|
||||
stops a cover, and the icon itself morphs between open and closed while a
|
||||
soft ring pulses as it travels.
|
||||
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
|
||||
comfort-range temperature fills, per-room signal heatmap.
|
||||
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
|
||||
explicit button, never an accidental tap.
|
||||
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
|
||||
auto-carousel, per-screen icon sizes.
|
||||
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
|
||||
drives the plan in real time, pouring its path out from under itself;
|
||||
current and previous cleanup runs are recorded server-side. Calibration is
|
||||
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
|
||||
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
|
||||
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
|
||||
**server-side** — one shared plan for every user and screen, synced live.
|
||||
|
||||
---
|
||||
|
||||
@@ -28,16 +89,27 @@ The integration consists of two parts that are installed together:
|
||||
|
||||
## How it differs from alternatives
|
||||
|
||||
A house plan in Home Assistant is usually built with `picture-elements`, `ha-floorplan` and similar solutions. There you have to write YAML by hand, calculate the coordinates of every icon, and edit the config again after every change. House Plan works differently:
|
||||
A house plan in Home Assistant is usually built with `picture-elements`,
|
||||
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
|
||||
dashboard. Those either lock you into YAML/SVG, or store the plan in the
|
||||
Lovelace card config. House Plan is a **shared live map** backed by a Home
|
||||
Assistant integration:
|
||||
|
||||
| | House Plan | Typical solutions (picture-elements / ha-floorplan) |
|
||||
|---|---|---|
|
||||
| **Setup** | Entirely through the UI, with the mouse | Manual YAML and code editing |
|
||||
| **Adding devices** | Automatic, by room | You type in every entity by hand |
|
||||
| **Icon coordinates** | Drag with the mouse | You count pixels and write them into the config |
|
||||
| **Room markup** | Built-in outline editor | You draw in an external SVG editor |
|
||||
| **Storage** | On the HA server (shared by all devices) | In the dashboard YAML |
|
||||
| **Zoom** | Smooth zoom, everything stays crisp (vector) | Usually a fixed image |
|
||||
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
|
||||
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
|
||||
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
|
||||
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
|
||||
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
|
||||
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
|
||||
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
|
||||
|
||||
**One sentence:** House Plan is the shared, area-aware live map of your home —
|
||||
not a general-purpose CAD package. Its Background editor covers practical
|
||||
decor, labels and furniture; if you need unrestricted architectural drafting,
|
||||
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
|
||||
every tablet on the same live layout.
|
||||
|
||||
Key advantages in short:
|
||||
|
||||
@@ -45,10 +117,39 @@ Key advantages in short:
|
||||
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
|
||||
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
|
||||
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
|
||||
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
|
||||
a light is shining, a socket is powering, a fan is spinning, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
|
||||
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
|
||||
that mode is the best available fallback. Orange = open / unlocked.
|
||||
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
|
||||
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
|
||||
A translucent icon = unavailable. Dark = idle.
|
||||
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
|
||||
furniture, edit physical styles and transform every object with the same
|
||||
selection model. The plan image has its own move/resize/rotate tool, numeric
|
||||
properties and shared Undo/Redo.
|
||||
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
|
||||
|
||||
---
|
||||
|
||||
## Wall tablet / TV (kiosk mode)
|
||||
|
||||
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
|
||||
(or tick "Wall device (kiosk) mode" in the card editor):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
|
||||
```
|
||||
|
||||
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
|
||||
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
|
||||
tune icon and text sizes for THIS screen (saved per device). To hide Home
|
||||
Assistant's own header use the companion app's kiosk settings or the
|
||||
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
|
||||
|
||||
## Installation
|
||||
|
||||
One click if you already run HACS:
|
||||
@@ -109,7 +210,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

|
||||
|
||||
In the dialog, set a **name** (for example, "1st floor") and **upload a background** — a floor-plan image in SVG, PNG or JPG format. Both fields are required: without a plan the "Save" button stays disabled.
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
|
||||
|
||||

|
||||
|
||||
@@ -133,15 +234,17 @@ Rooms may not overlap: a click strictly inside an existing room, or an outline t
|
||||
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
|
||||
|
||||
|
||||
### Doors, windows and locks
|
||||
### Doors, windows, gates and locks
|
||||
|
||||
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
|
||||
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
|
||||
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
|
||||
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
|
||||
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
|
||||
|
||||
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
|
||||
draws itself in as the real door opens; a window opens its two casements. While open, the moving
|
||||
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
|
||||
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
|
||||
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
|
||||
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
|
||||
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
|
||||
on the opening shows a status card with both states instead.
|
||||
|
||||
@@ -152,7 +255,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
|
||||
By default only meaningful devices make it onto the plan — service records, bridges and duplicates are filtered out. If you need to see **absolutely all** devices of the area, enable the **👁 "Show all devices"** button in the header.
|
||||
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
|
||||
|
||||
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
|
||||
|
||||
@@ -201,6 +304,10 @@ turned the way it is mounted.
|
||||
|
||||

|
||||
|
||||
### Styling the plan with card-mod (advanced, unsupported)
|
||||
|
||||
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Uninstalling
|
||||
@@ -212,11 +319,29 @@ turned the way it is mounted.
|
||||
|
||||
---
|
||||
|
||||
## Getting help & sharing your plan
|
||||
|
||||
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
|
||||
setup help, feature ideas, and screenshots of your plans. The fastest way to
|
||||
reach the author and other users.
|
||||
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
|
||||
reports and feature requests (please attach your House Plan version).
|
||||
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
|
||||
longer-form ideas.
|
||||
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
|
||||
([на русском](docs/CHANGELOG.ru.md)).
|
||||
|
||||
When reporting a problem, the version number helps a lot: it is shown in the
|
||||
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
|
||||
Services → House Plan**.
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by curation (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
|
||||
|
||||
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
|
||||
|
||||
|
||||
+137
-20
@@ -1,13 +1,73 @@
|
||||
# 🏠 House Plan — интерактивный план дома для Home Assistant
|
||||
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
|
||||
|
||||
**Живая карта вашего дома прямо в Home Assistant: этажи, комнаты и устройства на настоящем плане — с реальными состояниями, температурой и уровнем сигнала. Всё настраивается мышкой, без единой строчки YAML.**
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||

|
||||
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · **[Продуктовый аудит и план улучшений](docs/PRODUCT-IMPROVEMENT-PLAN.ru.md)**
|
||||
|
||||
🇬🇧 [English documentation](README.md)
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
|
||||
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
|
||||
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
|
||||
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
|
||||
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
|
||||
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
|
||||
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
|
||||
> возвращается в исходное состояние.
|
||||
|
||||
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Главное**
|
||||
|
||||
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
|
||||
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
weather-сущности.
|
||||
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
|
||||
останавливает штору, а сам значок морфится между открытым и закрытым
|
||||
видом и мягко пульсирует кольцом, пока штора едет.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
никогда случайным тапом.
|
||||
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
|
||||
этажами, автокарусель, свои размеры на каждом экране.
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
|
||||
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Что это и зачем
|
||||
|
||||
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
|
||||
@@ -28,16 +88,26 @@ House Plan показывает ваш умный дом так, как он в
|
||||
|
||||
## Чем отличается от аналогов
|
||||
|
||||
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan` и подобные решения. Там приходится вручную писать YAML, вычислять координаты каждой иконки и заново править конфиг при каждом изменении. House Plan устроен иначе:
|
||||
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
|
||||
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
|
||||
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
|
||||
план** на серверной интеграции Home Assistant:
|
||||
|
||||
| | House Plan | Обычные решения (picture-elements / ha-floorplan) |
|
||||
|---|---|---|
|
||||
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML и правка кода |
|
||||
| **Добавление устройств** | Автоматически по комнатам | Каждую сущность вписываете руками |
|
||||
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели и пишете в конфиг |
|
||||
| **Разметка комнат** | Встроенный редактор контуров | Рисуете в стороннем редакторе SVG |
|
||||
| **Хранение** | На сервере HA (общее для всех устройств) | В YAML дашборда |
|
||||
| **Масштаб** | Плавный зум, всё остаётся чётким (вектор) | Обычно фиксированная картинка |
|
||||
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
|
||||
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
|
||||
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
|
||||
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
|
||||
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
|
||||
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
|
||||
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
|
||||
|
||||
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
|
||||
универсальная CAD-система. Редактор подложки покрывает практический декор,
|
||||
надписи и мебель; для свободного архитектурного черчения лучше отдельный
|
||||
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
|
||||
на всех планшетах.
|
||||
|
||||
Ключевые преимущества коротко:
|
||||
|
||||
@@ -45,10 +115,36 @@ House Plan показывает ваш умный дом так, как он в
|
||||
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
|
||||
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
|
||||
Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
|
||||
## Настенный планшет / ТВ (киоск-режим)
|
||||
|
||||
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
|
||||
галочка «Режим настенного устройства» в редакторе карточки):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
|
||||
```
|
||||
|
||||
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
|
||||
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
|
||||
настройка размеров значков и текста для ЭТОГО экрана (хранится на
|
||||
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
|
||||
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
|
||||
|
||||
## Установка
|
||||
|
||||
В один клик, если у вас уже есть HACS:
|
||||
@@ -111,7 +207,7 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
|
||||
|
||||

|
||||
|
||||
@@ -135,15 +231,15 @@ title: План дома
|
||||
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
|
||||
|
||||
|
||||
### Двери, окна и замки
|
||||
### Двери, окна, ворота и замки
|
||||
|
||||
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
|
||||
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
|
||||
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
|
||||
окно 120 см), датчик открытия и — для двери — **замок**.
|
||||
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
|
||||
|
||||
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
|
||||
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
|
||||
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
|
||||
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
|
||||
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
|
||||
переключить — клик по проёму показывает карточку с обоими статусами.
|
||||
|
||||
@@ -154,7 +250,7 @@ title: План дома
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||
@@ -205,6 +301,10 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
### Свои стили через card-mod (для продвинутых, без поддержки)
|
||||
|
||||
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
@@ -216,11 +316,28 @@ title: План дома
|
||||
|
||||
---
|
||||
|
||||
## Помощь и обмен опытом
|
||||
|
||||
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
|
||||
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
|
||||
связаться с автором и другими пользователями.
|
||||
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
|
||||
и запросы фич (пожалуйста, указывайте версию House Plan).
|
||||
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
|
||||
развёрнутых обсуждений.
|
||||
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
|
||||
|
||||
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
|
||||
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
|
||||
быстрее.
|
||||
|
||||
---
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
@@ -18,6 +20,8 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
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, create_data
|
||||
|
||||
@@ -28,9 +32,10 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanUploadView
|
||||
from .http_api import HouseplanContentView, HouseplanUploadView
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
return True
|
||||
|
||||
|
||||
@@ -45,6 +50,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
||||
entry.runtime_data = data
|
||||
|
||||
# server-side vacuum trails: the integration records the path itself
|
||||
from .trails import TrailRecorder
|
||||
recorder = TrailRecorder(hass, data)
|
||||
await recorder.async_setup()
|
||||
# setdefault: the CI harness sets entries up without async_setup, so
|
||||
# hass.data[DOMAIN] may not exist yet — a KeyError here failed EVERY
|
||||
# downstream WS test with unknown_error
|
||||
hass.data.setdefault(DOMAIN, {})["trail_recorder"] = recorder
|
||||
|
||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||
files_path = Path(hass.config.path(FILES_DIR))
|
||||
@@ -61,14 +75,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
|
||||
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the card bundle stays public — Lovelace resources must be.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
|
||||
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
@@ -95,11 +109,146 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
module_url, module_url,
|
||||
)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
# square and a plan is centred inside it. Nothing about the drawing changes
|
||||
# — the box is padded and the numbers re-expressed against it.
|
||||
# The two stores are written independently, and the lock is no transaction:
|
||||
# a crash between the writes used to leave the config in square coordinates
|
||||
# with the layout still in the old ones — permanently, because the config
|
||||
# write had already deleted the `aspect` fields the layout half needed
|
||||
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
|
||||
# and each half carries its own trigger with its own write: the config half
|
||||
# removes `aspect`, the layout half removes the saved intent. Whatever
|
||||
# half is missing after a crash, the next start finishes exactly it.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config")
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
layout = lay_stored.get("layout") or {}
|
||||
lay_meta = {
|
||||
k: v for k, v in lay_stored.items()
|
||||
if k not in ("layout", "rev", "geom_pending")
|
||||
}
|
||||
pending = {
|
||||
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
|
||||
}
|
||||
merged = {**pending, **pending_from_config(cfg)}
|
||||
if merged:
|
||||
lay_rev = int(lay_stored.get("rev", 0))
|
||||
if merged != pending: # 1. the durable intent, before anything moves
|
||||
await data.store.async_save(
|
||||
{**lay_meta, "layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
)
|
||||
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})
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await data.store.async_save({**lay_meta, "layout": layout, "rev": lay_rev + 1})
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
||||
)
|
||||
# only once both halves are durable — a client refetching on this
|
||||
# event must never see one migrated half and one old one
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
# Finish an explicit whole-plan optimization/undo interrupted between the
|
||||
# config and layout store writes. The target was persisted before either
|
||||
# visible half changed, so setup can always converge on the requested pair.
|
||||
optimize_revs: tuple[int, int] | None = None
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
pending = lay_stored.get("optimize_pending")
|
||||
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
|
||||
and isinstance(pending.get("layout"), dict):
|
||||
target_config = pending["config"]
|
||||
target_layout = pending["layout"]
|
||||
config_rev = int(stored.get("rev", 0))
|
||||
layout_rev = int(lay_stored.get("rev", 0))
|
||||
if stored.get("config") != target_config:
|
||||
config_rev += 1
|
||||
await data.config_store.async_save({
|
||||
"config": target_config,
|
||||
"rev": config_rev,
|
||||
})
|
||||
if lay_stored.get("layout", {}) != target_layout:
|
||||
layout_rev += 1
|
||||
layout_meta = {
|
||||
k: v for k, v in lay_stored.items()
|
||||
if k not in ("layout", "rev", "optimize_pending", "optimize_backup")
|
||||
}
|
||||
if not pending.get("clear_backup") and "optimize_backup" in lay_stored:
|
||||
layout_meta["optimize_backup"] = lay_stored["optimize_backup"]
|
||||
await data.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": target_layout,
|
||||
"rev": layout_rev,
|
||||
})
|
||||
optimize_revs = (config_rev, layout_rev)
|
||||
_LOGGER.warning("House Plan: completed an interrupted plan optimization")
|
||||
if optimize_revs is not None:
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
#
|
||||
# A commit collects what that commit superseded, which is the right rule for
|
||||
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
||||
# after the file has already uploaded, lose the connection after the upload
|
||||
# succeeded, or call the API directly, and the file is unreferenced with no
|
||||
# future write to notice it (HP-1461-01). The earlier version of this sweep
|
||||
# only removed streaming temporaries, which are a different, narrower case.
|
||||
#
|
||||
# Passing the CURRENT configuration as both sides means "nothing was
|
||||
# superseded": every referenced file is preserved and only unreferenced ones
|
||||
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
|
||||
# so it cannot decide from a snapshot that a commit is about to replace.
|
||||
async def _sweep(_now=None) -> None:
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
# `data` from the closure, NOT get_data(hass): during
|
||||
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
|
||||
# async_loaded_entries() does not list it and the lookup returned
|
||||
# None. The startup pass then silently degraded to removing
|
||||
# streaming temporaries only, and the real collection waited a full
|
||||
# day — restarting more often than that meant it never ran at all
|
||||
# (HP-1462-01). The callback is unregistered with the entry, so
|
||||
# closing over its runtime data matches the lifecycle exactly.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
|
||||
def _collect() -> int:
|
||||
n = sweep_upload_temps(files_dir)
|
||||
# same config on both sides: nothing is superseded, so this
|
||||
# only ever collects what the shared rules call abandoned
|
||||
n += collect_attachments(files_dir, cfg, cfg)
|
||||
n += collect_plans(plans_dir, cfg, cfg)
|
||||
return n
|
||||
|
||||
n = await hass.async_add_executor_job(_collect)
|
||||
if n:
|
||||
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||
|
||||
data.sweep = _sweep
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if rec:
|
||||
rec.teardown()
|
||||
"""Unload the entry.
|
||||
|
||||
WS commands and the HTTP view are global (async_setup) and stay registered —
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
"""Single source of truth for the write-authorization policy.
|
||||
|
||||
The WS and HTTP paths used to duplicate this decision and drifted apart: the
|
||||
WS copy was fixed to fail closed while the upload view still failed OPEN when
|
||||
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
|
||||
one behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY
|
||||
from .store import get_entry
|
||||
|
||||
|
||||
def may_write(hass: HomeAssistant, user) -> bool:
|
||||
"""True when `user` may modify House Plan data.
|
||||
|
||||
Fails CLOSED: when the entry cannot be read — during a reload, or while the
|
||||
integration is disabled — the policy is unknown, and "unknown" is not the
|
||||
same as "permissive": only admins are allowed through.
|
||||
"""
|
||||
is_admin = bool(getattr(user, "is_admin", False))
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
# Default TRUE when the key is absent (audit P0-4, 2026-08-05): the card
|
||||
# UI has always been admin-gated, and an unset option must not open every
|
||||
# write WS/HTTP path to every authenticated household user.
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True))
|
||||
return is_admin if admin_only else True
|
||||
@@ -18,7 +18,7 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(title="House Plan", data={}, options=user_input)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=False): bool}),
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -32,7 +32,8 @@ class HouseplanOptionsFlow(config_entries.OptionsFlow):
|
||||
async def async_step_init(self, user_input=None):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
current = self.config_entry.options.get(CONF_ADMIN_ONLY, False)
|
||||
# Match auth.may_write: missing key ⇒ admin-only (audit P0-4).
|
||||
current = self.config_entry.options.get(CONF_ADMIN_ONLY, True)
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=current): bool}),
|
||||
|
||||
@@ -9,9 +9,43 @@ FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
|
||||
# How many paths one houseplan/content/sign call may carry. The card batches to
|
||||
# the same number; a client that sends more used to get a partial answer with no
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
|
||||
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
|
||||
# stopped at the door instead. These bound the whole store, not one request: by
|
||||
# default any authenticated user may upload, and a per-request cap of 8/50 MB
|
||||
# says nothing about how many requests there are (HP-1470-01).
|
||||
MAX_PLANS_BYTES = 256 * 1024 * 1024
|
||||
MAX_PLANS_FILES = 200
|
||||
# How many the picker asks for at once — newest first.
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# An uploaded plan that no accepted configuration references is collected only
|
||||
# once it is this old. Age is a race guard, not a policy: a plan uploaded
|
||||
# seconds ago may belong to another client's transaction that has not written
|
||||
# its configuration yet (review R3-1).
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
|
||||
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||
# deleting detached plans, then by racing the save that was about to reference a
|
||||
# retried upload. What is left is deliberately simple: files go when the user's
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.40.1"
|
||||
VERSION = "1.60.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -31,6 +31,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"has_plan": bool(s.get("plan_url")),
|
||||
"rooms": len(s.get("rooms", [])),
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"room_drafts": len(s.get("room_drafts", [])),
|
||||
"partitions": len(s.get("partitions", [])),
|
||||
"wall_columns": len(s.get("wall_columns", [])),
|
||||
}
|
||||
for s in config.get("spaces", [])
|
||||
],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,171 @@
|
||||
"""One-time migration to a square canvas — pure, so it can be tested alone.
|
||||
|
||||
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
|
||||
it: x by the width, y by the HEIGHT. Making every canvas square without touching
|
||||
the numbers would stretch every plan vertically.
|
||||
|
||||
Nothing about the drawing changes here. The canvas is padded to a square —
|
||||
top and bottom for a wide plan, left and right for a tall one — and the
|
||||
coordinates are re-expressed against that larger box. In render units it is a
|
||||
uniform scale plus an offset, so angles, room proportions and relative positions
|
||||
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
|
||||
tall plan the width grew, so the same wall would otherwise measure less.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def transform_for(aspect: float) -> tuple[float, float, float, float]:
|
||||
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
|
||||
|
||||
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
|
||||
axis's factor; both are the same uniform scale in RENDER units, which is
|
||||
why angles are preserved.
|
||||
"""
|
||||
a = float(aspect)
|
||||
if not a or a <= 0:
|
||||
a = 1.0
|
||||
k = min(1.0, a) # how much the old box shrinks inside the square
|
||||
kx = k # x was normalised by the width
|
||||
ky = k / a # y was normalised by the height (= width / aspect)
|
||||
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
|
||||
|
||||
|
||||
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
|
||||
if isinstance(p, (list, tuple)) and len(p) >= 2:
|
||||
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
|
||||
return p
|
||||
|
||||
|
||||
def migrate_space(space: dict[str, Any]) -> bool:
|
||||
"""Rewrite one space in place. Returns True when anything was changed."""
|
||||
if "aspect" not in space:
|
||||
return False
|
||||
try:
|
||||
aspect = float(space.get("aspect") or 1)
|
||||
except (TypeError, ValueError):
|
||||
aspect = 1.0
|
||||
dx, dy, kx, ky = transform_for(aspect)
|
||||
space.pop("aspect", None)
|
||||
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("x") is not None:
|
||||
room["x"] = dx + float(room["x"]) * kx
|
||||
if room.get("y") is not None:
|
||||
room["y"] = dy + float(room["y"]) * ky
|
||||
if room.get("w") is not None:
|
||||
room["w"] = float(room["w"]) * kx
|
||||
if room.get("h") is not None:
|
||||
room["h"] = float(room["h"]) * ky
|
||||
if room.get("poly"):
|
||||
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
|
||||
|
||||
for draft in space.get("room_drafts") or []:
|
||||
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
|
||||
|
||||
for part in space.get("partitions") or []:
|
||||
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
|
||||
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
|
||||
|
||||
for column in space.get("wall_columns") or []:
|
||||
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
|
||||
|
||||
for op in space.get("openings") or []:
|
||||
op["x"] = dx + float(op.get("x", 0)) * kx
|
||||
op["y"] = dy + float(op.get("y", 0)) * ky
|
||||
# a length is measured along the wall, and the render scale is uniform
|
||||
if op.get("length") is not None:
|
||||
op["length"] = float(op["length"]) * kx
|
||||
|
||||
for shape in space.get("decor") or []:
|
||||
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
|
||||
if shape.get(a) is not None:
|
||||
shape[a] = dx + float(shape[a]) * fx
|
||||
if shape.get(b) is not None:
|
||||
shape[b] = dy + float(shape[b]) * fy
|
||||
if shape.get("w") is not None:
|
||||
shape["w"] = float(shape["w"]) * kx
|
||||
if shape.get("h") is not None:
|
||||
shape["h"] = float(shape["h"]) * ky
|
||||
|
||||
# The viewport becomes the whole square rather than the transformed old
|
||||
# rectangle. It is what the grid is drawn over and what "fit to screen"
|
||||
# fits, so keeping the old box would leave the new margins outside the
|
||||
# canvas — no dots, nothing to draw on — which is exactly the room this
|
||||
# change was meant to give.
|
||||
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
|
||||
# canvas, so a wall now covers fewer cells; without this every measurement
|
||||
# in the plan would silently shrink.
|
||||
if kx != 1:
|
||||
try:
|
||||
cell = float(space.get("cell_cm") or 5)
|
||||
except (TypeError, ValueError):
|
||||
cell = 5.0
|
||||
space["cell_cm"] = round(cell / kx, 4)
|
||||
|
||||
# The image keeps its own proportions and is centred; the space no longer
|
||||
# has any of its own.
|
||||
if space.get("plan_url") and not space.get("plan_aspect"):
|
||||
space["plan_aspect"] = round(aspect, 6)
|
||||
return True
|
||||
|
||||
|
||||
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""{space_id: old aspect} for every space still carrying one.
|
||||
|
||||
This is the migration INTENT. The two stores are written independently and
|
||||
either write can fail, so the intent has to survive on its own: it is saved
|
||||
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
|
||||
the same write that stores the migrated layout. A crash between the writes
|
||||
leaves the intent behind, and the next start finishes the missing half —
|
||||
each half is idempotent because its trigger (`aspect` in the config, the
|
||||
saved intent for the layout) travels with that half's own write.
|
||||
"""
|
||||
out: dict[str, float] = {}
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
if "aspect" not in space:
|
||||
continue
|
||||
try:
|
||||
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
|
||||
except (TypeError, ValueError):
|
||||
out[str(space.get("id"))] = 1.0
|
||||
return out
|
||||
|
||||
|
||||
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
|
||||
"""The config half: migrate every space still carrying an `aspect`.
|
||||
|
||||
`layout` is accepted for backward compatibility and migrated with the
|
||||
factors found in the config — callers that can crash between store writes
|
||||
should use `pending_from_config()` + `migrate_layout()` instead, so the
|
||||
layout half does not depend on state the config half just deleted.
|
||||
"""
|
||||
factors = pending_from_config(config)
|
||||
if not factors:
|
||||
return False
|
||||
for space in config.get("spaces") or []:
|
||||
migrate_space(space)
|
||||
if layout:
|
||||
migrate_layout(layout, factors)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
|
||||
"""The layout half: marker and label positions of the spaces in `pending`."""
|
||||
changed = False
|
||||
for pos in (layout or {}).values():
|
||||
if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
|
||||
continue
|
||||
dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
|
||||
if pos.get("x") is not None:
|
||||
pos["x"] = dx + float(pos["x"]) * kx
|
||||
if pos.get("y") is not None:
|
||||
pos["y"] = dy + float(pos["y"]) * ky
|
||||
changed = True
|
||||
return changed
|
||||
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -18,8 +20,12 @@ except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, FILES_DIR, FILES_URL
|
||||
from .store import get_entry
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -31,6 +37,79 @@ from .validation import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK = 64 * 1024
|
||||
# batch disk writes: one executor job per megabyte instead of per chunk
|
||||
_FLUSH_AT = 1024 * 1024
|
||||
|
||||
_MIME = {
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
class HouseplanContentView(HomeAssistantView):
|
||||
"""Authenticated read access to plans and marker files (audit B1).
|
||||
|
||||
The directories used to be exposed as unauthenticated static paths, so
|
||||
anyone who could reach the HA endpoint could pull floor plans and uploaded
|
||||
manuals without logging in. This view keeps the same URLs but requires a
|
||||
Home Assistant session (or a signed path, which the frontend uses for
|
||||
<image href> inside the SVG).
|
||||
"""
|
||||
|
||||
url = "/api/houseplan/content/{kind}/{sub}/{name}"
|
||||
name = "api:houseplan:content"
|
||||
requires_auth = True
|
||||
|
||||
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if kind not in ("plans", "files"):
|
||||
return web.Response(status=404)
|
||||
safe_sub = sanitize_marker_id(sub)
|
||||
safe_name = sanitize_filename(name)
|
||||
if not safe_sub or not safe_name:
|
||||
return web.Response(status=404)
|
||||
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
|
||||
# plans live flat in one directory: the sub segment is a placeholder ("_")
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
# defence in depth: the sanitizers already strip separators
|
||||
if not str(path).startswith(str(base)):
|
||||
return web.Response(status=404)
|
||||
|
||||
if not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
suffix = path.suffix.lower()
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||
}
|
||||
if suffix == ".svg":
|
||||
# An uploaded SVG is user content served from Home Assistant's own
|
||||
# origin. Inside the card it is referenced by <image>, where scripts
|
||||
# never run — but the same url opened as a top-level document is a
|
||||
# live document of this origin, and a <script> in it reaches the
|
||||
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
|
||||
# needs write access, which by default every authenticated user has,
|
||||
# and the signed url is easy to hand to an admin).
|
||||
#
|
||||
# `sandbox` with no allow-* tokens drops the document into an opaque
|
||||
# origin: no scripts, no same-origin access, no forms. The explicit
|
||||
# directives below are belt and braces for older engines. Only SVG
|
||||
# gets this — a CSP on a PDF response can break the browser's built-in
|
||||
# viewer, and a raster image cannot execute anything in the first place.
|
||||
headers["Content-Security-Policy"] = (
|
||||
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
)
|
||||
# FileResponse streams from disk: a 50 MB manual used to be read whole
|
||||
# into memory and copied into the response body, so a couple of parallel
|
||||
# downloads could push a small Home Assistant host into swap (HP-1454-06).
|
||||
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
@@ -42,62 +121,135 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
entry = get_entry(hass)
|
||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
if admin_only:
|
||||
user = request.get("hass_user")
|
||||
if user is None or not user.is_admin:
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
files_root = Path(hass.config.path(FILES_DIR))
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
blob: bytes | None = None
|
||||
too_large = False
|
||||
# Every temporary file this request creates, promoted or not. The outer
|
||||
# `finally` removes whatever is left: a dropped connection, a second
|
||||
# `file` part or a failure while promoting used to leave a `.upload-*`
|
||||
# behind for good, and the collector only ever walks marker folders, so
|
||||
# nothing would have picked it up (HP-1460-02).
|
||||
temps: list[Path] = []
|
||||
error: tuple[dict, int] | None = None
|
||||
|
||||
def _new_tmp() -> Path:
|
||||
files_root.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
|
||||
os.close(fd)
|
||||
return Path(name)
|
||||
|
||||
def _flush(target: Path, blocks: list[bytes]) -> None:
|
||||
with open(target, "ab") as fh:
|
||||
for block in blocks:
|
||||
fh.write(block)
|
||||
|
||||
def _cleanup(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
filename = part.filename or "file"
|
||||
# read in chunks, aborting at the limit, instead of loading the whole file into memory
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
too_large = True
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
if filename is not None:
|
||||
# one upload per request: a second part would strand
|
||||
# the first temporary file and make the response
|
||||
# ambiguous about which url was returned
|
||||
error = ({"error": "one_file_only"}, 400)
|
||||
break
|
||||
chunks.append(chunk)
|
||||
if too_large:
|
||||
break
|
||||
blob = b"".join(chunks)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
filename = part.filename or "file"
|
||||
if file_ext(filename) not in FILE_EXTENSIONS:
|
||||
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
|
||||
break
|
||||
# Stream to a temporary file instead of collecting the
|
||||
# whole upload in memory and copying it again into one
|
||||
# buffer: a 50 MB manual used to cost ~100 MB of RSS
|
||||
# mid-request (HP-1454-06). Blocks are batched so this
|
||||
# is one executor job per megabyte, not per 64 KB.
|
||||
tmp = await hass.async_add_executor_job(_new_tmp)
|
||||
temps.append(tmp)
|
||||
size = 0
|
||||
pending: list[bytes] = []
|
||||
buffered = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
error = (
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
|
||||
413,
|
||||
)
|
||||
break
|
||||
pending.append(chunk)
|
||||
buffered += len(chunk)
|
||||
if buffered >= _FLUSH_AT:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
pending, buffered = [], 0
|
||||
if error:
|
||||
break
|
||||
if pending:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
error = ({"error": "bad_request"}, 400)
|
||||
|
||||
if too_large:
|
||||
if error:
|
||||
return web.json_response(error[0], status=error[1])
|
||||
if not temps or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
tmp_path = temps[0]
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT,
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
|
||||
except OSError:
|
||||
pass
|
||||
target_dir = files_root / marker_id
|
||||
safe_name = filename
|
||||
|
||||
def _promote() -> str:
|
||||
"""Claim a free name, then move the finished upload onto it.
|
||||
|
||||
Never overwrite an existing attachment: its bytes may be
|
||||
referenced by the stored configuration, and this upload is not
|
||||
part of that transaction — a cancelled dialog or a rejected save
|
||||
would leave the old url serving the new content (HP-1454-02).
|
||||
The name is reserved atomically, so two uploads racing on the
|
||||
same filename cannot agree on it (HP-1460-01).
|
||||
"""
|
||||
name = reserve_filename(target_dir, safe_name)
|
||||
try:
|
||||
os.replace(tmp_path, target_dir / name)
|
||||
except OSError:
|
||||
(target_dir / name).unlink(missing_ok=True)
|
||||
raise
|
||||
return name
|
||||
|
||||
try:
|
||||
name = await hass.async_add_executor_job(_promote)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
temps.remove(tmp_path) # it is the attachment now, not a temporary
|
||||
return web.json_response(
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
|
||||
)
|
||||
if blob is None or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
ext = file_ext(filename)
|
||||
if ext not in FILE_EXTENSIONS:
|
||||
return web.json_response(
|
||||
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
|
||||
)
|
||||
|
||||
safe_name = sanitize_filename(filename)
|
||||
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
|
||||
path = target_dir / safe_name
|
||||
|
||||
def _write() -> int:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(blob)
|
||||
return int(path.stat().st_mtime)
|
||||
|
||||
mtime = await hass.async_add_executor_job(_write)
|
||||
return web.json_response(
|
||||
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
||||
)
|
||||
finally:
|
||||
# BaseException too: cancelling the request task raises
|
||||
# asyncio.CancelledError, which an `except Exception` never saw —
|
||||
# an aborted large upload leaked its temporary file every time
|
||||
if temps:
|
||||
await hass.async_add_executor_job(_cleanup, list(temps))
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.40.1"
|
||||
"version": "1.60.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
|
||||
|
||||
The file system is not part of the configuration store's transaction, so who
|
||||
may write or delete a plan or an attachment, and when, is a correctness
|
||||
question rather than housekeeping. It lives here, apart from the WebSocket and
|
||||
HTTP plumbing, precisely because it is the part that has to be reasoned about
|
||||
and tested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Streaming uploads land here first. The prefix is a dot so the name can never
|
||||
# collide with an attachment (sanitize_filename strips leading dots) and is easy
|
||||
# to sweep.
|
||||
TMP_PREFIX = ".upload-"
|
||||
|
||||
|
||||
def reserve_filename(directory: Path, name: str) -> str:
|
||||
"""Atomically claim a free name inside `directory` and return it.
|
||||
|
||||
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
|
||||
moment it is chosen. The previous version asked `exists()` and returned a
|
||||
string; two uploads racing between the check and the write agreed on the
|
||||
same name and one silently overwrote the other, both reporting success
|
||||
(HP-1460-01). The caller writes the real bytes over the placeholder — it
|
||||
owns the name by then — and must remove it if it never gets that far.
|
||||
|
||||
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
|
||||
the content view sanitises the name in the request too, so a name it would
|
||||
shorten or rewrite is a file that is written and then never served.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Split the extension off the RAW name: sanitize_filename() truncates to
|
||||
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
|
||||
# attachment would be stored — and served — without its type.
|
||||
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
stem, dot, suffix = base.rpartition(".")
|
||||
if not dot:
|
||||
stem, suffix = base, ""
|
||||
stem = sanitize_filename(stem)
|
||||
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
|
||||
i = 1
|
||||
while True:
|
||||
tag = "" if i == 1 else f"-{i}"
|
||||
# budget the stem so the WHOLE name fits, including the collision tag —
|
||||
# appending "-2" to an already maximal name produced a url the view
|
||||
# truncated back to something else, i.e. a permanent 404
|
||||
room = MAX_FILENAME - len(ext) - len(tag)
|
||||
candidate = (stem[:room] if room > 0 else "f") + tag + ext
|
||||
candidate = sanitize_filename(candidate)
|
||||
if candidate.startswith("."): # a name that is only an extension
|
||||
candidate = "file" + candidate
|
||||
try:
|
||||
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
i += 1
|
||||
if i > 10000: # pathological directory; do not spin forever
|
||||
raise
|
||||
continue
|
||||
os.close(fd)
|
||||
return candidate
|
||||
|
||||
|
||||
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
""""<marker>/<file>" for every attachment a configuration references."""
|
||||
out: set[str] = set()
|
||||
for m in (cfg or {}).get("markers") or []:
|
||||
for pdf in m.get("pdfs") or []:
|
||||
url = pdf.get("url") if isinstance(pdf, dict) else None
|
||||
if not isinstance(url, str) or "/files/" not in url:
|
||||
continue
|
||||
rel = url.split("?", 1)[0].split("/files/", 1)[1]
|
||||
if rel.count("/") == 1:
|
||||
out.add(rel)
|
||||
return out
|
||||
|
||||
|
||||
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
|
||||
"""Remove abandoned streaming temporaries (HP-1460-02).
|
||||
|
||||
The request itself deletes its own, but a hard kill — a restart mid-upload,
|
||||
an OOM — leaves one behind, and the attachment collector only walks marker
|
||||
folders, so it would never be seen. Age-gated for the same reason as the
|
||||
rest: a fresh one belongs to a request still in flight.
|
||||
"""
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.name.startswith(TMP_PREFIX):
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def collect_attachments(
|
||||
files_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||
behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
# Removing an attachment from a device that still exists is the user saying
|
||||
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||
# device that is GONE is a different transition, and its files follow the
|
||||
# same rule as a deleted space's plan: kept.
|
||||
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||
# Same distinction as for plans. A staging folder (`up_*`) is different: it
|
||||
# only ever holds an upload from a dialog that was never saved, so the short
|
||||
# rule is exactly right there even on the timer.
|
||||
now_s = time.time() if now is None else now
|
||||
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
continue
|
||||
for item in items:
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging:
|
||||
# Same rule as for plans: not asked for, so kept. A file in
|
||||
# a device's folder that the device does not list is an
|
||||
# upload whose save was rejected — and ageing those out
|
||||
# raced the retry that was about to reference them.
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
|
||||
try:
|
||||
next(folder.iterdir())
|
||||
except StopIteration:
|
||||
try:
|
||||
folder.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
class QuotaError(Exception):
|
||||
"""A store limit would be exceeded. Carries what to tell the user."""
|
||||
|
||||
def __init__(self, reason: str, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def dir_usage(path: Path) -> tuple[int, int]:
|
||||
"""(bytes, files) below `path`, ignoring what we cannot read."""
|
||||
total = count = 0
|
||||
if not path.is_dir():
|
||||
return 0, 0
|
||||
for item in path.rglob("*"):
|
||||
try:
|
||||
if item.is_file():
|
||||
total += item.stat().st_size
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
return total, count
|
||||
|
||||
|
||||
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit.
|
||||
|
||||
Deliberately not an age rule. Files are never removed for getting old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: at the moment somebody asks to store something new.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
used, count = dir_usage(path)
|
||||
if count + 1 > max_files:
|
||||
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
|
||||
if used + incoming > max_bytes:
|
||||
raise QuotaError(
|
||||
"quota_exceeded",
|
||||
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
|
||||
f"{max_bytes // 1024 // 1024} MB",
|
||||
)
|
||||
try:
|
||||
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
|
||||
except OSError:
|
||||
return
|
||||
if free - incoming < MIN_FREE_BYTES:
|
||||
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
if not isinstance(url, str) or not url:
|
||||
return ""
|
||||
return url.split("?", 1)[0].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
"""Plan file names a configuration references."""
|
||||
out: set[str] = set()
|
||||
for sp in (cfg or {}).get("spaces") or []:
|
||||
name = plan_basename(sp.get("plan_url"))
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""space id -> the plan file it references ('' when it has none)."""
|
||||
return {
|
||||
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||
for sp in (cfg or {}).get("spaces") or []
|
||||
}
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
|
||||
|
||||
|
||||
def collect_plans(
|
||||
plans_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Drop plan files the accepted configuration made obsolete (review R3-1).
|
||||
|
||||
Called inside the config write lock, right after the new revision is
|
||||
stored, so it decides from the two configurations that actually bracket the
|
||||
commit instead of trusting a client to say what may be deleted. The earlier
|
||||
design — a `plan/cleanup` command carrying `keep` — could not be ordered
|
||||
against another client's commit: a delayed call removed the file that
|
||||
client had just saved, leaving the accepted configuration pointing at
|
||||
nothing, which is the damage copy-on-write was introduced to prevent.
|
||||
|
||||
Two rules, both conservative:
|
||||
* a file the OLD configuration referenced and the new one does not was
|
||||
authoritative and has been superseded — remove it;
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
a file-system problem must not turn a durable commit into a failed call.
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
# A commit knows what it superseded. The timer only knows what nothing
|
||||
# points at *right now*, and for a plan that is a reversible state: the
|
||||
# editor detaches the image when a space switches to "draw" and says the
|
||||
# file stays on disk. So the scheduled pass keeps anything belonging to a
|
||||
# space that still exists, and waits a month for the rest.
|
||||
# A space with NO plan_url has had its image detached — reversible, and the
|
||||
# editor promises the file stays. A space that HAS one is different: any
|
||||
# other file of its own is a superseded or rejected upload, so the short
|
||||
# rule is right for those. Getting this distinction wrong (protecting
|
||||
# nothing) destroyed two detached plans on 2026-07-28.
|
||||
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||
# other file of its own can only be a superseded or rejected upload.
|
||||
old_by_space = plan_by_space(old_cfg)
|
||||
new_by_space = plan_by_space(new_cfg)
|
||||
# A file that left the configuration tells us nothing on its own: replacing a
|
||||
# plan, detaching one and deleting a space all look identical from
|
||||
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||
# (HP-1465-01 — the guards below were written and then never reached,
|
||||
# because the code decided "superseded" before asking why).
|
||||
replaced = {
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
# The directory can vanish or turn unreadable between the check and the
|
||||
# walk. This is housekeeping running behind a commit that is already
|
||||
# durable, so it reports "nothing collected" instead of failing (R4-1).
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
if item.name not in replaced:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
||||
return removed
|
||||
@@ -20,7 +20,7 @@ rules:
|
||||
status: done
|
||||
config-flow-test-coverage:
|
||||
status: done
|
||||
comment: tests_backend/test_config_flow.py (runs in CI on Python 3.13).
|
||||
comment: tests_backend/test_ha_config_flow.py (runs in CI on Python 3.13).
|
||||
dependency-transparency:
|
||||
status: done
|
||||
comment: No external requirements.
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, PLANS_DIR, PLANS_URL
|
||||
from .const import CONTENT_URL, DOMAIN, PLANS_DIR, PLANS_URL
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
|
||||
@@ -25,9 +25,15 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
||||
res = []
|
||||
for sp in spaces:
|
||||
url = sp.get("plan_url") or ""
|
||||
if not url.startswith(PLANS_URL + "/"):
|
||||
# both the legacy static URL and the authenticated content URL
|
||||
prefix = None
|
||||
if url.startswith(PLANS_URL + "/"):
|
||||
prefix = PLANS_URL + "/"
|
||||
elif url.startswith(CONTENT_URL + "/plans/_/"):
|
||||
prefix = CONTENT_URL + "/plans/_/"
|
||||
if prefix is None:
|
||||
continue # external/legacy URL — not ours to verify
|
||||
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
|
||||
fname = url[len(prefix) :].split("?", 1)[0]
|
||||
if not (plans_dir / fname).is_file():
|
||||
res.append((sp.get("id", "?"), fname))
|
||||
return res
|
||||
@@ -45,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# clear stale issues for spaces that are fine again (or gone)
|
||||
for sp in spaces:
|
||||
sid = sp.get("id", "?")
|
||||
if sid not in broken:
|
||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
|
||||
# issues for spaces that still exist, so deleting or renaming a space with a
|
||||
# missing plan left its warning in Repairs forever, with nothing left to fix
|
||||
# it (HP-1454-09). Enumerate what we actually published instead.
|
||||
registry = ir.async_get(hass)
|
||||
stale = [
|
||||
issue_id
|
||||
for (domain, issue_id) in list(registry.issues)
|
||||
if domain == DOMAIN
|
||||
and issue_id.startswith("broken_plan_")
|
||||
and issue_id[len("broken_plan_") :] not in broken
|
||||
]
|
||||
for issue_id in stale:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -42,6 +43,17 @@ class HouseplanData:
|
||||
# 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)
|
||||
# A separate, narrower lock for the check-quota→write-file pair of an
|
||||
# upload. Without it N parallel uploads all measure the store BEFORE any
|
||||
# of them writes, and all pass a quota only one of them fits under
|
||||
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
|
||||
# stall config/layout commits.
|
||||
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Collect files nothing references any more. Set during setup, which also
|
||||
# runs it once and schedules it daily. Exposed so it can be invoked
|
||||
# directly — a test that fakes a 24 h jump proves the timer fires, not that
|
||||
# the work happens, and those are different claims.
|
||||
sweep: Callable[[], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
@@ -27,6 +27,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"config_rev": cfg_raw.get("rev", 0),
|
||||
"spaces": len(config.get("spaces", [])),
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
|
||||
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
|
||||
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
|
||||
Executable
+274
@@ -0,0 +1,274 @@
|
||||
"""Server-side vacuum trails.
|
||||
|
||||
The integration records the robot's path ITSELF by watching the source
|
||||
entity's state changes — no card involvement. This removes every client-side
|
||||
race (N open tabs would fight over writes), survives page reloads by
|
||||
construction, and keeps recording while no card is open at all. Stored: the
|
||||
current run and one previous run per marker (owner call 2026-07-31 — users
|
||||
want to see where the cleanup has already been).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
|
||||
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
|
||||
"""Map-id normalisation contract, shared with the frontend.
|
||||
|
||||
Mirrors src/vacuum.ts vacMapIdFromAttrs (source attrs, `??`-chain) plus the
|
||||
card's _vacMapId fallback to the vacuum entity's selected_map. The FIRST
|
||||
value that is not None wins — truthiness is wrong here: a zero-based
|
||||
`map_index: 0` is a valid first map and an empty string is still an id.
|
||||
The old `or`-chain dropped the zero, so the server stored trails under a
|
||||
key the renderer never looked up (HP-1540-02).
|
||||
"""
|
||||
for v in (
|
||||
src_attrs.get("map_name"),
|
||||
src_attrs.get("current_map"),
|
||||
src_attrs.get("map_index"),
|
||||
src_attrs.get("selected_map"),
|
||||
vac_attrs.get("selected_map"),
|
||||
):
|
||||
if v is not None:
|
||||
return str(v)
|
||||
return "default"
|
||||
|
||||
|
||||
class TrailBook:
|
||||
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
|
||||
|
||||
A run is {"map_id", "started", "ended", "points": [[x, y], …]} in RAW
|
||||
robot coordinates — recalibration never invalidates a stored trail.
|
||||
"""
|
||||
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
|
||||
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
rec["previous"] = cur
|
||||
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
return False
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
half = pts[0::2]
|
||||
if half[-1] != pts[-1]:
|
||||
half.append(pts[-1])
|
||||
cur["points"] = half
|
||||
return True
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and not cur.get("ended"):
|
||||
cur["ended"] = now
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete(self, marker: str) -> bool:
|
||||
"""Forget every stored run of one plan marker."""
|
||||
return self.data.pop(marker, None) is not None
|
||||
|
||||
|
||||
class TrailRecorder:
|
||||
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
|
||||
self.hass = hass
|
||||
self.rt = rt
|
||||
self.store = Store(hass, 1, f"{DOMAIN}.trails")
|
||||
self.book = TrailBook()
|
||||
# HP-1540-03: one source may feed SEVERAL markers — the same robot
|
||||
# placed on two floors is the documented multi-floor case, and a plain
|
||||
# source → (marker, vacuum) dict silently kept only the last one
|
||||
self.pairs: dict[str, list[tuple[str, str]]] = {} # source → [(marker, vacuum), …]
|
||||
self._unsub_track = None
|
||||
self._unsub_save = None
|
||||
self._last_fire = 0.0
|
||||
# HP-1540-05: config/set fires refresh as a detached task; two of them
|
||||
# interleaving across the awaited load both subscribed and the loser's
|
||||
# unsub handle was overwritten — a leak until HA restart
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self._closed = False
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
self.book = TrailBook(await self.store.async_load() or {})
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""(Re)subscribe after any config change — markers may come and go.
|
||||
|
||||
Serialised (HP-1540-05): the lock makes unsubscribe-then-resubscribe
|
||||
atomic across the awaited config load, so overlapping refresh tasks can
|
||||
no longer both subscribe and strand one callback forever. The _closed
|
||||
check covers teardown() racing a refresh that is parked on its await.
|
||||
"""
|
||||
async with self._refresh_lock:
|
||||
stored = await self.rt.config_store.async_load() or {}
|
||||
if self._closed:
|
||||
return
|
||||
cfg = stored.get("config") or {}
|
||||
pairs: dict[str, list[tuple[str, str]]] = {}
|
||||
for m in cfg.get("markers") or []:
|
||||
if m.get("removed") is True:
|
||||
continue
|
||||
v = m.get("vacuum") or {}
|
||||
src = v.get("source")
|
||||
if not src or v.get("live") is False:
|
||||
continue
|
||||
vac = self._vacuum_entity(m)
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(src, []).append((str(m.get("id")), vac))
|
||||
self.pairs = pairs
|
||||
self._resubscribe()
|
||||
# A run already in progress (HA restarted mid-cleanup, or the user
|
||||
# just finished calibrating) must start recording NOW, not at the
|
||||
# next state change — otherwise the first seconds of the path are
|
||||
# lost.
|
||||
for src in self.pairs:
|
||||
self._sample(src, time.time())
|
||||
|
||||
async def async_delete(self, marker: str) -> bool:
|
||||
"""Stop and erase one marker without racing subscription refresh/save."""
|
||||
async with self._refresh_lock:
|
||||
# The trail book owns deletion. When it has no such marker, this
|
||||
# is a no-op and must not silently damage the live tracking graph.
|
||||
removed = self.book.delete(marker)
|
||||
if not removed:
|
||||
return False
|
||||
for src in list(self.pairs):
|
||||
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
|
||||
if kept:
|
||||
self.pairs[src] = kept
|
||||
else:
|
||||
del self.pairs[src]
|
||||
self._resubscribe()
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
return True
|
||||
|
||||
def _resubscribe(self) -> None:
|
||||
"""Replace the state subscription for the current pair graph."""
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
# deduplicated: two markers of one robot share source AND vacuum
|
||||
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
|
||||
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
|
||||
if ents and not self._closed:
|
||||
self._unsub_track = async_track_state_change_event(
|
||||
self.hass, sorted(ents), self._on_state
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
|
||||
# not re-subscribe after this cleanup has already run
|
||||
self._closed = True
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
|
||||
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
|
||||
b = str(m.get("binding") or "")
|
||||
if b.startswith("entity:vacuum."):
|
||||
return b[len("entity:"):]
|
||||
if b.startswith("device:"):
|
||||
reg = er.async_get(self.hass)
|
||||
for e in er.async_entries_for_device(reg, b[len("device:"):]):
|
||||
if e.entity_id.startswith("vacuum."):
|
||||
return e.entity_id
|
||||
return None
|
||||
|
||||
def _sample(self, src: str, now: float) -> bool:
|
||||
"""Record one point (or end the run) for EVERY marker fed by src.
|
||||
|
||||
HP-1540-03: the same source serves one marker per floor — all of them
|
||||
must receive the point, not just whichever survived the dict.
|
||||
"""
|
||||
changed = False
|
||||
for marker, vac in self.pairs.get(src) or ():
|
||||
st_vac = self.hass.states.get(vac)
|
||||
# "no state yet" is NOT "stopped": during HA boot the vacuum reads
|
||||
# unavailable and ending the run here would split one cleanup into
|
||||
# current+previous on every restart (observed live: 21 points
|
||||
# became previous, the same run restarted at 5)
|
||||
if not st_vac or st_vac.state in ("unavailable", "unknown"):
|
||||
continue
|
||||
if st_vac.state not in MOVING_STATES:
|
||||
changed |= self.book.end_run(marker, now)
|
||||
continue
|
||||
st_src = self.hass.states.get(src)
|
||||
attrs = st_src.attributes if st_src else {}
|
||||
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
|
||||
# Server-side these attributes are often OBJECTS (Tasshack keeps a
|
||||
# Point dataclass in memory — it only becomes a dict when
|
||||
# serialised to the frontend). Caught live on the owner's X50: the
|
||||
# recorder saw every state change and rejected every single one.
|
||||
if isinstance(raw, dict):
|
||||
px, py = raw.get("x"), raw.get("y")
|
||||
else:
|
||||
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
|
||||
try:
|
||||
x, y = float(px), float(py) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
map_id = resolve_map_id(attrs, st_vac.attributes)
|
||||
changed |= self.book.on_point(marker, map_id, x, y, now)
|
||||
return changed
|
||||
|
||||
@callback
|
||||
def _on_state(self, event: Any) -> None:
|
||||
eid = event.data.get("entity_id")
|
||||
now = time.time()
|
||||
changed = False
|
||||
for src, pair_list in self.pairs.items():
|
||||
if eid == src or any(eid == vac for _, vac in pair_list):
|
||||
changed |= self._sample(src, now)
|
||||
if changed:
|
||||
self._schedule_save()
|
||||
if now - self._last_fire >= FIRE_THROTTLE_S:
|
||||
self._last_fire = now
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
if self._unsub_save:
|
||||
return
|
||||
|
||||
async def _save(_now: Any) -> None:
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
|
||||
self._unsub_save = async_call_later(self.hass, SAVE_DELAY_S, _save)
|
||||
@@ -16,6 +16,9 @@ MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# The name length the content view will accept back in a request. Anything a
|
||||
# generated name must fit inside, collision tag included (HP-1460-01).
|
||||
MAX_FILENAME = 120
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
@@ -33,7 +36,7 @@ def sanitize_marker_id(value: str) -> str:
|
||||
def sanitize_filename(value: str) -> str:
|
||||
"""Drop the path and leading dots, keep a safe file name."""
|
||||
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
|
||||
|
||||
|
||||
def file_ext(filename: str) -> str:
|
||||
@@ -47,13 +50,147 @@ def valid_space_id(value: str) -> bool:
|
||||
|
||||
|
||||
# ---------- voluptuous schemas ----------
|
||||
def _finite(value):
|
||||
"""Coerce to float and reject NaN/Infinity (audit B5).
|
||||
|
||||
'NaN' and 'Infinity' pass Coerce(float) and serialize to null on write,
|
||||
silently corrupting a stored position forever.
|
||||
"""
|
||||
f = float(value)
|
||||
if f != f or f in (float("inf"), float("-inf")):
|
||||
raise vol.Invalid("coordinate must be a finite number")
|
||||
return f
|
||||
|
||||
|
||||
# generous caps: the product targets 20-200 devices and a handful of floors
|
||||
MAX_SPACES = 50
|
||||
MAX_ROOMS = 400
|
||||
MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_WALLS = 500
|
||||
MAX_ROOM_DRAFTS = 200
|
||||
MAX_DRAFT_SEGMENTS = 2000
|
||||
MAX_PARTITIONS = 2000
|
||||
MAX_WALL_COLUMNS = 500
|
||||
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
|
||||
# Every span is a piece of a shared boundary, so there can never be more of
|
||||
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
|
||||
MAX_OPEN_SPANS = 500
|
||||
MAX_LAYOUT = 5000
|
||||
# Inner limits (HP-1454-05). The outer collections were capped, the collections
|
||||
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
|
||||
# list passed validation, then made the card build gigantic SVG attributes and
|
||||
# walk them on every render. Any authenticated writer could store one, and with
|
||||
# `admin_only` off that is every user. These are product limits, not guesses: a
|
||||
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
|
||||
# one switch.
|
||||
MAX_POLY_POINTS = 500
|
||||
MAX_OPEN_TO = 50
|
||||
MAX_CONTROLS = 200
|
||||
MAX_PDFS = 50
|
||||
MAX_KNOWN_DEVICES = 20000
|
||||
MAX_TEXT = 500 # names, models, ids
|
||||
MAX_DESCRIPTION = 4000
|
||||
MAX_URL = 2000
|
||||
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
|
||||
# payload larger than the frame never reaches the handler at all — the socket
|
||||
# closes with 1009 and the user sees a dropped connection instead of an error
|
||||
# they can act on. For scale, a real three-floor home with ~200 devices stores
|
||||
# about 70 KB, so this is ~30x headroom.
|
||||
MAX_CONFIG_BYTES = 2 * 1024 * 1024
|
||||
CELL_CM_MIN = 0.1
|
||||
CELL_CM_MAX = 1000.0
|
||||
|
||||
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
# The canvas is UNBOUNDED (docs/CANVAS.md). Coordinates are still normalised —
|
||||
# 1.0 is still one canvas width — but there is no frame any more, so a plan may
|
||||
# legitimately live at 2.7 or -1.4. The range below is GARBAGE INSURANCE, not a
|
||||
# boundary: at the product's own scale (cell_cm=5, 240 cells across the unit
|
||||
# width) 5000 is about 60 km of plan, unreachable in a home, while a stored
|
||||
# 1e100 still cannot stretch every client's view until the plan is invisible
|
||||
# (HP-1500-03 / HP-1501-01). Widened from +/-4 on 2026-08-03.
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
# corruption (HP-1501-01, the room-geometry twin of HP-1500-03).
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
|
||||
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
|
||||
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
|
||||
# client. The floor is one thousandth of the canvas (1 render unit): far below
|
||||
# any real room, but keeps the maths finite. The CEILING follows the canvas
|
||||
# (docs/CANVAS.md) — a room on an unbounded plane may legitimately be wider
|
||||
# than the old unit square — while staying strictly positive.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
|
||||
|
||||
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
|
||||
# coordinate: strictly positive, and bounded by what a person could mean —
|
||||
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
|
||||
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
|
||||
PLAN_SCALE_MIN = 0.01
|
||||
PLAN_SCALE_MAX = 100.0
|
||||
|
||||
|
||||
def _view_box(value):
|
||||
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 4:
|
||||
raise vol.Invalid("view_box must be [x, y, w, h]")
|
||||
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
|
||||
|
||||
|
||||
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
|
||||
|
||||
# A virtual stretch shorter than this is a click, not a span. Mirrors
|
||||
# OPEN_SPAN_MIN_UNITS in src/open-spans.ts (normalised units).
|
||||
OPEN_SPAN_MIN_LEN = 1e-3
|
||||
|
||||
|
||||
def _open_span(value):
|
||||
"""One virtual stretch: exactly two distinct finite points a/b.
|
||||
|
||||
AUD-159B6-03: the field used to ride on `extra=ALLOW_EXTRA` — any shape
|
||||
passed the backend and the card crashed reading `e.a[0]` on render, for
|
||||
every reader of that space. A degenerate span (a == b) is not a stretch
|
||||
either: it can never be hit, closed or drawn, so it is corruption, not data.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid("open_span must be an object with a/b points")
|
||||
a = POINT(value.get("a"))
|
||||
b = POINT(value.get("b"))
|
||||
if abs(a[0] - b[0]) < OPEN_SPAN_MIN_LEN and abs(a[1] - b[1]) < OPEN_SPAN_MIN_LEN:
|
||||
raise vol.Invalid("open_span: a and b must differ")
|
||||
out = {k: v for k, v in value.items() if k not in ("a", "b")}
|
||||
out["a"] = a
|
||||
out["b"] = b
|
||||
return out
|
||||
|
||||
|
||||
def _dedupe_open_spans(value):
|
||||
"""Drop repeats of the same stretch (either direction) — one wall, one span."""
|
||||
seen = set()
|
||||
out = []
|
||||
for span in value:
|
||||
a, b = span["a"], span["b"]
|
||||
fwd = (round(a[0], 6), round(a[1], 6), round(b[0], 6), round(b[1], 6))
|
||||
key = min(fwd, (fwd[2], fwd[3], fwd[0], fwd[1]))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(span)
|
||||
return out
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
@@ -65,80 +202,344 @@ def _require_geometry(room: dict) -> dict:
|
||||
ROOM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("open_to"): [str],
|
||||
vol.Optional("x"): vol.Coerce(float),
|
||||
vol.Optional("y"): vol.Coerce(float),
|
||||
vol.Optional("w"): vol.Coerce(float),
|
||||
vol.Optional("h"): vol.Coerce(float),
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
vol.Required("id"): _TEXT,
|
||||
vol.Required("name"): _TEXT,
|
||||
vol.Optional("area"): _TEXT_OR_NONE,
|
||||
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
|
||||
vol.Optional("settings"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
|
||||
vol.Optional("temp_source"): vol.Any(str, None),
|
||||
vol.Optional("hum_source"): vol.Any(str, None),
|
||||
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): _GEOM,
|
||||
vol.Optional("y"): _GEOM,
|
||||
vol.Optional("w"): _EXTENT,
|
||||
vol.Optional("h"): _EXTENT,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_require_geometry,
|
||||
)
|
||||
|
||||
def _north_deg(value):
|
||||
"""Compass (docs/SUN.md): strict integer degrees, 0..359.
|
||||
|
||||
Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
|
||||
int in Python — none of those is a compass reading a client stored.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
if not 0 <= value <= 359:
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
return value
|
||||
|
||||
|
||||
_BG_MODE = vol.In(["static", "daynight"])
|
||||
|
||||
SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("show_borders"): bool,
|
||||
vol.Optional("show_names"): bool,
|
||||
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
# per-space background around the plan; absent = inherit the global one
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
# "draw less" switches; absent = False = everything is drawn as before
|
||||
vol.Optional("hide_decor"): bool,
|
||||
vol.Optional("hide_openings"): bool,
|
||||
vol.Optional("label_temp"): bool,
|
||||
vol.Optional("label_hum"): bool,
|
||||
vol.Optional("label_lqi"): bool,
|
||||
vol.Optional("label_light"): bool,
|
||||
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
|
||||
# sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
|
||||
vol.Optional("north_deg"): vol.Any(None, _north_deg),
|
||||
vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
|
||||
vol.Optional("sun_rays"): vol.Any(None, bool),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
|
||||
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
|
||||
# underscores in both halves. The bound is a sanity limit, not a policy.
|
||||
MAX_ENTITY_ID = 255
|
||||
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
|
||||
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
|
||||
# A caption is a caption: the inline-reference template is bounded. Attribute
|
||||
# and unit bounds below apply only to legacy beta.9 link fields, which remain
|
||||
# accepted so an older saved plan can reach the frontend and migrate on edit.
|
||||
MAX_DECOR_TEXT = 200
|
||||
MAX_DECOR_ATTR = 64
|
||||
MAX_DECOR_UNIT = 16
|
||||
# The text block is scaled by dragging its corners; the range is what a human
|
||||
# could mean on a 1000-unit canvas, the rest is garbage insurance.
|
||||
DECOR_TEXT_SCALE_MIN = 0.15
|
||||
DECOR_TEXT_SCALE_MAX = 20.0
|
||||
DECOR_TEXT_CM_MAX = 2000.0
|
||||
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
|
||||
# the backend must accept a plan written by a NEWER card, and a card that has
|
||||
# learnt a new symbol must not have to wait for the integration to be updated
|
||||
# before the user can save. What is enforced is the shape of the id — a flat
|
||||
# lowercase name — and its length; an id this backend has never heard of simply
|
||||
# renders as nothing in an older card.
|
||||
MAX_FURN_SYMBOL = 32
|
||||
_FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
|
||||
vol.Match(r"^[a-z0-9_]+$"))
|
||||
# …and its size: strictly positive, capped by the same canvas insurance limit
|
||||
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
|
||||
_FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
|
||||
# Physical centimetres are canonical. `width` remains accepted so plans
|
||||
# written by older cards keep their exact appearance until edited.
|
||||
vol.Optional("width_cm"): vol.All(_finite, vol.Range(min=0.1, max=100)),
|
||||
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
|
||||
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
|
||||
# it used to be pinned to -1..2, i.e. "one canvas of slack around the square".
|
||||
_NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM,
|
||||
vol.Optional("line_style"): vol.In(["solid", "dashed"])},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
|
||||
vol.Optional("fill"): bool},
|
||||
# sizes are extents — negative/zero is garbage, not "canvas slack"
|
||||
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Optional("fill"): bool,
|
||||
vol.Optional("fill_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"])},
|
||||
# the template: newlines are the user's own line breaks and are
|
||||
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
|
||||
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
|
||||
# — the block is scaled by its corner handles — but a plan
|
||||
# written before that keeps it, and it is read as the scale it
|
||||
# used to render at. Kept in the schema so it stays BOUNDED.
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"]),
|
||||
# Canonical physical font size plus the legacy scale. Both are
|
||||
# accepted so older plans remain pixel-identical until edited
|
||||
# or explicitly optimized.
|
||||
vol.Optional("size_cm"): vol.All(
|
||||
_finite, vol.Range(min=0.1, max=DECOR_TEXT_CM_MAX)),
|
||||
vol.Optional("scale"): vol.All(
|
||||
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
# Legacy one-value link (beta.9 and earlier). New labels store
|
||||
# every `{entity[:attribute]}` reference directly in `text`;
|
||||
# these stay accepted solely for backward compatibility.
|
||||
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
|
||||
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
|
||||
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
# A piece of furniture (docs/FURNITURE.md): a symbol id, a normalised box
|
||||
# and an optional rotation. It is a NEW kind, so no existing plan carries
|
||||
# it, nothing is migrated, and an integration that has this branch reads
|
||||
# every older config byte-for-byte as before.
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "furniture",
|
||||
vol.Required("symbol"): _FURN_SYMBOL,
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
|
||||
def _wall_endpoints_pair(entry: dict) -> dict:
|
||||
"""Exact wall endpoints are useful only as a complete a/b pair."""
|
||||
if ("a" in entry) != ("b" in entry):
|
||||
raise vol.Invalid("wall exact endpoints require both a and b")
|
||||
return entry
|
||||
|
||||
|
||||
WALL_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("key"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
# New writes retain exact normalized interval endpoints. The old
|
||||
# key remains the compatibility lookup; endpoints preserve a
|
||||
# differing-thickness breakpoint after a virtual span is closed.
|
||||
vol.Optional("a"): vol.All([_NORM], vol.Length(min=2, max=2)),
|
||||
vol.Optional("b"): vol.All([_NORM], vol.Length(min=2, max=2)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_wall_endpoints_pair,
|
||||
)
|
||||
|
||||
|
||||
def _room_draft_segments(value: dict) -> dict:
|
||||
"""An open draft has exactly one thickness per consecutive edge."""
|
||||
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
|
||||
raise vol.Invalid("room draft segments must match consecutive point pairs")
|
||||
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
|
||||
raise vol.Invalid("room draft consecutive points must differ")
|
||||
return value
|
||||
|
||||
|
||||
ROOM_DRAFT_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
|
||||
vol.Required("segments"): vol.All(
|
||||
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
|
||||
extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(min=1, max=499),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_room_draft_segments,
|
||||
)
|
||||
|
||||
def _partition_nonzero(value: dict) -> dict:
|
||||
if value["a"] == value["b"]:
|
||||
raise vol.Invalid("partition endpoints must differ")
|
||||
return value
|
||||
|
||||
|
||||
PARTITION_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("a"): POINT,
|
||||
vol.Required("b"): POINT,
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_partition_nonzero,
|
||||
)
|
||||
|
||||
def _strict_wall_column(value: dict) -> dict:
|
||||
"""Reject shape-inapplicable or non-canonical column fields."""
|
||||
if value["shape"] == "circle" and "angle" in value:
|
||||
raise vol.Invalid("angle is allowed only for square wall columns")
|
||||
if value["shape"] == "square" and value.get("angle", 0) >= 90:
|
||||
raise vol.Invalid("square wall column angle must be in [0, 90)")
|
||||
return value
|
||||
|
||||
|
||||
WALL_COLUMN_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("shape"): vol.In(["square", "circle"]),
|
||||
vol.Required("center"): POINT,
|
||||
# Outer side for a square, outer diameter for a circle.
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=150)),
|
||||
vol.Optional("angle"): vol.All(
|
||||
_finite, vol.Range(min=0, max=90)
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_strict_wall_column,
|
||||
)
|
||||
|
||||
|
||||
def _space_geometry_invariants(value: dict) -> dict:
|
||||
"""All stored geometry shares ids; draft segments also have a space cap."""
|
||||
seen: set[str] = set()
|
||||
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
|
||||
for item in value.get(key, []):
|
||||
item_id = item.get("id")
|
||||
if not item_id:
|
||||
continue
|
||||
if item_id in seen:
|
||||
raise vol.Invalid("geometry object ids must be unique within a space")
|
||||
seen.add(item_id)
|
||||
draft_segments = sum(
|
||||
len(item.get("segments", [])) for item in value.get("room_drafts", [])
|
||||
)
|
||||
if draft_segments > MAX_DRAFT_SEGMENTS:
|
||||
raise vol.Invalid("too many saved room-draft segments")
|
||||
return value
|
||||
|
||||
|
||||
SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
|
||||
vol.Required("title"): str,
|
||||
# Physical grid scale. It feeds every px/cell -> centimetres migration,
|
||||
# so NaN/Infinity or an absurd value must not be allowed to manufacture
|
||||
# invalid decor sizes later.
|
||||
vol.Optional("cell_cm"): vol.All(
|
||||
_finite, vol.Range(min=CELL_CM_MIN, max=CELL_CM_MAX)
|
||||
),
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("decor"): [DECOR_SCHEMA],
|
||||
vol.Optional("openings"): [
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
# `aspect` is gone; the background image keeps its own proportions and
|
||||
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
|
||||
# send the old field — it is dropped rather than trusted, because the
|
||||
# coordinates it comes with were normalised against a different box.
|
||||
vol.Remove("aspect"): object,
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
|
||||
# resized per axis and rotated. Every transform field is optional; its
|
||||
# complete absence is the pre-v1.58.0 behaviour exactly, and an old
|
||||
# config validates unchanged. The offset is a normalised
|
||||
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
|
||||
# the scale is a positive multiplier in a range a human could mean.
|
||||
vol.Optional("plan_x"): vol.Any(None, _COORD),
|
||||
vol.Optional("plan_y"): vol.Any(None, _COORD),
|
||||
vol.Optional("plan_scale"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
# New writes may stretch each axis independently and rotate. The old
|
||||
# uniform field remains a read-compatible fallback for both axes.
|
||||
vol.Optional("plan_scale_x"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
vol.Optional("plan_scale_y"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
vol.Optional("plan_angle"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=-360.0, max=360.0))
|
||||
),
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): vol.All([
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): vol.Coerce(float),
|
||||
vol.Required("y"): vol.Coerce(float),
|
||||
vol.Required("angle"): vol.Coerce(float),
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.Required("type"): vol.Any("door", "window", "gate"),
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
# a SIZE: strictly positive, capped by the canvas insurance
|
||||
# limit rather than by the old unit square (docs/CANVAS.md)
|
||||
vol.Required("length"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
vol.Optional("invert"): bool,
|
||||
@@ -147,51 +548,123 @@ SPACE_SCHEMA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
],
|
||||
], vol.Length(max=MAX_OPENINGS)),
|
||||
# Wall thickness (docs/WALL-THICKNESS.md): keyed by a segment identity
|
||||
# (midpoint + direction), thickness always in centimetres. Optional —
|
||||
# a space without `walls` validates and renders exactly as before.
|
||||
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
|
||||
vol.Optional("room_drafts"): vol.All(
|
||||
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
|
||||
),
|
||||
vol.Optional("partitions"): vol.All(
|
||||
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
|
||||
),
|
||||
vol.Optional("wall_columns"): vol.All(
|
||||
[WALL_COLUMN_SCHEMA], vol.Length(max=MAX_WALL_COLUMNS)
|
||||
),
|
||||
# Open (virtual) wall stretches: a piece of a shared boundary that the
|
||||
# user opened. Optional and bounded — a space without `open_spans`
|
||||
# validates exactly as before, and the legacy `rooms[].open_to` index
|
||||
# keeps working on its own (AUD-159B6-03).
|
||||
vol.Optional("open_spans"): vol.All(
|
||||
vol.Length(max=MAX_OPEN_SPANS), [_open_span], _dedupe_open_spans,
|
||||
),
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
|
||||
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
|
||||
# list is not a limit, it is a hope. `Remove` returns the key stripped.
|
||||
vol.Remove("segments"): object,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
), _space_geometry_invariants)
|
||||
MARKER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
|
||||
vol.Required("binding"): str,
|
||||
vol.Required("binding"): vol.All(
|
||||
str,
|
||||
vol.Length(min=1, max=MAX_TEXT),
|
||||
vol.Match(r"^(device:.+|entity:.+|virtual)$"),
|
||||
),
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("model"): vol.Any(str, None),
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("controls"): vol.Any([str], None),
|
||||
# A binding-level tombstone: not rendered or aggregated, but retained
|
||||
# so automatic discovery does not put a deleted device straight back.
|
||||
vol.Optional("removed"): bool,
|
||||
vol.Optional("name"): _TEXT_OR_NONE,
|
||||
vol.Optional("icon"): _TEXT_OR_NONE,
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
),
|
||||
vol.Optional("tap_confirm"): vol.Any(bool, None),
|
||||
# live robot vacuums (docs/VACUUM.md): everything optional so configs
|
||||
# from older versions stay valid untouched
|
||||
vol.Optional("vacuum"): vol.Any(
|
||||
None,
|
||||
vol.Schema({
|
||||
vol.Optional("live"): vol.Any(bool, None),
|
||||
vol.Optional("trail"): vol.Any(bool, None),
|
||||
vol.Optional("trail_mode"): vol.Any(
|
||||
None, vol.In(["never", "cleaning", "always"])
|
||||
),
|
||||
vol.Optional("room_highlight"): vol.Any(bool, None),
|
||||
vol.Optional("source"): vol.Any(str, None),
|
||||
# one 6-number affine per robot map; numbers must be finite
|
||||
vol.Optional("calibration"): vol.Schema(
|
||||
{str: vol.All([_finite], vol.Length(min=6, max=6))}
|
||||
),
|
||||
vol.Optional("segment_map"): vol.Schema({str: str}),
|
||||
}),
|
||||
),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
|
||||
# offered, but remains accepted while old stores migrate to icon_ripple.
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", "static_icon", None),
|
||||
vol.Optional("ripple_color"): vol.Any(
|
||||
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
|
||||
),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
vol.Optional("pdfs"): vol.All(
|
||||
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(max=MAX_PDFS),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): [SPACE_SCHEMA],
|
||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
vol.Optional("known_devices"): [str],
|
||||
vol.Optional("new_device_ids"): [str],
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+40
-1
@@ -6,10 +6,45 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
|
||||
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
||||
// ---- assertion harness (audit T1) --------------------------------------
|
||||
// Until 2026-07-27 the smokes printed booleans and always exited 0: a broken
|
||||
// build reported success. `check()` accumulates named failures, `finish()`
|
||||
// prints them and sets the exit code.
|
||||
const _failures = [];
|
||||
let _pageErrors = 0;
|
||||
|
||||
/** Assert one named fact. `expected` defaults to true. */
|
||||
export function check(name, actual, expected = true) {
|
||||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
if (!ok) _failures.push(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Assert a whole result object: every key must equal true unless listed. */
|
||||
export function checkAll(out, expected = {}) {
|
||||
for (const [k, v] of Object.entries(out)) check(k, v, k in expected ? expected[k] : true);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Print the result, report failures, close the browser, set the exit code. */
|
||||
export async function finish(browser, out) {
|
||||
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
|
||||
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
|
||||
await browser?.close?.();
|
||||
if (_failures.length) {
|
||||
console.error('\nFAILED (' + _failures.length + '):');
|
||||
for (const f of _failures) console.error(' - ' + f);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('OK');
|
||||
}
|
||||
}
|
||||
|
||||
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
|
||||
page.on('pageerror', (e) => console.log('EXC', e.message));
|
||||
// audit T1: an exception inside the card used to be logged and ignored
|
||||
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
|
||||
await page.route('**/*', (r) => {
|
||||
const u = new URL(r.request().url());
|
||||
let p = decodeURIComponent(u.pathname);
|
||||
@@ -21,6 +56,10 @@ export async function launch(viewport = { width: 820, height: 760 }, scale = 1)
|
||||
});
|
||||
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
|
||||
// HP-1552: the first-open boot veil hides the plan (visibility:hidden) until
|
||||
// the stage height settles — real pointer interaction cannot hit a hidden
|
||||
// plan, so every smoke starts where the user does: with the plan revealed.
|
||||
await page.waitForFunction(() => window.__card._booting === false, { timeout: 9000 });
|
||||
// hass flows continuously in production; the stub sets it once — nudge a rebuild
|
||||
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
|
||||
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Backdrop image (docs/BACKDROP.md) — two shots for the owner:
|
||||
// backdrop_frame — the transform frame mid-gesture: dashed outline, four
|
||||
// finger-sized corner handles, and the live "W × H" badge
|
||||
// stating the picture's real size through cell_cm;
|
||||
// backdrop_paper — the new paper rule on an image plan: the opaque sheet is
|
||||
// the ROOM CONTOURS, so the scene colour is visible around
|
||||
// them and under the parts of the picture nobody has drawn
|
||||
// over. The picture is scaled down here so both are obvious.
|
||||
// Usage: node demo/shot_backdrop.mjs <outdir>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const settle = () => page.waitForTimeout(260);
|
||||
|
||||
// ---- 1) the frame, mid-drag ---------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('decor');
|
||||
c._decorTool = 'backdrop';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
{
|
||||
// grab the bottom-right handle and hold it mid-gesture, so the shot carries
|
||||
// the frame AND the live badge at once
|
||||
const [ax, ay] = await screenPt(1000, 900);
|
||||
const [bx, by] = await screenPt(820, 760);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 4 });
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_frame.png` });
|
||||
await page.mouse.up();
|
||||
}
|
||||
await settle();
|
||||
|
||||
// ---- 2) the paper is the rooms ------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
// a picture pulled well inside the plan and an acid scene colour: what shows
|
||||
// through around the rooms is the SCENE, which is the whole new rule
|
||||
sp.plan_x = 0.16; sp.plan_y = 0.10; sp.plan_scale = 0.62;
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#7bd389' };
|
||||
c._cfgEpoch++; c._frame = null;
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._fitAll();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_paper.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir + '/backdrop_frame.png, ' + outDir + '/backdrop_paper.png');
|
||||
@@ -0,0 +1,33 @@
|
||||
// Capture: a curtain on the move — the semantic transition ring around a
|
||||
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
|
||||
// the shot is deterministic.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
|
||||
attributes: { friendly_name: 'Curtain', device_class: 'curtain' } } } };
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-transition').getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_move.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,70 @@
|
||||
// Capture: one curtain in the four states an owner sees — closed, open,
|
||||
// opening, closing (owner's contract 2026-08-04). The plate is the plain
|
||||
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
|
||||
// and the two travelling ones add the breathing transition ring (frozen at a
|
||||
// visible frame so the shot is deterministic).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
|
||||
const STATES = ['closed', 'open', 'opening', 'closing'];
|
||||
await page.evaluate(async (STATES) => {
|
||||
const c = window.__card;
|
||||
const devices = {}; const entities = {}; const states = {};
|
||||
STATES.forEach((s, i) => {
|
||||
const id = 'd_cur' + i;
|
||||
devices[id] = { id, name: 'Curtain ' + s, model: 'Roller shade driver E1',
|
||||
area_id: 'garden', identifiers: [['demo', id]], entry_type: null, via_device_id: null };
|
||||
entities['cover.cur' + i] = { entity_id: 'cover.cur' + i, device_id: id, platform: 'demo' };
|
||||
states['cover.cur' + i] = { entity_id: 'cover.cur' + i, state: s,
|
||||
attributes: { friendly_name: 'Curtain ' + s, device_class: 'curtain' } };
|
||||
});
|
||||
c.hass = { ...c.hass,
|
||||
devices: { ...c.hass.devices, ...devices },
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states } };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i,
|
||||
tap_action: 'cover', display: 'icon_ripple' })),
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
const layout = { ...c._layout };
|
||||
STATES.forEach((s, i) => {
|
||||
const d = c._devices.find((x) => x.bindingRef === 'd_cur' + i);
|
||||
if (d) layout[d.id] = { s: 'garden', x: 0.18 + i * 0.215, y: 0.5 };
|
||||
});
|
||||
c._layout = layout;
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
}, STATES);
|
||||
await page.waitForTimeout(500);
|
||||
// captions under each badge, so the four states are readable in the file
|
||||
const box = await page.evaluate((STATES) => {
|
||||
const c = window.__card;
|
||||
const els = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.devlayer .dev')]
|
||||
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
|
||||
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||||
els.forEach((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cap = document.createElement('div');
|
||||
cap.textContent = STATES[i];
|
||||
cap.style.cssText = `position:fixed;left:${r.x + r.width / 2}px;top:${r.y + r.height + 10}px;`
|
||||
+ 'transform:translateX(-50%);font:600 13px system-ui,sans-serif;color:#1c2530;'
|
||||
+ 'letter-spacing:.02em;z-index:9999;padding:2px 7px;border-radius:6px;'
|
||||
+ 'background:rgba(255,255,255,0.88);white-space:nowrap;';
|
||||
document.body.appendChild(cap);
|
||||
minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.width);
|
||||
minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.height);
|
||||
});
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}, STATES);
|
||||
const pad = 55;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_states.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad),
|
||||
width: box.w + pad * 2, height: box.h + pad * 2 + 22 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,36 @@
|
||||
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = ''; // hand-drawn plan: white paper — the risky case for a white day
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
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 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 },
|
||||
} } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
}, [az, el]);
|
||||
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 browser.close();
|
||||
console.log('shots done');
|
||||
@@ -0,0 +1,63 @@
|
||||
// Скриншоты библиотеки мебели: палитра и расставленная мебель на плане.
|
||||
import { launch } from './serve.mjs';
|
||||
const OUT = process.env.HP_SHOT_DIR || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
sr.querySelectorAll('.modetab')[2].click();
|
||||
await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'furniture';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
sr.querySelector('.furnitem[data-symbol="sofa"]').click();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: `${OUT}/furniture_palette.png` });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const P = 1000 / 240;
|
||||
const cm = (v) => (v / 5) * P / 1000; // cm -> normalised
|
||||
const put = (id, symbol, w, h, cx, cy, angle) => ({
|
||||
id, kind: 'furniture', symbol,
|
||||
x: cx / 1000 - cm(w) / 2, y: cy / 1000 - cm(h) / 2,
|
||||
w: cm(w), h: cm(h), color: '#8d6e63', width: 3,
|
||||
...(angle ? { angle } : {}),
|
||||
});
|
||||
// r1 living 40..550 x 140..580 | r2 kitchen 550..960 x 140..460
|
||||
// r3 bedroom 550..960 x 460..860 | r4 hall 40..550 x 580..860
|
||||
c._curSpaceCfg.decor = [
|
||||
put('f1', 'sofa', 220, 90, 250, 140 + 37.5),
|
||||
put('f2', 'coffee_table', 110, 60, 250, 300),
|
||||
put('f3', 'tv', 120, 30, 250, 570),
|
||||
put('f4', 'armchair', 90, 85, 90, 340, 90),
|
||||
put('f5', 'table_dining', 140, 80, 700, 300),
|
||||
put('f6', 'chair', 45, 45, 700, 240),
|
||||
put('f7', 'chair', 45, 45, 700, 360, 180),
|
||||
put('f8', 'fridge', 60, 65, 590, 140 + 27, 0),
|
||||
put('f9', 'stove', 60, 60, 670, 140 + 25),
|
||||
put('f10', 'kitchen_sink', 80, 60, 760, 140 + 25),
|
||||
put('f11', 'dishwasher', 60, 60, 840, 140 + 25),
|
||||
put('f12', 'bed_double', 160, 200, 700, 460 + 42),
|
||||
put('f13', 'nightstand', 45, 40, 620, 470),
|
||||
put('f14', 'nightstand', 45, 40, 780, 470),
|
||||
put('f15', 'wardrobe', 100, 60, 910, 700, 90),
|
||||
put('f16', 'washer', 60, 60, 120, 860 - 25, 180),
|
||||
put('f17', 'toilet', 40, 70, 220, 860 - 29, 180),
|
||||
put('f18', 'bathtub', 170, 75, 400, 860 - 31, 180),
|
||||
put('f19', 'plant', 40, 40, 500, 620),
|
||||
put('f20', 'shower', 90, 90, 490, 700),
|
||||
];
|
||||
c._decorTool = 'select';
|
||||
c._decorSel = 'f1';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/furniture_plan.png` });
|
||||
await browser.close();
|
||||
console.log('shots written to', OUT);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.binding !== 'device:d_motion');
|
||||
c._serverCfg.markers.push({ id: 'd_motion', binding: 'device:d_motion', display: 'icon_ripple' });
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
// the flash is keyed to a WITNESSED off→on transition (owner's rule
|
||||
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
|
||||
const set = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
|
||||
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
};
|
||||
await set('off');
|
||||
await set('on');
|
||||
// freeze the pulse at a visible frame for a deterministic capture
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.activity-ring.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-event');
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/motion_sense.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,27 @@
|
||||
// Capture: the shoulder rulers + centre tick while PLACING a new opening
|
||||
// (Opening tool, cursor hovering the wall centre) — owner 2026-08-03.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const pt = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return { x: r.left + ((293.7 - vx) / vw) * r.width, y: r.top + ((141 - vy) / vh) * r.height,
|
||||
sx: r.left, sy: r.top, sw: r.width, sh: r.height };
|
||||
});
|
||||
await page.mouse.move(pt.x, pt.y, { steps: 4 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/opening_place.png',
|
||||
clip: { x: pt.sx, y: pt.sy, width: pt.sw, height: Math.min(pt.sh, 420) } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,45 @@
|
||||
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join on a
|
||||
// sharp corner shoots a spike far past the walls that meet there; past the
|
||||
// miter limit it flips to a flat bevel. Room borders now join round, like the
|
||||
// decor lines already did. Two rooms make the defect obvious: an L (a concave
|
||||
// 90° corner plus the convex ones) and a wedge with a ~45° apex — the angle
|
||||
// where the miter spike is longest before the miter limit clips it to a bevel.
|
||||
// Usage: node demo/shot_room_joins.mjs <outdir> <name>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const name = process.argv[3] || 'room_joins';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 6);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
delete sp.plan_url;
|
||||
sp.rooms = [
|
||||
{ id: 'rL', name: '', area: 'living_room',
|
||||
poly: [[0.06, 0.04], [0.52, 0.04], [0.52, 0.14], [0.94, 0.14], [0.94, 0.26], [0.06, 0.26]] },
|
||||
{ id: 'rW', name: '', area: 'kitchen',
|
||||
poly: [[0.20, 0.318], [0.90, 0.55], [0.20, 0.782]] },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, show_names: false,
|
||||
fill_mode: 'none', room_color: '#3ea6ff', room_opacity: 1 };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
// crop tight around the wedge's ~28° apex: at plan scale the whole stage hides
|
||||
// a 5-unit spike, and the spike is the entire point of the shot
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const svg = c.shadowRoot.querySelector('.stage svg');
|
||||
const poly = [...svg.querySelectorAll('polygon.room')].pop();
|
||||
const pts = poly.getAttribute('points').split(' ').map((s) => s.split(',').map(Number));
|
||||
const apex = pts.reduce((m, p) => (p[0] > m[0] ? p : m), pts[0]);
|
||||
const m = svg.getScreenCTM();
|
||||
return { x: m.a * apex[0] + m.c * apex[1] + m.e, y: m.b * apex[0] + m.d * apex[1] + m.f };
|
||||
});
|
||||
await page.screenshot({ path: `${outDir}/${name}.png`,
|
||||
clip: { x: box.x - 62, y: box.y - 30, width: 72, height: 60 } });
|
||||
await browser.close();
|
||||
console.log('shot written to ' + outDir + '/' + name + '.png');
|
||||
@@ -0,0 +1,53 @@
|
||||
// Screenshots for docs/SUN.md: evening wedges over the glow fill + the compass
|
||||
// dial in the general settings. Usage: node demo/shot_sun.mjs <outdir>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// instant colors for the still shot: the day/night transition is tens of seconds
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = {
|
||||
...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight',
|
||||
};
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
// a warm evening: the sun low in the west; the living-room light off so
|
||||
// the glow pool does not swamp the west wedge
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 265, elevation: 3 },
|
||||
},
|
||||
} };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: outDir + '/sun_rays.png' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._openSettingsDialog();
|
||||
c._settingsDialog = { ...c._settingsDialog, northDeg: 330 };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot
|
||||
.querySelector('hp-dialog').shadowRoot.querySelector('.surface'));
|
||||
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Brightness check for the wedges (owner 2026-08-03: «поярче, иногда плохо
|
||||
// видны»). Two hardest cases side by side: a DAYLIGHT sun on white paper (the
|
||||
// wedge colour is nearly neutral there) and a low sun over the dark glow
|
||||
// canvas. Usage: node demo/shot_sun_bright.mjs <outdir> [suffix]
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] ? '_' + process.argv[3] : '';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// the fade is a 2 s animation; reduced motion pins the layer at full opacity
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const setup = (fill, azimuth, elevation, ceiling) => page.evaluate(async ([fill, azimuth, elevation, ceiling]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: fill };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: ceiling },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [fill, azimuth, elevation, ceiling]);
|
||||
|
||||
const shoot = async (name) => {
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}${tag}.png` });
|
||||
};
|
||||
|
||||
// 1) plain daylight on white paper: neutral wedge colour, brightest canvas
|
||||
await setup('none', 265, 24, 'off');
|
||||
await shoot('sun_bright_paper');
|
||||
// 2) a low warm sun over the dark glow canvas
|
||||
await setup('glow', 265, 4, 'off');
|
||||
await shoot('sun_bright_glow');
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -0,0 +1,64 @@
|
||||
// Stills for the rim (docs/SUN.md, «The rim»). Two scenes — white paper and
|
||||
// the dark glow canvas — and, in each, the SAME frame at several rim peaks so
|
||||
// the value can be picked by looking rather than by arguing. The alphas other
|
||||
// than the shipped one are applied by rewriting the `hp-sunrim-*` stops in the
|
||||
// DOM, which is why a rebuild is not needed to compare them.
|
||||
// node demo/shot_sun_rim.mjs <outdir> [alphas, default 0,0.3,0.42,0.5]
|
||||
// `0` is the "before" frame: the wedge alone, with no rim at all.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const ALPHAS = (process.argv[3] || '0,0.3,0.42,0.5').split(',').map(Number);
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no fades
|
||||
|
||||
const setup = async (scene) => {
|
||||
await page.evaluate(async (sc) => {
|
||||
const c = window.__card;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = null; // plain paper, nothing to hide behind
|
||||
sp.settings = { ...(sp.settings || {}), fill_mode: sc === 'dark' ? 'glow' : 'none' };
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, sun_rays: true,
|
||||
bg_mode: 'static', bg_color: sc === 'dark' ? '#101720' : '#f2f2f0' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: sc === 'dark' ? 'on' : 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 250, elevation: 35 } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, scene);
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
// Rescale every rim gradient to `peak`, always from the stops as BUILT — a
|
||||
// scale relative to the current values would collapse to zero the moment the
|
||||
// 0 frame is taken and never come back.
|
||||
const setAlpha = async (peak) => {
|
||||
await page.evaluate((a) => {
|
||||
for (const g of window.__card.shadowRoot.querySelectorAll('linearGradient[id^=hp-sunrim-]')) {
|
||||
const stops = [...g.querySelectorAll('stop')];
|
||||
if (!g.__base) g.__base = stops.map((s) => Number(s.getAttribute('stop-opacity')));
|
||||
const p0 = g.__base[0] || 1;
|
||||
stops.forEach((s, i) => s.setAttribute('stop-opacity', (a * (g.__base[i] / p0)).toFixed(4)));
|
||||
}
|
||||
}, peak);
|
||||
await page.waitForTimeout(120);
|
||||
};
|
||||
|
||||
for (const scene of ['light', 'dark']) {
|
||||
await setup(scene);
|
||||
for (const a of ALPHAS) {
|
||||
await setAlpha(a);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/rim_${scene}_${String(a).replace('.', '')}.png` });
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
|
||||
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
|
||||
// interesting one — the north and south windows get a nearly wall-grazing sun,
|
||||
// the case that used to hang a bright kerb across the floor. Usage:
|
||||
// node demo/shot_sun_short.mjs <outdir> <prefix>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] || 'sun_rays_short';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
|
||||
|
||||
const setup = async (az, el) => {
|
||||
await page.evaluate(async ([azimuth, elevation]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [az, el]);
|
||||
await page.waitForTimeout(350);
|
||||
};
|
||||
|
||||
// low sun in the west — the long shafts, the case that hit the far walls
|
||||
await setup(265, 5);
|
||||
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_low.png` });
|
||||
|
||||
// high southern sun — the short noon pools
|
||||
await setup(180, 55);
|
||||
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -33,7 +33,7 @@ const res = await page.evaluate(async () => {
|
||||
const a = devs[0], b = devs[1];
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / (1000 / (c._curSpaceCfg.aspect || 1)) } };
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
const W = 1000, H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const W = 1000, H = 1000; // square canvas
|
||||
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.decorGuide = guides() >= 1;
|
||||
@@ -52,5 +52,5 @@ const res = await page.evaluate(async () => {
|
||||
out.noneInView = guides() === 0;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Аудит v1.49.0: HP-1490-03 (редакторы видят весь холст) и HP-1490-04
|
||||
// (Save ждёт пропорции выбранного сохранённого плана, старые не наследуются).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const out = {};
|
||||
|
||||
// ---- HP-1490-03: content-fit только в просмотре -------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
// рукописное пространство: одна маленькая комната в центре квадрата
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
cfg.spaces[0].rooms = [{ id: 'r1', name: 'One', area: 'living_room',
|
||||
poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }];
|
||||
c._serverCfg = cfg; c._model = null; c._view = null; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inView = c._baseVb();
|
||||
o.viewIsContentFit = inView[2] < 999; // меньше холста (устройства тоже содержимое)
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
// docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь
|
||||
// холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда
|
||||
// рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место
|
||||
// даёт запас панорамирования (§5) и зум-аут до 3x.
|
||||
o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0;
|
||||
o.editorCanPanPastContent = (() => {
|
||||
const fit = c._viewOr(c._baseVb());
|
||||
const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit);
|
||||
return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет
|
||||
})();
|
||||
o.editorZoomOutGivesRoom = (() => {
|
||||
c._resetZoom(); c._applyView(1 / 3);
|
||||
const wide = c._view.w; c._resetZoom();
|
||||
return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано
|
||||
})();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9;
|
||||
})();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const back = c._baseVb();
|
||||
o.contentFitRestored = back[2] < 999;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- HP-1490-04: Save ждёт aspect --------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
let saved = null;
|
||||
let signDelay = 500; // подпись приходит поздно
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: [
|
||||
{ name: 'wide.svg', url: '/api/houseplan/content/plans/_/wide.svg', size: 10, modified: 1, used_by: [] },
|
||||
] };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
await new Promise((r) => setTimeout(r, signDelay));
|
||||
const urls = {}; for (const p of m.paths) urls[p] = '/assets/wide.svg'; return { urls };
|
||||
}
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
if (m.type === 'houseplan/config/get') return { config: saved || c._serverCfg, rev: c._cfgRev || 0 };
|
||||
return base(m);
|
||||
} };
|
||||
// страница отдаёт /assets/wide.svg размером 800x200 (создан рядом)
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
c._useServerPlan('/api/houseplan/content/plans/_/wide.svg');
|
||||
o.oldAspectCleared = c._spaceDialog.savedAspect === undefined;
|
||||
// Save сразу, до прихода подписи
|
||||
const p = c._saveSpaceDialog();
|
||||
await p;
|
||||
o.savedUrl = saved?.spaces?.[0]?.plan_url === '/api/houseplan/content/plans/_/wide.svg';
|
||||
const a = saved?.spaces?.[0]?.plan_aspect;
|
||||
o.savedAspectIsReal = Math.abs((a || 0) - 4) < 0.01; // 800x200
|
||||
o.notTheOldAspect = a !== 1.25;
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Backdrop image: move, uniform scale, and the new paper rule (docs/BACKDROP.md).
|
||||
*
|
||||
* The whole contract, on the demo's f1 — which IS an image plan, so every
|
||||
* assertion here lands on exactly the case that used to behave differently:
|
||||
* • the transform frame exists in the backdrop editor and nowhere else;
|
||||
* • dragging the body moves the picture and writes plan_x/plan_y;
|
||||
* • a corner handle scales UNIFORMLY about the opposite corner;
|
||||
* • live badges state the real size in metres, through cell_cm;
|
||||
* • position and size always land on the grid, including with Shift;
|
||||
* • the paper follows the ROOMS even here, and the picture is drawn above
|
||||
* the paper and below the walls;
|
||||
* • «Вписать всё» still finds a picture that has been dragged away;
|
||||
* • the static card reads the same three fields.
|
||||
*/
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(s);
|
||||
c._bdDrag = null;
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
const mode = async (m) => {
|
||||
await page.evaluate((m) => {
|
||||
const c = window.__card;
|
||||
c._setMode(m); c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, m);
|
||||
await page.waitForTimeout(220); // editor chrome transition owns stage geometry
|
||||
};
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
|
||||
const spaceCfg = () => page.evaluate(() => {
|
||||
const sp = window.__card._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
return {
|
||||
x: sp.plan_x ?? null, y: sp.plan_y ?? null,
|
||||
sx: sp.plan_scale_x ?? null, sy: sp.plan_scale_y ?? null,
|
||||
legacy: sp.plan_scale ?? null, angle: sp.plan_angle ?? null,
|
||||
};
|
||||
});
|
||||
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
|
||||
const imageRect = () => page.evaluate(() => {
|
||||
const im = window.__card.renderRoot.querySelector('.stage svg image');
|
||||
if (!im) return null;
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const frameRect = () => page.evaluate(() => {
|
||||
const b = window.__card.renderRoot.querySelector('.bdframe .bdbox');
|
||||
if (!b) return null;
|
||||
const a = (n) => Number(b.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const badges = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel.bdmeasure')].map((e) => e.textContent.trim()));
|
||||
/** Screen position of a render-unit point (the svg viewBox fills the stage). */
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const near = (a, b, tol = 1e-4) => Math.abs(a - b) <= tol;
|
||||
|
||||
// f1: plan_aspect 1.25 → the centred default is 1000×800 at (0,100)
|
||||
const BASE = { x: 0, y: 100, w: 1000, h: 800 };
|
||||
const PITCH = 1000 / 240;
|
||||
// "on a node", allowing for the 6 decimals the normalised config keeps
|
||||
// (5e-7 normalised ≈ 5e-4 render units ≈ 1e-4 of a step)
|
||||
const onGrid = (v, tol = 1e-3) => Math.abs(v / PITCH - Math.round(v / PITCH)) < tol;
|
||||
/** Metres a render-unit length is worth on the demo plan (cell_cm = 5). */
|
||||
const metres = (u) => (((u / PITCH) * 5) / 100).toFixed(2) + ' m';
|
||||
|
||||
// ---------- 1) the frame lives in the backdrop editor and nowhere else -----
|
||||
await mode('view');
|
||||
await settle();
|
||||
check('no_frame_in_view', await q('.bdframe'), 0);
|
||||
await mode('plan');
|
||||
await settle();
|
||||
check('no_frame_in_plan', await q('.bdframe'), 0);
|
||||
const tool = (t) => page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
c._decorTool = t; c._decorDraft = null; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, t);
|
||||
|
||||
await mode('decor');
|
||||
await settle();
|
||||
// Every Background session starts predictably on Select. The image frame is
|
||||
// exclusive to its own tool and therefore cannot steal a Select/pan gesture.
|
||||
check('no_frame_until_picture_tool', await q('.bdframe'), 0);
|
||||
check('no_handles_until_picture_tool', await q('.bdframe .bdhandle'), 0);
|
||||
check('no_picture_frame_until_picture_tool', await frameRect(), null);
|
||||
check('untouched_picture_is_where_it_always_was', await imageRect(), BASE);
|
||||
// …and the picture has a tool of its own, offered because f1 HAS a picture
|
||||
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 8);
|
||||
check('select_tool_armed_on_open', await page.evaluate(() => window.__card._decorTool), 'select');
|
||||
check('no_grab_cursor_under_select', await page.evaluate(() =>
|
||||
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
|
||||
await tool('backdrop');
|
||||
await settle();
|
||||
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
|
||||
check('four_corners_plus_rotate_under_picture_tool', await q('.bdframe .bdhandle'), 5);
|
||||
check('frame_hugs_the_picture_under_picture_tool', await frameRect(), BASE);
|
||||
check('grab_cursor_under_picture_tool', await page.evaluate(() =>
|
||||
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
|
||||
// a drawing tool owns the drag instead — no frame competing with it
|
||||
await tool('rect');
|
||||
await settle();
|
||||
check('no_frame_under_a_drawing_tool', await q('.bdframe'), 0);
|
||||
await tool('backdrop');
|
||||
await settle();
|
||||
|
||||
// ---------- 2) drag the body: the picture moves, the plan does not --------
|
||||
const roomsBefore = await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms));
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
// +102 / +52 render units: neither is a whole number of grid steps, so the
|
||||
// snap has something to do (24 steps = 100, 12 steps = 50)
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
const live = await badges();
|
||||
check('live_badge_while_dragging', live.length, 1);
|
||||
// the demo's cell_cm is 5 → the untouched picture is 240 cells wide, i.e.
|
||||
// 12.00 m by 9.60 m. Moving does not change the size, and the badge says so.
|
||||
check('live_badge_is_the_real_size', live[0], `${metres(BASE.w)} × ${metres(BASE.h)}`);
|
||||
check('live_badge_is_metres', live[0], '12.00 m × 9.60 m');
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
}
|
||||
check('badge_gone_after_release', (await badges()).length, 0);
|
||||
{
|
||||
const cfg = await spaceCfg();
|
||||
const im = await imageRect();
|
||||
// the gesture asked for +102/+52 render units; the pointer arrives in whole
|
||||
// screen pixels, so the ASSERTION is the contract, not the arithmetic: the
|
||||
// corner lands on a lattice node within one step of where the finger let go
|
||||
check('plan_x_saved', near(cfg.x, im.x / 1000, 1e-6), true);
|
||||
check('plan_y_saved', near(cfg.y, (im.y - BASE.y) / 1000, 1e-6), true);
|
||||
check('scale_untouched_by_a_move', near(cfg.sx, 1) && near(cfg.sy, 1), true);
|
||||
check('legacy_scale_not_written', cfg.legacy, null);
|
||||
check('size_untouched_by_a_move', [im.w, im.h], [BASE.w, BASE.h]);
|
||||
check('corner_lands_on_a_grid_node', onGrid(im.x) && onGrid(im.y), true);
|
||||
// …at the node NEAREST the finger: 102 → 100 (24 steps), 152 → 150 (36).
|
||||
// Exact, and it is meant to be — nothing may rescale the stage mid-gesture
|
||||
// (the frame is frozen while _bdDrag is live; the toolbar does not reflow),
|
||||
// or the picture would run away from the finger.
|
||||
check('landed_on_the_node_nearest_the_finger', [im.x, im.y], [100, 150]);
|
||||
check('frame_follows_the_picture', await frameRect(), im);
|
||||
}
|
||||
check('the_plan_geometry_never_moved', await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms)), roomsBefore);
|
||||
|
||||
// ---------- 3) Shift cannot step off the grid -----------------------------
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.keyboard.down('Shift');
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up('Shift');
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
check('shift_still_lands_on_the_nearest_node', [im.x, im.y], [100, 150]);
|
||||
check('shift_stays_on_the_lattice', onGrid(im.x) && onGrid(im.y), true);
|
||||
}
|
||||
|
||||
// ---------- 4) a corner handle scales UNIFORMLY about the opposite one ----
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
// the bottom-right corner of the default rect is (1000, 900); pull it in to
|
||||
// (700, ...) — the top-left corner (0,100) must not budge and the ratio must
|
||||
// stay 1.25, whatever the vertical component of the gesture says
|
||||
const [ax, ay] = await screenPt(BASE.x + BASE.w, BASE.y + BASE.h);
|
||||
const [bx, by] = await screenPt(700, 600);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await settle();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
check('live_badge_while_scaling', (await badges()).length, 1);
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
check('opposite_corner_stayed_put', near(im.x, BASE.x, 1e-6) && near(im.y, BASE.y, 1e-6), true);
|
||||
check('scaled_down', im.w < BASE.w, true);
|
||||
check('uniform_no_stretch', near(im.w / im.h, BASE.w / BASE.h, 1e-9), true);
|
||||
const cfg = await spaceCfg();
|
||||
check('axis_scales_saved', near(cfg.sx, im.w / BASE.w, 1e-5)
|
||||
&& near(cfg.sy, im.h / BASE.h, 1e-5), true);
|
||||
check('proportional_scale_keeps_axes_equal', near(cfg.sx, cfg.sy, 1e-6), true);
|
||||
check('scaled_width_is_on_the_grid', onGrid(im.x + im.w), true);
|
||||
// …and the badge states the picture's REAL size through cell_cm
|
||||
check('badge_matches_the_new_size', await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._bdDrag = { kind: 'move', pid: 1, sx: 0, sy: 0, base: { x: 0, y: 100, w: 1000, h: 800 },
|
||||
p0: { dx: 0, dy: 0, k: 1 }, fx: 0, fy: 0, sgx: 0, sgy: 0, moved: false };
|
||||
c.requestUpdate();
|
||||
return c.updateComplete.then(() =>
|
||||
c.renderRoot.querySelector('.measurelabel.bdmeasure')?.textContent.trim() ?? null);
|
||||
}), `${metres(im.w)} × ${metres(im.h)}`);
|
||||
await page.evaluate(() => { const c = window.__card; c._bdDrag = null; c.requestUpdate(); return c.updateComplete; });
|
||||
}
|
||||
|
||||
// ---------- 5) the reset button ------------------------------------------
|
||||
check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
|
||||
await page.evaluate(() => { const c = window.__card; c._bdReset?.(); c.requestUpdate(); return c.updateComplete; });
|
||||
await settle();
|
||||
check('reset_puts_the_picture_back', await imageRect(), BASE);
|
||||
check('reset_clears_the_fields', await spaceCfg(), {
|
||||
x: null, y: null, sx: null, sy: null, legacy: null, angle: null,
|
||||
});
|
||||
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
|
||||
|
||||
// ---------- 5b) the SELECT tool still pans right over the picture ---------
|
||||
// The body of the picture is most of the screen; claiming it outside its own
|
||||
// tool would take away the one-finger pan (docs/BACKDROP.md §2), which is why
|
||||
// moving is a tool. smoke_pan_any_zoom guards the same thing from the outside.
|
||||
await tool('select');
|
||||
await settle();
|
||||
check('select_tool_still_pans_over_the_picture', await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const before = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerDown({ pointerId: 91, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
const after = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerUp({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
await c.updateComplete;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
return Math.abs(after.x - before.x) > 1 && sp.plan_x == null;
|
||||
}), true);
|
||||
|
||||
// ---------- 6) the paper is the ROOMS, and the picture sits on it ---------
|
||||
await mode('view');
|
||||
await settle();
|
||||
const layers = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const m = c._spaceModel();
|
||||
const svgEl = c.renderRoot.querySelector('.stage svg');
|
||||
const kids = [...svgEl.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
const papers = [...svgEl.querySelectorAll('.hp-paper')];
|
||||
return {
|
||||
paperShapes: papers.length,
|
||||
rooms: m.rooms.length,
|
||||
// not one paper shape the size of the picture any more
|
||||
imageSizedPaper: papers.some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(p.getAttribute('height')) - m.bg.h) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'), iRoom: idx('.room-outline, .room'),
|
||||
};
|
||||
});
|
||||
check('paper_is_one_shape_per_room', layers.paperShapes, layers.rooms);
|
||||
check('no_paper_the_size_of_the_picture', layers.imageSizedPaper, false);
|
||||
check('picture_is_above_the_paper', layers.iImage > layers.iPaper && layers.iPaper >= 0, true);
|
||||
check('picture_is_below_the_walls', layers.iImage < layers.iRoom, true);
|
||||
|
||||
// ---------- 6b) editors keep a WHITE sheet under the grid with a backdrop --
|
||||
const editorWhite = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const navSettled = () => new Promise((resolve) => setTimeout(resolve, 220));
|
||||
const probe = async (mode) => {
|
||||
c._setMode(mode);
|
||||
await upd();
|
||||
await navSettled();
|
||||
const stage = sr().querySelector('.stage');
|
||||
const paper = sr().querySelector('.stage svg .hp-paper');
|
||||
return {
|
||||
mode,
|
||||
stageWhite: getComputedStyle(stage).backgroundColor === 'rgb(255, 255, 255)',
|
||||
paperWhite: !paper || getComputedStyle(paper).fill === 'rgb(255, 255, 255)',
|
||||
hasBg: !!c._spaceModel().bg,
|
||||
noNoplan: !stage.classList.contains('noplan'),
|
||||
};
|
||||
};
|
||||
const plan = await probe('plan');
|
||||
const devices = await probe('devices');
|
||||
const decor = await probe('decor');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
await navSettled();
|
||||
const viewStage = sr().querySelector('.stage');
|
||||
// View with a backdrop must NOT force white — theme/card colour stays
|
||||
const viewForcedWhite = getComputedStyle(viewStage).backgroundColor === 'rgb(255, 255, 255)'
|
||||
&& !viewStage.classList.contains('noplan');
|
||||
return { plan, devices, decor, viewForcedWhite, hasBg: plan.hasBg };
|
||||
});
|
||||
check('editors_have_backdrop', editorWhite.hasBg, true);
|
||||
check('plan_editor_stage_white_with_backdrop', editorWhite.plan.stageWhite && editorWhite.plan.noNoplan, true);
|
||||
check('plan_editor_paper_white_with_backdrop', editorWhite.plan.paperWhite, true);
|
||||
check('devices_editor_stage_white_with_backdrop', editorWhite.devices.stageWhite, true);
|
||||
check('decor_editor_stage_white_with_backdrop', editorWhite.decor.stageWhite, true);
|
||||
check('view_with_backdrop_is_not_forced_white', editorWhite.viewForcedWhite, false);
|
||||
|
||||
// ---------- 7) «Вписать всё» keeps a picture that was dragged away --------
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.plan_x = 1.5; sp.plan_y = 1.5; sp.plan_scale = 0.5;
|
||||
c._cfgEpoch++; c._frame = null; c.requestUpdate();
|
||||
return c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const fit = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._fitAll();
|
||||
const b = c._baseVb();
|
||||
const bg = c._spaceModel().bg;
|
||||
return { b, bg };
|
||||
});
|
||||
check('the_frame_reaches_the_moved_picture',
|
||||
fit.b[0] <= fit.bg.x && fit.b[1] <= fit.bg.y
|
||||
&& fit.b[0] + fit.b[2] >= fit.bg.x + fit.bg.w
|
||||
&& fit.b[1] + fit.b[3] >= fit.bg.y + fit.bg.h, true);
|
||||
check('and_the_moved_picture_is_where_the_transform_says',
|
||||
near(fit.bg.x, 1500) && near(fit.bg.y, 1600) && near(fit.bg.w, 500) && near(fit.bg.h, 400), true);
|
||||
|
||||
// ---------- 8) the static card reads the same three fields ---------------
|
||||
const stat = await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return { ok: true };
|
||||
} };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage svg image') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const root = card.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const im = root.querySelector('image');
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
const kids = [...root.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
return {
|
||||
rect: { x: a('x'), y: a('y'), w: a('width'), h: a('height') },
|
||||
papers: root.querySelectorAll('.hp-paper').length,
|
||||
imageSizedPaper: [...root.querySelectorAll('.hp-paper')].some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - a('width')) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'),
|
||||
};
|
||||
});
|
||||
check('static_card_applies_the_transform', stat.rect, { x: 1500, y: 1600, w: 500, h: 400 });
|
||||
check('static_card_papers_the_rooms', stat.papers, 4);
|
||||
check('static_card_has_no_picture_paper', stat.imageSizedPaper, false);
|
||||
check('static_card_draws_the_picture_on_the_paper', stat.iImage > stat.iPaper, true);
|
||||
|
||||
await restore();
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,366 @@
|
||||
// New setting "background color around the plan": global (config.settings.bg_color,
|
||||
// gear dialog) with a per-space override (space dialog, empty = inherit), applied
|
||||
// to the stage in view and kiosk modes and to the static space-card. Editors keep
|
||||
// their own canvas; the open dialogs preview the pending color live.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stageBg = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
|
||||
};
|
||||
const waitStageBg = async (accept) => {
|
||||
const deadline = performance.now() + 700;
|
||||
let value = await stageBg();
|
||||
while (!accept(value) && performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
value = await stageBg();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const rgb = (hex) => `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
|
||||
|
||||
const themeBg = await stageBg(); // theme default from the stylesheet
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
|
||||
// 1) global setting colors the stage
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#224466' };
|
||||
out.globalApplies = (await stageBg()) === rgb('#224466');
|
||||
|
||||
// 2) per-space override wins
|
||||
sp.settings = { ...(sp.settings || {}), bg_color: '#663311' };
|
||||
out.spaceOverrides = (await stageBg()) === rgb('#663311');
|
||||
|
||||
// 3) clearing the space setting inherits the global again
|
||||
delete sp.settings.bg_color;
|
||||
out.clearInheritsGlobal = (await stageBg()) === rgb('#224466');
|
||||
|
||||
// 4) clearing the global returns the theme default
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
out.clearRestoresTheme = (await stageBg()) === themeBg;
|
||||
|
||||
// 5) invalid stored value is ignored (renderer never trusts garbage)
|
||||
c._serverCfg.settings.bg_color = 'url(javascript:alert(1))';
|
||||
out.garbageIgnored = (await stageBg()) === themeBg;
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
|
||||
// 6) the settings dialog previews its pending color live + saves it
|
||||
let saved = null;
|
||||
const origWS = c.hass.callWS;
|
||||
c.hass.callWS = async (m) => {
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
return origWS.call(c.hass, m);
|
||||
};
|
||||
c._openSettingsDialog();
|
||||
await c.updateComplete;
|
||||
out.dialogHasBgRow = !!sr().querySelector('hp-dialog .colorrow input[type=color]');
|
||||
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
|
||||
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
|
||||
await c._saveSettingsDialog();
|
||||
out.dialogSaves = saved?.settings?.bg_color === '#0a2a4a';
|
||||
out.savedApplies = (await stageBg()) === rgb('#0a2a4a');
|
||||
|
||||
// 7) the space dialog previews and saves the override
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, bgColor: '#4a0a2a' };
|
||||
out.spaceDialogPreviews = (await stageBg()) === rgb('#4a0a2a');
|
||||
saved = null;
|
||||
await c._saveSpaceDialog();
|
||||
const savedSp = saved?.spaces?.find((s) => s.id === c._space);
|
||||
out.spaceDialogSaves = savedSp?.settings?.bg_color === '#4a0a2a';
|
||||
out.spaceSavedApplies = (await stageBg()) === rgb('#4a0a2a');
|
||||
|
||||
// ...and clearing it in the dialog inherits the global again
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await c.updateComplete;
|
||||
out.spaceDialogPrefilled = c._spaceDialog.bgColor === '#4a0a2a';
|
||||
c._spaceDialog = { ...c._spaceDialog, bgColor: null };
|
||||
await c._saveSpaceDialog();
|
||||
// over the wire `undefined` keys vanish — check the JSON form, like the backend sees it
|
||||
const wire = JSON.parse(JSON.stringify(saved));
|
||||
const savedSp2 = wire?.spaces?.find((s) => s.id === c._space);
|
||||
out.spaceDialogClears = !('bg_color' in (savedSp2?.settings || {}));
|
||||
out.backToGlobal = (await stageBg()) === rgb('#0a2a4a');
|
||||
c.hass.callWS = origWS;
|
||||
|
||||
// 8) editors keep their own canvas (inline bg only in view/kiosk)
|
||||
c._setMode('decor');
|
||||
const editorBg = await waitStageBg((value) => value !== rgb('#0a2a4a'));
|
||||
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
|
||||
c._setMode('view');
|
||||
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
|
||||
|
||||
// 9) kiosk instance respects the setting
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c.hass;
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:860px;height:860px;z-index:99';
|
||||
document.body.appendChild(k);
|
||||
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.requestUpdate();
|
||||
await k.updateComplete;
|
||||
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
|
||||
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
|
||||
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) => {
|
||||
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);
|
||||
} };
|
||||
const scHost = document.createElement('div');
|
||||
document.body.appendChild(scHost);
|
||||
const sc = document.createElement('houseplan-space-card');
|
||||
sc.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
|
||||
sc.hass = hass2;
|
||||
scHost.appendChild(sc);
|
||||
const t0 = Date.now();
|
||||
while (!sc.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) await new Promise((r) => setTimeout(r, 80));
|
||||
await sc.updateComplete;
|
||||
const st = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
|
||||
// ...and the space override beats the global there too
|
||||
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
|
||||
sc.hass = { ...hass2 };
|
||||
sc._cfg = null; // force a config re-read
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
sc.requestUpdate(); await sc.updateComplete;
|
||||
const st2 = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardSpaceWins = !!st2 && getComputedStyle(st2).backgroundColor === rgb('#654321');
|
||||
scHost.remove();
|
||||
|
||||
// tidy: drop the color so later smokes on this page see the theme default
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
const spx = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
if (spx?.settings) delete spx.settings.bg_color;
|
||||
await stageBg();
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
|
||||
// The scene bg_color must never bleed through the plan itself: opaque
|
||||
// .hp-paper shapes sit under everything the plan draws. Since v1.58.0 the
|
||||
// paper is the ROOM CONTOURS and ONLY them — one shape per room, never their
|
||||
// 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.
|
||||
const paper = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
await upd();
|
||||
const m = c._spaceModel();
|
||||
const shapes = () => [...sr().querySelectorAll('.stage svg .hp-paper')];
|
||||
const p = shapes()[0];
|
||||
out.paperExists = !!p;
|
||||
out.imagePlanPapersTheRooms = shapes().length === m.rooms.length;
|
||||
// …and NOT the image: no paper rect the size of the backdrop any more
|
||||
out.noImageSizedPaper = ![...sr().querySelectorAll('.stage svg rect.hp-paper')].some((r) =>
|
||||
Math.abs(Number(r.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(r.getAttribute('height')) - m.bg.h) < 1e-6);
|
||||
if (p) {
|
||||
const cs = getComputedStyle(p);
|
||||
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
|
||||
&& 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 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');
|
||||
// editors keep the paper too (their canvas ignores bg_color anyway)
|
||||
c._setMode('decor');
|
||||
await upd();
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
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 },
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
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;
|
||||
delete c._serverCfg.settings.north_deg;
|
||||
c._cfgRev++;
|
||||
await upd();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, paper);
|
||||
|
||||
// ---- 12) drawn plan: the paper follows the ROOM CONTOURS ---------------
|
||||
// Owner: «белая подложка должна быть по границам комнат» — an L-shaped house
|
||||
// plus a detached building must NOT grow a white bounding rectangle. One
|
||||
// paper shape per room, in exactly the room's own geometry; the acid scene
|
||||
// colour shows in the L's pocket and between the buildings, right up to the
|
||||
// exterior walls; inside the rooms it never shows. A live resize preview
|
||||
// (_rszPreview) moves the paper together with the dragged wall.
|
||||
const drawn = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
sp.plan_url = ''; // hand-drawn plan now
|
||||
// binary-exact fractions -> exact render coords (0.125*1000 = 125 etc.)
|
||||
sp.rooms = [
|
||||
{ id: 'rL', name: 'L', poly: [[0.125, 0.125], [0.625, 0.125], [0.625, 0.375], [0.375, 0.375], [0.375, 0.625], [0.125, 0.625]] },
|
||||
{ id: 'rQ', name: 'Q', x: 0.75, y: 0.75, w: 0.1875, h: 0.1875 },
|
||||
];
|
||||
sp.openings = [];
|
||||
sp.decor = [];
|
||||
c._devices = []; // no icons over the pixel probes below
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
const papers = () => [...sr().querySelectorAll('.stage.noplan svg .hp-paper')];
|
||||
let ps = papers();
|
||||
out.paperPerRoom = ps.length === sp.rooms.length;
|
||||
out.paperShapesMatchRooms = ps.length === 2
|
||||
&& ps[0].tagName.toLowerCase() === 'polygon'
|
||||
&& ps[0].getAttribute('points') === sp.rooms[0].poly.map((p) => (p[0] * 1000) + ',' + (p[1] * 1000)).join(' ')
|
||||
&& ps[1].tagName.toLowerCase() === 'rect';
|
||||
out.drawnPaperWhite = ps.length > 0 && ps.every((p) => getComputedStyle(p).fill === 'rgb(255, 255, 255)');
|
||||
out.paperNoStroke = ps.length > 0 && ps.every((p) => {
|
||||
const cs = getComputedStyle(p);
|
||||
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;
|
||||
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
|
||||
// must move WITH the room, not lag behind until the drop
|
||||
const pv = JSON.parse(JSON.stringify(sp));
|
||||
pv.rooms[0].poly = pv.rooms[0].poly.map(([x, y]) => [x === 0.625 ? 0.6875 : x, y]);
|
||||
c._rszPreview = { space: c._space, sp: pv };
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.previewMovesPaper = (papers()[0]?.getAttribute('points') || '').includes('687.5,125');
|
||||
c._rszPreview = null;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.previewRestores = (papers()[0]?.getAttribute('points') || '').includes('625,125');
|
||||
// screen coords for the pixel probes (svg user units -> viewport px)
|
||||
const svgEl = sr().querySelector('.stage svg');
|
||||
const ctm = svgEl.getScreenCTM();
|
||||
const toScr = (x, y) => { const q = svgEl.createSVGPoint(); q.x = x; q.y = y; const r = q.matrixTransform(ctm); return [r.x, r.y]; };
|
||||
out.probes = {
|
||||
pocket: toScr(500, 500), // the L's notch — bg must show here
|
||||
gap: toScr(687, 300), // between the two buildings
|
||||
inside: [toScr(250, 250), toScr(500, 250), toScr(250, 500), toScr(843.75, 843.75)],
|
||||
};
|
||||
return out;
|
||||
});
|
||||
const { probes } = drawn;
|
||||
delete drawn.probes;
|
||||
Object.assign(res, drawn);
|
||||
|
||||
// pixel proof: the acid scene colour IS in the L's pocket and between the
|
||||
// buildings (where the old bounding-box paper drew a white square), and is
|
||||
// NOT inside any room
|
||||
if (probes) {
|
||||
const shot = (await page.screenshot()).toString('base64');
|
||||
const px = await page.evaluate(async ({ shot, probes }) => {
|
||||
const img = new Image();
|
||||
img.src = 'data:image/png;base64,' + shot;
|
||||
await img.decode();
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = img.width;
|
||||
cv.height = img.height;
|
||||
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const at = ([x, y]) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
|
||||
const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
|
||||
return {
|
||||
acidInPocket: acid(at(probes.pocket)),
|
||||
acidBetweenBuildings: acid(at(probes.gap)),
|
||||
noAcidInsideRooms: probes.inside.every((q) => !acid(at(q))),
|
||||
};
|
||||
}, { shot, probes });
|
||||
Object.assign(res, px);
|
||||
} else {
|
||||
res.acidInPocket = false;
|
||||
res.acidBetweenBuildings = false;
|
||||
res.noAcidInsideRooms = false;
|
||||
}
|
||||
|
||||
// ---- 13) the static space-card follows the same paper contract ----------
|
||||
const stat = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
// NOTE: the config-store cache is module-level and already primed by the
|
||||
// earlier static-card sections (with the PRE-§12 rooms), so a mocked
|
||||
// callWS would never reach the card — inject the snapshot directly.
|
||||
const mk = async (cfg) => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
|
||||
el._snap = { config: cfg, rev: 1, layout: {} };
|
||||
el._loadedOnce = true;
|
||||
el.hass = c.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000)
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await el.updateComplete;
|
||||
return { el, host };
|
||||
};
|
||||
const base = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
base.spaces.find((s) => s.id === c._space).plan_url = '/assets/f1.svg';
|
||||
base.settings = { ...(base.settings || {}), bg_color: '#ff00ff' };
|
||||
const a = await mk(base);
|
||||
const pa = a.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
|
||||
out.staticPaperExists = !!pa;
|
||||
out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
|
||||
&& getComputedStyle(pa).fill !== 'none';
|
||||
a.host.remove();
|
||||
// drawn plan: per-room paper shapes (the L + the detached rect from §12)
|
||||
const drawnCfg = JSON.parse(JSON.stringify(base));
|
||||
const dsp = drawnCfg.spaces.find((s) => s.id === c._space);
|
||||
dsp.plan_url = '';
|
||||
const b = await mk(drawnCfg);
|
||||
const pbs = [...b.el.renderRoot.querySelectorAll('.hp-static-stage svg .hp-paper')];
|
||||
out.staticDrawnPaperPerRoom = pbs.length === dsp.rooms.length;
|
||||
out.staticDrawnPaperShapes = pbs.length === 2
|
||||
&& pbs[0].tagName.toLowerCase() === 'polygon'
|
||||
&& pbs[1].tagName.toLowerCase() === 'rect';
|
||||
b.host.remove();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, stat);
|
||||
checkAll({ ...paper, ...drawn, ...stat,
|
||||
acidInPocket: res.acidInPocket,
|
||||
acidBetweenBuildings: res.acidBetweenBuildings,
|
||||
noAcidInsideRooms: res.noAcidInsideRooms });
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -15,7 +15,7 @@ const res = await page.evaluate(async () => {
|
||||
radios()[1].click(); await c.updateComplete;
|
||||
out.dropShown = !!sr().querySelector('.dropbtn');
|
||||
out.panelOpen = !!sr().querySelector('.droppanel');
|
||||
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
|
||||
const saveBtn = [...sr().querySelectorAll('hp-dialog .btn.on')].pop();
|
||||
out.saveDisabled = saveBtn?.disabled === true;
|
||||
// без чекбокса в списке нет individual-сущностей устройств
|
||||
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
|
||||
@@ -32,7 +32,7 @@ const res = await page.evaluate(async () => {
|
||||
sr().querySelector('.cand').click(); await c.updateComplete;
|
||||
out.picked = c._markerDialog.binding !== '' && c._markerDialog.binding !== 'virtual';
|
||||
out.panelClosed = !sr().querySelector('.droppanel');
|
||||
out.saveEnabled = [...sr().querySelectorAll('.dialog .btn.on')].pop()?.disabled === false;
|
||||
out.saveEnabled = [...sr().querySelectorAll('hp-dialog .btn.on')].pop()?.disabled === false;
|
||||
// радио назад на «Виртуальное» → binding=virtual
|
||||
radios()[0].click(); await c.updateComplete;
|
||||
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
|
||||
@@ -46,5 +46,5 @@ const res = await page.evaluate(async () => {
|
||||
c._markerDialog = null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
|
||||
//
|
||||
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
|
||||
// frame — hiding a marker that had wandered into the yard left the visible
|
||||
// house a dot in the corner (the full card AND the static
|
||||
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
|
||||
//
|
||||
// DEV-2C947-02: inside an editor the frame only grows (a frame that shrank
|
||||
// mid-drag would move the ground under the pointer); that union was memoised
|
||||
// without the mode, so it survived the way back into View.
|
||||
//
|
||||
// DEV-2C947-03: a room-outlier is rejected from the frame, but iconUnit()
|
||||
// measured ALL rooms — so the stray it had just rejected still blew every
|
||||
// marker of the main plan up ~91x.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// Load a config/layout into the card AND into the demo's WS stub, so the
|
||||
// static card (which fetches its own copy) sees exactly the same house.
|
||||
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
|
||||
const c = window.__card;
|
||||
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
|
||||
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
|
||||
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
|
||||
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
|
||||
for (const k of Object.keys(srvLay)) delete srvLay[k];
|
||||
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
|
||||
c._serverCfg = srvCfg;
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = sp;
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout, space]);
|
||||
|
||||
// ---------------------------------------------------------------- 01: hidden
|
||||
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
|
||||
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
|
||||
// never runs and the far position is guaranteed to stay in the core — the
|
||||
// frame either sees the marker or it does not.
|
||||
const oneRoom = (hidden) => ({
|
||||
spaces: [{
|
||||
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
|
||||
}],
|
||||
// a made-up area: no auto device of the demo house lands in this space, so
|
||||
// the only content is the room and the one marker below
|
||||
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
|
||||
|
||||
await load(oneRoom(false), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const b = c._baseVb();
|
||||
return {
|
||||
// premise: while it IS drawn, the marker is content and the frame holds it
|
||||
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
|
||||
visibleMarkerWidensTheFrame: b[2] > 50000,
|
||||
};
|
||||
}));
|
||||
|
||||
await load(oneRoom(true), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
|
||||
// the room is 100..900 render units, padded by 5 % of the longer side
|
||||
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
|
||||
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
|
||||
// and the room fills the screen instead of being a dot 112x too small
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
|
||||
o.roomFillsTheStage = poly.width > st.width * 0.7;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// the same house on the STATIC card (space-render.ts drew `devs` but framed
|
||||
// `spaceDevs` — the identical split)
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const o = {};
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'width:400px;height:400px';
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
|
||||
el.hass = window.__card.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
await el.updateComplete;
|
||||
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.staticFrameIsTheVisibleRoom = vb.length === 4
|
||||
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------- 02: editor -> view
|
||||
const oneRoomAt = (x0) => ({
|
||||
spaces: [{
|
||||
id: 'e1', title: 'Editor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[x0, 0.1], [x0 + 0.8, 0.1], [x0 + 0.8, 0.9], [x0, 0.9]] }],
|
||||
}],
|
||||
markers: [], settings: { filter_seeded: true },
|
||||
});
|
||||
|
||||
await load(oneRoomAt(0.1), {}, 'e1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const round = () => c._baseVb().map((n) => Math.round(n));
|
||||
o.viewFrameBefore = JSON.stringify(round()) === JSON.stringify([60, 60, 880, 880]);
|
||||
// move the only room 5 canvases to the right, inside the Plan editor
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms[0].poly = [[5.1, 0.1], [5.9, 0.1], [5.9, 0.9], [5.1, 0.9]];
|
||||
c._serverCfg = cfg;
|
||||
c._modelCache = null;
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const inEditor = round();
|
||||
// inside the editor the frame is the UNION — that is deliberate: it bounds
|
||||
// pan and defines zoom 1, and it must not shrink under a live gesture
|
||||
o.editorFrameOnlyGrows = inEditor[2] > 5000;
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
const back = round();
|
||||
o.viewFrameFollowsTheRoomAgain = JSON.stringify(back) === JSON.stringify([5060, 60, 880, 880]);
|
||||
if (!o.viewFrameFollowsTheRoomAgain) console.log('frame after exit', back, 'editor', inEditor);
|
||||
// and back into an editor it starts from the CURRENT geometry, not the old union
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
o.reenteringTheEditorDoesNotResurrectTheUnion =
|
||||
JSON.stringify(round()) === JSON.stringify([5060, 60, 880, 880]);
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------- 03: icons and outliers
|
||||
// Three rooms in the core plus one dragged 90 canvases out. The frame rejects
|
||||
// the stray (that part always worked); the ICON UNIT used to measure all four
|
||||
// rooms, so every marker of the plan the user is looking at came out ~91x too
|
||||
// big and covered the house.
|
||||
const withFarRoom = (far) => ({
|
||||
spaces: [{
|
||||
id: 'i1', title: 'Icons', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'A', area: 'zx_a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
|
||||
{ id: 'r2', name: 'B', area: 'zx_b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
|
||||
{ id: 'r3', name: 'C', area: 'zx_c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
|
||||
...(far ? [{ id: 'r4', name: 'Far', area: 'zx_d', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] }] : []),
|
||||
],
|
||||
}],
|
||||
markers: [{ id: 'm_one', binding: 'device:d_light1', area: 'zx_a' }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const ICON_POS = { m_one: { s: 'i1', x: 0.2, y: 0.2 } };
|
||||
|
||||
const iconPx = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
// the badge is --dev-size plus a 1 px border on each side (smoke_icon_scale)
|
||||
return { px: el ? Math.round((el.getBoundingClientRect().width - 2) * 100) / 100 : null,
|
||||
outliers: c._outliers, frameW: Math.round(c._baseVb()[2]) };
|
||||
});
|
||||
|
||||
await load(withFarRoom(false), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const plain = await iconPx();
|
||||
await load(withFarRoom(true), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const strayed = await iconPx();
|
||||
out.strayRoomIsRejectedFromTheFrame = strayed.outliers === 1 && strayed.frameW < 1000;
|
||||
out.strayRoomDoesNotInflateTheIcons =
|
||||
plain.px > 4 && Math.abs(strayed.px - plain.px) < 0.5;
|
||||
if (!out.strayRoomDoesNotInflateTheIcons) console.log('icon px', plain, strayed);
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
// Reproduce the narrow wall-tablet/mobile dialog from the field report.
|
||||
await page.setViewportSize({ width: 390, height: 760 });
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// устройство с управляемой сущностью
|
||||
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
|
||||
out.hasDev = !!dev;
|
||||
c._infoCard = dev; await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
out.infoUsesWideShell = sr().querySelector('hp-dialog')?.hasAttribute('wide') === true;
|
||||
const footer = sr().querySelector('.infofooter');
|
||||
const footerBox = footer?.getBoundingClientRect();
|
||||
const footerButtons = [...(footer?.querySelectorAll('button') || [])];
|
||||
out.footerActionsContained = !!footerBox && footerButtons.length >= 2 && footerButtons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.left >= footerBox.left - 1 && rect.right <= footerBox.right + 1;
|
||||
});
|
||||
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
|
||||
const body = sr().querySelector('hp-dialog .body');
|
||||
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|
||||
|| body.querySelector('.entlist') === body.children[0];
|
||||
const rows = [...sr().querySelectorAll('.entrow')];
|
||||
out.rows = rows.length > 0;
|
||||
// 2) у переключаемой сущности — кнопка с крупной зоной нажатия
|
||||
const btn = sr().querySelector('.entbtn');
|
||||
out.hasButton = !!btn;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.tapTarget = box ? box.height >= 30 && box.width >= 60 : null;
|
||||
// 3) кнопка реально переключает
|
||||
const before = calls.length;
|
||||
btn.click(); await c.updateComplete;
|
||||
out.toggles = calls.length > before && calls.at(-1)[1] === 'toggle';
|
||||
// 4) замок никогда не переключается из карточки
|
||||
const n = calls.length;
|
||||
c._cardToggle('lock.front_door');
|
||||
out.lockNeverToggles = calls.length === n;
|
||||
// 5) диагностические/конфиг-сущности не засоряют список
|
||||
const ents = c._cardEntities(dev).map((e) => e.eid);
|
||||
out.noConfigEntities = ents.every((e) => {
|
||||
const cat = c.hass.entities[e]?.entity_category;
|
||||
return cat !== 'config' && cat !== 'diagnostic';
|
||||
});
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -45,7 +45,20 @@ const res = await page.evaluate(async () => {
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.delroomIgnored = !confirmCalled;
|
||||
// 6) Delete has exactly one semantic result: the clicked room disappears;
|
||||
// it never closes a virtual boundary or merges a neighbour into it.
|
||||
const roomCount = c._curSpaceCfg.rooms.length;
|
||||
const deleteId = room.id;
|
||||
window.confirm = () => true;
|
||||
c._deleteRoomClick(c._roomCenter(room));
|
||||
await c.updateComplete;
|
||||
out.deleteOnlyClickedRoom = c._curSpaceCfg.rooms.length === roomCount - 1
|
||||
&& !c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
|
||||
c._undoGeometry();
|
||||
await c.updateComplete;
|
||||
out.deleteUndoRestoresRoom = c._curSpaceCfg.rooms.length === roomCount
|
||||
&& c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Ревью R2-3: климат комнат считался отдельным обходом реестра на каждую
|
||||
// комнату и каждую величину — 60 комнат × 2000 сущностей съедали кадр на
|
||||
// перечитывании метаданных, которые не менялись. Карта строится один раз на
|
||||
// снимок hass; при этом новые состояния датчиков обязаны попадать в неё сразу.
|
||||
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;
|
||||
|
||||
// считаем обходы реестра через ownKeys — именно его дёргает Object.entries
|
||||
let scans = 0;
|
||||
const wrap = (h) => {
|
||||
const ents = h.entities;
|
||||
const traced = new Proxy(ents, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
|
||||
return { ...h, entities: traced };
|
||||
};
|
||||
const fresh = () => wrap(window.__mkHass());
|
||||
|
||||
// включаем и заливку по температуре, и подписи — два потребителя климата
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true, label_hum: true },
|
||||
})};
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
const fewRooms = scans;
|
||||
|
||||
// повторные рендеры на том же снимке hass реестр не трогают
|
||||
scans = 0;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.scansOnRerender = scans;
|
||||
|
||||
// главный инвариант: обходов НЕ становится больше от числа комнат
|
||||
const f1 = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
const extra = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
extra.push({ id: 'gen' + i, name: 'R' + i, area: 'living_room',
|
||||
poly: [[0.01, 0.01], [0.02, 0.01], [0.02, 0.02], [0.01, 0.02]] });
|
||||
}
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) =>
|
||||
s.id !== 'f1' ? s : { ...s, rooms: [...s.rooms, ...extra] }) };
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
out.roomCount = c._spaceModel('f1').rooms.length;
|
||||
out.scansSameWith44Rooms = scans === fewRooms;
|
||||
out.scansPerUpdate = scans;
|
||||
|
||||
// при этом новое состояние датчика обязано быть видно, а не взято из кэша
|
||||
out.tempBefore = c._climate().get('living_room')?.temp;
|
||||
const h = fresh();
|
||||
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '33.3' } };
|
||||
c.hass = h; await c.updateComplete;
|
||||
out.tempAfter = c._climate().get('living_room')?.temp;
|
||||
out.climateIsMap = c._climate() instanceof Map;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
|
||||
// scansPerUpdate = 1: active-registry projection is shared by the climate map
|
||||
// and buildDevices. Важно
|
||||
// не само число, а что оно не растёт вместе с числом комнат.
|
||||
checkAll(res, {
|
||||
scansOnRerender: 0,
|
||||
roomCount: 44,
|
||||
scansSameWith44Rooms: true,
|
||||
scansPerUpdate: 1,
|
||||
tempBefore: 22.4,
|
||||
tempAfter: 33.3,
|
||||
climateIsMap: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,147 @@
|
||||
// Опция «Использовать датчик температуры устройства» (marker.use_climate_temp):
|
||||
// климат-устройства (кондиционеры, термостаты) знают температуру комнаты
|
||||
// (attributes.current_temperature). По умолчанию ВЫКЛ — ни плашки, ни вклада
|
||||
// в среднюю. Включена через диалог (реальный клик по чекбоксу): плашка .tval
|
||||
// у значка + голос в средней комнаты (термометр 20 + климат 23.5 → 21.75,
|
||||
// т.е. 21.8 по сетке карточки 0.1°). Unavailable-климат не ломает ничего.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// --- впрыскиваем климат-устройства в демо-hass ---------------------------
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const h = window.__mkHass();
|
||||
const inject = (hass) => ({
|
||||
...hass,
|
||||
devices: {
|
||||
...hass.devices,
|
||||
d_ac: { id: 'd_ac', name: 'Air conditioner', model: 'AC-12', area_id: 'living_room', identifiers: [['demo', 'd_ac']], entry_type: null, via_device_id: null },
|
||||
d_trv: { id: 'd_trv', name: 'Radiator valve', model: 'TRV-1', area_id: 'bedroom', identifiers: [['demo', 'd_trv']], entry_type: null, via_device_id: null },
|
||||
},
|
||||
entities: {
|
||||
...hass.entities,
|
||||
'climate.ac': { entity_id: 'climate.ac', device_id: 'd_ac', platform: 'demo' },
|
||||
'climate.trv': { entity_id: 'climate.trv', device_id: 'd_trv', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
...hass.states,
|
||||
// термометр гостиной — ровно 20, чтобы средняя с климатом была детерминированной
|
||||
'sensor.living_temp': { ...hass.states['sensor.living_temp'], state: '20' },
|
||||
'climate.ac': { entity_id: 'climate.ac', state: 'cool', attributes: { friendly_name: 'Air conditioner', current_temperature: 23.5, temperature: 24 } },
|
||||
// мёртвый климат: state unavailable, атрибутов нет
|
||||
'climate.trv': { entity_id: 'climate.trv', state: 'unavailable', attributes: { friendly_name: 'Radiator valve' } },
|
||||
},
|
||||
});
|
||||
window.__injectClimate = () => { c.hass = inject(window.__mkHass()); };
|
||||
window.__injectClimate();
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
});
|
||||
await page.waitForFunction(() => window.__card._devices.some((d) => d.entities?.includes('climate.ac')));
|
||||
|
||||
// --- без опции: ни плашки, ни вклада в среднюю ---------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await c.updateComplete;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
o.acBuilt = !!ac;
|
||||
o.noBadgeByDefault = c._liveTemp(ac) === null;
|
||||
o.avgWithoutOption = c._climate().get('living_room')?.temp; // === 20
|
||||
return o;
|
||||
}), { avgWithoutOption: 20 });
|
||||
|
||||
// --- диалог: чекбокс есть у климат-устройства, нет у обычного, клик мышью --
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// у обычного термометра чекбокса нет
|
||||
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
|
||||
c._openMarkerDialog(th); await c.updateComplete;
|
||||
o.noCheckboxForThermometer = !sr().querySelector('hp-dialog .climrow');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// у кондиционера — есть и по умолчанию снят
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
const row = sr().querySelector('hp-dialog .climrow input');
|
||||
o.checkboxForClimate = !!row;
|
||||
o.uncheckedByDefault = !!row && !row.checked;
|
||||
if (row) row.scrollIntoView({ block: 'center' });
|
||||
return o;
|
||||
}));
|
||||
|
||||
// реальный клик по чекбоксу — координатами, через весь стек событий
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const r = sr.querySelector('hp-dialog .climrow input')?.getBoundingClientRect();
|
||||
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
|
||||
});
|
||||
check('checkboxClickable', !!box);
|
||||
if (box) await page.mouse.click(box.x, box.y);
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await c.updateComplete;
|
||||
o.clickTicksState = c._markerDialog?.useClimateTemp === true;
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:d_ac');
|
||||
o.savedInMarker = m?.use_climate_temp === true;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- с опцией: плашка «23.5°», средняя пересчиталась ----------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
await c.updateComplete;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
o.liveTempFromClimate = c._liveTemp(ac); // === 23.5
|
||||
const badges = [...sr().querySelectorAll('.dev .tval')].map((el) => el.textContent.trim());
|
||||
o.badgeVisible = badges.includes('23.5°');
|
||||
// средняя гостиной: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
|
||||
o.avgWithOption = c._climate().get('living_room')?.temp;
|
||||
// и на КАРТОЧКЕ КОМНАТЫ: включаем метрику температуры и читаем текст
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.settings = { ...(sp.settings || {}), label_temp: true, show_names: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const labels = [...sr().querySelectorAll('.roomlabel .rlm')].map((el) => el.textContent.trim());
|
||||
o.roomCardShowsAvg = labels.some((t) => t.includes('21.8°'));
|
||||
return o;
|
||||
}), { liveTempFromClimate: 23.5, avgWithOption: 21.8 });
|
||||
|
||||
// --- unavailable-климат: опция включена, но ни плашки, ни вклада ----------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
c._serverCfg.markers = [...c._serverCfg.markers,
|
||||
{ id: 'd_trv', binding: 'device:d_trv', use_climate_temp: true, hidden: false }];
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const trv = c._devices.find((d) => d.entities?.includes('climate.trv'));
|
||||
o.unavailNoBadge = !!trv && c._liveTemp(trv) === null;
|
||||
o.unavailNoVote = c._climate().get('bedroom')?.temp ?? null; // в спальне нет термометров
|
||||
// а гостиная как была 21.8
|
||||
o.avgStillCorrect = c._climate().get('living_room')?.temp;
|
||||
return o;
|
||||
}), { unavailNoVote: null, avgStillCorrect: 21.8 });
|
||||
|
||||
// --- чекбокс переживает пересоздание диалога ------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
o.survivesReopen = c._markerDialog?.useClimateTemp === true;
|
||||
o.checkboxChecked = sr().querySelector('hp-dialog .climrow input')?.checked === true;
|
||||
c._markerDialog = null;
|
||||
return o;
|
||||
}));
|
||||
|
||||
checkAll(out, { avgWithoutOption: 20, liveTempFromClimate: 23.5, avgWithOption: 21.8, unavailNoVote: null, avgStillCorrect: 21.8 });
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,67 @@
|
||||
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
|
||||
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
|
||||
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
|
||||
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
|
||||
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
|
||||
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 base = c.hass.callWS;
|
||||
const writes = [];
|
||||
let rev = 10;
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((r) => { releaseFirst = r; });
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
const n = writes.length + 1;
|
||||
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
|
||||
if (n === 1) await firstGate; // первый ответ задержан
|
||||
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||
rev += 1;
|
||||
return { ok: true, rev };
|
||||
}
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
const r = await base(m);
|
||||
return { config: JSON.parse(JSON.stringify(r.config)), rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
c._cfgRev = rev;
|
||||
|
||||
// правка №1 и, пока первая запись висит, правка №2
|
||||
c._serverCfg.spaces[0].title = 'FIRST';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.oneInFlight = writes.length === 1;
|
||||
|
||||
c._serverCfg.spaces[0].title = 'SECOND';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
|
||||
|
||||
releaseFirst();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
|
||||
out.writes = writes.length;
|
||||
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
|
||||
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
|
||||
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
|
||||
out.noConflictToast = !(c._toast || '').length;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
oneInFlight: true,
|
||||
stillOneInFlight: true,
|
||||
writes: 2,
|
||||
revisions: [10, 11],
|
||||
secondCarriedTheEdit: true,
|
||||
editSurvived: true,
|
||||
noConflictToast: true,
|
||||
});
|
||||
await finish(browser);
|
||||
+43
-4
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -19,7 +19,8 @@ const res = await page.evaluate(async () => {
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights, icon: 'mdi:light-switch' };
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights,
|
||||
icon: 'mdi:light-switch', display: 'icon_ripple' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.name === 'Выключатель');
|
||||
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
|
||||
@@ -36,6 +37,7 @@ const res = await page.evaluate(async () => {
|
||||
// 3) значок отражает цели: горит одна → класс on
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
|
||||
out.stateOn = !!c._stateClass(dev2()).includes('on');
|
||||
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
out.stateOff = c._stateClass(dev2()) === '';
|
||||
// 4) без tap_action=toggle клик НЕ переключает (инфо)
|
||||
@@ -55,7 +57,44 @@ const res = await page.evaluate(async () => {
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
const last = calls.at(-1);
|
||||
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
|
||||
|
||||
// Opening and saving must not rewrite configuration that runtime cannot
|
||||
// currently execute: YAML-only domains and duplicates are still user data.
|
||||
const losslessControls = [lights[0], 'input_boolean.yaml_only', lights[0], 'sensor.vendor_option'];
|
||||
cfg.controls = [...losslessControls];
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
c._openMarkerDialog(dev2()); await c.updateComplete;
|
||||
out.dialogKeepsUnknownAndDuplicateControls = JSON.stringify(c._markerDialog?.controls)
|
||||
=== JSON.stringify(losslessControls);
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const savedLossless = c._serverCfg.markers.find((m) => m.name === 'Выключатель')?.controls;
|
||||
out.saveKeepsUnknownAndDuplicateControls = JSON.stringify(savedLossless)
|
||||
=== JSON.stringify(losslessControls);
|
||||
|
||||
// Legacy configs could store a standalone entity as its own external
|
||||
// control. That self-reference must neither become a light source/group nor
|
||||
// disappear behind ambiguous dialog UI. Its normal Toggle action still
|
||||
// operates the entity, and an ON hood/fan still has working-state yellow.
|
||||
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
|
||||
out.hasStandaloneSwitch = !!own;
|
||||
const self = own && {
|
||||
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
|
||||
space: c._space, area: room.area, entities: [own], primary: own,
|
||||
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
|
||||
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
|
||||
};
|
||||
if (self) {
|
||||
await setSt({ [own]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), self);
|
||||
out.selfControlUsesDirectToggle = JSON.stringify(calls.at(-1))
|
||||
=== JSON.stringify(['homeassistant', 'toggle', own]);
|
||||
c._openMarkerDialog(self); await c.updateComplete;
|
||||
out.selfControlAbsentFromDialog = c._markerDialog?.controls.length === 0;
|
||||
c._markerDialog = null;
|
||||
await setSt({ [own]: 'on' });
|
||||
out.selfSwitchStillShowsWorkingState = c._stateClass(self).includes('on');
|
||||
}
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// A cover NEVER wears a coloured plate (owner's contract 2026-08-04):
|
||||
// «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто
|
||||
// за счёт морфинга иконки».
|
||||
//
|
||||
// Before this the domain shared one branch with `valve`: 'open'/'opening' put
|
||||
// the orange «открыто» frame on the badge (.dev.open — a FILLED orange plate,
|
||||
// --hp-open #ff9f43, not just a border), and the travelling states carried it
|
||||
// underneath the breathing ring. Now a cover returns no plate class at all:
|
||||
//
|
||||
// closed / open / ajar -> neutral plate, the ICON tells the state
|
||||
// opening / closing -> neutral plate + activity-transition when the
|
||||
// marker opts into «Icon + activity»
|
||||
//
|
||||
// The frame itself is NOT gone: door/window binary sensors and locks still
|
||||
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
|
||||
// re-checked at the bottom, or «no yellow for curtains» would quietly cost
|
||||
// the whole house its «открыто» indication.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// --- colour probes: resolve the tokens the same way the badge does ------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)'); // «включено»
|
||||
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
|
||||
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
|
||||
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
|
||||
|
||||
// --- the gate marker, alone in the Garden, told it IS the curtain -------
|
||||
const setMarker = async () => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state, attributes: { friendly_name: 'Gate', ...attrs } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — a colour read in the same frame is
|
||||
// still mid-fade and would pass on a plate that ends up orange
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await setMarker();
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
|
||||
const devEl = () => sr().querySelector('.devlayer .dev');
|
||||
const cls = () => [...(devEl()?.classList || [])];
|
||||
const plate = () => getComputedStyle(devEl()).backgroundColor;
|
||||
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
|
||||
|
||||
/** One state: the plate must be the neutral one, and nothing else. */
|
||||
const plateIsNeutral = (tag) => {
|
||||
o[tag + 'PlateNeutral'] = plate() === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = plate() !== YELLOW;
|
||||
o[tag + 'NotOrange'] = plate() !== ORANGE;
|
||||
o[tag + 'NoPlateClass'] = !cls().includes('on') && !cls().includes('open');
|
||||
};
|
||||
|
||||
plateIsNeutral('closed');
|
||||
o.closedNoRing = !cls().includes('activity-transition');
|
||||
o.closedIconClosed = icon() === 'mdi:curtains-closed';
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
plateIsNeutral('open');
|
||||
// This fixture jumps closed → open without an intermediate HA state, so
|
||||
// the unified resolver supplies the agreed ~3.3 s transition fallback.
|
||||
o.openDirectTransition = cls().includes('activity-transition');
|
||||
o.openIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
plateIsNeutral('ajar');
|
||||
o.ajarIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
plateIsNeutral('opening');
|
||||
o.openingRings = cls().includes('activity-transition');
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
|
||||
const cs = ring ? getComputedStyle(ring) : null;
|
||||
return !!cs && cs.animationName === 'hp-activity-breathe' && cs.animationDuration === '2.2s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
plateIsNeutral('closing');
|
||||
o.closingRings = cls().includes('activity-transition');
|
||||
|
||||
// --- the morph covers every class, both ways ---------------------------
|
||||
const morph = async (dc, closedIcon, openIcon) => {
|
||||
await setCover('closed', dc ? { device_class: dc } : {});
|
||||
const a = icon();
|
||||
await setCover('open', dc ? { device_class: dc } : {});
|
||||
const b = icon();
|
||||
return a === closedIcon && b === openIcon && a !== b;
|
||||
};
|
||||
o.morphBlind = await morph('blind', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShade = await morph('shade', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShutter = await morph('shutter', 'mdi:window-shutter', 'mdi:window-shutter-open');
|
||||
o.morphCurtain = await morph('curtain', 'mdi:curtains-closed', 'mdi:curtains');
|
||||
o.morphWindow = await morph('window', 'mdi:window-closed', 'mdi:window-open');
|
||||
o.morphAwning = await morph('awning', 'mdi:awning-outline', 'mdi:awning');
|
||||
o.morphDoor = await morph('door', 'mdi:door-closed', 'mdi:door-open');
|
||||
o.morphGarage = await morph('garage', 'mdi:garage', 'mdi:garage-open');
|
||||
o.morphGate = await morph('gate', 'mdi:gate', 'mdi:gate-open');
|
||||
o.morphDamper = await morph('damper', 'mdi:circle-slice-8', 'mdi:circle-outline');
|
||||
// no device_class at all (z2m ships plenty): the gate's auto icon is the
|
||||
// one the name rule «ворота|garage|gate» hands out, and it morphs too
|
||||
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
|
||||
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
|
||||
// …and an unreadable state morphs nothing, plate still neutral
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
plateIsNeutral('unknown');
|
||||
o.unknownNoMorph = icon() === 'mdi:garage-variant';
|
||||
o.unknownNoRing = !cls().includes('activity-transition');
|
||||
|
||||
// --- NOT touched: the «открыто» frame elsewhere ------------------------
|
||||
// the demo house has a lock and a window sensor on the ground floor
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const elOf = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'unlocked', attributes: { friendly_name: 'Front door' } },
|
||||
'binary_sensor.window': { entity_id: 'binary_sensor.window', state: 'on', attributes: { friendly_name: 'Window', device_class: 'window' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const lockEl = elOf(dev('d_lock'));
|
||||
const winEl = elOf(dev('d_window'));
|
||||
o.lockMarkerFound = !!lockEl;
|
||||
o.windowMarkerFound = !!winEl;
|
||||
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
|
||||
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
|
||||
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
|
||||
&& getComputedStyle(winEl).backgroundColor === ORANGE;
|
||||
// …and a locked lock is neutral again, so the frame still MEANS something
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
|
||||
|
||||
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
|
||||
// only thing it has to say «открыт» with (deliberately left alone)
|
||||
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
|
||||
const clsOfState = (d, states) => {
|
||||
const saved = c.hass;
|
||||
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
|
||||
entity_id,
|
||||
{ entity_id, platform: 'demo', disabled_by: null },
|
||||
]));
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states },
|
||||
};
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
|
||||
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
|
||||
// and the cover's own class string, straight from the source
|
||||
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: { display: 'icon_ripple' } };
|
||||
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
|
||||
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'activity-transition';
|
||||
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'activity-transition';
|
||||
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,230 @@
|
||||
// «Открыть/закрыть» on a curtain whose cover entity used to lose to an option
|
||||
// switch in registry/domain order.
|
||||
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
|
||||
// по-прежнему инфо-карточка»).
|
||||
//
|
||||
// The device below is his, entity for entity: an Aqara «Roller shade driver
|
||||
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
|
||||
// `switch.*_reverse_direction` beside it. The shared role resolver must pick
|
||||
// the real cover for status, while the explicit tap action decides whether a
|
||||
// click controls it. A mixed lamp+cover still stays a lamp unless the owner
|
||||
// explicitly selects the cover action.
|
||||
//
|
||||
// End to end: the action is chosen in the dialog and SAVED through the normal
|
||||
// path, and the click is done on the marker the card rebuilt from that config.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
|
||||
// ---- the owner's curtain driver, joined to the demo house --------------
|
||||
const ENTS = {
|
||||
// hidden by the integration — exactly as z2m ships it
|
||||
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
|
||||
// a MIXED device: a lamp that also happens to ship a cover. Its primary is
|
||||
// meaningfully the light, and nothing below may take that away from it.
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
};
|
||||
const coverState = (state, attrs = {}) => ({
|
||||
entity_id: 'cover.office_curtain', state,
|
||||
attributes: { friendly_name: 'Office curtain', ...attrs },
|
||||
});
|
||||
const push = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null },
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'cover.office_curtain': coverState(state, attrs),
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: 'opening', attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
},
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
// park both extra markers in their own corners: the DOM assertions below
|
||||
// find a marker by its position, and the auto grid would sit them on top
|
||||
// of the demo house's own bedroom devices
|
||||
const cu = c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
if (cu && mx) {
|
||||
c._layout = { ...c._layout,
|
||||
[cu.id]: { s: cu.space, x: 0.06, y: 0.94 },
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
o.deviceIsOnThePlan = !!curtain();
|
||||
// Registry order and visibility no longer let an option switch outrank the
|
||||
// actual functional entity.
|
||||
o.primaryIsTheCover = curtain()?.primary === 'cover.office_curtain';
|
||||
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
|
||||
|
||||
// ---- the dialog offers the action and SAVES it -------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
|
||||
o.dialogOffersCover = optionValues().includes('cover');
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover', display: 'icon_ripple' };
|
||||
await c.updateComplete;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
o.markerSaved = saved?.tap_action === 'cover';
|
||||
|
||||
// ---- and the tap on THAT marker opens the curtain -----------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const tap = async () => {
|
||||
c._clickDevice({ stopPropagation() {} }, curtain());
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
};
|
||||
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.noInfoCard = !c._infoCard;
|
||||
c._infoCard = null;
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
|
||||
|
||||
// the service switch, which used to be the whole story, is never touched
|
||||
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
|
||||
|
||||
// ---- the guarded classes still degrade, read off the COVER --------------
|
||||
await push('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.garageCallsNothing = calls.length === 0;
|
||||
o.garageShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
|
||||
// ---- and the marker SHOWS that cover, not the service switch -----------
|
||||
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
|
||||
// "открыто", ни морфинга иконки». (The «открыто» frame itself was retired
|
||||
// for covers later the same day — the marker still had to start speaking for
|
||||
// the cover, which is what this section pins.) Same cause, same helper:
|
||||
// _stateClass and
|
||||
// the icon morph read d.primary, so the plan reported the state of
|
||||
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
|
||||
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
|
||||
// exactly when the owner has explicitly chosen «Открыть/закрыть».
|
||||
const devEl = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
|
||||
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
const setSwitch = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state, attributes: {} } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
o.markerIsOnScreen = !!devEl(curtain());
|
||||
o.closedIsNeitherOpenNorMoving =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('activity-transition');
|
||||
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
// the plate is neutral in every cover state since 2026-08-04 — the morph is
|
||||
// the whole open/closed story (smoke_cover_no_plate.mjs)
|
||||
o.openWearsNoColouredPlate =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('on');
|
||||
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
o.travellingBreathes = clsOf(curtain()).includes('activity-transition');
|
||||
await push('closing', { device_class: 'curtain' });
|
||||
o.closingBreathesToo = clsOf(curtain()).includes('activity-transition');
|
||||
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('activity-transition');
|
||||
|
||||
// the service switch never speaks for the marker again: reverse-direction
|
||||
// ON used to paint the curtain yellow («включено») while it stood still
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
|
||||
|
||||
// Status and action are independent: taking the explicit action away keeps
|
||||
// the resolved cover status, but a click returns to the ordinary info path.
|
||||
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
savedMarker.tap_action = 'info';
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
|
||||
&& !clsOf(curtain()).includes('on');
|
||||
savedMarker.tap_action = 'cover';
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
// ---- the auditor's own probe: BOTH entities visible ---------------------
|
||||
// The same rule must hold when BOTH entities are visible.
|
||||
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.visibleCoverIsThePrimary = curtain()?.primary === 'cover.office_curtain';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.visibleCoverAlsoIndicates = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
o.noInfoCardForTheVisibleCover = !c._infoCard;
|
||||
|
||||
// ---- a MIXED device is not hijacked ------------------------------------
|
||||
// A lit lamp that also owns a cover keeps the lamp: no explicit «Open/close»
|
||||
// on this marker, so nothing changes for it — and even if there were one,
|
||||
// a shining light still wins the yellow plate (the glow and the badge may
|
||||
// never disagree, owner's principle 2026-07-29).
|
||||
o.mixedPrimaryIsTheLight = mixed()?.primary === 'light.mixed';
|
||||
o.mixedCoverIsTravelling = c.hass.states['cover.mixed'].state === 'opening';
|
||||
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('activity-transition');
|
||||
// its icon is the device's own auto icon, NOT the cover's open/closed pair
|
||||
o.mixedIconIsNotMorphedByTheCover =
|
||||
iconOf(mixed()) === mixed()?.icon && !iconOf(mixed()).includes('curtains');
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,216 @@
|
||||
// An EXPLICIT «Открыть/закрыть» marker is a curtain, and a curtain is never
|
||||
// painted — audit DEV-1DA1-01 (P2).
|
||||
//
|
||||
// `_stateClass` used to answer in this order: bound `controls` first, then any
|
||||
// lit `light.*` of the device, and only then the entity the tap acts on
|
||||
// (`_actEntity` → `_coverIndicator`). So the two combinations below still made
|
||||
// a curtain yellow, in flat contradiction with the owner's contract («у штор
|
||||
// не должно быть жёлтой подложки НИКОГДА») and with the promise that a marker
|
||||
// indicates the entity its tap drives:
|
||||
//
|
||||
// 1. a MIXED device (a lamp that also ships a blind) told `tap_action:
|
||||
// 'cover'` — the lit `light.mixed` returned 'on' before the cover branch
|
||||
// was ever reached, so the travelling blind lost its breathing ring too;
|
||||
// 2. a curtain marker with a bound `controls` target that happened to be on
|
||||
// — `controls` returned 'on' first, same result.
|
||||
//
|
||||
// The order now starts with the explicit cover: saying «this thing is a
|
||||
// curtain» in the dialog is the strongest statement the card has about what a
|
||||
// marker IS, so nothing may paint over it. Everything else keeps the old
|
||||
// precedence — a marker that was NOT told it is a curtain is untouched, and
|
||||
// that is re-checked at the bottom.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// ---- the plate colours, resolved the way the badge resolves them -------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)');
|
||||
const ORANGE = probe('var(--hp-open)');
|
||||
const NEUTRAL = probe('var(--hp-bg)');
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
|
||||
// =======================================================================
|
||||
// Part 1 — the class itself, straight from _stateClass, over every state
|
||||
// =======================================================================
|
||||
const withStates = (states, fn) => {
|
||||
const saved = c.hass;
|
||||
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
|
||||
entity_id,
|
||||
{ entity_id, platform: 'demo', disabled_by: null },
|
||||
]));
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states },
|
||||
};
|
||||
const r = fn();
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
const st = (state, attrs = {}) => ({ state, attributes: attrs });
|
||||
/** The auditor's marker #1: primary is a LIT light, the cover is explicit. */
|
||||
const litLamp = {
|
||||
id: 'p_lamp', primary: 'light.mixed', entities: ['light.mixed', 'cover.mixed'],
|
||||
tapAction: 'cover', marker: { display: 'icon_ripple' },
|
||||
};
|
||||
/** The auditor's marker #2: a cover marker whose bound control is on. */
|
||||
const controlled = {
|
||||
id: 'p_ctrl', primary: 'cover.mixed', entities: ['cover.mixed'],
|
||||
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'], display: 'icon_ripple' },
|
||||
};
|
||||
const CV = { closed: '', open: '', opening: 'activity-transition', closing: 'activity-transition' };
|
||||
for (const [state, want] of Object.entries(CV)) {
|
||||
const states = {
|
||||
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
|
||||
'switch.control': st('on', {}),
|
||||
'cover.mixed': st(state, { device_class: 'curtain' }),
|
||||
};
|
||||
o['litLamp_' + state] = withStates(states, () => c._stateClass(litLamp)) === want;
|
||||
o['controlled_' + state] = withStates(states, () => c._stateClass(controlled)) === want;
|
||||
}
|
||||
// an unreachable cover still only fades — never yellow, never orange
|
||||
o.litLampUnavailFades = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(litLamp)) === 'unavail';
|
||||
o.controlledUnavailFades = withStates({
|
||||
'switch.control': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(controlled)) === 'unavail';
|
||||
// …and a selected cover with no state is unavailable, never borrowed from
|
||||
// the lit neighbour that the explicit cover action deliberately outranks.
|
||||
o.litLampNoCoverStateIsUnavailable = withStates({
|
||||
'light.mixed': st('on', {}),
|
||||
}, () => c._stateClass(litLamp)) === 'unavail';
|
||||
|
||||
// ---- the OTHER side of the rule: nothing changed for the rest ----------
|
||||
// the very same mixed device WITHOUT the explicit action keeps its primary
|
||||
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null, marker: null };
|
||||
o.mixedWithoutTheOptionStaysYellow = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === 'on';
|
||||
o.mixedWithoutTheOptionOffIsNeutral = withStates({
|
||||
'light.mixed': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === '';
|
||||
// a wall switch with controls and NO cover in sight still mirrors them
|
||||
const remote = {
|
||||
id: 'p_remote', primary: 'sensor.remote', entities: ['sensor.remote'], tapAction: null,
|
||||
marker: { id: 'p_remote', binding: 'device:y', controls: ['light.hall'] },
|
||||
};
|
||||
o.remoteMirrorsItsControls = withStates({ 'light.hall': st('on', {}) },
|
||||
() => c._stateClass(remote)) === 'on';
|
||||
o.remoteOffIsNeutral = withStates({ 'light.hall': st('off', {}) },
|
||||
() => c._stateClass(remote)) === '';
|
||||
// a curtain marker with controls that are OFF is still just a curtain
|
||||
o.controlledOffStillRings = withStates({
|
||||
'switch.control': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(controlled)) === 'activity-transition';
|
||||
// an explicit «cover» marker with NO cover among its entities falls back to
|
||||
// the old order — the statement is only as strong as the entity behind it
|
||||
const noCover = {
|
||||
id: 'p_nocover', primary: 'light.mixed', entities: ['light.mixed'], tapAction: 'cover', marker: null,
|
||||
};
|
||||
o.explicitCoverWithoutACoverKeepsThePrimary = withStates({ 'light.mixed': st('on', {}) },
|
||||
() => c._stateClass(noCover)) === 'on';
|
||||
|
||||
// =======================================================================
|
||||
// Part 2 — the real markers on the real plan: the PLATE, not just a class
|
||||
// =======================================================================
|
||||
const ENTS = {
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', device_id: 'd_ctrl', platform: 'demo' },
|
||||
'switch.control': { entity_id: 'switch.control', device_id: 'd_wall', platform: 'demo' },
|
||||
};
|
||||
const push = async (coverState) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null },
|
||||
d_ctrl: { id: 'd_ctrl', name: 'Office curtain', model: 'Curtain motor',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_ctrl']], entry_type: null, via_device_id: null },
|
||||
d_wall: { id: 'd_wall', name: 'Wall switch', model: 'Wall switch',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_wall']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: coverState, attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', state: coverState, attributes: { friendly_name: 'Office curtain', device_class: 'curtain' } },
|
||||
'switch.control': { entity_id: 'switch.control', state: 'on', attributes: { friendly_name: 'Wall switch' } },
|
||||
},
|
||||
};
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'], display: 'icon_ripple' },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
const ct = c._devices.find((x) => x.bindingRef === 'd_ctrl');
|
||||
if (mx && ct) {
|
||||
c._layout = { ...c._layout,
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 },
|
||||
[ct.id]: { s: ct.space, x: 0.06, y: 0.94 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — read a settled colour, not a fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await push('opening');
|
||||
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
const elOf = (d) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(d);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const report = (tag, ref) => {
|
||||
const d = dev(ref);
|
||||
const el = d && elOf(d);
|
||||
o[tag + 'MarkerFound'] = !!el;
|
||||
if (!el) return;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = bg !== YELLOW;
|
||||
o[tag + 'NotOrange'] = bg !== ORANGE;
|
||||
o[tag + 'NoOnClass'] = !el.classList.contains('on') && !el.classList.contains('open');
|
||||
o[tag + 'Rings'] = el.classList.contains('activity-transition');
|
||||
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
|
||||
};
|
||||
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
|
||||
o.controlIsTrulyOn = c.hass.states['switch.control'].state === 'on';
|
||||
report('litLampDom', 'd_mixed');
|
||||
report('controlledDom', 'd_ctrl');
|
||||
|
||||
// …and at rest (closed) they are plain, with no ring
|
||||
await push('closed');
|
||||
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
|
||||
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
|
||||
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
|
||||
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('activity-transition');
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,197 @@
|
||||
// Covers: the «Open/close» tap action + the travelling indication
|
||||
// (owner's contract 2026-08-03).
|
||||
//
|
||||
// closed -> cover.open_cover
|
||||
// open / ajar -> cover.close_cover
|
||||
// opening / closing -> cover.stop_cover (a tap during travel is a STOP;
|
||||
// the next tap simply travels the other way)
|
||||
// unknown -> cover.toggle, no morphing, no pulse
|
||||
//
|
||||
// The option is offered ONLY for a binding that HAS a cover entity, and never
|
||||
// for the guarded classes (garage/door/gate) — for those a value smuggled into
|
||||
// the config degrades to 'info', exactly like a card-wide toggle does.
|
||||
// With «Icon + activity», travelling breathes a soft activity ring (the
|
||||
// vacuum puck's 2.2s period) and the plate stays NEUTRAL — yellow means work.
|
||||
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
|
||||
// frame included: open/closed is told by the icon morph alone (the full
|
||||
// contract lives in smoke_cover_no_plate.mjs).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
const YELLOW = 'rgb(255, 212, 92)'; // --hp-on, the «включено» plate
|
||||
|
||||
const gateEntity = (state, attrs) => ({
|
||||
entity_id: 'cover.gate', state,
|
||||
attributes: { friendly_name: 'Gate', ...attrs },
|
||||
});
|
||||
/** Push a cover state into hass keeping the service recorder in place. */
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: { ...c.hass.states, 'cover.gate': gateEntity(state, attrs) },
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
/** Marker for the gate device: tap action + optional confirmation.
|
||||
* `solo` also hides the mower, leaving exactly one badge in the Garden —
|
||||
* the indication checks then read the cover's own plate, not a neighbour's. */
|
||||
const setMarker = async (tapAction, confirm = false, solo = false) => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction,
|
||||
tap_confirm: confirm || null, display: 'icon_ripple' },
|
||||
...(solo ? [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] : []),
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const gate = () => c._devices.find((x) => x.bindingRef === 'd_gate');
|
||||
const tap = async () => { c._clickDevice({ stopPropagation() {} }, gate()); await c.updateComplete; };
|
||||
// never throw when the feature is missing: the smoke must REPORT the gap
|
||||
const lastCall = () => calls[calls.length - 1] || ['-', '-', {}];
|
||||
// the gate is the only cover in the Garden space; the plan draws no names
|
||||
// inside the badge, so the icon set of the space is the readable handle
|
||||
const devEl = () => sr().querySelector('.dev');
|
||||
const icons = () => [...sr().querySelectorAll('.dev ha-icon')].map((e) => e.getAttribute('icon'));
|
||||
|
||||
// the gate lives in the Garden space — look at it, not at the ground floor
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- the dialog offers the option only where it makes sense ------------
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
|
||||
// …and the confirmation checkbox follows the choice, like it does for run/toggle
|
||||
const rowsBefore = sr().querySelectorAll('hp-dialog .srcrow').length;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
|
||||
o.confirmCheckboxShownForCover = sr().querySelectorAll('hp-dialog .srcrow').length > rowsBefore;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// …and neither does a device without a cover entity at all
|
||||
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
|
||||
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// ---- the tap itself ----------------------------------------------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.closedOpens = JSON.stringify(lastCall()) === JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.gate' }]);
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openCloses = lastCall()[0] === 'cover' && lastCall()[1] === 'close_cover';
|
||||
|
||||
// a half-drawn curtain is still 'open' for HA — it closes
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.ajarCloses = lastCall()[1] === 'close_cover';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.closingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
// no readable state — a plain cover.toggle, nothing clever
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.unknownToggles = lastCall()[0] === 'cover' && lastCall()[1] === 'toggle';
|
||||
|
||||
// ---- the confirmation checkbox works for this action too ---------------
|
||||
await setMarker('cover', true);
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.confirmAsks = !!c._tapConfirm && c._tapConfirm.text.includes('Gate');
|
||||
c._tapConfirm = null; await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmCancelCallsNothing = calls.length === 0;
|
||||
await tap();
|
||||
const conf = c._tapConfirm; c._tapConfirm = null; conf?.exec?.();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
|
||||
|
||||
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.garageSavedCoverCallsNothing = calls.length === 0;
|
||||
o.garageSavedCoverShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
|
||||
// ---- the indication ----------------------------------------------------
|
||||
const afterAnim = () => {
|
||||
const el = devEl()?.querySelector('.activity-ring.transition i:first-child');
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
return { name: cs.animationName, dur: cs.animationDuration };
|
||||
};
|
||||
await setMarker('cover', false, true); // the gate alone on the plan
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
// .dev animates `background 0.15s`: lit recycles the badge node the hidden
|
||||
// mower used, so a plate colour read in the same frame is still mid-fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
|
||||
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
const a1 = afterAnim();
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-activity-breathe' && a1?.dur === '2.2s';
|
||||
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
|
||||
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.pulseWhileClosing = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
o.closingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
o.noPulseWhenOpen = sr().querySelectorAll('.dev.activity-transition').length === 0;
|
||||
o.iconMorphOpen = icons().includes('mdi:curtains') && !icons().includes('mdi:curtains-closed');
|
||||
// owner 2026-08-04: never a coloured plate on a cover — not even «открыто»
|
||||
o.openWearsNoFrame = sr().querySelectorAll('.dev.open').length === 0;
|
||||
o.openPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
o.directCloseGetsFallback = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
o.iconMorphClosed = icons().includes('mdi:curtains-closed') && !icons().includes('mdi:curtains');
|
||||
|
||||
await setCover('open', { device_class: 'shutter' });
|
||||
o.iconMorphShutterOpen = icons().includes('mdi:window-shutter-open');
|
||||
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
o.noPulseWhenUnknown = sr().querySelectorAll('.dev.activity-transition').length === 0;
|
||||
o.noMorphWhenUnknown = icons().includes(gate().icon)
|
||||
&& !icons().some((i) => i.startsWith('mdi:curtains'));
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
+245
-6
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -11,7 +11,25 @@ const res = await page.evaluate(async () => {
|
||||
tabs[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
out.decorBar = !!sr().querySelector('.editbar.decorbar');
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
|
||||
// The compact colour/opacity control must escape the clipped editor/dialog
|
||||
// surface through the browser top layer and remain inside the viewport.
|
||||
const picker = sr().querySelector('.decorbar hp-color-opacity');
|
||||
const trigger = picker?.shadowRoot?.querySelector('.trigger');
|
||||
trigger?.click();
|
||||
await picker?.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const popup = picker?.shadowRoot?.querySelector('.picker');
|
||||
const popupRect = popup?.getBoundingClientRect();
|
||||
out.colorPickerUsesTopLayer = !!popup?.matches(':popover-open');
|
||||
out.colorPickerInsideViewport = !!popupRect
|
||||
&& popupRect.left >= 0 && popupRect.top >= 0
|
||||
&& popupRect.right <= innerWidth && popupRect.bottom <= innerHeight;
|
||||
trigger?.click();
|
||||
await picker?.updateComplete;
|
||||
// seven tools (the six drawing ones + «Мебель», docs/FURNITURE.md) plus
|
||||
// «Картинка-подложка», which f1 offers because it HAS a picture
|
||||
// (docs/BACKDROP.md §2); a hand-drawn space still shows seven
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn.dtool').length === 8;
|
||||
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
|
||||
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
@@ -25,16 +43,73 @@ const res = await page.evaluate(async () => {
|
||||
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
|
||||
// 3) линия и овал
|
||||
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
|
||||
await c.updateComplete;
|
||||
// превью рисуемой линии уже с круглыми концами (зубцы на стыках)
|
||||
const draftLine = sr().querySelector('.decorlayer line.ddraft');
|
||||
out.draftLineRound = !!draftLine
|
||||
&& draftLine.getAttribute('stroke-linecap') === 'round'
|
||||
&& draftLine.getAttribute('stroke-linejoin') === 'round';
|
||||
c._decorCommitDraft();
|
||||
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.threeShapes = c._decorList.length === 3;
|
||||
// сохранённые линии рендерятся с круглыми концами: диаметр = толщине линии,
|
||||
// стык двух линий под углом — без зубца (скриншот владельца)
|
||||
await c.updateComplete;
|
||||
const lines = [...sr().querySelectorAll('.decorlayer line.dshape')];
|
||||
out.savedLineRound = lines.length > 0 && lines.every((l) =>
|
||||
l.getAttribute('stroke-linecap') === 'round' && l.getAttribute('stroke-linejoin') === 'round');
|
||||
// вырожденная фигура не сохраняется
|
||||
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
out.degenerateSkipped = c._decorList.length === 3;
|
||||
// 4) надпись через диалог
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
|
||||
c._decorDraft = { kind: 'rect', a: [g, g], b: [g * 5, g], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
out.flatRectSkipped = c._decorList.length === 3;
|
||||
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
|
||||
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
|
||||
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
|
||||
const label = () => sr().querySelector('.measurelabel.dmeasure');
|
||||
const txt = () => (label() ? label().textContent.trim() : null);
|
||||
const cellCm = 5; // cell_cm по умолчанию
|
||||
const fmt = (px) => (((px / g) * cellCm) / 100).toFixed(2) + ' m';
|
||||
const vw = () => c._viewOr(c._baseVb());
|
||||
c._decorTool = 'line';
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
|
||||
await c.updateComplete;
|
||||
out.lineBadgeShown = !!label();
|
||||
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
|
||||
out.lineBadgeText = txt();
|
||||
out.lineBadgeMatchesGeometry = txt() === fmt(g * 12) + ' · 0°';
|
||||
// ...ровно на середине отрезка (владелец: «плашка на середине линии»)
|
||||
out.lineBadgeAtTheMiddle = !!label() && Math.abs(
|
||||
parseFloat(label().style.left) - ((g * 10 - vw().x) / vw().w) * 100) < 0.02;
|
||||
// 0° кратен 45° — плашка зелёная, как у стены на 45°
|
||||
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
|
||||
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
|
||||
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
|
||||
// прямоугольник показывает габарит и чистую площадь
|
||||
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.rectBadgeHasSizeAndArea = txt().startsWith(fmt(g * 8) + ' × ' + fmt(g * 6) + ' · ')
|
||||
&& /(?:m²|ft²)$/.test(txt());
|
||||
// отпустили — плашки нет
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.badgeGoneAfterRelease = !label();
|
||||
c._curSpaceCfg.decor = c._decorList.slice(0, 3); // назад к трём фигурам
|
||||
c._decorTool = 'select';
|
||||
await c.updateComplete;
|
||||
out.stillThreeShapes = c._decorList.length === 3;
|
||||
// 4) надпись через диалог. Поля `size` в диалоге больше нет — размер задаётся
|
||||
// углами блока (см. smoke_decor_text); сохранённый старый `size` по-прежнему
|
||||
// читается как начальный масштаб.
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', color: '#0000ff' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
|
||||
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
|
||||
@@ -50,10 +125,173 @@ const res = await page.evaluate(async () => {
|
||||
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
|
||||
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
|
||||
c._decorMove = null;
|
||||
// 5b) инструмент рисования ВЛАДЕЕТ холстом: клик по существующей фигуре
|
||||
// начинает новую фигуру в этой точке, а не выделяет старую (владелец
|
||||
// 2026-08-04: «нельзя поставить начало линии на конец другой»).
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
// экранные координаты точки холста (обратное _screenToVb)
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||||
};
|
||||
const press = (el, x, y, pid) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
pointerId: pid, clientX, clientY, button: 0, isPrimary: true }));
|
||||
};
|
||||
// подопытная линия с известными концами
|
||||
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; c._decorMove = null;
|
||||
const decorBefore = c._decorList.slice();
|
||||
c._curSpaceCfg.decor = [{ id: 'dcprobe', kind: 'line', x1: (g * 6) / 1000, y1: (g * 30) / 1000,
|
||||
x2: (g * 18) / 1000, y2: (g * 30) / 1000, color: '#000000', width: 3 }];
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const probe = () => [...sr().querySelectorAll('.decorlayer line.dshape')]
|
||||
.find((l) => Math.abs(+l.getAttribute('x1') - g * 6) < 0.01);
|
||||
out.probeRendered = !!probe();
|
||||
// — инструмент «линия»: жмём ровно на КОНЕЦ линии
|
||||
c._decorTool = 'line'; await c.updateComplete;
|
||||
// фигура вообще перестаёт быть мишенью для указателя
|
||||
out.shapeInertUnderLineTool = getComputedStyle(probe()).pointerEvents === 'none';
|
||||
press(probe(), g * 18, g * 30, 41);
|
||||
await c.updateComplete;
|
||||
out.lineToolStartsDraft = !!c._decorDraft && c._decorDraft.kind === 'line';
|
||||
// ...и именно из этой точки (снап к концу старой линии)
|
||||
out.draftStartsAtTheEnd = !!c._decorDraft
|
||||
&& Math.abs(c._decorDraft.a[0] - g * 18) < 0.51 && Math.abs(c._decorDraft.a[1] - g * 30) < 0.51;
|
||||
out.lineToolNoSelect = c._decorSel === null;
|
||||
out.lineToolNoMove = !c._decorMove;
|
||||
c._decorDraft = null; await c.updateComplete;
|
||||
// — прямоугольник и овал ведут себя так же
|
||||
for (const [tool, key] of [['rect', 'rectToolStartsDraft'], ['ellipse', 'ellipseToolStartsDraft']]) {
|
||||
c._decorTool = tool; c._decorSel = null; await c.updateComplete;
|
||||
press(probe(), g * 12, g * 30, 42);
|
||||
await c.updateComplete;
|
||||
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
|
||||
c._decorDraft = null;
|
||||
}
|
||||
// — «текст» по ЛИНИИ открывает диалог новой надписи, а не выделяет её.
|
||||
// Инертность нетекстовых фигур под рисующими инструментами остаётся в силе;
|
||||
// единственное исключение — надпись под инструментом «текст», она открывает
|
||||
// СВОЮ форму (владелец 2026-08-04, пинится в smoke_decor_text).
|
||||
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
|
||||
press(probe(), g * 12, g * 30, 43);
|
||||
await c.updateComplete;
|
||||
out.textToolOpensDialog = !!c._decorTextDialog && !c._decorTextDialog.id && c._decorSel === null;
|
||||
c._decorTextDialog = null;
|
||||
// — а вот в «выборе» всё по-прежнему: клик выделяет и берёт на перетаскивание
|
||||
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; await c.updateComplete;
|
||||
out.shapeLiveUnderSelect = getComputedStyle(probe()).pointerEvents !== 'none';
|
||||
press(probe(), g * 12, g * 30, 44);
|
||||
await c.updateComplete;
|
||||
out.selectStillSelects = c._decorSel === 'dcprobe';
|
||||
out.selectStillGrabs = !!c._decorMove && c._decorMove.id === 'dcprobe';
|
||||
out.selectMakesNoDraft = !c._decorDraft;
|
||||
c._decorMove = null;
|
||||
// Double click in Select is the universal properties gesture for every
|
||||
// object, not only text. It must go through the rendered listener so this
|
||||
// also pins the event wiring, then persist the common colour/width fields.
|
||||
probe().dispatchEvent(new MouseEvent('dblclick', {
|
||||
bubbles: true, composed: true, cancelable: true, button: 0,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.doubleClickOpensObjectDialog = c._decorShapeDialog?.id === 'dcprobe'
|
||||
&& !!sr().querySelector('hp-dialog .dfill') === false;
|
||||
out.lineStyleDefaultsSolid = c._decorShapeDialog?.lineStyle === 'solid'
|
||||
&& sr().querySelectorAll('input[name="decor-line-style"]').length === 2
|
||||
&& !c._decorList.find((x) => x.id === 'dcprobe')?.line_style;
|
||||
const angleInput = sr().querySelector('hp-dialog input[min="-180"][max="180"]');
|
||||
if (angleInput) {
|
||||
angleInput.value = '12.3456';
|
||||
angleInput.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
}
|
||||
out.angleInputKeepsWorkingPrecision = c._decorShapeDialog?.angle === '12.3456'
|
||||
&& sr().querySelector('hp-dialog input[min="-180"][max="180"]')?.value === '12.3456';
|
||||
out.lineStyleAbsentFromToolbar = !sr().querySelector('.editbar input[name="decor-line-style"]');
|
||||
c._decorShapeDialog = {
|
||||
...c._decorShapeDialog, color: '#123456', widthCm: 6.5, lineStyle: 'dashed',
|
||||
};
|
||||
c._decorSaveShape(); await c.updateComplete;
|
||||
const editedProbe = c._decorList.find((x) => x.id === 'dcprobe');
|
||||
out.objectDialogSavesStyle = editedProbe?.color === '#123456'
|
||||
&& editedProbe?.width_cm === 6.5 && editedProbe?.width === undefined
|
||||
&& editedProbe?.line_style === 'dashed'
|
||||
&& c._decorShapeDialog === null;
|
||||
out.dashedLineRendered = !!probe()?.getAttribute('stroke-dasharray');
|
||||
c._undoGeometry(); await c.updateComplete;
|
||||
const undoSolid = !c._decorList.find((x) => x.id === 'dcprobe')?.line_style;
|
||||
c._redoGeometry(); await c.updateComplete;
|
||||
out.lineStyleUndoRedo = undoSolid
|
||||
&& c._decorList.find((x) => x.id === 'dcprobe')?.line_style === 'dashed';
|
||||
const selectHit = sr().querySelector('.decorlayer line.dselecthit');
|
||||
const dashedPaint = probe();
|
||||
let gapPicked = null;
|
||||
if (selectHit && dashedPaint) {
|
||||
const sw = Number(dashedPaint.getAttribute('stroke-width')) || 1;
|
||||
const gapPoint = dashedPaint.getPointAtLength(Math.min(dashedPaint.getTotalLength() * 0.75, sw * 5.5));
|
||||
const screenPoint = new DOMPoint(gapPoint.x, gapPoint.y).matrixTransform(dashedPaint.getScreenCTM());
|
||||
gapPicked = sr().elementFromPoint(screenPoint.x, screenPoint.y);
|
||||
}
|
||||
out.dashedLineSelectableAcrossGaps = gapPicked === selectHit
|
||||
&& selectHit?.classList.contains('dshape')
|
||||
&& selectHit?.dataset.hp === 'decor'
|
||||
&& selectHit?.dataset.id === 'dcprobe';
|
||||
// Erase gets a constant screen-space target around hairlines. Exercise the
|
||||
// browser's actual SVG hit-test 6 px away from a sub-pixel painted stroke;
|
||||
// dispatching directly to the proxy would only prove that its listener exists.
|
||||
c._replaceDecor('dcprobe', { width_cm: 0.1, width: undefined });
|
||||
c._decorTool = 'erase'; await c.updateComplete;
|
||||
const paintedProbe = sr().querySelector('.decorlayer line.dshape:not(.derasehit)');
|
||||
const eraseHit = sr().querySelector('.decorlayer line.derasehit');
|
||||
const matrix = paintedProbe?.getScreenCTM();
|
||||
let picked = null;
|
||||
if (paintedProbe && eraseHit && matrix) {
|
||||
const p1 = new DOMPoint(+paintedProbe.getAttribute('x1'), +paintedProbe.getAttribute('y1')).matrixTransform(matrix);
|
||||
const p2 = new DOMPoint(+paintedProbe.getAttribute('x2'), +paintedProbe.getAttribute('y2')).matrixTransform(matrix);
|
||||
const dx = p2.x - p1.x, dy = p2.y - p1.y, len = Math.hypot(dx, dy) || 1;
|
||||
picked = sr().elementFromPoint((p1.x + p2.x) / 2 - (dy / len) * 6,
|
||||
(p1.y + p2.y) / 2 + (dx / len) * 6);
|
||||
}
|
||||
out.eraseHairlineHasWideHitTarget = picked === eraseHit
|
||||
&& getComputedStyle(eraseHit).strokeWidth === '16px'
|
||||
&& getComputedStyle(eraseHit).pointerEvents === 'stroke';
|
||||
picked?.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: 45, button: 0, isPrimary: true,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.eraseWideHitOpensConfirmation = c._decorEraseConfirm?.id === 'dcprobe';
|
||||
c._decorEraseConfirm = null;
|
||||
c._decorSel = null;
|
||||
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
|
||||
c._decorTool = 'select'; await c.updateComplete;
|
||||
out.decorRestored = c._decorList.length === decorBefore.length;
|
||||
// Switching modes during a live move cancels the transaction and restores
|
||||
// its start instead of leaving mutated geometry with a forgotten pointer.
|
||||
const liveShape = c._decorList[0];
|
||||
const liveBefore = JSON.parse(JSON.stringify(liveShape));
|
||||
const liveSnapshot = c._geometrySnapshot();
|
||||
c._decorMove = {
|
||||
id: liveShape.id, start: [0, 0], orig: liveBefore, pid: 99,
|
||||
moved: true, before: liveSnapshot,
|
||||
};
|
||||
c._curSpaceCfg.decor = c._decorList.map((shape) => shape.id === liveShape.id
|
||||
? { ...shape, x: (shape.x || 0) + 0.2 }
|
||||
: shape);
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
const restoredLive = c._decorList.find((shape) => shape.id === liveShape.id);
|
||||
out.modeSwitchCancelsLiveDecor = !c._decorMove
|
||||
&& JSON.stringify(restoredLive) === JSON.stringify(liveBefore);
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
// 6) erase удаляет
|
||||
const n0 = c._decorList.length;
|
||||
c._decorTool = 'erase';
|
||||
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
|
||||
out.eraseAsksConfirmation = c._decorList.length === n0 && !!c._decorEraseConfirm;
|
||||
c._confirmDecorErase();
|
||||
out.eraseWorks = c._decorList.length === n0 - 1;
|
||||
// 7) Esc-лестница: инструмент → select → выход
|
||||
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
|
||||
@@ -70,5 +308,6 @@ const res = await page.evaluate(async () => {
|
||||
out.deleteKey = c._decorList.length === n1 - 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значение плашки зафиксировано числом: 12 клеток × cell_cm 5 = 0.60 m, 0°
|
||||
checkAll(res, { lineBadgeText: '0.60 m · 0°' });
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Текстовый блок декора (правки владельца 2026-08-04):
|
||||
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока и
|
||||
// после явной правки хранится физически в `size_cm`; старый `size`
|
||||
// до этого продолжает читаться без визуальной миграции;
|
||||
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
|
||||
// выравнивается по центру;
|
||||
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
|
||||
// (шаг 5°, Shift — мимо шага); ВИДИМЫЙ размер ручки — четверть хит-зоны
|
||||
// (правка владельца 2026-08-05 «уменьшить в 4 раза»), сама хит-зона
|
||||
// прежняя, пальцевая: 1.8 % видимого вида;
|
||||
// 4) при инструменте «текст» клик по УЖЕ РАЗМЕЩЁННОЙ надписи открывает её
|
||||
// форму, а не создаёт новую; по пустому месту и по НЕтекстовой фигуре —
|
||||
// создаёт новую (нетекстовые фигуры остаются инертными, см. smoke_decor).
|
||||
// ПАДАЕТ на сборке до этих правок: в диалоге ещё радиокнопки размера, перевод
|
||||
// строки схлопывается в одну строку, ручек нет, а «текст» по надписи создаёт
|
||||
// вторую надпись поверх первой.
|
||||
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
|
||||
// смок должен показать список провалов, а не упасть с исключением
|
||||
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
|
||||
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
|
||||
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||||
};
|
||||
const ev = (type, target, x, y, extra = {}) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
|
||||
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
|
||||
};
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
|
||||
|
||||
// ================= 1. размера шрифта в диалоге больше нет ================
|
||||
c._decorTool = 'text';
|
||||
c._decorTextDialog = { x: 0.3, y: 0.3, text: '', color: '#223344' };
|
||||
await c.updateComplete;
|
||||
out.noSizePicker = !sr().querySelector('input[name="dtsize"]');
|
||||
out.textareaNotInput = !!sr().querySelector('hp-dialog textarea.dtarea');
|
||||
c._decorTextDialog = null; await c.updateComplete;
|
||||
|
||||
// старый `size` продолжает рисоваться ровно как раньше: 'l' = 30px
|
||||
c._curSpaceCfg.decor = [
|
||||
{ id: 'dtl', kind: 'text', x: 0.25, y: 0.25, text: 'Старый', size: 'l', color: '#223344' },
|
||||
{ id: 'dts', kind: 'text', x: 0.25, y: 0.4, text: 'Мелкий', size: 's', color: '#223344' },
|
||||
{ id: 'dtm', kind: 'text', x: 0.25, y: 0.55, text: 'Обычный', color: '#223344' },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyLarge = +attr('dtl', 'font-size') === 30;
|
||||
out.legacySmall = +attr('dts', 'font-size') === 14;
|
||||
out.noSizeIsMedium = +attr('dtm', 'font-size') === 20;
|
||||
|
||||
// ================= 2. многострочный текст, по центру =====================
|
||||
c._decorTool = 'text';
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: ' Гараж\nпод ключ ', color: '#223344' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const multi = c._decorList.find((x) => x.text && x.text.includes('\n'));
|
||||
out.newlineStored = !!multi && multi.text === 'Гараж\nпод ключ'; // края обрезаны, середина цела
|
||||
const tsp = tspans(multi.id);
|
||||
out.twoLinesRendered = tsp.length === 2
|
||||
&& tsp[0].textContent === 'Гараж' && tsp[1].textContent === 'под ключ';
|
||||
out.linesCentredHorizontally = tsp.length === 2 && !!el(multi.id)
|
||||
&& getComputedStyle(el(multi.id)).textAnchor === 'middle'
|
||||
&& tsp[0].getAttribute('x') === tsp[1].getAttribute('x');
|
||||
// блок центрирован и по вертикали: якорь ровно между строками
|
||||
const ay = multi.y * 1000;
|
||||
const y0 = tsp[0] ? +tsp[0].getAttribute('y') : NaN;
|
||||
const y1 = tsp[1] ? +tsp[1].getAttribute('y') : NaN;
|
||||
out.blockCentredVertically = Math.abs((y0 + y1) / 2 - ay) < 0.01 && y1 > y0;
|
||||
// длинная строка НЕ переносится сама (docs/LIVE-TEXT.md: никаких автопереносов)
|
||||
open(multi); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'x'.repeat(120) };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.noAutoWrap = tspans(multi.id).length === 1;
|
||||
open(c._decorList.find((x) => x.id === multi.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'Гараж\nпод ключ' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
|
||||
// ================= 3. ручки масштаба и поворота ==========================
|
||||
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
|
||||
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.fourCornersAndRotate = !!frame()
|
||||
&& frame().querySelectorAll('.dthandle').length === 5
|
||||
&& !!frame().querySelector('.dtrot');
|
||||
|
||||
// --- визуал в 4 раза меньше, хит-зона прежняя (docs/LIVE-TEXT.md §3) ---
|
||||
out.fiveVisibleKnobs = frame()?.querySelectorAll('.dtknob').length === 5;
|
||||
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
|
||||
const hitR = rOf('.dthandle.dtrot');
|
||||
const knobR = rOf('.dtknob');
|
||||
const vw = c._viewOr(c._baseVb());
|
||||
const expectHit = Math.max(vw.w, vw.h) * 0.018;
|
||||
out.hitRadiusUnchanged = Math.abs(hitR - expectHit) < 0.1; // прежние 1.8 %
|
||||
out.knobIsAQuarterOfTheHit = Math.abs(knobR * 4 - hitR) / Math.max(hitR, 1e-9) < 0.05;
|
||||
out.knobTakesNoPointer = getComputedStyle(frame().querySelector('.dtknob')).pointerEvents === 'none';
|
||||
out.handleTakesThePointer = getComputedStyle(frame().querySelector('.dthandle')).pointerEvents !== 'none';
|
||||
// …и палец, попавший МИМО бусины, но внутрь прежнего круга, всё ещё берёт ручку
|
||||
const rotEl = frame().querySelector('.dtrot');
|
||||
const rcx = +rotEl.getAttribute('cx'), rcy = +rotEl.getAttribute('cy');
|
||||
const far = toScreen(rcx + hitR * 0.7, rcy); // > knobR, < hitR
|
||||
const hitEl = sr().elementFromPoint(far.clientX, far.clientY);
|
||||
out.fingerZoneStillCatchesTheHandle = !!hitEl?.classList?.contains?.('dthandle');
|
||||
// линия использует общую рамку, но только с двумя ручками концов
|
||||
c._curSpaceCfg.decor = [...c._decorList, { id: 'dline', kind: 'line',
|
||||
x1: 0.1, y1: 0.8, x2: 0.4, y2: 0.8, color: '#000000', width: 3 }];
|
||||
c._cfgEpoch++; c._decorSel = 'dline'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(40); await c.updateComplete;
|
||||
const lineFrame = sr().querySelector('.dtframe.dtlineframe');
|
||||
out.lineGetsEndpointFrame = !!lineFrame
|
||||
&& lineFrame.querySelectorAll('.dthandle.dtendpoint').length === 2
|
||||
&& !lineFrame.querySelector('.dtrot');
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
|
||||
// тянем угол: физический размер растёт пропорционально расстоянию от якоря
|
||||
const anchor = () => {
|
||||
const sh = c._decorList.find((x) => x.id === 'dtm');
|
||||
return [sh.x * 1000, sh.y * 1000];
|
||||
};
|
||||
const [ax, ay2] = anchor();
|
||||
const mediumSizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtm'));
|
||||
const corner = frame()?.querySelector('.dthandle.dt-nwse') || stageEl();
|
||||
ev('pointerdown', corner, ax + 40, ay2);
|
||||
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
|
||||
ev('pointermove', stageEl(), ax + 80, ay2);
|
||||
await c.updateComplete;
|
||||
const scaled = c._decorList.find((x) => x.id === 'dtm');
|
||||
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
|
||||
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
|
||||
out.fontFollowsScale = +attr('dtm', 'font-size') === 40;
|
||||
ev('pointerup', stageEl(), ax + 80, ay2);
|
||||
out.dragEnded = !c._dtDrag;
|
||||
|
||||
// старый `size` становится физическим `size_cm` только после явной правки
|
||||
c._decorSel = 'dtl'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const [lx, ly] = [c._decorList.find((x) => x.id === 'dtl').x * 1000,
|
||||
c._decorList.find((x) => x.id === 'dtl').y * 1000];
|
||||
const legacySizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtl'));
|
||||
ev('pointerdown', frame()?.querySelector('.dthandle.dt-nwse') || stageEl(), lx + 50, ly);
|
||||
ev('pointermove', stageEl(), lx + 100, ly);
|
||||
await c.updateComplete;
|
||||
const grown = c._decorList.find((x) => x.id === 'dtl');
|
||||
out.legacySizeBecomesPhysical = grown.size === undefined && grown.scale === undefined
|
||||
&& Math.abs((grown.size_cm || 0) - legacySizeCm0 * 2) < 0.01;
|
||||
ev('pointerup', stageEl(), lx + 100, ly);
|
||||
|
||||
// ручка поворота: шаг 5°, Shift — мимо шага
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const [rx, ry] = anchor();
|
||||
const rot = frame()?.querySelector('.dthandle.dtrot') || stageEl();
|
||||
ev('pointerdown', rot, rx + 100, ry);
|
||||
out.rotateStarted = c._dtDrag?.kind === 'rotate';
|
||||
ev('pointermove', stageEl(), rx + 100, ry + 100); // ровно 45°
|
||||
await c.updateComplete;
|
||||
out.rotates = Math.abs((c._decorList.find((x) => x.id === 'dtm').angle || 0) - 45) < 1e-6;
|
||||
ev('pointermove', stageEl(), rx + 100, ry + 2); // ~1.1° → прилипает к 0
|
||||
await c.updateComplete;
|
||||
out.snapsTo5 = (c._decorList.find((x) => x.id === 'dtm').angle || 0) === 0;
|
||||
ev('pointermove', stageEl(), rx + 100, ry + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const free = c._decorList.find((x) => x.id === 'dtm').angle;
|
||||
out.shiftGoesFree = free > 0.5 && free < 5; // тот же жест, но точный угол
|
||||
ev('pointerup', stageEl(), rx + 100, ry + 2);
|
||||
await c.updateComplete;
|
||||
out.rotationRendered = /rotate\(/.test(attr('dtm', 'transform') || '');
|
||||
// …а без Shift тот же 1.1° снова прилипает к нулю
|
||||
// повернули обратно в ноль — поле исчезает, фигура снова «прямая»
|
||||
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot') || stageEl(), rx + 100, ry);
|
||||
ev('pointermove', stageEl(), rx + 100, ry);
|
||||
ev('pointerup', stageEl(), rx + 100, ry);
|
||||
await c.updateComplete;
|
||||
out.zeroAngleDropped = c._decorList.find((x) => x.id === 'dtm').angle === undefined;
|
||||
|
||||
// ================= 4. инструмент «текст» по надписи = редактировать =======
|
||||
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null;
|
||||
await c.updateComplete;
|
||||
const before = c._decorList.length;
|
||||
const target = c._decorList.find((x) => x.id === 'dtm');
|
||||
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
|
||||
out.chromiumTextUsesAtomicBoundingBox = getComputedStyle(el('dtm')).pointerEvents === 'bounding-box';
|
||||
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
|
||||
await c.updateComplete;
|
||||
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
|
||||
&& c._decorTextDialog.text === target.text;
|
||||
out.textToolMakesNoSecondLabel = c._decorList.length === before;
|
||||
out.textToolDoesNotSelect = c._decorSel === null;
|
||||
c._decorTextDialog = null; await c.updateComplete;
|
||||
|
||||
// по пустому месту — новая надпись
|
||||
ev('pointerdown', stageEl(), 60, 940);
|
||||
await c.updateComplete;
|
||||
out.textToolOnEmptyCreates = !!c._decorTextDialog && !c._decorTextDialog.id;
|
||||
c._decorTextDialog = null; await c.updateComplete;
|
||||
|
||||
// по НЕтекстовой фигуре — тоже новая надпись: она осталась инертной
|
||||
const lineEl = sr().querySelector('.decorlayer line.dshape');
|
||||
out.lineStillInert = getComputedStyle(lineEl).pointerEvents === 'none';
|
||||
ev('pointerdown', lineEl, 250, 800);
|
||||
await c.updateComplete;
|
||||
out.textToolOnAShapeCreates = !!c._decorTextDialog && !c._decorTextDialog.id
|
||||
&& c._decorSel === null;
|
||||
c._decorTextDialog = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,60 @@
|
||||
// Shared face parity for the same live light on the full plan, the marker
|
||||
// editor preview and houseplan-space-card.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const face = (node) => {
|
||||
if (!node) return null;
|
||||
const semantic = ['on', 'open', 'alarm', 'unavail', 'valonly', 'activity-running', 'activity-event', 'activity-presence', 'activity-transition'];
|
||||
return {
|
||||
classes: semantic.filter((name) => node.classList.contains(name)),
|
||||
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
|
||||
value: node.querySelector('.valtext')?.textContent?.trim() || '',
|
||||
temp: node.querySelector('.tval')?.textContent?.trim() || '',
|
||||
hum: node.querySelector('.hval')?.textContent?.trim() || '',
|
||||
scale: node.style.getPropertyValue('--dev-scale'),
|
||||
rippleScale: node.style.getPropertyValue('--ripple-scale'),
|
||||
rippleColor: node.style.getPropertyValue('--ripple-color'),
|
||||
};
|
||||
};
|
||||
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const planFace = face(sr().querySelector('.dev[data-id="d_light1"]'));
|
||||
|
||||
c._setMode('devices');
|
||||
const device = c._devices.find((item) => item.id === 'd_light1');
|
||||
c._openMarkerDialog(device);
|
||||
await c.updateComplete;
|
||||
const preview = sr().querySelector('hp-device-preview');
|
||||
await preview?.updateComplete;
|
||||
const previewFace = face(preview?.renderRoot?.querySelector('.dev'));
|
||||
const providerShown = /demo/i.test(preview?.renderRoot?.querySelector('.previewfacts')?.textContent || '');
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = c.hass;
|
||||
document.body.appendChild(card);
|
||||
const started = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - started < 6000) await wait(60);
|
||||
await card.updateComplete;
|
||||
const staticNode = card.renderRoot.querySelector('.dev[data-id="d_light1"]');
|
||||
const staticFace = face(staticNode);
|
||||
|
||||
return {
|
||||
allFacesPresent: !!planFace && !!previewFace && !!staticFace,
|
||||
planPreviewEqual: JSON.stringify(planFace) === JSON.stringify(previewFace),
|
||||
planStaticEqual: JSON.stringify(planFace) === JSON.stringify(staticFace),
|
||||
providerShown,
|
||||
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
let pageError = null;
|
||||
window.addEventListener('error', (e) => { pageError = String(e.message); });
|
||||
// сохранение падает, а диалог закрыт до ответа — карточка не должна умереть
|
||||
c.hass = { ...c.hass, callWS: async (msg) => {
|
||||
if (String(msg.type).endsWith('/set')) { await new Promise((r) => setTimeout(r, 60)); throw new Error('boom'); }
|
||||
return { config: c._serverCfg, rev: c._cfgRev };
|
||||
} };
|
||||
await c.updateComplete;
|
||||
// 1) диалог общих настроек
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
const p = c._saveSettingsDialog();
|
||||
c._settingsDialog = null; // Esc во время сохранения
|
||||
await c.updateComplete;
|
||||
await p.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await c.updateComplete;
|
||||
out.settingsStaysClosed = c._settingsDialog === null;
|
||||
out.cardAliveAfterSettings = !!sr().querySelector('.stage');
|
||||
// 2) диалог правил
|
||||
c._openRulesDialog(); await c.updateComplete;
|
||||
const p2 = c._saveRules();
|
||||
c._rulesDialog = null; await c.updateComplete;
|
||||
await p2.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await c.updateComplete;
|
||||
out.rulesStaysClosed = c._rulesDialog === null;
|
||||
out.cardAliveAfterRules = !!sr().querySelector('.stage');
|
||||
// 3) диалог устройства
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
|
||||
const p3 = c._saveMarker();
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
await p3.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await c.updateComplete;
|
||||
out.markerStaysClosed = c._markerDialog === null;
|
||||
out.cardAliveAfterMarker = !!sr().querySelector('.stage');
|
||||
out.noPageError = pageError === null;
|
||||
// 4) при ОТКРЫТОМ диалоге ошибка снимает busy (поведение сохранено)
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
const p4 = c._saveSettingsDialog();
|
||||
await p4.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.busyClearedWhenOpen = c._settingsDialog !== null && c._settingsDialog.busy === false;
|
||||
c._settingsDialog = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,70 @@
|
||||
// HA-disabled lifecycle: saved marker -> forced hidden -> service ghost and
|
||||
// blocked Show -> same marker restored after reactivation, without config writes.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const paint = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'd_light1'),
|
||||
{ id: 'd_light1', binding: 'device:d_light1' },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('view');
|
||||
await paint();
|
||||
const configBefore = JSON.stringify(c._serverCfg);
|
||||
const layoutBefore = JSON.stringify(c._layout);
|
||||
const visibleBefore = !!sr().querySelector('.dev[data-id="d_light1"]');
|
||||
|
||||
window.__setRegistryDisabled('device', 'd_light1', 'user');
|
||||
c.hass = window.__mkHass();
|
||||
await wait(220);
|
||||
await paint();
|
||||
const hiddenInView = !sr().querySelector('.dev[data-id="d_light1"]');
|
||||
|
||||
c._showHidden = true;
|
||||
c._setMode('devices');
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
await paint();
|
||||
const ghost = sr().querySelector('.dev[data-id="d_light1"][data-binding-status="ha-disabled"]');
|
||||
const disabledDevice = c._devices.find((device) => device.id === 'd_light1');
|
||||
c._openMarkerDialog(disabledDevice);
|
||||
await paint();
|
||||
const hiddenBeforeShow = c._markerDialog?.hideFromPlan;
|
||||
c._toggleMarkerDialogVisibility();
|
||||
await paint();
|
||||
const showBlocked = /disabled|deactiv/i.test(c._toast)
|
||||
&& c._markerDialog?.hideFromPlan === hiddenBeforeShow
|
||||
&& sr().querySelector('.toast')?.getAttribute('role') === 'alert';
|
||||
c._markerDialog = null;
|
||||
|
||||
window.__setRegistryDisabled('device', 'd_light1', null);
|
||||
c.hass = window.__mkHass();
|
||||
await wait(220);
|
||||
c._showHidden = false;
|
||||
c._setMode('view');
|
||||
await paint();
|
||||
|
||||
return {
|
||||
visibleBefore,
|
||||
hiddenInView,
|
||||
labelledGhost: !!ghost,
|
||||
showBlocked,
|
||||
restored: !!sr().querySelector('.dev[data-id="d_light1"]'),
|
||||
configUnchanged: JSON.stringify(c._serverCfg) === configBefore,
|
||||
layoutUnchanged: JSON.stringify(c._layout) === layoutBefore,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,126 @@
|
||||
// DEV-B58-01 — the infinite canvas reached the FRAME and the drawing, but not
|
||||
// the drag handlers. A device marker was clamped into the CONTENT FRAME (with
|
||||
// a 0.8 % inset), a room label into the space's stored view_box — which, for
|
||||
// every plan the card has ever written, is the old unit square. The owner and a
|
||||
// user hit the same wall: "названия комнат и устройства не перетаскиваются
|
||||
// дальше старых границ холста".
|
||||
//
|
||||
// Every interactive coordinate now has ONE bound: the ±5000 the backend
|
||||
// enforces (docs/CANVAS.md §9). This smoke starts from an ORDINARY plan — one
|
||||
// that lives inside 0..1, so the old clamps really were in the way — and drags
|
||||
// each kind of thing out to 2.5 / 2.2 and beyond.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const NORM_W = 1000;
|
||||
const near = (a, b, eps = 1 / 240) => Math.abs(a - b) <= eps + 1e-9;
|
||||
const onGrid = (v) => Math.abs(v * 240 - Math.round(v * 240)) < 1e-9;
|
||||
|
||||
// the demo plan is an ordinary one: rooms inside the old unit square
|
||||
const b0 = c._baseVb();
|
||||
o.startsFromAnOrdinaryPlan = b0[0] + b0[2] < 1.3 * NORM_W && b0[1] + b0[3] < 1.3 * NORM_W;
|
||||
|
||||
/** Turn a wanted RENDER-unit delta into the clientX/clientY a handler wants. */
|
||||
const mk = (dxu, dyu, sx = 400, sy = 400, extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: sx + (dxu / v.w) * rect.width,
|
||||
clientY: sy + (dyu / v.h) * rect.height,
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
|
||||
// ---- (a) a DEVICE MARKER goes to 2.5 / 2.2 ---------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => !d.virtual);
|
||||
const p0 = c._pos(dev);
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y), dev);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev);
|
||||
await c.updateComplete;
|
||||
const saved = c._layout[dev.id];
|
||||
o.markerLeftTheOldCanvas = near(saved.x, 2.5) && near(saved.y, 2.2);
|
||||
o.markerIsOnTheGrid = onGrid(saved.x) && onGrid(saved.y);
|
||||
|
||||
// ---- (b) it SURVIVES a rebuild ---------------------------------------
|
||||
c._drag = null;
|
||||
c._modelCache = null; c._frame = null; c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const back = c._pos(c._devices.find((d) => d.id === dev.id));
|
||||
o.markerSurvivesRebuild = near(back.x / NORM_W, 2.5) && near(back.y / NORM_W, 2.2);
|
||||
// …and the frame followed it out there instead of pretending it is elsewhere
|
||||
const b1 = c._baseVb();
|
||||
o.frameFollowedTheMarker = b1[0] + b1[2] > 2.4 * NORM_W;
|
||||
|
||||
// ---- (c) a ROOM LABEL, the worse of the two (it clamped to view_box) --
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
|
||||
const lp = c._labelPos(room, c._space);
|
||||
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
|
||||
c._labelMove(mk(2.7 * NORM_W - lp.x, 2.4 * NORM_W - lp.y), room, c._space);
|
||||
c._labelUp(room);
|
||||
await c.updateComplete;
|
||||
const sl = c._layout['rl_' + room.id];
|
||||
o.labelLeftTheOldCanvas = near(sl.x, 2.7) && near(sl.y, 2.4);
|
||||
o.labelIsOnTheGrid = onGrid(sl.x) && onGrid(sl.y);
|
||||
c._drag = null;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const lp2 = c._labelPos(room, c._space);
|
||||
o.labelSurvivesRebuild = near(lp2.x / NORM_W, 2.7) && near(lp2.y / NORM_W, 2.4);
|
||||
|
||||
// ---- (d) DECOR born far out, and moved further still -------------------
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._decorTool = 'rect';
|
||||
c._decorDraft = { kind: 'rect', a: [2.6 * NORM_W, 2.6 * NORM_W], b: [2.9 * NORM_W, 2.8 * NORM_W], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
const sh = c._decorList[c._decorList.length - 1];
|
||||
o.decorBornFarOut = near(sh.x, 2.6) && near(sh.y, 2.6);
|
||||
// the decor mover works in ABSOLUTE svg points, so the gesture is built the
|
||||
// way _decorShapeDown builds it: grab at the shape, release 0.6/0.5 away
|
||||
const atUnits = (X, Y) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
|
||||
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true,
|
||||
});
|
||||
};
|
||||
const grab = c._svgPoint(atUnits(2.6 * NORM_W, 2.6 * NORM_W));
|
||||
c._decorMove = { id: sh.id, start: grab,
|
||||
orig: JSON.parse(JSON.stringify(sh)), pid: 2, moved: false };
|
||||
c._decorMoveUpdate(atUnits(3.2 * NORM_W, 3.1 * NORM_W));
|
||||
await c.updateComplete;
|
||||
const sh2 = c._decorList.find((x) => x.id === sh.id);
|
||||
o.decorMovedFurtherOut = near(sh2.x, 3.2) && near(sh2.y, 3.1);
|
||||
c._decorMove = null;
|
||||
|
||||
// ---- (e) the ONE bound that is left is the backend's ±5000 ------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev2 = c._devices.find((d) => d.id !== dev.id && !d.virtual);
|
||||
const q0 = c._pos(dev2);
|
||||
c._drag = { id: dev2.id, sx: 400, sy: 400, ox: q0.x, oy: q0.y, moved: false };
|
||||
c._pointerMove(mk(9e9, 9e9), dev2);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev2);
|
||||
c._drag = null;
|
||||
o.garbageStillClamped = c._layout[dev2.id].x === 5000 && c._layout[dev2.id].y === 5000;
|
||||
|
||||
// ---- (f) Shift cannot land between the nodes ---------------------------
|
||||
const dev3 = c._devices.find((d) => d.id !== dev.id && d.id !== dev2.id && !d.virtual);
|
||||
const r0 = c._pos(dev3);
|
||||
const halfStep = NORM_W / 240 / 2;
|
||||
c._drag = { id: dev3.id, sx: 400, sy: 400, ox: r0.x, oy: r0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y, 400, 400, { shiftKey: true }), dev3);
|
||||
c._drag = null;
|
||||
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
|
||||
|
||||
c._setMode('view');
|
||||
return o;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Draw toolbar wall thickness (docs/WALL-THICKNESS.md §6): default 15 cm on
|
||||
* commit, live preview, shared neighbour cm kept.
|
||||
*/
|
||||
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 upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
|
||||
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
|
||||
c._setMode('plan');
|
||||
c._tool = 'draw';
|
||||
await upd();
|
||||
out.fieldDefault = c._drawWallFieldValue === '15' || c._drawWallFieldValue === '15.0';
|
||||
out.fieldInBar = !!sr().querySelector('.editbar .drawwall input');
|
||||
const wallsButton = sr().querySelector('.editbar button.on');
|
||||
out.drawButtonNamedRoomOutline = ['Room outline', 'Контур комнаты']
|
||||
.includes((wallsButton?.textContent || '').trim());
|
||||
out.fieldImmediatelyAfterWalls = wallsButton?.nextElementSibling?.matches('label.drawwall') === true;
|
||||
|
||||
// draw a small room away from existing rooms (garden/demo f1 may have rooms)
|
||||
// use empty space if available, else clear rooms on a copy path
|
||||
const space = sp();
|
||||
const savedRooms = JSON.parse(JSON.stringify(space.rooms || []));
|
||||
const savedWalls = space.walls ? JSON.parse(JSON.stringify(space.walls)) : null;
|
||||
space.rooms = [];
|
||||
delete space.walls;
|
||||
await upd();
|
||||
|
||||
const g = c._gridPitch;
|
||||
// 4×4 cell square
|
||||
const pts = [
|
||||
[100, 100], [100 + 8 * g, 100], [100 + 8 * g, 100 + 8 * g], [100, 100 + 8 * g],
|
||||
];
|
||||
c._path = [];
|
||||
c._tool = 'draw';
|
||||
for (const p of pts) {
|
||||
c._path = [...c._path, p];
|
||||
c._cursorPt = p;
|
||||
await upd();
|
||||
}
|
||||
// close
|
||||
c._path = [...c._path, pts[0]];
|
||||
await upd();
|
||||
out.previewWhileOpen = !!sr().querySelector('.drawwall-preview')
|
||||
|| c._drawWallCm === 15;
|
||||
// force closed state if helper exists
|
||||
if (!c._contourClosed) {
|
||||
// last point equals first — _contourClosed should be true
|
||||
}
|
||||
out.contourClosed = !!c._contourClosed;
|
||||
c._nameSel = 'ThickDraw';
|
||||
c._areaSel = '';
|
||||
c._commitRoom();
|
||||
await upd();
|
||||
|
||||
out.roomSaved = (space.rooms || []).some((r) => r.name === 'ThickDraw');
|
||||
out.wallsApplied = (space.walls || []).length >= 4
|
||||
&& (space.walls || []).every((w) => w.cm === 15);
|
||||
out.bodyDrawn = sr().querySelectorAll('[data-hp="wall"]').length >= 1;
|
||||
|
||||
// second room sharing the right edge — neighbour keeps 15, new edges get 20
|
||||
c._drawWallField = '20';
|
||||
const r1 = space.rooms.find((r) => r.name === 'ThickDraw');
|
||||
const poly = r1.poly.map((p) => [p[0] * 1000, p[1] * (c._spaceH || 1000)]);
|
||||
// shared vertical: right edge of r1
|
||||
const x1 = Math.max(...poly.map((p) => p[0]));
|
||||
const y0 = Math.min(...poly.map((p) => p[1]));
|
||||
const y1 = Math.max(...poly.map((p) => p[1]));
|
||||
const w = 6 * g;
|
||||
c._path = [
|
||||
[x1, y0], [x1 + w, y0], [x1 + w, y1], [x1, y1], [x1, y0],
|
||||
];
|
||||
await upd();
|
||||
c._nameSel = 'Neighbour';
|
||||
c._commitRoom();
|
||||
await upd();
|
||||
|
||||
const sharedStill15 = (space.walls || []).some((w) => w.cm === 15);
|
||||
const has20 = (space.walls || []).some((w) => w.cm === 20);
|
||||
out.sharedKept = sharedStill15 && has20;
|
||||
|
||||
// restore demo space
|
||||
space.rooms = savedRooms;
|
||||
if (savedWalls) space.walls = savedWalls;
|
||||
else delete space.walls;
|
||||
c._path = [];
|
||||
await upd();
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -11,7 +11,7 @@ const res = await page.evaluate(async () => {
|
||||
document.body.appendChild(c1);
|
||||
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
await new Promise(r=>setTimeout(r,150));
|
||||
@@ -31,7 +31,7 @@ const res = await page.evaluate(async () => {
|
||||
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
|
||||
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, view_box:[0,0,1,1],
|
||||
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
@@ -70,5 +70,18 @@ const res = await page.evaluate(async () => {
|
||||
out.bigRenderMs = Math.round(performance.now() - t1);
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
// сборка 100+ устройств должна укладываться в бюджет (docs/TESTING.md)
|
||||
// timings are asserted as budgets, not frozen values (CI machines vary)
|
||||
check("bigBuildMs under 200ms", res.bigBuildMs < 200);
|
||||
check("bigRenderMs under 100ms", res.bigRenderMs < 100);
|
||||
delete res.bigBuildMs;
|
||||
delete res.bigRenderMs;
|
||||
checkAll(res, {
|
||||
"emptyDevices": 0,
|
||||
"emptyCount": "0 dev.",
|
||||
"xssPwned": false,
|
||||
"xssDeviceRendered": 1,
|
||||
"bigCount": 162,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// На телефоне в редакторах не работали зум и навигация жестами: pointerdown
|
||||
// сцены выходил сразу при _markup, так что пинч и пан не начинались вовсе.
|
||||
// Рисование кликается, жесты двигаются — они совместимы; палец с движением
|
||||
// панорамирует, два пальца зумируют, отпускание после жеста не рисует точку.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
const pd = (c, id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (c, id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (c, id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
|
||||
// --- пинч-зум в редакторе плана -----------------------------------------
|
||||
out.pinchZoomsInPlanEditor = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan');
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
const z0 = c._zoom;
|
||||
pd(1, 300, 300); pd(2, 400, 300); // два пальца
|
||||
pm(1, 250, 300); pm(2, 450, 300); // разводим
|
||||
const zoomed = c._zoom > z0 * 1.5;
|
||||
pu(1, 250, 300); pu(2, 450, 300);
|
||||
return zoomed && c._path.length === 0;
|
||||
});
|
||||
|
||||
// --- пан одним пальцем в редакторе, точка не рисуется --------------------
|
||||
out.panWorksAndDoesNotDraw = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
const st = c._stageEl;
|
||||
c._zoomAt(st.clientWidth / 2, st.clientHeight / 2, 3); // есть куда панорамировать
|
||||
const v0 = { ...c._view };
|
||||
pd(3, 300, 300);
|
||||
pm(3, 380, 340); pm(3, 420, 360);
|
||||
const panned = Math.abs(c._view.x - v0.x) > 1 || Math.abs(c._view.y - v0.y) > 1;
|
||||
const suppressed = c._suppressClick === true;
|
||||
c._markupClick({ composedPath: () => [], clientX: 420, clientY: 360 }); // синтезированный click после пана
|
||||
const noDot = c._path.length === 0;
|
||||
pu(3, 420, 360);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return panned && suppressed && noDot;
|
||||
});
|
||||
|
||||
// --- обычный клик без движения по-прежнему рисует ------------------------
|
||||
out.tapStillDraws = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
c._resetZoom();
|
||||
c._tool = 'draw';
|
||||
const before = c._path.length;
|
||||
pd(4, 300, 300); pu(4, 300, 300);
|
||||
const st = c._stageEl.getBoundingClientRect();
|
||||
c._markupClick({ composedPath: () => [], clientX: st.left + 200, clientY: st.top + 200 });
|
||||
const drew = c._path.length > before;
|
||||
c._path = []; c._setMode('view');
|
||||
return drew;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
+108
-4
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -19,21 +19,125 @@ const res = await page.evaluate(async () => {
|
||||
// 3) повторный клик по активной вкладке — ничего
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
out.reclickNoop = c._mode === 'plan';
|
||||
// Let the View -> editor grid transition finish first. A real user cannot
|
||||
// switch editors in the same zero-layout frame, and competing WAAPI/CSS
|
||||
// height animations in that frame are intentionally suppressed.
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
// 4) прямое переключение План → Устройства
|
||||
// Deliberately make the outgoing bar taller: the editor transition must
|
||||
// interpolate real content height, not merely fade two equal-height rows.
|
||||
const planBarBeforeSwap = sr().querySelector('.editorchrome .editbar');
|
||||
planBarBeforeSwap.style.minHeight = `${planBarBeforeSwap.getBoundingClientRect().height + 36}px`;
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
out.directSwitch = c._mode === 'devices';
|
||||
out.devBar = !!sr().querySelector('.editbar.devbar');
|
||||
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
|
||||
const swapChrome = sr().querySelector('.editorchrome');
|
||||
const swapInner = swapChrome.querySelector('.editorchrome-inner');
|
||||
out.editorSwapAnimatesHeight = swapChrome.getAnimations()
|
||||
.some((animation) => animation.id === 'hp-editor-height-swap');
|
||||
out.editorSwapAnimatesContent = swapInner.getAnimations()
|
||||
.some((animation) => animation.id === 'hp-editor-content-swap');
|
||||
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
|
||||
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
|
||||
// 6) крестик на панели → Просмотр
|
||||
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
|
||||
out.barCloseWorks = c._mode === 'view' && !sr().querySelector('.editbar');
|
||||
const chrome = sr().querySelector('.editorchrome');
|
||||
out.barCloseStarts = c._mode === 'view' && !chrome.classList.contains('open')
|
||||
&& chrome.getAttribute('aria-hidden') === 'true';
|
||||
// The last bar deliberately remains mounted while its grid row collapses.
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
out.barCloseWorks = c._mode === 'view'
|
||||
&& getComputedStyle(chrome).visibility === 'hidden'
|
||||
&& chrome.getBoundingClientRect().height < 1;
|
||||
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
|
||||
tabs()[1].click(); await c.updateComplete;
|
||||
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
|
||||
out.tabCrossWorks = c._mode === 'view';
|
||||
|
||||
// Layout-independent editor history: physical KeyZ works when `key` is the
|
||||
// Russian «я», while an input keeps its native browser history.
|
||||
tabs()[0].click(); await c.updateComplete;
|
||||
const realUndo = c._undoGeometry;
|
||||
const realRedo = c._redoGeometry;
|
||||
let undoCalls = 0;
|
||||
let redoCalls = 0;
|
||||
c._undoGeometry = () => { undoCalls += 1; };
|
||||
c._redoGeometry = () => { redoCalls += 1; };
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
|
||||
}));
|
||||
out.layoutIndependentUndo = undoCalls === 1;
|
||||
const editorInput = sr().querySelector('.editbar input');
|
||||
editorInput.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
|
||||
}));
|
||||
out.inputKeepsNativeUndo = undoCalls === 1;
|
||||
// QWERTZ swaps the physical Y/Z codes. The labelled key wins when it is a
|
||||
// Latin letter; otherwise both isZ/isY became true and Redo ran first.
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyY', ctrlKey: true, bubbles: true, composed: true,
|
||||
}));
|
||||
out.qwertzCtrlZIsUndo = undoCalls === 2 && redoCalls === 0;
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'y', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
|
||||
}));
|
||||
out.qwertzCtrlYIsRedo = undoCalls === 2 && redoCalls === 1;
|
||||
// On AZERTY the physical KeyZ carries W: Ctrl+W must remain the browser's
|
||||
// close-tab shortcut, never an editor Undo.
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'w', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
|
||||
}));
|
||||
out.azertyCtrlWIsNotUndo = undoCalls === 2 && redoCalls === 1;
|
||||
c._undoGeometry = realUndo;
|
||||
c._redoGeometry = realRedo;
|
||||
|
||||
// Ctrl+click closes a valid draft without adding the click as a new point;
|
||||
// the same gesture must refuse a draft with fewer than two existing edges.
|
||||
const savedPath = c._path;
|
||||
const savedRoomDialog = c._roomDialog;
|
||||
const savedNameSel = c._nameSel;
|
||||
const savedAreaSel = c._areaSel;
|
||||
const ctrlClick = () => {
|
||||
let prevented = false;
|
||||
c._markupClick({
|
||||
ctrlKey: true, metaKey: false, clientX: 0, clientY: 0,
|
||||
preventDefault() { prevented = true; }, composedPath() { return []; },
|
||||
});
|
||||
return prevented;
|
||||
};
|
||||
c._tool = 'draw';
|
||||
c._roomDialog = false;
|
||||
c._path = [[-120, -120], [-80, -120]];
|
||||
const shortBefore = JSON.stringify(c._path);
|
||||
out.ctrlCloseRequiresTwoEdges = ctrlClick()
|
||||
&& !c._roomDialog && JSON.stringify(c._path) === shortBefore;
|
||||
c._path = [[-120, -120], [-80, -120], [-80, -80]];
|
||||
out.ctrlCloseValidContour = ctrlClick() && c._roomDialog
|
||||
&& c._path.length === 4
|
||||
&& c._path[0][0] === c._path[3][0] && c._path[0][1] === c._path[3][1];
|
||||
await c.updateComplete;
|
||||
const roomDialog = sr().querySelector('hp-dialog.roomdialog');
|
||||
const roomBody = roomDialog?.querySelector('.body');
|
||||
out.roomDialogUsesMediumWidth = roomDialog?.wide === true
|
||||
&& roomDialog.hasAttribute('wide');
|
||||
out.roomDialogHasNoHorizontalScroll = !!roomBody
|
||||
&& roomBody.scrollWidth <= roomBody.clientWidth + 1;
|
||||
c._nameSel = 'No-area room';
|
||||
c._areaSel = '';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const noAreaSave = sr().querySelector('hp-dialog.roomdialog .room-save');
|
||||
out.noAreaUsesRegularSave = !!noAreaSave && !noAreaSave.disabled
|
||||
&& sr().querySelectorAll('hp-dialog.roomdialog .room-save').length === 1;
|
||||
c._path = savedPath;
|
||||
c._roomDialog = savedRoomDialog;
|
||||
c._nameSel = savedNameSel;
|
||||
c._areaSel = savedAreaSel;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"labels": ["Plan editor", "Device editor", "Background editor"],
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import { launch } from './serve.mjs';
|
||||
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 frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const deepActive = () => {
|
||||
let active = document.activeElement;
|
||||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||||
return active;
|
||||
};
|
||||
|
||||
// A11Y-02: the shared native fallback carries the same modal/focus contract
|
||||
// that ha-dialog supplies in Home Assistant.
|
||||
const opener = document.createElement('button');
|
||||
opener.textContent = 'open settings';
|
||||
sr.append(opener);
|
||||
opener.focus();
|
||||
c._openSettingsDialog(); await c.updateComplete; await frame();
|
||||
const hp = sr.querySelector('hp-dialog');
|
||||
const native = hp?.shadowRoot?.querySelector('dialog');
|
||||
const titleId = native?.getAttribute('aria-labelledby');
|
||||
out.sharedShell = !!hp && !!native?.open;
|
||||
out.modalSemantics = native?.getAttribute('role') === 'dialog'
|
||||
&& native?.getAttribute('aria-modal') === 'true'
|
||||
&& !!titleId && !!hp.shadowRoot.getElementById(titleId)?.textContent.trim();
|
||||
let active = deepActive();
|
||||
out.initialFocus = !!active && (hp.contains(active) || hp.shadowRoot.contains(active));
|
||||
const focusable = [...hp.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])')];
|
||||
const last = focusable.at(-1);
|
||||
last?.focus();
|
||||
last?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Tab', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
active = deepActive();
|
||||
out.focusTrap = active?.classList?.contains('close') === true;
|
||||
active?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await c.updateComplete; await frame();
|
||||
out.restoreFocus = !c._settingsDialog && deepActive() === opener;
|
||||
opener.remove();
|
||||
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// общие настройки
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
@@ -31,5 +70,5 @@ const res = await page.evaluate(async () => {
|
||||
await esc(); out.undoPointStillWorks = c._path.length === 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { launch, check, 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;
|
||||
// 1) кнопка настроек комнаты в редакторе плана: заметная, фиксированного размера
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const btn = sr().querySelector('.rlgearbtn');
|
||||
out.gearButtonShown = !!btn;
|
||||
const cs = btn ? getComputedStyle(btn) : null;
|
||||
// 2026-07-29: владелец вдвое уменьшил кнопку — контракт теперь не «не
|
||||
// меньше N px», а «размер от иконки устройства и кликабельность»
|
||||
out.gearReadable = cs ? parseFloat(cs.fontSize) > 0 && cs.pointerEvents === 'auto' : null;
|
||||
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
// (в редакторе плана .dev скрыты display:none — сравнивать не с чем)
|
||||
out.gearTapTarget = box ? box.height > 6 && box.height < 40 : null;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
// 2) комната без имени тоже получает кнопку (её там и называют)
|
||||
const room = c._curSpaceCfg.rooms[0];
|
||||
const savedName = room.name;
|
||||
room.name = '';
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.unnamedStillHasGear = sr().querySelectorAll('.rlgearbtn').length >= 1;
|
||||
room.name = savedName; c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
// 3) метрики стали крупнее: 0.75em вместо 0.62em
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
|
||||
c._setMode('view'); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.querySelector('.rlmetrics'));
|
||||
if (lbl) {
|
||||
const nameSz = parseFloat(getComputedStyle(lbl.querySelector('.rlname')).fontSize);
|
||||
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
|
||||
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
|
||||
} else out.metricsRatio = 'no-metrics';
|
||||
// 4) касание помечает сессию как тач и гасит тултип
|
||||
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
|
||||
c._notePointer(new PointerEvent('pointerdown', { pointerType: 'touch' }));
|
||||
out.touchClearsTip = c._tip === null;
|
||||
c._showTip(new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), 'x', 'y');
|
||||
out.noTipAfterTouch = !c._tip;
|
||||
return out;
|
||||
});
|
||||
check('metricsRatio 0.75', res.metricsRatio, 0.75);
|
||||
delete res.metricsRatio;
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,58 @@
|
||||
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 spId = c._space;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const lbl = () => sr().querySelector('.roomlabel');
|
||||
const nameSize = () => parseFloat(getComputedStyle(lbl().querySelector('.rlname')).fontSize);
|
||||
const metaEl = () => lbl().querySelector('.rlmetrics');
|
||||
const n0 = nameSize();
|
||||
const m0 = metaEl() ? parseFloat(getComputedStyle(metaEl()).fontSize) : null;
|
||||
out.baseline = n0 > 0;
|
||||
// 1) пер-комнатный name_scale ×2 — имя удвоилось, подписи нет
|
||||
const room = c._curSpaceCfg.rooms.find((r) => r.name && r.area);
|
||||
room.settings = { ...(room.settings || {}), name_scale: 2 };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const lblR = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
|
||||
const n1 = parseFloat(getComputedStyle(lblR.querySelector('.rlname')).fontSize);
|
||||
out.nameDoubled = Math.abs(n1 / n0 - 2) < 0.05;
|
||||
const meta1 = lblR.querySelector('.rlmetrics');
|
||||
out.metaUntouched = meta1 ? Math.abs(parseFloat(getComputedStyle(meta1).fontSize) - m0) < 0.5 : 'no-meta';
|
||||
// 2) label_scale ×2 — подписи удвоились
|
||||
room.settings.label_scale = 2;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const meta2 = lblR.querySelector('.rlmetrics');
|
||||
out.metaDoubled = meta2 && m0 ? Math.abs(parseFloat(getComputedStyle(meta2).fontSize) / m0 - 2) < 0.05 : 'no-meta';
|
||||
// 3) базовый множитель пространства ×1.5 — умножает всё
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), card_font_scale: 1.5 } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const lblR2 = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
|
||||
const n2 = parseFloat(getComputedStyle(lblR2.querySelector('.rlname')).fontSize);
|
||||
out.spaceMultiplies = Math.abs(n2 / n0 - 3) < 0.1; // 2 × 1.5
|
||||
// 4) диалог комнаты: слайдеры + живой пример реагирует
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const model = c._spaceModel().rooms.find((r) => r.id === room.id);
|
||||
c._openRoomEdit(model); await c.updateComplete;
|
||||
out.slidersPrefilled = c._roomNameScale === 2 && c._roomLabelScale === 2;
|
||||
const pv = () => sr().querySelector('.cardpreview .cpname');
|
||||
const p0 = parseFloat(pv().style.fontSize);
|
||||
c._roomNameScale = 1; c.requestUpdate(); await c.updateComplete;
|
||||
const p1 = parseFloat(pv().style.fontSize);
|
||||
out.previewLive = Math.abs(p0 / p1 - 2) < 0.05;
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
// 5) превью есть и в диалоге пространства
|
||||
c._openSpaceDialog('edit', spId); await c.updateComplete;
|
||||
out.spacePreview = !!sr().querySelector('.cardpreview');
|
||||
out.spaceSlider = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('space.card_font'));
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,98 @@
|
||||
// Browser contract for saved room drafts, independent partitions and columns.
|
||||
// This deliberately exercises the card's editor transactions rather than
|
||||
// injecting finished records: create, drag, edit and Delete must all pass
|
||||
// through the same paths a pointer/keyboard gesture uses.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const PITCH = 1000 / 240;
|
||||
window.confirm = () => true;
|
||||
c._serverCfg = {
|
||||
spaces: [{ id: 'physical', title: 'Physical', cell_cm: 5,
|
||||
view_box: [0, 0, 1, 1], rooms: [] }],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
c._space = 'physical';
|
||||
c._mode = 'plan';
|
||||
c._modelCache = null;
|
||||
// Keep the smoke local: editor transactions still mutate the real config,
|
||||
// invalidate derived models and history, but do not need a websocket write.
|
||||
c._saveConfig = () => { c._cfgEpoch++; c._modelCache = null; c.requestUpdate(); };
|
||||
await c.updateComplete;
|
||||
|
||||
c._tool = 'partition';
|
||||
c._drawWallField = '15';
|
||||
c._partitionClick([100, 100], false);
|
||||
c._partitionClick([300, 100], false);
|
||||
let sp = c._serverCfg.spaces[0];
|
||||
o.partitionCreated = sp.partitions?.length === 1 && sp.partitions[0].cm === 15;
|
||||
|
||||
c._tool = 'column';
|
||||
c._drawWallField = '30';
|
||||
c._columnClick([200, 200]);
|
||||
sp = c._serverCfg.spaces[0];
|
||||
o.columnCreatedSquare = sp.wall_columns?.length === 1
|
||||
&& sp.wall_columns[0].shape === 'square' && sp.wall_columns[0].cm === 30;
|
||||
|
||||
c._tool = 'draw';
|
||||
c._drawWallField = '12';
|
||||
c._path = [[100, 300], [200, 300]];
|
||||
c._activeDraftId = null;
|
||||
c._draftSegmentCms = [];
|
||||
c._persistActiveDraftSegment();
|
||||
sp = c._serverCfg.spaces[0];
|
||||
o.draftPersistedImmediately = sp.room_drafts?.length === 1
|
||||
&& sp.room_drafts[0].segments[0].cm === 12;
|
||||
|
||||
// Endpoint-to-endpoint join is one atomic record: the active id survives
|
||||
// and segment thickness follows geometric order.
|
||||
sp.room_drafts = [
|
||||
{ id: 'join-a', points: [[0.1, 0.4], [0.3, 0.4]], segments: [{ cm: 10 }] },
|
||||
{ id: 'join-b', points: [[0.3, 0.4], [0.5, 0.4]], segments: [{ cm: 20 }] },
|
||||
];
|
||||
c._modelCache = null;
|
||||
c._activeDraftId = 'join-a';
|
||||
c._path = [[100, 400], [300, 400]];
|
||||
c._draftSegmentCms = [10];
|
||||
const join = c._draftEndAt([300, 400], 'join-a');
|
||||
if (join) c._mergeDraftEndpoint(join);
|
||||
sp = c._serverCfg.spaces[0];
|
||||
o.draftEndpointJoinIsAtomic = sp.room_drafts?.length === 1
|
||||
&& sp.room_drafts[0].id === 'join-a'
|
||||
&& JSON.stringify(sp.room_drafts[0].segments.map((segment) => segment.cm)) === '[10,20]';
|
||||
|
||||
const part = c._spaceModel().partitions[0];
|
||||
c._physicalDrag = {
|
||||
pid: 71, kind: 'partition', id: part.id, start: [...part.a],
|
||||
startClient: [0, 0], before: c._geometrySnapshot(), moved: true,
|
||||
base: JSON.parse(JSON.stringify(part)), delta: [PITCH, PITCH],
|
||||
};
|
||||
c._physicalUp(new PointerEvent('pointerup', { pointerId: 71, bubbles: true }));
|
||||
sp = c._serverCfg.spaces[0];
|
||||
o.partitionDragIsRigidAndGridBound = Math.abs(sp.partitions[0].a[0] - (0.1 + PITCH / 1000)) < 1e-9
|
||||
&& Math.abs((sp.partitions[0].b[0] - sp.partitions[0].a[0]) - 0.2) < 1e-9
|
||||
&& Math.abs(sp.partitions[0].a[1] - sp.partitions[0].b[1]) < 1e-9;
|
||||
|
||||
c._physicalSel = { kind: 'partition', id: sp.partitions[0].id };
|
||||
c._deletePhysicalSelection();
|
||||
o.deleteRemovesPartition = !c._serverCfg.spaces[0].partitions;
|
||||
|
||||
const draftId = c._serverCfg.spaces[0].room_drafts[0].id;
|
||||
c._physicalSel = { kind: 'draft', id: draftId, segment: 0 };
|
||||
c._deletePhysicalSelection();
|
||||
o.deleteOnDraftRemovesWholeOutline = !c._serverCfg.spaces[0].room_drafts;
|
||||
|
||||
c._tool = 'partition';
|
||||
c._drawWallField = '120';
|
||||
c._partitionClick([400, 100], false);
|
||||
c._partitionClick([500, 100], false);
|
||||
o.invalidThicknessCreatesNothing = !c._serverCfg.spaces[0].partitions;
|
||||
|
||||
return o;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,284 @@
|
||||
// Библиотека мебели в декор-слое (docs/FURNITURE.md, задача владельца №2):
|
||||
// 1) в панели декора есть инструмент «Мебель», он открывает ПАЛИТРУ с
|
||||
// обязательными группами (мебель / техника / сантехника) и превью;
|
||||
// 2) выбранный символ ставится кликом по плану в РЕАЛЬНОМ размере — через
|
||||
// cell_cm пространства, и сразу оказывается выделенным в «выбрать»;
|
||||
// 3) поля ширины/глубины в палитре меняют размер ДО размещения;
|
||||
// 4) магнит к стене: предмет прижимается к ближайшей стене и доворачивается
|
||||
// по ней; Shift не отключает обязательную привязку (docs/CANVAS.md §9.4);
|
||||
// 5) у выделенного предмета та же рамка, что у текстового блока, — угловые
|
||||
// ручки (НЕзависимые ширина и глубина) и ручка поворота с шагом 5°;
|
||||
// 6) во время растягивания угла показываются живые плашки размеров;
|
||||
// 7) фигура пишется в конфиг и переживает пересборку, хуки card-mod на месте.
|
||||
// ПАДАЕТ на сборке до этой задачи: инструмента «Мебель» нет, палитры нет,
|
||||
// kind:'furniture' не рисуется, магнита и плашек не существует. Смок при этом
|
||||
// null-safe — он показывает СПИСОК провалов, а не одно исключение.
|
||||
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
const el = (id) => (id ? sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
|
||||
const attr = (id, n) => { const e = el(id); return e ? e.getAttribute(n) : null; };
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||||
};
|
||||
const ev = (type, target, x, y, extra = {}) => {
|
||||
// null-safe: на сборке ДО этой задачи фигуры нет вовсе, а значит нет и
|
||||
// её координат — жест просто не происходит, и смок печатает список
|
||||
// провалов вместо одного исключения
|
||||
if (!target || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
|
||||
cancelable: true, pointerId: 11, clientX, clientY, button: 0, isPrimary: true, ...extra }));
|
||||
};
|
||||
const PITCH = 1000 / 240; // GRID_PITCH — одна клетка сетки
|
||||
const CELL = 5; // cell_cm демо-пространства (по умолчанию)
|
||||
const cmToUnits = (cm) => (cm / CELL) * PITCH;
|
||||
const near = (a, b, tol) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;
|
||||
const pe = (id) => { const e = el(id); return e ? getComputedStyle(e).pointerEvents : null; };
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'select'; c._decorSel = null; c._furnPalette = null;
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
out.cellCmIsFive = c._cellCm === CELL;
|
||||
|
||||
// ================= 1. инструмент и палитра ==============================
|
||||
const toolBtn = [...sr().querySelectorAll('.decorbar .dtool')]
|
||||
.find((b) => /мебел|furnitur/i.test(b.textContent || ''));
|
||||
out.furnitureToolInTheBar = !!toolBtn;
|
||||
out.noPaletteBeforeTheTool = !sr().querySelector('.furnpalette');
|
||||
if (toolBtn) toolBtn.click();
|
||||
await c.updateComplete;
|
||||
out.toolIsSelected = c._decorTool === 'furniture';
|
||||
const pal = () => sr().querySelector('.furnpalette');
|
||||
out.paletteOpens = !!pal();
|
||||
const groups = [...(pal()?.querySelectorAll('.furngroup') || [])]
|
||||
.map((g) => g.getAttribute('data-group'));
|
||||
out.groupsArePresent = ['furniture', 'appliance', 'sanitary'].every((g) => groups.includes(g));
|
||||
out.everyGroupHasSymbols = groups.length > 0 && [...pal().querySelectorAll('.furnrow')]
|
||||
.every((r) => r.querySelectorAll('.furnitem').length > 0);
|
||||
const item = (sym) => pal()?.querySelector(`.furnitem[data-symbol="${sym}"]`);
|
||||
const pick = (sym) => { const b = item(sym); if (b) b.click(); };
|
||||
out.sofaIsInThePalette = !!item('sofa');
|
||||
out.toiletIsInThePalette = !!item('toilet');
|
||||
out.washerIsInThePalette = !!item('washer');
|
||||
const prevD = item('sofa')?.querySelector('svg.furnprev path')?.getAttribute('d') || '';
|
||||
out.previewIsDrawn = prevD.length > 10;
|
||||
out.namesAreLocalised = /\S/.test(item('sofa')?.querySelector('span')?.textContent || '');
|
||||
|
||||
// ================= 2. размер по умолчанию и поля =========================
|
||||
pick('sofa'); await c.updateComplete;
|
||||
out.pickArmsTheSymbol = c._furnPalette?.symbol === 'sofa';
|
||||
out.defaultSizeIsTheSymbols = c._furnPalette?.w === 220 && c._furnPalette?.h === 90;
|
||||
const wIn = () => pal()?.querySelector('input.furnw');
|
||||
const hIn = () => pal()?.querySelector('input.furnh');
|
||||
out.sizeFieldsAppear = !!wIn() && !!hIn();
|
||||
out.fieldsShowMetres = +(wIn()?.value ?? NaN) === 2.2 && +(hIn()?.value ?? NaN) === 0.9;
|
||||
out.selectedItemIsMarked = !!item('sofa')?.classList.contains('on');
|
||||
|
||||
// ================= 3. размещение в реальном размере ======================
|
||||
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
|
||||
ev('pointerdown', stageEl(), 300, 300);
|
||||
await c.updateComplete;
|
||||
const sofa = c._decorList.find((s) => s.kind === 'furniture');
|
||||
const sofaId = sofa?.id ?? null;
|
||||
const sofaNow = () => c._decorList.find((s) => s.id === sofaId) || {};
|
||||
out.placedOnClick = sofa?.symbol === 'sofa';
|
||||
out.realWidthThroughCellCm = near((sofa?.w ?? NaN) * 1000, cmToUnits(220), 1e-6);
|
||||
out.realDepthThroughCellCm = near((sofa?.h ?? NaN) * 1000, cmToUnits(90), 1e-6);
|
||||
out.centredOnTheClick = !!sofa
|
||||
&& near((sofa.x + sofa.w / 2) * 1000, 300, 1e-6)
|
||||
&& near((sofa.y + sofa.h / 2) * 1000, 300, 1e-6);
|
||||
out.noAngleWhenStraight = !!sofa && sofa.angle === undefined;
|
||||
out.selectedRightAway = !!sofaId && c._decorSel === sofaId;
|
||||
out.toolWentBackToSelect = c._decorTool === 'select';
|
||||
out.paletteDisarmed = c._furnPalette === null;
|
||||
// …и она НАРИСОВАНА, со всеми хуками card-mod (docs/STYLING-HOOKS.md §3)
|
||||
out.rendered = el(sofaId)?.tagName?.toLowerCase() === 'path';
|
||||
out.hookHp = attr(sofaId, 'data-hp') === 'decor';
|
||||
out.hookKind = attr(sofaId, 'data-kind') === 'furniture';
|
||||
out.hookSymbol = attr(sofaId, 'data-symbol') === 'sofa';
|
||||
out.strokedNotFilled = attr(sofaId, 'fill') === 'none'
|
||||
&& attr(sofaId, 'stroke') === c._decorStyle.color;
|
||||
out.pathIsAtRealSize = /^M0 0H183\.333V75H0Z/.test(attr(sofaId, 'd') || '');
|
||||
|
||||
// ================= 4. свой размер до размещения ==========================
|
||||
c._decorTool = 'furniture'; await c.updateComplete;
|
||||
pick('toilet'); await c.updateComplete;
|
||||
if (wIn()) {
|
||||
wIn().value = '0.5';
|
||||
wIn().dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
await c.updateComplete;
|
||||
out.fieldEditsTheArmedSize = Math.abs((c._furnPalette?.w ?? 0) - 50) < 1e-6;
|
||||
ev('pointerdown', stageEl(), 300, 400);
|
||||
await c.updateComplete;
|
||||
const wc = c._decorList.find((s) => s.symbol === 'toilet');
|
||||
out.typedSizeIsUsed = near((wc?.w ?? NaN) * 1000, cmToUnits(50), 1e-6)
|
||||
&& near((wc?.h ?? NaN) * 1000, cmToUnits(70), 1e-6);
|
||||
|
||||
// ================= 5. магнит к стене =====================================
|
||||
c._decorTool = 'furniture'; await c.updateComplete;
|
||||
pick('bed_double'); await c.updateComplete;
|
||||
ev('pointerdown', stageEl(), 300, 150); // 10 единиц от стены y=140
|
||||
await c.updateComplete;
|
||||
const bed = c._decorList.find((s) => s.symbol === 'bed_double');
|
||||
const depth = cmToUnits(200);
|
||||
out.wallMagnetPullsItFlat = !!bed
|
||||
&& near((bed.y + bed.h / 2) * 1000, 140 + depth / 2, 1e-6);
|
||||
out.wallMagnetKeepsTheAngle = !!bed && (bed.angle || 0) === 0; // стена горизонтальна
|
||||
out.wallMagnetQuantisesAlongTheWall = !!bed
|
||||
&& Math.abs((bed.x + bed.w / 2) * 1000 - 300) <= PITCH + 1e-6;
|
||||
|
||||
// …Shift больше не отключает магнит
|
||||
c._decorTool = 'furniture'; await c.updateComplete;
|
||||
pick('bed_single'); await c.updateComplete;
|
||||
ev('pointerdown', stageEl(), 301.7, 151.3, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const free = c._decorList.find((s) => s.symbol === 'bed_single');
|
||||
out.shiftKeepsTheMagnet = !!free
|
||||
&& !near((free.y + free.h / 2) * 1000, 151.3, 1e-6)
|
||||
&& free.angle === undefined;
|
||||
|
||||
// …магнит работает и при ПЕРЕТАСКИВАНИИ: тянем диван к левой стене (x=40)
|
||||
c._decorTool = 'select'; c._decorSel = sofaId; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(40); await c.updateComplete;
|
||||
ev('pointerdown', el(sofaId), 300, 300);
|
||||
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
|
||||
ev('pointermove', stageEl(), 55, 300); // 15 единиц от стены x=40
|
||||
await c.updateComplete;
|
||||
const pulled = sofaNow();
|
||||
out.dragMagnetTurnsItToTheWall = Math.abs(Math.abs(pulled.angle || 0) - 90) < 1e-6;
|
||||
out.dragMagnetPressesTheBack =
|
||||
near((pulled.x + pulled.w / 2) * 1000, 40 + cmToUnits(90) / 2, 1e-6);
|
||||
ev('pointermove', stageEl(), 300, 300, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const shiftDragGridIndex = (sofaNow().x * 1000) / PITCH;
|
||||
out.shiftDragRemainsGridBound = near(shiftDragGridIndex, Math.round(shiftDragGridIndex), 1e-6);
|
||||
ev('pointerup', stageEl(), 300, 300, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
|
||||
// ================= 6. рамка, углы, плашки, поворот =======================
|
||||
c._decorSel = sofaId; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.sameFrameAsTheTextBlock = !!frame()
|
||||
&& frame().querySelectorAll('.dthandle').length === 5
|
||||
&& frame().querySelectorAll('.dtknob').length === 5
|
||||
&& !!frame().querySelector('.dtrot');
|
||||
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
|
||||
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
|
||||
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
|
||||
out.frameBoxIsTheShapeBox = !!c._dtBox
|
||||
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
|
||||
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
|
||||
|
||||
// угол: НЕзависимые ширина и глубина + живые плашки
|
||||
const before = { w: sofaNow().w, h: sofaNow().h };
|
||||
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
|
||||
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
|
||||
const se = handles[3] || stageEl(); // SE — четвёртый угол
|
||||
ev('pointerdown', se, b.x + b.w, b.y + b.h);
|
||||
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
|
||||
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const grown = sofaNow();
|
||||
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
|
||||
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
|
||||
&& near(((grown.h * 1000) / PITCH) % 1, 0, 1e-6);
|
||||
out.oppositeCornerStaysPut = near(grown.x * 1000, b.x, 1e-6) && near(grown.y * 1000, b.y, 1e-6);
|
||||
const plates = [...sr().querySelectorAll('.measurelabel.furnmeasure')];
|
||||
out.twoLivePlates = plates.length === 2;
|
||||
out.platesShowRealLengths = plates.length === 2
|
||||
&& plates.every((p) => /\d/.test(p.textContent))
|
||||
&& plates.some((p) => /m|м|′/.test(p.textContent));
|
||||
// …и без Shift размер прилипает к сетке
|
||||
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
|
||||
await c.updateComplete;
|
||||
const snapped = sofaNow();
|
||||
const wCells = (snapped.w * 1000) / PITCH;
|
||||
const hCells = (snapped.h * 1000) / PITCH;
|
||||
// Normalised config is rounded to six decimals, so a mathematical cell 27
|
||||
// may read back as 26.9999999. Compare with the nearest integer, not `% 1`.
|
||||
out.sizeSnapsToTheGrid = near(wCells, Math.round(wCells), 1e-4)
|
||||
&& near(hCells, Math.round(hCells), 1e-4);
|
||||
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
|
||||
await c.updateComplete;
|
||||
out.cornerDragEnded = !c._dtDrag;
|
||||
out.noPlatesAfterTheDrag = !sr().querySelector('.measurelabel.furnmeasure');
|
||||
|
||||
// поворот: шаг 5°, Shift мимо шага — та же механика, что у текста
|
||||
await sleep(40); await c.updateComplete;
|
||||
const cx = (sofaNow().x + sofaNow().w / 2) * 1000;
|
||||
const cy = (sofaNow().y + sofaNow().h / 2) * 1000;
|
||||
const piv = c._dtPivot ? c._dtPivot(sofaNow()) : [NaN, NaN];
|
||||
out.rotatesAboutTheCENTRE = near(piv[0], cx, 1e-6) && near(piv[1], cy, 1e-6);
|
||||
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot'), cx + 100, cy);
|
||||
out.rotateStarted = c._dtDrag?.kind === 'rotate';
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 100); // ровно 45°
|
||||
await c.updateComplete;
|
||||
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
|
||||
await c.updateComplete;
|
||||
out.snapsTo5 = (sofaNow().angle || 0) === 0;
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const fine = sofaNow().angle;
|
||||
out.shiftGoesPastTheStep = fine > 0.5 && fine < 5;
|
||||
ev('pointerup', stageEl(), cx + 100, cy + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
|
||||
|
||||
// ================= 7. конфиг и пересборка ================================
|
||||
const stored = JSON.parse(JSON.stringify(sofaNow()));
|
||||
out.storedShapeIsComplete = stored.kind === 'furniture' && typeof stored.symbol === 'string'
|
||||
&& ['x', 'y', 'w', 'h'].every((k) => Number.isFinite(stored[k]));
|
||||
c.hass = { ...c.hass }; // полная пересборка
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(40); await c.updateComplete;
|
||||
const after = sofaNow();
|
||||
out.survivesARebuild = after.symbol === stored.symbol
|
||||
&& near(after.w, stored.w, 1e-12) && near(after.h, stored.h, 1e-12)
|
||||
&& near(after.x, stored.x, 1e-12) && near(after.y, stored.y, 1e-12);
|
||||
out.stillRenderedAfterRebuild = !!el(sofaId);
|
||||
c._decorTool = 'select';
|
||||
c._decorShapeDbl({ preventDefault() {}, stopPropagation() {} }, after);
|
||||
await c.updateComplete;
|
||||
out.objectDialogSelectsStoredSymbol = sr().querySelector(
|
||||
'.body select.namein option:checked',
|
||||
)?.value === stored.symbol;
|
||||
c._decorShapeDialog = null;
|
||||
|
||||
// ================= 8. инертность и удаление ==============================
|
||||
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
|
||||
out.inertUnderTheStamp = pe(sofaId) === 'none';
|
||||
const n0 = c._decorList.length;
|
||||
ev('pointerdown', stageEl(), 320, 320); // символ НЕ выбран
|
||||
await c.updateComplete;
|
||||
out.noSymbolNoStamp = c._decorList.length === n0;
|
||||
c._decorTool = 'erase'; await c.updateComplete;
|
||||
ev('pointerdown', el(sofaId), 300, 300);
|
||||
await c.updateComplete;
|
||||
// Eraser is intentionally two-step now; the generic decor smoke verifies
|
||||
// the dialog itself, while this furniture scenario confirms the action.
|
||||
c._confirmDecorErase();
|
||||
await c.updateComplete;
|
||||
out.eraseRemovesIt = !!sofaId && !c._decorList.some((s) => s.id === sofaId);
|
||||
// а в режиме просмотра мебель видна и не кликается
|
||||
c._setMode('view'); await c.updateComplete; await sleep(30);
|
||||
const left = c._decorList.find((s) => s.kind === 'furniture');
|
||||
out.visibleInView = !!left && !!el(left.id);
|
||||
out.inertInView = !!left && pe(left.id) === 'none';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -25,11 +25,78 @@ const res = await page.evaluate(async () => {
|
||||
out.dialogOpens = !!c._spaceDialog;
|
||||
out.tabNotSwitched = c._space === cur;
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
// кнопка "+" по-прежнему только в Плане
|
||||
out.noAddInView = !sr().querySelector('.tab.tabadd');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.addInPlan = !!sr().querySelector('.tab.tabadd');
|
||||
// «+» — навигационное действие, а не инструмент редактора плана
|
||||
// (владелец 2026-08-04): кнопка есть во ВСЕХ режимах и кликабельна.
|
||||
const addBtn = () => sr().querySelector('.tab.tabadd');
|
||||
const hittable = () => {
|
||||
const b = addBtn();
|
||||
if (!b) return 'нет кнопки';
|
||||
const r = b.getBoundingClientRect();
|
||||
if (r.width < 8 || r.height < 8) return `кнопка ${r.width}x${r.height}px`;
|
||||
const hit = sr().elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return hit && hit.closest && hit.closest('.tab.tabadd') === b ? true : 'перекрыта';
|
||||
};
|
||||
out.addInView = hittable();
|
||||
for (const m of ['plan', 'devices', 'decor']) {
|
||||
c._setMode(m); await c.updateComplete;
|
||||
out['addIn_' + m] = hittable();
|
||||
}
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// …и открывает именно диалог СОЗДАНИЯ
|
||||
const b0 = addBtn();
|
||||
if (b0) { b0.click(); await c.updateComplete; }
|
||||
out.addOpensCreate = !!c._spaceDialog && c._spaceDialog.mode === 'create';
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"alignDelta": 0,
|
||||
});
|
||||
|
||||
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
|
||||
// (display:none у .hdr не считается: скрытый в DOM узел всё ещё кликабелен
|
||||
// программно и всё ещё ловится автотестами как «есть»)
|
||||
const kio = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
c.hass = window.__mkHass();
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
document.body.appendChild(c);
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
c.hass = { ...c.hass }; await c.updateComplete;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
out.kioskNoAddButton = !sr.querySelector('.tab.tabadd');
|
||||
c.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(kio)) check(k, v);
|
||||
|
||||
// мобильный вид: ряд вкладок переносится, а не выезжает за карточку
|
||||
await page.setViewportSize({ width: 390, height: 760 });
|
||||
const narrow = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const head = sr.querySelector('.head');
|
||||
const btn = sr.querySelector('.tab.tabadd');
|
||||
out.narrowAddPresent = !!btn;
|
||||
out.narrowNoHOverflow = head.scrollWidth <= head.clientWidth + 1
|
||||
? true : `scrollW=${head.scrollWidth} > clientW=${head.clientWidth}`;
|
||||
const cr = sr.querySelector('ha-card').getBoundingClientRect();
|
||||
const br = btn ? btn.getBoundingClientRect() : null;
|
||||
out.narrowAddInsideCard = !br ? 'нет кнопки'
|
||||
: br.right <= cr.right + 1 && br.left >= cr.left - 1 ? true
|
||||
: `btn ${Math.round(br.left)}..${Math.round(br.right)} vs card ${Math.round(cr.left)}..${Math.round(cr.right)}`;
|
||||
out.narrowAddHittable = !br ? 'нет кнопки' : (() => {
|
||||
const hit = sr.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2);
|
||||
return hit && hit.closest && hit.closest('.tab.tabadd') === btn ? true : 'перекрыта';
|
||||
})();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(narrow)) check(k, v);
|
||||
|
||||
await finish(browser, { ...res, ...kio, ...narrow });
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
|
||||
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.rows = sr().querySelectorAll('.gsrow').length;
|
||||
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
|
||||
out.groups = [...sr().querySelectorAll('hp-dialog .dispsection')].map((l) => l.textContent.trim());
|
||||
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
|
||||
out.aboutVersion = sr().querySelector('hp-dialog .aboutver')?.textContent.trim() ?? null;
|
||||
out.aboutLinks = [...sr().querySelectorAll('hp-dialog a.aboutlink')].map((a) => ({
|
||||
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
|
||||
// 2) сменить цвет light_on и сохранить
|
||||
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
|
||||
await c._saveSettingsDialog(); await c.updateComplete;
|
||||
@@ -26,5 +31,27 @@ const res = await page.evaluate(async () => {
|
||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
|
||||
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
|
||||
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
|
||||
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
|
||||
const bundle = readFileSync(new URL('./srv/assets/houseplan-card.js', import.meta.url), 'utf8');
|
||||
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
|
||||
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
|
||||
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
|
||||
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"rows": 15, // 11 цветов (включая wall_fill) + радиус свечения + фон
|
||||
// + «Оптимизировать планы» (docs/CANVAS.md §9)
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Walls", "Stage background", "Sun", "Plan maintenance", "About"],
|
||||
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
|
||||
"aboutLinks": [
|
||||
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
|
||||
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
|
||||
],
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"lqiBefore": 7,
|
||||
"lqiAfter": 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
+25
-7
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -44,7 +44,7 @@ const res = await page.evaluate(async () => {
|
||||
const c2 = c._roomCenter(r2);
|
||||
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
// общая стена вертикальная — дверь ставим на неё
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
const doorPt = (() => {
|
||||
let best = null, bd = 1e9;
|
||||
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
|
||||
@@ -57,9 +57,8 @@ const res = await page.evaluate(async () => {
|
||||
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
const c1 = c._roomCenter(r1);
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||||
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
|
||||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
@@ -73,7 +72,7 @@ const res = await page.evaluate(async () => {
|
||||
const minX = Math.min(...poly1.map((p) => p[0]));
|
||||
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) };
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
|
||||
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
|
||||
@@ -93,6 +92,22 @@ const res = await page.evaluate(async () => {
|
||||
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
|
||||
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
// 6в) флаг «источник света»: умный выключатель с обычными светильниками
|
||||
const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.')));
|
||||
if (swDev) {
|
||||
const swEid = swDev.entities.find((e) => e.startsWith('switch.'));
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } };
|
||||
const spotsBefore = sr().querySelectorAll('.glowlayer circle').length;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...(c._serverCfg.markers || []).filter((m) => m.id !== swDev.id),
|
||||
{ id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true },
|
||||
] };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1;
|
||||
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== swDev.id) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore;
|
||||
} else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; }
|
||||
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
|
||||
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
|
||||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
|
||||
@@ -101,5 +116,8 @@ const res = await page.evaluate(async () => {
|
||||
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"spots": 1,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -33,5 +33,5 @@ const res = await page.evaluate(async () => {
|
||||
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// «Всё строго по сетке» (docs/CANVAS.md §9). Two halves:
|
||||
// 1. every element placed or dragged by hand lands on a node — devices, room
|
||||
// labels, decor, room vertices, resize handles; an opening lands ON ITS
|
||||
// WALL at a whole number of steps along it; positional off-grid placement
|
||||
// is not supported. Shift keeps the room-outline point on the grid while
|
||||
// additionally constraining its wall to the nearest 45° direction;
|
||||
// 2. the explicit «Оптимизировать планы» action moves a deliberately
|
||||
// detuned plan onto the grid through one atomic config+layout commit,
|
||||
// tells the truth about how much it moves, and is idempotent.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const NORM_W = 1000, GRID_N = 240;
|
||||
const PITCH = NORM_W / GRID_N;
|
||||
const onGridN = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
|
||||
const onGridR = (v) => Math.abs(v / PITCH - Math.round(v / PITCH)) < 1e-7;
|
||||
|
||||
/** A click/pointer event at a given RENDER-unit point of the current view. */
|
||||
const at = (X, Y, type = 'pointermove', extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent(type, {
|
||||
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
|
||||
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
// an off-grid target: a third of a step past a node, in both axes
|
||||
const OFF = PITCH / 3;
|
||||
|
||||
// ---- 1a) the snap itself, the one gate every gesture goes through -----
|
||||
const s1 = c._snap([100 + OFF, 200 + OFF]);
|
||||
o.snapRoundsToTheNode = onGridR(s1[0]) && onGridR(s1[1]);
|
||||
const s2 = c._snap([100 + OFF, 200 + OFF], { shiftKey: true });
|
||||
o.shiftCannotSuspendTheSnap = onGridR(s2[0]) && onGridR(s2[1]);
|
||||
o.snapAlsoClampsTheGarbage = c._snap([1e12, -1e12])[0] === 5000 * NORM_W;
|
||||
// the step is NORM_W/GRID_N and nothing else — it did NOT change with the
|
||||
// infinite canvas, so no plan's nodes ever moved out from under it
|
||||
o.pitchIsTheCanvasFreeConstant = Math.abs(c._gridPitch - PITCH) < 1e-12;
|
||||
|
||||
// ---- 1b) a DEVICE dropped with the mouse lands on a node --------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => !d.virtual);
|
||||
const p0 = c._pos(dev);
|
||||
const mkDelta = (dxu, dyu, extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: 400 + (dxu / v.w) * rect.width, clientY: 400 + (dyu / v.h) * rect.height,
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mkDelta(137.7 - p0.x + OFF, 251.3 - p0.y + OFF), dev);
|
||||
c._drag = null;
|
||||
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
|
||||
|
||||
// ---- 1c) a ROOM LABEL too ---------------------------------------------
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
|
||||
const lp = c._labelPos(room, c._space);
|
||||
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
|
||||
c._labelMove(mkDelta(311.4 - lp.x + OFF, 402.9 - lp.y + OFF), room, c._space);
|
||||
c._drag = null;
|
||||
o.roomLabelLandsOnANode = onGridN(c._layout['rl_' + room.id].x)
|
||||
&& onGridN(c._layout['rl_' + room.id].y);
|
||||
// …and one that has never been dragged is on a node as well
|
||||
const other = c._spaceModel(c._space).rooms.find((r) => r.name && r.id !== room.id);
|
||||
const op = c._labelPos(other, c._space);
|
||||
o.untouchedLabelIsOnANode = onGridR(op.x) && onGridR(op.y);
|
||||
|
||||
// ---- 1d) DECOR: draft, text anchor, and a move ------------------------
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._decorTool = 'rect';
|
||||
// dispatched for real, so the handler sees a target (it looks for .dshape)
|
||||
sr().querySelector('.stage').dispatchEvent(at(300 + OFF, 300 + OFF, 'pointerdown', { pointerId: 21 }));
|
||||
o.decorDraftStartsOnANode = onGridR(c._decorDraft.a[0]) && onGridR(c._decorDraft.a[1]);
|
||||
c._decorDraft = { ...c._decorDraft, b: c._snap([420 + OFF, 380 + OFF]) };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
const dsh = c._decorList[c._decorList.length - 1];
|
||||
o.decorShapeIsOnNodes = onGridN(dsh.x) && onGridN(dsh.y)
|
||||
&& onGridN(dsh.x + dsh.w) && onGridN(dsh.y + dsh.h);
|
||||
c._decorTool = 'text';
|
||||
sr().querySelector('.stage').dispatchEvent(at(500 + OFF, 500 + OFF, 'pointerdown', { pointerId: 22 }));
|
||||
o.decorTextAnchorIsOnANode = onGridN(c._decorTextDialog.x) && onGridN(c._decorTextDialog.y);
|
||||
c._decorTextDialog = null;
|
||||
// a shape that is ALREADY off the grid is put on it by one drag: the mover
|
||||
// snaps the resulting anchor, not the delta (which used to preserve the drift)
|
||||
const sp = c._curSpaceCfg;
|
||||
sp.decor = [...c._decorList, { id: 'dcOff', kind: 'rect',
|
||||
x: 0.3013, y: 0.4017, w: 0.1, h: 0.05, color: '#889', width: 2 }];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const off = c._decorList.find((x) => x.id === 'dcOff');
|
||||
c._decorMove = { id: 'dcOff', start: c._svgPoint(at(301.3, 401.7)),
|
||||
orig: JSON.parse(JSON.stringify(off)), pid: 9, moved: false };
|
||||
c._decorMoveUpdate(at(351.3, 451.7));
|
||||
await c.updateComplete;
|
||||
const off2 = c._decorList.find((x) => x.id === 'dcOff');
|
||||
o.oneDragPutsADriftedShapeOnTheGrid = onGridN(off2.x) && onGridN(off2.y);
|
||||
c._decorMove = null;
|
||||
sp.decor = sp.decor.filter((x) => x.id !== 'dcOff' && x.id !== dsh.id);
|
||||
|
||||
// ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it ---
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._tool = 'opening';
|
||||
const spm = c._spaceModel(c._space);
|
||||
const wall = (() => {
|
||||
for (const r of spm.rooms) {
|
||||
const poly = r.poly || null;
|
||||
if (!poly) continue;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) > PITCH * 20) return [a, b];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
o.foundAWallToTestWith = !!wall;
|
||||
if (wall) {
|
||||
const [a, b] = wall;
|
||||
const mid = [(a[0] + b[0]) / 2 + 0.37 * PITCH, (a[1] + b[1]) / 2 + 0.4];
|
||||
c._openingClick(mid);
|
||||
const d = c._openingDialog;
|
||||
o.openingPlaced = !!d;
|
||||
if (d) {
|
||||
// exactly on the wall segment…
|
||||
const t = ((d.x - a[0]) * (b[0] - a[0]) + (d.y - a[1]) * (b[1] - a[1]))
|
||||
/ ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
|
||||
const px = a[0] + t * (b[0] - a[0]), py = a[1] + t * (b[1] - a[1]);
|
||||
o.openingSitsOnItsWall = Math.hypot(d.x - px, d.y - py) < 1e-6;
|
||||
// …and at a whole number of steps along it (or at the wall's own centre,
|
||||
// which is what the magnet is for and what the ruler shows)
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
const along = t * len;
|
||||
o.openingIsQuantisedAlongTheWall =
|
||||
Math.abs(along / PITCH - Math.round(along / PITCH)) < 1e-6
|
||||
|| Math.abs(along - len / 2) < 1e-6;
|
||||
}
|
||||
c._openingDialog = null;
|
||||
}
|
||||
|
||||
// ---- 1f) a room VERTEX drawn by hand ----------------------------------
|
||||
c._tool = 'draw'; c._path = [];
|
||||
c._markupClick(at(220 + OFF, 220 + OFF, 'click'));
|
||||
o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]);
|
||||
c._path = [[200, 200]];
|
||||
c._markupClick(at(240 + OFF, 310 + OFF, 'click', { shiftKey: true }));
|
||||
const locked = c._path[1];
|
||||
const ldx = Math.abs(locked[0] - 200), ldy = Math.abs(locked[1] - 200);
|
||||
o.shiftLocksRoomWallTo45 = c._path.length === 2
|
||||
&& (ldx < 1e-7 || ldy < 1e-7 || Math.abs(ldx - ldy) < 1e-7)
|
||||
&& onGridR(locked[0]) && onGridR(locked[1]);
|
||||
c._path = []; c._tool = 'draw';
|
||||
|
||||
// ---- 2) «Оптимизировать планы» -----------------------------------------
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// a deliberately detuned fixture: a third of a step off, everywhere
|
||||
const D = 1 / GRID_N / 3;
|
||||
const FIX = { spaces: [{
|
||||
id: 'g1', title: 'Grid test', view_box: [0, 0, 1, 1],
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'A', area: 'living_room',
|
||||
poly: [[0.2 + D, 0.2], [0.5, 0.2 - D], [0.5 + D, 0.5], [0.2, 0.5]] },
|
||||
{ id: 'r2', name: 'B', area: 'kitchen', x: 0.5, y: 0.2, w: 0.3 + D, h: 0.3 },
|
||||
],
|
||||
openings: [{ id: 'o1', type: 'window', x: 0.3 + D, y: 0.2 + D / 2, angle: 0, length: 0.06 }],
|
||||
decor: [{ id: 'd1', kind: 'line', x1: 0.1 + D, y1: 0.7, x2: 0.9, y2: 0.7 - D }],
|
||||
}], markers: [], settings: {} };
|
||||
const FIXLAY = { d_light1: { s: 'g1', x: 0.3 + D, y: 0.3 }, rl_r1: { s: 'g1', x: 0.25 + D, y: 0.25 } };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(FIX));
|
||||
c._layout = JSON.parse(JSON.stringify(FIXLAY));
|
||||
c._modelCache = null; c._frame = null; c._space = 'g1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
c._openAlignDialog();
|
||||
await c.updateComplete;
|
||||
const dlg = c._alignDialog;
|
||||
o.alignDialogOpens = !!dlg && !!sr().querySelector('hp-dialog .alignmsg');
|
||||
o.alignCountsWhatMoves = !!dlg && dlg.report.moved > 0 && dlg.report.moved <= dlg.report.total;
|
||||
// half a step of 5 cm cells ≈ 2.5 cm; a third of a step is well under that
|
||||
o.alignPromisesASmallShift = !!dlg && dlg.cm > 0 && dlg.cm < 3;
|
||||
o.alignExplainsSafeUndo = (sr().querySelector('hp-dialog .rhint')?.textContent || '').length > 20;
|
||||
|
||||
// capture what the network is told, then run it
|
||||
const sent = [];
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m.type); return base(m); } };
|
||||
await c._runAlignToGrid();
|
||||
await c.updateComplete;
|
||||
o.alignDialogClosed = c._alignDialog === null;
|
||||
o.alignUsedAtomicConfigLayoutWrite = sent.filter((t) => t === 'houseplan/plan/optimize').length === 1;
|
||||
|
||||
const g = c._serverCfg.spaces[0];
|
||||
o.alignedRoomPoly = g.rooms[0].poly.every((p) => onGridN(p[0]) && onGridN(p[1]));
|
||||
o.alignedRoomRect = onGridN(g.rooms[1].x) && onGridN(g.rooms[1].x + g.rooms[1].w);
|
||||
o.alignedDecor = onGridN(g.decor[0].x1) && onGridN(g.decor[0].y1)
|
||||
&& onGridN(g.decor[0].x2) && onGridN(g.decor[0].y2);
|
||||
o.alignedLayout = onGridN(c._layout.d_light1.x) && onGridN(c._layout.rl_r1.x);
|
||||
// the opening stayed ON the top wall of r1 rather than being rounded off it
|
||||
const w0 = g.rooms[0].poly[0], w1 = g.rooms[0].poly[1];
|
||||
const tt = ((g.openings[0].x - w0[0]) * (w1[0] - w0[0]) + (g.openings[0].y - w0[1]) * (w1[1] - w0[1]))
|
||||
/ ((w1[0] - w0[0]) ** 2 + (w1[1] - w0[1]) ** 2);
|
||||
o.alignedOpeningStaysOnItsWall = Math.hypot(
|
||||
g.openings[0].x - (w0[0] + tt * (w1[0] - w0[0])),
|
||||
g.openings[0].y - (w0[1] + tt * (w1[1] - w0[1]))) < 1e-9;
|
||||
|
||||
// ---- 2b) …and the second run has nothing to do ------------------------
|
||||
c._openAlignDialog();
|
||||
await c.updateComplete;
|
||||
o.secondRunMovesNothing = c._alignDialog.report.moved === 0;
|
||||
o.secondRunOffersNoButton = !sr().querySelector('hp-dialog .btn.on');
|
||||
c._alignDialog = null;
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- 2c) the promise is an UPPER BOUND, in EACH space's own scale -----
|
||||
// AUD-158B1-01: one normalised maximum converted through the first space's
|
||||
// cell size promised 2.5 cm for a vertex that moved 50 cm on the floor above.
|
||||
const MULTI = { spaces: [
|
||||
{ id: 'm1', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'ma', name: 'A', area: 'living_room',
|
||||
poly: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]] }] },
|
||||
{ id: 'm2', title: 'Attic', cell_cm: 100, view_box: [0, 0, 1, 1],
|
||||
// the ONLY off-grid vertex on the plan, and it is on the 100 cm floor
|
||||
rooms: [{ id: 'mb', name: 'B', area: 'kitchen',
|
||||
poly: [[0.2 + 1 / GRID_N / 2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]] }] },
|
||||
], markers: [], settings: {} };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(MULTI));
|
||||
c._layout = {};
|
||||
c._modelCache = null; c._frame = null; c._space = 'm1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._openAlignDialog(); await c.updateComplete;
|
||||
const md = c._alignDialog;
|
||||
o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51;
|
||||
o.alignPromiseNamesTheSpaceItBelongsTo = !!md && md.where === 'Attic'
|
||||
&& (sr().querySelector('hp-dialog .body')?.textContent || '').includes('Attic');
|
||||
c._alignDialog = null; await c.updateComplete;
|
||||
|
||||
// ---- 2d) an angle-only opening fix is offerable (AUD-158B1-02) --------
|
||||
// its centre is already on the wall; only the stored angle is wrong, and the
|
||||
// batch used to report "nothing to move" while returning a different plan
|
||||
const TURN = { spaces: [{ id: 'a1', title: 'Flat', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r1', name: 'A', area: 'living_room',
|
||||
poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
|
||||
openings: [{ id: 'oa', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }],
|
||||
}], markers: [], settings: {} };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(TURN));
|
||||
c._layout = {};
|
||||
c._modelCache = null; c._frame = null; c._space = 'a1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._openAlignDialog(); await c.updateComplete;
|
||||
const ad = c._alignDialog;
|
||||
o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1;
|
||||
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('hp-dialog .btn.on');
|
||||
await c._runAlignToGrid();
|
||||
await c.updateComplete;
|
||||
o.angleOnlyOpeningIsActuallyFixed = c._serverCfg.spaces[0].openings[0].angle === 0;
|
||||
|
||||
return o;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -14,5 +14,5 @@ const res = await page.evaluate(async () => {
|
||||
out.opensInView = !!c._settingsDialog;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Native HA controls in dialogs (ha-switch / ha-slider) + the fallback.
|
||||
// The demo env has no HA frontend, so every OTHER smoke already runs the
|
||||
// fallback branch (plain input[type=checkbox|range]). Here we check both:
|
||||
// 1) fallback: without ha-* the dialogs render the classic inputs;
|
||||
// 2) ha branch: after registering lightweight ha-switch/ha-slider stubs
|
||||
// (checked/value + `change`/`input`, the real contract of the HA ones)
|
||||
// a re-opened dialog renders them, values flow BOTH ways.
|
||||
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;
|
||||
|
||||
// --- 1) fallback branch: no ha-* registered -> classic inputs ----------
|
||||
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
|
||||
out.fallbackCheckbox = !!sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
|
||||
out.fallbackRange = !!sr().querySelector('hp-dialog input[type=range]');
|
||||
out.fallbackNoHaSwitch = !sr().querySelector('hp-dialog ha-switch');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// --- 2) register stubs mimicking the HA contract -----------------------
|
||||
customElements.define('ha-switch', class extends HTMLElement {
|
||||
#on = false;
|
||||
get checked() { return this.#on; }
|
||||
set checked(v) { this.#on = !!v; }
|
||||
get disabled() { return this.hasAttribute('disabled'); }
|
||||
set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
|
||||
});
|
||||
customElements.define('ha-slider', class extends HTMLElement {
|
||||
#v = 0;
|
||||
get value() { return this.#v; }
|
||||
set value(v) { this.#v = Number(v); }
|
||||
set min(_) {} set max(_) {} set step(_) {}
|
||||
});
|
||||
|
||||
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
|
||||
const switches = [...sr().querySelectorAll('hp-dialog ha-switch')];
|
||||
out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
|
||||
out.haNoPlainCheckbox = !sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
|
||||
const sliders = [...sr().querySelectorAll('hp-dialog ha-slider')];
|
||||
out.haSliderRendered = sliders.length >= 2; // size + angle
|
||||
|
||||
// --- 3) card -> control: state is pushed into the element --------------
|
||||
c._markerDialog = { ...c._markerDialog, isLight: true, hideFromPlan: false };
|
||||
await c.updateComplete;
|
||||
out.downstreamChecked = [...sr().querySelectorAll('hp-dialog ha-switch')].some((el) => el.checked === true);
|
||||
|
||||
// --- 4) control -> card: change with .checked lands in the dialog ------
|
||||
const before = c._markerDialog.hideFromPlan;
|
||||
const sw = [...sr().querySelectorAll('hp-dialog ha-switch')].find((el) => el.checked === before);
|
||||
sw.checked = !before;
|
||||
sw.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
out.upstreamChecked = c._markerDialog.hideFromPlan === !before || c._markerDialog.isLight === !before
|
||||
|| c._markerDialog.useClimateTemp === !before || c._markerDialog.tapConfirm === !before;
|
||||
|
||||
// --- 5) slider both ways ----------------------------------------------
|
||||
const sizeBefore = c._markerDialog.size;
|
||||
const sl = [...sr().querySelectorAll('hp-dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
|
||||
out.downstreamValue = !!sl;
|
||||
sl.value = 2;
|
||||
sl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
out.upstreamValue = c._markerDialog.size === 2;
|
||||
// `change` (fired on release, HA-version dependent) reaches the same handler
|
||||
sl.value = 2.5;
|
||||
sl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
out.upstreamValueChangeEvt = c._markerDialog.size === 2.5;
|
||||
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// --- 6) general settings: opacity sliders take the ha branch too -------
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.gsHaSliders = sr().querySelectorAll('hp-dialog ha-slider').length >= 9;
|
||||
const gsl = sr().querySelector('hp-dialog ha-slider');
|
||||
gsl.value = 55;
|
||||
gsl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
out.gsUpstream = Math.round((Object.values(c._settingsDialog.colors)[0].a) * 100) === 55;
|
||||
c._settingsDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res, {});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,279 @@
|
||||
// docs/FILTERING.md: скрытие — явная галка. Конфиг материализуется сеятелем
|
||||
// (filter_seeded), галка в диалоге у всех устройств, «Показать скрытые» —
|
||||
// локальный режим редактора, скрытые рисуются призраками и только там.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// --- сеятель материализует конфиг при загрузке ---------------------------
|
||||
out.configSeeded = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const t0 = Date.now();
|
||||
while (!c._serverCfg?.settings?.filter_seeded && Date.now() - t0 < 3000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
return c._serverCfg?.settings?.filter_seeded === true;
|
||||
});
|
||||
|
||||
// --- галка прячет, счётчик не считает, призрак только в редакторе --------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const visibleIds = () => [...sr().querySelectorAll('.dev')].length;
|
||||
const before = visibleIds();
|
||||
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
o.hiddenNotRendered = visibleIds() === before - 1;
|
||||
const built = c._devices.find((x) => x.id === d.id);
|
||||
o.stillBuilt = !!built && built.hidden === true;
|
||||
o.countExcludesHidden = (sr().querySelector('.count')?.textContent || '').includes(String(before - 1));
|
||||
|
||||
// в просмотре тумблера нет эффекта — призраки только в редакторе устройств
|
||||
c._showHidden = true; c.requestUpdate(); await c.updateComplete;
|
||||
o.noGhostsInView = !sr().querySelector('.dev.ghost');
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
o.ghostInEditor = !!sr().querySelector('.dev.ghost');
|
||||
// призрак — конфигурация, не статус: ни жёлтого, ни unavail, ни тревоги
|
||||
const g = sr().querySelector('.dev.ghost');
|
||||
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
|
||||
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
|
||||
// и он синий, а не тёмный — отличим от недоступного устройства
|
||||
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
|
||||
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
|
||||
// тумблер локальный: конфиг не трогается
|
||||
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
|
||||
|
||||
// --- авто-устройство тоже можно полностью удалить с плана --------------
|
||||
const ghost = c._devices.find((x) => x.id === d.id);
|
||||
c._openMarkerDialog(ghost); await c.updateComplete;
|
||||
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
|
||||
o.deleteForAuto = !!sr().querySelector('hp-dialog .btn.danger');
|
||||
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
|
||||
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:' + d.bindingRef);
|
||||
o.untickKeepsMarker = !!m && m.hidden === false;
|
||||
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
|
||||
o.deviceVisibleAgain = !!back && !back.hidden;
|
||||
|
||||
// --- у виртуального кнопка «Удалить» есть; удаляется только выбранный ---
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, name: 'delete-one-a', binding: 'virtual' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, name: 'delete-one-b', binding: 'virtual' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const virtualA = c._devices.find((x) => x.virtual && x.name === 'delete-one-a');
|
||||
const virtualB = c._devices.find((x) => x.virtual && x.name === 'delete-one-b');
|
||||
c._openMarkerDialog(virtualA); await c.updateComplete;
|
||||
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
|
||||
const savedConfirm = window.confirm;
|
||||
window.confirm = () => true;
|
||||
await c._deleteMarker(); await c.updateComplete;
|
||||
window.confirm = savedConfirm;
|
||||
o.deleteOnlySelectedVirtual = !!virtualA && !!virtualB
|
||||
&& !(c._serverCfg.markers || []).some((m) => m.id === virtualA.id)
|
||||
&& (c._serverCfg.markers || []).some((m) => m.id === virtualB.id);
|
||||
|
||||
// A re-added HA binding replaces its tombstone and receives a fresh layout
|
||||
// position instead of inheriting the deleted marker's last coordinates.
|
||||
const readd = c._devices.find((x) => x.bindingKind === 'device' && !x.hidden);
|
||||
const oldPos = { s: readd.space, x: 0.913, y: 0.917 };
|
||||
c._layout = { ...c._layout, [readd.id]: oldPos };
|
||||
c._openMarkerDialog(readd); await c.updateComplete;
|
||||
window.confirm = () => true;
|
||||
await c._deleteMarker(); await c.updateComplete;
|
||||
window.confirm = savedConfirm;
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
c._markerDialog = {
|
||||
...c._markerDialog,
|
||||
bindingMode: 'ha',
|
||||
binding: `device:${readd.bindingRef}`,
|
||||
name: readd.name,
|
||||
};
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const rebound = (c._serverCfg.markers || [])
|
||||
.filter((m) => m.binding === `device:${readd.bindingRef}`);
|
||||
const fresh = c._layout[readd.id];
|
||||
o.readdReplacesTombstone = rebound.length === 1 && rebound[0].removed !== true;
|
||||
o.readdUsesFreshPosition = !!fresh
|
||||
&& (Math.abs(fresh.x - oldPos.x) > 1e-6 || Math.abs(fresh.y - oldPos.y) > 1e-6);
|
||||
|
||||
c._markerDialog = null; c._setMode('view');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1510-02: призрак не показывает live-значения ----------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// датчик с числом + display:value, скрыт
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'sensor.power_meter': { state: '42', attributes: { unit_of_measurement: 'kW' } } } };
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: 'pm', binding: 'entity:sensor.power_meter',
|
||||
display: 'value', hidden: true, space: 'f1' });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
const pm = ghosts.find((g) => g.textContent.includes('42')) || null;
|
||||
o.ghostHidesValue = pm === null; // «42 kW» не отрисован
|
||||
o.ghostNoLiveBadges = ghosts.every((g) =>
|
||||
!g.querySelector('.valtext') && !g.querySelector('.tval') && !g.querySelector('.hval') && !g.querySelector('.lqi'));
|
||||
// у КАЖДОГО призрака есть базовая иконка — «noicon» не оправдание
|
||||
// (HP-1511-02: ripple-призрак был безликим пульсом)
|
||||
o.ghostKeepsIcon = ghosts.every((g) => !!g.querySelector('ha-icon') && !g.classList.contains('noicon'));
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- Legacy ripple → icon+activity; ghost keeps icon and has no effect -----
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const lamp = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device' && !x.hidden);
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._serverCfg.markers.push({ id: lamp.id, binding: 'device:' + lamp.bindingRef,
|
||||
display: 'ripple', hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
|
||||
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.activity-ring'));
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-01: авто-сетка одинакова на обеих карточках ------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
// один видимый, остальные зоны living_room скрыты; layout пуст
|
||||
const vis = c._devices.filter((x) => x.area === 'living_room' && !x.virtual);
|
||||
const keep = vis[0];
|
||||
for (const d of vis.slice(1)) {
|
||||
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
|
||||
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
|
||||
}
|
||||
cfg.markers = cfg.markers.filter((m) => m.id !== keep.id || !m.hidden);
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._layout = {}; // пустой layout: работают только авто-позиции
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const full = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
|
||||
.find((el) => el.style.left && !el.classList.contains('ghost'));
|
||||
// проценты полной карточки считаются от content-fit view — переводим в vb
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const fullVb = full && [
|
||||
v.x + (parseFloat(full.style.left) / 100) * v.w,
|
||||
v.y + (parseFloat(full.style.top) / 100) * v.h,
|
||||
];
|
||||
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
// rev уникален: у статичной карточки модульный кэш конфига по rev,
|
||||
// и одинаковый rev в соседних тестах подсовывает чужой конфиг
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 31 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 31 };
|
||||
return { ok: true };
|
||||
},
|
||||
// модульный кэш конфига инвалидируется только через это событие — стаб
|
||||
// сохраняет колбэк, чтобы следующий тест мог сбросить кэш
|
||||
connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('[style*="left"]') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
|
||||
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
|
||||
// docs/CANVAS.md §4: статичная карточка теперь кадрирует СОДЕРЖИМОЕ, как и
|
||||
// полная, а не квадрат 0..1000 — координаты берём из её же viewBox
|
||||
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '0 0 1000 1000')
|
||||
.trim().split(/\s+/).map(Number);
|
||||
const stVb = st && [
|
||||
svb[0] + (parseFloat(st.style.left) / 100) * svb[2],
|
||||
svb[1] + (parseFloat(st.style.top) / 100) * svb[3],
|
||||
];
|
||||
o.autoGridParity = !!fullVb && !!stVb
|
||||
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
|
||||
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1510-01: LQI комнаты одинаков на полной и статичной карточке ------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), fill_mode: 'lqi', show_borders: true };
|
||||
// прячем ВСЕ устройства зоны living_room: комнату красит только скрытое
|
||||
for (const d of c._devices.filter((x) => x.area === 'living_room' && !x.virtual)) {
|
||||
if (!cfg.markers.some((m) => m.id === d.id)) {
|
||||
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
|
||||
} else {
|
||||
cfg.markers = cfg.markers.map((m) => (m.id === d.id ? { ...m, hidden: true } : m));
|
||||
}
|
||||
}
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const fullRoom = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.room')]
|
||||
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
|
||||
const fullFill = fullRoom ? (fullRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
|
||||
|
||||
window.__hpInvalidate?.(); // сбросить модульный кэш от предыдущей карточки
|
||||
const hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 57 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 57 };
|
||||
return { ok: true };
|
||||
},
|
||||
connection: { subscribeEvents: async () => () => {} } };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.room') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const stRoom = [...card.renderRoot.querySelectorAll('.room')]
|
||||
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
|
||||
const stFill = stRoom ? (stRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
|
||||
o.fullPaintsHiddenLqi = !!fullFill;
|
||||
o.staticPaintsHiddenLqi = !!stFill;
|
||||
o.lqiParity = !!fullFill && fullFill === stFill;
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* The two "draw less" space options and the virtual-wall rule (owner
|
||||
* 2026-08-05), plus the handle beads that came with them.
|
||||
*
|
||||
* • «Скрыть декоративный слой» hides decor everywhere EXCEPT its own editor;
|
||||
* • «Скрыть проёмы» hides doors/windows everywhere EXCEPT the plan editor,
|
||||
* and does not touch what an opening MEANS (the light still gets through);
|
||||
* • a space that does not draw room borders draws no dashed virtual walls
|
||||
* in View; every editor still does, because the complete centreline span
|
||||
* must remain visible while editing;
|
||||
* • both switches survive a round trip through the dialog;
|
||||
* • every corner handle paints a bead a quarter of its hit radius, and the
|
||||
* hit radius itself is unchanged.
|
||||
*/
|
||||
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 upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
|
||||
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
const set = async (patch) => {
|
||||
const s = sp();
|
||||
s.settings = { ...(s.settings || {}), ...patch };
|
||||
await upd();
|
||||
};
|
||||
const mode = async (m) => { c._setMode(m); await upd(); };
|
||||
const n = (sel) => sr().querySelectorAll(sel).length;
|
||||
|
||||
// a shape and an opening to look for, on the space we are on
|
||||
const s0 = sp();
|
||||
s0.decor = [{ id: 'hl1', kind: 'rect', x: 0.3, y: 0.3, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
s0.openings = [{ id: 'hl2', type: 'door', x: 0.3, y: 0.2, angle: 0, length: 0.09 }];
|
||||
await mode('view');
|
||||
await set({ hide_decor: undefined, hide_openings: undefined, show_borders: true });
|
||||
|
||||
// ---- 1) the baseline: both layers are drawn ---------------------------
|
||||
out.decorDrawnByDefault = n('.decorlayer .dshape') > 0;
|
||||
out.openingDrawnByDefault = n('[data-hp="opening"]') > 0;
|
||||
|
||||
// ---- 2) hide the decor layer ------------------------------------------
|
||||
await set({ hide_decor: true });
|
||||
out.decorHiddenInView = n('.decorlayer .dshape') === 0;
|
||||
await mode('devices');
|
||||
out.decorHiddenInDevices = n('.decorlayer .dshape') === 0;
|
||||
// …but its own editor still shows it, or it could not be edited
|
||||
await mode('decor');
|
||||
out.decorVisibleInItsEditor = n('.decorlayer .dshape') > 0;
|
||||
await mode('view');
|
||||
// the shape itself was never touched
|
||||
out.decorShapeKept = (sp().decor || []).length === 1;
|
||||
await set({ hide_decor: undefined });
|
||||
out.decorBackAfterOff = n('.decorlayer .dshape') > 0;
|
||||
|
||||
// ---- 3) hide the openings ---------------------------------------------
|
||||
const opCount = () => n('[data-hp="opening"]');
|
||||
const before = opCount();
|
||||
await set({ hide_openings: true });
|
||||
out.openingsHiddenInView = opCount() === 0 && before > 0;
|
||||
await mode('plan');
|
||||
c._tool = 'opening';
|
||||
await upd();
|
||||
out.openingsVisibleInPlanEditor = opCount() > 0;
|
||||
await mode('view');
|
||||
// the opening is still an opening: it stayed in the config untouched
|
||||
out.openingKept = (sp().openings || []).length === 1;
|
||||
await set({ hide_openings: undefined });
|
||||
out.openingsBackAfterOff = opCount() === before;
|
||||
|
||||
// ---- 4) virtual walls follow the borders switch ------------------------
|
||||
// an open boundary is a link between two rooms that share a wall; the demo's
|
||||
// first two rooms do (smoke_render_parity relies on the same fact)
|
||||
const rooms = sp().rooms || [];
|
||||
rooms[0].open_to = [rooms[1].id];
|
||||
await upd();
|
||||
out.openWallsFound = c._openPairs().length > 0;
|
||||
await set({ show_borders: true });
|
||||
out.openWallsDrawnWithBorders = n('.openwalls .openwall') > 0;
|
||||
await set({ show_borders: false });
|
||||
out.openWallsGoneWithoutBorders = n('.openwalls .openwall') === 0;
|
||||
// Every editor still draws them. The dash deliberately reaches the physical
|
||||
// centreline there, even if show_borders is off; only View hides it.
|
||||
await mode('plan');
|
||||
c._tool = 'boundary';
|
||||
await upd();
|
||||
out.openWallsVisibleInPlanEditor = n('.openwalls .openwall') > 0;
|
||||
await mode('devices');
|
||||
out.openWallsVisibleInDevicesEditor = n('.openwalls .openwall') > 0;
|
||||
await mode('decor');
|
||||
out.openWallsVisibleInDecorEditor = n('.openwalls .openwall') > 0;
|
||||
await mode('view');
|
||||
await set({ show_borders: true });
|
||||
|
||||
// ---- 5) the dialog carries both switches round trip --------------------
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await upd();
|
||||
const rows = [...sr().querySelectorAll('hp-dialog .srcrow')].map((r) => r.textContent.trim());
|
||||
out.dialogOffersHideDecor = rows.some((t) => /декорат|decorative/i.test(t));
|
||||
out.dialogOffersHideOpenings = rows.some((t) => /проём|проем|openings|doors and windows/i.test(t));
|
||||
out.dialogReadsOff = c._spaceDialog.hideDecor === false && c._spaceDialog.hideOpenings === false;
|
||||
c._spaceDialog = { ...c._spaceDialog, hideDecor: true, hideOpenings: true };
|
||||
await c._saveSpaceDialog();
|
||||
await upd();
|
||||
out.savedBoth = sp().settings.hide_decor === true && sp().settings.hide_openings === true;
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await upd();
|
||||
out.dialogReadsOn = c._spaceDialog.hideDecor === true && c._spaceDialog.hideOpenings === true;
|
||||
c._spaceDialog = { ...c._spaceDialog, hideDecor: false, hideOpenings: false };
|
||||
await c._saveSpaceDialog();
|
||||
await upd();
|
||||
// switched off = nothing is stored (undefined, which JSON drops on the way
|
||||
// to the server), not a stored `false` — the same rule bg_color follows
|
||||
out.clearedNotStored = sp().settings.hide_decor === undefined
|
||||
&& sp().settings.hide_openings === undefined
|
||||
&& !('hide_decor' in JSON.parse(JSON.stringify(sp().settings)));
|
||||
c._spaceDialog = null;
|
||||
await upd();
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
|
||||
// ---- 6) the beads: a quarter of the hit radius, and the hit is unchanged --
|
||||
const rad = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('decor'); c._decorTool = 'backdrop';
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
const hit = sr().querySelector('.bdframe .bdhandle');
|
||||
const knob = sr().querySelector('.bdframe .bdknob');
|
||||
const view = c._viewOr(c._baseVb());
|
||||
const o = {
|
||||
hasBoth: !!hit && !!knob,
|
||||
hitR: hit ? Number(hit.getAttribute('r')) : 0,
|
||||
knobR: knob ? Number(knob.getAttribute('r')) : 0,
|
||||
expectHit: Math.max(view.w, view.h) * 0.02,
|
||||
knobInert: knob ? getComputedStyle(knob).pointerEvents === 'none' : false,
|
||||
// the bead sits exactly on the handle
|
||||
sameCentre: hit && knob
|
||||
&& hit.getAttribute('cx') === knob.getAttribute('cx')
|
||||
&& hit.getAttribute('cy') === knob.getAttribute('cy'),
|
||||
};
|
||||
// …and the resize tool's corner beads follow the same rule
|
||||
c._setMode('plan'); c._tool = 'resize';
|
||||
c._rszSel = (c._curSpaceCfg.rooms || [])[0]?.id || null;
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
const rh = sr().querySelector('.rszcorner');
|
||||
const rk = sr().querySelector('.rszknob');
|
||||
o.rszBoth = !!rh && !!rk;
|
||||
o.rszQuarter = rh && rk
|
||||
? Math.abs(Number(rk.getAttribute('r')) * 4 - Number(rh.getAttribute('r'))) < 0.05
|
||||
: false;
|
||||
c._setMode('view');
|
||||
return o;
|
||||
});
|
||||
checkAll({
|
||||
handleAndBead: rad.hasBoth,
|
||||
hitRadiusUnchanged: Math.abs(rad.hitR - rad.expectHit) < 0.15,
|
||||
beadIsAQuarter: Math.abs(rad.knobR * 4 - rad.hitR) < 0.05,
|
||||
beadTakesNoPointer: rad.knobInert,
|
||||
beadOnTheHandle: rad.sameCentre,
|
||||
resizeCornerHasBead: rad.rszBoth,
|
||||
resizeBeadIsAQuarter: rad.rszQuarter,
|
||||
});
|
||||
|
||||
// ---- 7) every Background session starts on Select; image has its own tool --
|
||||
const armed = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
const o = {
|
||||
startsOnSelect: c._decorTool === 'select',
|
||||
inertUntilPictureTool: !c._bdMovable,
|
||||
hasPicture: !!c._curSpaceCfg.plan_url,
|
||||
};
|
||||
c._decorTool = 'backdrop'; c.requestUpdate(); await c.updateComplete;
|
||||
o.movable = c._bdMovable;
|
||||
// and the picture really moves under it: press inside the body, drag, release
|
||||
const r = c._bdRect;
|
||||
const before = { x: c._curSpaceCfg.plan_x ?? 0, y: c._curSpaceCfg.plan_y ?? 0 };
|
||||
const pt = [r.x + r.w / 2, r.y + r.h / 2];
|
||||
const ev = (x, y) => ({ pointerId: 42, clientX: x, clientY: y, target: c._stageEl,
|
||||
preventDefault() {}, stopPropagation() {}, shiftKey: false });
|
||||
// _svgPoint reads clientX/Y — go through the stage geometry
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const box = stage.getBoundingClientRect();
|
||||
const vb = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
const toScreen = (x, y) => [box.left + ((x - vb[0]) / vb[2]) * box.width,
|
||||
box.top + ((y - vb[1]) / vb[3]) * box.height];
|
||||
const [sx, sy] = toScreen(pt[0], pt[1]);
|
||||
const [ex, ey] = toScreen(pt[0] + vb[2] * 0.1, pt[1]);
|
||||
c._stagePointerDown(ev(sx, sy));
|
||||
c._stagePointerMove(ev(ex, ey));
|
||||
c._stagePointerUp(ev(ex, ey));
|
||||
await c.updateComplete;
|
||||
o.moved = (c._curSpaceCfg.plan_x ?? 0) !== before.x;
|
||||
c._setMode('view');
|
||||
return o;
|
||||
});
|
||||
checkAll({
|
||||
backgroundStartsOnSelect: armed.startsOnSelect,
|
||||
pictureInertUntilOwnTool: armed.inertUntilPictureTool,
|
||||
pictureMovableUnderOwnTool: armed.movable,
|
||||
dragMovedThePicture: armed.moved,
|
||||
});
|
||||
|
||||
await finish(browser);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -17,7 +17,7 @@ const res = await page.evaluate(async () => {
|
||||
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
|
||||
// пикер (или фолбэк-инпут) получил placeholder
|
||||
const picker = sr().querySelector('ha-icon-picker');
|
||||
const inputs = [...sr().querySelectorAll('.dialog input')];
|
||||
const inputs = [...sr().querySelectorAll('hp-dialog input')];
|
||||
out.placeholderSet = picker
|
||||
? picker.placeholder === dev.icon
|
||||
: inputs.some((i) => i.placeholder === dev.icon);
|
||||
@@ -27,5 +27,5 @@ const res = await page.evaluate(async () => {
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Множитель «размер значка» обязан масштабировать ГЛИФ вместе с подложкой:
|
||||
// --mdc-icon-size считался от базового --icon-size, и «увеличенный» значок
|
||||
// был пустой рамкой вокруг дефолтной иконки (репорт пользователя, 2026-07-29).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
|
||||
const glyphW = () => {
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
|
||||
const ic = el?.querySelector('ha-icon');
|
||||
return ic ? ic.getBoundingClientRect().width : 0;
|
||||
};
|
||||
const badgeW = () => {
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
|
||||
return el ? el.getBoundingClientRect().width : 0;
|
||||
};
|
||||
// базовый размер
|
||||
const g1 = glyphW(), b1 = badgeW();
|
||||
// множитель ×3 на всех авто-устройствах через маркер первого
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== d.id);
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3 });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const scaled = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
|
||||
const g3 = scaled?.querySelector('ha-icon')?.getBoundingClientRect().width || 0;
|
||||
const b3 = scaled?.getBoundingClientRect().width || 0;
|
||||
o.badgeScales = b3 > b1 * 2.5;
|
||||
o.glyphScalesWithBadge = g3 > g1 * 2.5; // раньше глиф оставался базовым
|
||||
o.glyphFillsBadge = g3 / b3 > 0.45 && g3 / b3 < 0.8; // пропорция сохранена
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
|
||||
// Тот же класс бага для СПУТНИКОВ значка (репорт владельца, 2026-08-01):
|
||||
// бейдж значения (display:value) и temp-плашка считались от базового
|
||||
// --icon-size и оставались маленькими при size=2. Всё, что визуально
|
||||
// принадлежит устройству, обязано расти вместе с --dev-scale.
|
||||
const out2 = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const devEl = () => {
|
||||
const idx = c._devices.filter((x) => x.space === c._space).findIndex((x) => x.id === 'd_temp');
|
||||
return sr().querySelectorAll('.dev')[idx];
|
||||
};
|
||||
const setMarker = async (extra) => {
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== 'd_temp');
|
||||
c._serverCfg.markers.push({ id: 'd_temp', binding: 'device:d_temp', ...extra });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
};
|
||||
const fs = (el) => parseFloat(getComputedStyle(el).fontSize);
|
||||
// базовый --icon-size в px: ширина немасштабированного бейджа минус рамка 1px×2
|
||||
const plain = [...sr().querySelectorAll('.dev')].find(
|
||||
(e) => !e.classList.contains('ghost') && !e.classList.contains('valonly') && !(e.getAttribute('style') || '').includes('--dev-scale'));
|
||||
const iconPx = plain.getBoundingClientRect().width - 2;
|
||||
|
||||
// --- бейдж значения (valonly) ---
|
||||
await setMarker({ display: 'value', size: 1 });
|
||||
const v1 = devEl().getBoundingClientRect();
|
||||
const vf1 = fs(devEl().querySelector('.valtext'));
|
||||
// при size=1 дефолт не изменился: шрифт ровно прежние 0.45 * icon-size
|
||||
o.valDefaultKept = Math.abs(vf1 - iconPx * 0.45) < 0.75;
|
||||
await setMarker({ display: 'value', size: 2 });
|
||||
const v2 = devEl().getBoundingClientRect();
|
||||
const vf2 = fs(devEl().querySelector('.valtext'));
|
||||
o.valFontScales = vf2 / vf1 > 1.8 && vf2 / vf1 < 2.2;
|
||||
o.valPlateScales = (v2.height - 2) / (v1.height - 2) > 1.8 && (v2.height - 2) / (v1.height - 2) < 2.2
|
||||
&& v2.width / v1.width > 1.6 && v2.width / v1.width < 2.4;
|
||||
|
||||
// --- temp-плашка (.tval) рядом со значком ---
|
||||
await setMarker({ size: 1 });
|
||||
const t1 = devEl().querySelector('.tval').getBoundingClientRect();
|
||||
const tf1 = fs(devEl().querySelector('.tval'));
|
||||
// дефолт: line-height 0.68 * icon-size + рамка 1px×2
|
||||
o.tvalDefaultKept = Math.abs(t1.height - (iconPx * 0.68 + 2)) < 1;
|
||||
await setMarker({ size: 2 });
|
||||
const t2 = devEl().querySelector('.tval').getBoundingClientRect();
|
||||
const tf2 = fs(devEl().querySelector('.tval'));
|
||||
o.tvalScales = (t2.height - 2) / (t1.height - 2) > 1.8 && (t2.height - 2) / (t1.height - 2) < 2.2
|
||||
&& tf2 / tf1 > 1.8 && tf2 / tf1 < 2.2;
|
||||
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== 'd_temp');
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out2));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -43,5 +43,10 @@ const res = await page.evaluate(async () => {
|
||||
c._openingDialog = null; c._setMode('view');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"lockBadgeInert": "no-badge",
|
||||
"viewDevCursor": "pointer",
|
||||
"devModeCursor": "grab",
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square
|
||||
// renders whole, devices can be placed and saved out there, the opening view
|
||||
// follows the content, one far stray neither breaks the view nor hides itself,
|
||||
// zoom-out stops at 3x the content and the icons keep their proportion to it.
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const SHOTS = process.env.HP_SHOTS || '';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old
|
||||
// canvas. Before the infinite canvas the -25%..125% envelope threw every one
|
||||
// of these vertices away and the card framed empty space.
|
||||
const FAR_CFG = {
|
||||
spaces: [{
|
||||
id: 'f1', title: 'Ground floor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'Living room', area: 'living_room',
|
||||
poly: [[1.5, 1.5], [2.2, 1.5], [2.2, 2.2], [1.5, 2.2]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[2.2, 1.5], [3.0, 1.5], [3.0, 2.0], [2.2, 2.0]] },
|
||||
{ id: 'r3', name: 'Bedroom', area: 'bedroom',
|
||||
poly: [[2.2, 2.0], [3.0, 2.0], [3.0, 2.6], [2.2, 2.6]] },
|
||||
{ id: 'r4', name: 'Hallway', area: 'hallway',
|
||||
poly: [[1.5, 2.2], [2.2, 2.2], [2.2, 2.6], [1.5, 2.6]] },
|
||||
],
|
||||
openings: [{ id: 'o1', type: 'window', x: 1.8, y: 1.5, angle: 0, length: 0.15 }],
|
||||
decor: [{ id: 'd1', kind: 'line', x1: 1.4, y1: 2.7, x2: 3.1, y2: 2.7, color: '#8899aa' }],
|
||||
}, {
|
||||
id: 'garden', title: 'Garden', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'g1', name: 'Garden', area: 'garden',
|
||||
poly: [[0.03, 0.178], [0.97, 0.178], [0.97, 0.822], [0.03, 0.822]] }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const FAR_LAYOUT = {
|
||||
d_light1: { s: 'f1', x: 1.7, y: 1.7 }, d_lamp: { s: 'f1', x: 2.0, y: 1.9 },
|
||||
d_tv: { s: 'f1', x: 1.6, y: 2.1 }, d_temp: { s: 'f1', x: 1.9, y: 1.6 },
|
||||
d_kettle: { s: 'f1', x: 2.5, y: 1.7 }, d_leak: { s: 'f1', x: 2.9, y: 1.8 },
|
||||
d_bedlight: { s: 'f1', x: 2.6, y: 2.3 }, d_window: { s: 'f1', x: 2.9, y: 2.5 },
|
||||
d_lock: { s: 'f1', x: 1.6, y: 2.5 }, d_motion: { s: 'f1', x: 2.0, y: 2.4 },
|
||||
};
|
||||
|
||||
const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(JSON.stringify(c2));
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = 'f1';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout]);
|
||||
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
|
||||
// ---- (b) the plan past the old square renders WHOLE ----------------------
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
// the content is 1500..3000 x 1500..2700 render units, plus decor/openings
|
||||
o.frameHoldsFarPlan = b[0] < 1450 && b[0] + b[2] > 3050 && b[1] < 1450 && b[1] + b[3] > 2750;
|
||||
o.frameIsNotTheOldSquare = b[0] > 1000; // nothing is framed at the origin any more
|
||||
const v = c._viewOr(b);
|
||||
o.viewShowsFarPlan = v.x <= 1500 && v.x + v.w >= 3000;
|
||||
// every room polygon is inside the painted viewBox
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const polys = [...sr.querySelectorAll('polygon.room')];
|
||||
o.allRoomsDrawn = polys.length === 4;
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
o.roomsOnScreen = polys.every((p) => {
|
||||
const r = p.getBoundingClientRect();
|
||||
return r.width > 4 && r.height > 4 && r.left >= st.left - 2 && r.right <= st.right + 2
|
||||
&& r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
// and the markers landed on the plan, not in a corner of a phantom canvas
|
||||
const devs = [...sr.querySelectorAll('.devlayer .dev')];
|
||||
o.markersOnPlan = devs.length >= 10 && devs.every((d) => {
|
||||
const r = d.getBoundingClientRect();
|
||||
return r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- a device can be PLACED past the old square, and it saves -------------
|
||||
const ws = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sent = [];
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return base(m); } };
|
||||
// drop a virtual marker at 3.4 / 2.9 — a long way outside the old canvas
|
||||
const far = { s: 'f1', x: 3.4, y: 2.9 };
|
||||
c._layout = { ...c._layout, d_gate: far };
|
||||
c._dirtyPos.add('d_gate');
|
||||
await c._persistLayout?.();
|
||||
// and a room extended even further out, saved through the config writer
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms.push({ id: 'r5', name: 'Workshop',
|
||||
poly: [[3.1, 2.0], [3.8, 2.0], [3.8, 2.6], [3.1, 2.6]] });
|
||||
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
|
||||
await c._saveConfig?.();
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
return { sent: JSON.parse(JSON.stringify(sent)), layout: JSON.parse(JSON.stringify(c._layout)) };
|
||||
});
|
||||
out.farDeviceSaved = ws.layout.d_gate.x === 3.4 && ws.layout.d_gate.y === 2.9;
|
||||
const cfgMsg = ws.sent.filter((m) => m.type === 'houseplan/config/set').pop();
|
||||
const layMsg = ws.sent.filter((m) => m.type === 'houseplan/layout/update').pop();
|
||||
out.wsCarriedFarGeometry = !!cfgMsg
|
||||
&& JSON.stringify(cfgMsg.config).includes('3.8');
|
||||
out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4');
|
||||
|
||||
// the payload the card sends must pass the REAL backend schema (docs/CANVAS.md
|
||||
// §3: ±5000). Skipped, not failed, where voluptuous is not installed.
|
||||
out.backendAcceptsFarPayload = (() => {
|
||||
if (!cfgMsg) return false;
|
||||
const f = '/tmp/hp_infinite_payload.json';
|
||||
writeFileSync(f, JSON.stringify({ config: cfgMsg.config, layout: (layMsg || {}).layout || ws.layout }));
|
||||
try {
|
||||
execFileSync('python3', ['-c', 'import voluptuous'], { stdio: 'ignore' });
|
||||
} catch { console.log('(voluptuous absent — backend schema check skipped)'); return true; }
|
||||
const code = [
|
||||
'import json,sys',
|
||||
'sys.path.insert(0, "custom_components/houseplan")',
|
||||
'import validation as v',
|
||||
`d=json.load(open(${JSON.stringify(f)}))`,
|
||||
'v.CONFIG_SCHEMA(d["config"]); v.LAYOUT_SCHEMA(d["layout"])',
|
||||
'print("VALID")',
|
||||
].join('\n');
|
||||
try {
|
||||
return execFileSync('python3', ['-c', code], { encoding: 'utf8' }).includes('VALID');
|
||||
} catch (e) { console.log('backend refused the payload:', String(e.stdout || e.message).slice(-400)); return false; }
|
||||
})();
|
||||
|
||||
// ---- an OUTLIER: the view ignores it, the hint offers it ------------------
|
||||
await loadFar({
|
||||
...FAR_CFG,
|
||||
spaces: FAR_CFG.spaces.map((s) => (s.id === 'f1' ? { ...s } : s)),
|
||||
}, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
o.outlierReported = c._outliers === 1;
|
||||
const b = c._baseVb();
|
||||
o.outlierDoesNotCommandView = b[2] < 4000 && b[0] + b[2] < 5000; // still the house, not 90 canvases
|
||||
o.outlierViewStillHoldsPlan = b[0] < 1500 && b[0] + b[2] > 3000;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const hint = sr.querySelector('.farhint');
|
||||
o.hintShown = !!hint && !!hint.querySelector('button');
|
||||
o.hintIsNotAModal = !sr.querySelector('hp-dialog');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// «Показать» / «Вписать всё» takes the stray in
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
sr.querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
const o = {};
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000;
|
||||
o.fitAllStillHoldsPlan = v.x <= 1500 && v.y <= 1500;
|
||||
await c.updateComplete;
|
||||
o.hintGoneAfterShow = !sr.querySelector('.farhint');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- zoom-out stops at 3x, zoom-in unchanged -----------------------------
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
c._resetZoom();
|
||||
const fit = { ...c._viewOr(c._baseVb()) };
|
||||
c._applyView(0.001);
|
||||
o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9
|
||||
&& Math.abs(c._view.w - fit.w * 3) < fit.w * 0.02;
|
||||
c._applyView(50);
|
||||
o.zoomInUnchanged = c._zoom === 8;
|
||||
c._resetZoom();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- pan has slack, and "home is that way" brings you back ---------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const fit = { ...c._view };
|
||||
// pan most of a screen to the right: nothing pins the content over the scene
|
||||
c._view = c._clampView({ ...fit, x: fit.x + fit.w * 0.9 }, fit);
|
||||
o.panPastContentAllowed = c._view.x > fit.x + fit.w * 0.5;
|
||||
// walk right off the plan: the frame is entirely outside the view now
|
||||
c._view = { ...fit, x: fit.x + fit.w * 1.6 };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const arrow = sr.querySelector('.homearrow');
|
||||
o.homeArrowAppears = !!arrow;
|
||||
if (arrow) {
|
||||
arrow.click();
|
||||
await c.updateComplete;
|
||||
o.homeArrowFitsBack = Math.abs(c._view.x - fit.x) < 2 && Math.abs(c._view.w - fit.w) < 2;
|
||||
await c.updateComplete;
|
||||
o.homeArrowGone = !sr.querySelector('.homearrow');
|
||||
}
|
||||
// and it is NOT there while the plan is on screen
|
||||
c._resetZoom(); c.requestUpdate(); await c.updateComplete;
|
||||
o.noHomeArrowWhenVisible = !sr.querySelector('.homearrow');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- icons scale WITH THE PLAN across zooms (docs/CANVAS.md §6) ----------
|
||||
// The infinite canvas briefly made --icon-size a percentage of the viewport,
|
||||
// so a marker kept its pixel size at every zoom. The owner looked at it and
|
||||
// asked for the original contract back (2026-08-03): an icon is a percentage
|
||||
// of the PLAN, it grows when you zoom in and shrinks when you zoom out, like
|
||||
// everything else drawn on the plan. What the infinite canvas contributes is
|
||||
// only the base unit the percentage is taken OF: `iconUnit` instead of the
|
||||
// stored `vb.w`, checked right below on this very plan — which is drawn
|
||||
// entirely outside the old square.
|
||||
const farIcon = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
// the badge is --dev-size PLUS a 1px border on each side, and the border
|
||||
// is not part of the scaled geometry (same correction as smoke_icon_scale)
|
||||
const size = () => {
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
|
||||
};
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const at1 = size();
|
||||
c._applyView(4); await c.updateComplete;
|
||||
const at4 = size();
|
||||
c._applyView(1 / 3); await c.updateComplete;
|
||||
const atOut = size();
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
|
||||
o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02;
|
||||
if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut);
|
||||
return { o, at1 };
|
||||
});
|
||||
Object.assign(out, farIcon.o);
|
||||
|
||||
// ---- an OLD plan (small, with a view_box) opens exactly as before ---------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const cfg = {
|
||||
spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
|
||||
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
c._serverCfg = cfg; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions(); // the auto grid follows the new rooms
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const b = c._baseVb();
|
||||
// identical to what contentBounds produced before: the content bbox
|
||||
// (40..960 x 140..580) plus 5% of the longer side (920 -> 46)
|
||||
o.legacyFrameUnchanged = Math.abs(b[0] - -6) < 1 && Math.abs(b[1] - 94) < 1
|
||||
&& Math.abs(b[2] - 1012) < 1 && Math.abs(b[3] - 532) < 1;
|
||||
o.legacyNoHint = c._outliers === 0;
|
||||
// an EMPTY space falls back to the stored view_box hint (§4)
|
||||
c._serverCfg = { spaces: [{ id: 'f1', title: 'E', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }],
|
||||
markers: [], settings: {} };
|
||||
// a space with NOTHING in it at all — not even a marker parked in it
|
||||
c._layout = {}; c._modelCache = null; c._frame = null;
|
||||
c._devices = [];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const e = c._baseVb();
|
||||
o.emptySpaceUsesViewBoxHint = JSON.stringify(e.map(Math.round)) === '[100,200,500,400]';
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- and the icon does not DEGENERATE on the runaway plan ----------------
|
||||
// The catch of "a percentage of the plan" on an unbounded canvas: keep the
|
||||
// old fixed numerator (vb.w = NORM_W) and a plan drawn 2.3 canvases wide
|
||||
// divides it by a 2.5-canvas-wide frame — the marker shrinks by that factor
|
||||
// and keeps shrinking the further out the plan is drawn. `iconUnit` grows
|
||||
// with the plan, so the marker on the far plan measured above is the same
|
||||
// number of pixels as on this ordinary 0..1 one.
|
||||
out.farIconNotDegenerate = await page.evaluate(async (farPx) => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
// the small plan from the block above, with its devices back
|
||||
c._serverCfg = { spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
|
||||
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
|
||||
markers: [], settings: {} };
|
||||
c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
const px = el.getBoundingClientRect().width - 2;
|
||||
if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx);
|
||||
return px > 4 && Math.abs(px / farPx - 1) < 0.3;
|
||||
}, farIcon.at1);
|
||||
|
||||
// ---- screenshots ---------------------------------------------------------
|
||||
if (SHOTS) {
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_plan.png` });
|
||||
// fit-all with a stray far away
|
||||
await loadFar(FAR_CFG, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_hint.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
(c.shadowRoot || c.renderRoot).querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` });
|
||||
// adaptive grid, zoomed far out in the plan editor
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._resetZoom(); c._applyView(1 / 3); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._resetZoom(); c._applyView(4); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` });
|
||||
await page.evaluate(async () => { window.__card._setMode('view'); });
|
||||
}
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -47,13 +47,14 @@ const res = await page.evaluate(async () => {
|
||||
const evenodd = [...sr().querySelectorAll('path.room')].find((p) => p.getAttribute('fill-rule') === 'evenodd');
|
||||
out.evenoddPath = !!evenodd;
|
||||
out.holeInPath = evenodd ? (evenodd.getAttribute('d').match(/M /g) || []).length === 2 : null;
|
||||
// 4) остров кликабелен (элемент существует и он поверх дырки)
|
||||
const islandEl = [...sr().querySelectorAll('.room')].find((el) => {
|
||||
const d = el.getAttribute('points') || el.getAttribute('d') || '';
|
||||
return d.includes(String(cx)) && el !== evenodd;
|
||||
});
|
||||
out.islandRendered = !!islandEl;
|
||||
// 4) остров кликабелен (элемент существует и он поверх дырки).
|
||||
// Beta.5+ applies Draw thickness, so the painted contour is inset and no
|
||||
// longer contains the exact centreline coordinate — look up by room id.
|
||||
const islandEl = island
|
||||
? sr().querySelector(`[data-hp="room"][data-id="${island.id}"]`)
|
||||
: null;
|
||||
out.islandRendered = !!islandEl && islandEl !== evenodd;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c0 = window.__card;
|
||||
// создать киоск-экземпляр
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
c.hass = c0.hass;
|
||||
document.body.appendChild(c);
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
c.hass = { ...c0.hass };
|
||||
await c.updateComplete;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// 1) шапка скрыта
|
||||
const hdr = sr().querySelector('.hdr');
|
||||
out.headerHidden = !hdr || getComputedStyle(hdr).display === 'none';
|
||||
// 2) редакторы заблокированы
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.editorsBlocked = c._mode === 'view';
|
||||
// 3) свайп при 1:1 переключает пространство (и точки показываются)
|
||||
const s0 = c._space;
|
||||
const stage = sr().querySelector('.stage');
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
|
||||
await c.updateComplete;
|
||||
out.swipeSwitches = c._space !== s0;
|
||||
out.dotsShown = !!sr().querySelector('.kioskdots');
|
||||
// 4) при зуме свайп не переключает
|
||||
c._zoom = 2; const s1 = c._space;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
|
||||
await c.updateComplete;
|
||||
out.noSwipeZoomed = c._space === s1;
|
||||
// 5) двойной тап сбрасывает зум
|
||||
c._lastTap = Date.now() - 100;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
|
||||
await c.updateComplete;
|
||||
out.dblTapResets = c._zoom === 1;
|
||||
// 6) локальный множитель иконок применяется
|
||||
c._saveKioskScale({ icon: 2 }); await c.updateComplete;
|
||||
const dl = sr().querySelector('.devlayer');
|
||||
const size1 = dl.getAttribute('style');
|
||||
c._saveKioskScale({ icon: 1 }); await c.updateComplete;
|
||||
const size2 = sr().querySelector('.devlayer').getAttribute('style');
|
||||
const v1 = parseFloat(size1.match(/--icon-size:([\d.]+)/)[1]);
|
||||
const v2 = parseFloat(size2.match(/--icon-size:([\d.]+)/)[1]);
|
||||
out.iconScaleWorks = Math.abs(v1 / v2 - 2) < 0.01;
|
||||
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
|
||||
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
|
||||
c._kioskDialog = true; await c.updateComplete;
|
||||
out.dialogRenders = !!sr().querySelector('hp-dialog input[type="range"]');
|
||||
c._kioskDialog = false;
|
||||
// 8) карусель: тик двигает пространство, пауза после касания работает
|
||||
const cs = c._space;
|
||||
c._config = { ...c._config, cycle: 1 };
|
||||
c._cyclePausedUntil = 0;
|
||||
c._cycleTick(); await c.updateComplete;
|
||||
out.cycleAdvances = c._space !== cs;
|
||||
c._cyclePausedUntil = Date.now() + 60000;
|
||||
const cs2 = c._space;
|
||||
c._cycleTick(); await c.updateComplete;
|
||||
out.cyclePaused = c._space === cs2;
|
||||
// 9) обычная карточка (не киоск): шапка на месте, свайпа нет
|
||||
const sr0 = c0.shadowRoot || c0.renderRoot;
|
||||
out.normalHeader = !!sr0.querySelector('.hdr') && getComputedStyle(sr0.querySelector('.hdr')).display !== 'none';
|
||||
c.remove();
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,138 @@
|
||||
// A kiosk gesture is classified ONCE, and the release obeys that decision —
|
||||
// audit DEV-1DA1-02 (P2).
|
||||
//
|
||||
// `_stagePointerMove` locks the gesture on the first movement past 8 px
|
||||
// (`_panLock`: 'swipe' if it is horizontal enough inside the swipe zone, 'pan'
|
||||
// otherwise) and the plan then follows the finger. But `_stagePointerUp` used
|
||||
// to ignore that lock and ask `swipeTarget()` again, from the raw start→end
|
||||
// vector alone. A CURVED gesture — a small vertical lead-in that locks 'pan',
|
||||
// then a long horizontal sweep — therefore panned under the finger and still
|
||||
// switched the floor on release. On a wall tablet that is the worst kind of
|
||||
// surprise: you watch the plan drag along and land on another storey.
|
||||
//
|
||||
// The lock is now final: with `_panLock === 'pan'` the floor never changes, no
|
||||
// matter what the overall vector looks like. The two straight gestures keep
|
||||
// their old behaviour, and so does the motionless double tap (no movement,
|
||||
// no lock — the swipe path is never even reached by it).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c0 = window.__card;
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c0.hass;
|
||||
document.body.appendChild(k);
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
k.hass = { ...c0.hass };
|
||||
await k.updateComplete;
|
||||
const sr = k.shadowRoot || k.renderRoot;
|
||||
const stage = sr.querySelector('.stage');
|
||||
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
o.kioskHasSeveralSpaces = k._model.length > 1;
|
||||
|
||||
const home = async () => {
|
||||
const vb = k._baseVb();
|
||||
k._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
await k.updateComplete;
|
||||
};
|
||||
/**
|
||||
* Play one trajectory and report what the gesture decided and what it did.
|
||||
* `pts` are the moves; the finger lifts at the last one.
|
||||
*/
|
||||
const play = async (id, from, pts) => {
|
||||
await home();
|
||||
const s0 = k._space;
|
||||
const v0 = { ...k._viewOr(k._baseVb()) };
|
||||
fire('pointerdown', id, from[0], from[1]);
|
||||
let lockAfterLeadIn = null;
|
||||
let moved = false;
|
||||
pts.forEach((p, i) => {
|
||||
fire('pointermove', id, p[0], p[1]);
|
||||
if (i === 0) lockAfterLeadIn = k._panLock;
|
||||
if (Math.abs(k._viewOr(k._baseVb()).x - v0.x) > 1
|
||||
|| Math.abs(k._viewOr(k._baseVb()).y - v0.y) > 1) moved = true;
|
||||
});
|
||||
const lockBeforeRelease = k._panLock;
|
||||
const last = pts[pts.length - 1];
|
||||
fire('pointerup', id, last[0], last[1]);
|
||||
await k.updateComplete;
|
||||
return { s0, s1: k._space, lockAfterLeadIn, lockBeforeRelease, moved };
|
||||
};
|
||||
|
||||
// ---- 1. the auditor's curved pan: vertical lead-in, horizontal ending --
|
||||
// (500,300) → (502,312) locks 'pan' → curve left to (350,304) → release.
|
||||
// dx = -150, dy = +4: swipeTarget() would happily call that a swipe.
|
||||
const curvedPan = await play(51, [500, 300], [[502, 312], [460, 310], [400, 306], [350, 304]]);
|
||||
o.curvedPanLocksPan = curvedPan.lockAfterLeadIn === 'pan';
|
||||
o.curvedPanKeepsTheLock = curvedPan.lockBeforeRelease === 'pan';
|
||||
o.curvedPanActuallyPans = curvedPan.moved === true;
|
||||
o.curvedPanKeepsTheFloor = curvedPan.s1 === curvedPan.s0;
|
||||
// the same trajectory the other way round — a swipe to the right would have
|
||||
// been the previous floor, so the bug is symmetric and so is the fix
|
||||
const curvedPanRight = await play(52, [400, 300], [[402, 312], [460, 308], [520, 305], [560, 304]]);
|
||||
o.curvedPanRightLocksPan = curvedPanRight.lockAfterLeadIn === 'pan';
|
||||
o.curvedPanRightKeepsTheFloor = curvedPanRight.s1 === curvedPanRight.s0;
|
||||
// a long diagonal that ends up dominated by x, but started as a pan
|
||||
const diagonalPan = await play(53, [500, 300], [[496, 316], [420, 340], [330, 350], [260, 352]]);
|
||||
o.diagonalPanLocksPan = diagonalPan.lockAfterLeadIn === 'pan';
|
||||
o.diagonalPanKeepsTheFloor = diagonalPan.s1 === diagonalPan.s0;
|
||||
|
||||
// ---- 2. a gesture locked as a SWIPE keeps its own semantics ------------
|
||||
// horizontal lead-in locks 'swipe'; the plan must not slide under it, even
|
||||
// when the trajectory then bends vertically and the final vector no longer
|
||||
// qualifies — the floor simply stays, and nothing pans
|
||||
const curvedSwipe = await play(54, [600, 300], [[540, 302], [536, 380], [545, 500]]);
|
||||
o.curvedSwipeLocksSwipe = curvedSwipe.lockAfterLeadIn === 'swipe';
|
||||
o.curvedSwipeNeverPans = curvedSwipe.moved === false;
|
||||
o.curvedSwipeThatDiesChangesNothing = curvedSwipe.s1 === curvedSwipe.s0;
|
||||
// a swipe that bends but still ends as a swipe does switch the floor
|
||||
const bentSwipe = await play(55, [600, 300], [[540, 302], [470, 330], [420, 340]]);
|
||||
o.bentSwipeLocksSwipe = bentSwipe.lockAfterLeadIn === 'swipe';
|
||||
o.bentSwipeNeverPans = bentSwipe.moved === false;
|
||||
o.bentSwipeStillSwitches = bentSwipe.s1 !== bentSwipe.s0;
|
||||
|
||||
// ---- 3. the straight gestures are exactly as they were -----------------
|
||||
const straightSwipe = await play(56, [600, 300], [[540, 302], [480, 305], [450, 305]]);
|
||||
o.straightSwipeSwitches = straightSwipe.s1 !== straightSwipe.s0;
|
||||
o.straightSwipeDoesNotPan = straightSwipe.moved === false;
|
||||
const straightPan = await play(57, [450, 200], [[452, 260], [454, 330]]);
|
||||
o.straightPanPans = straightPan.moved === true;
|
||||
o.straightPanKeepsTheFloor = straightPan.s1 === straightPan.s0;
|
||||
|
||||
// ---- 4. a motionless double tap still resets the zoom ------------------
|
||||
// no movement means no lock at all, so nothing above can reach this path
|
||||
await home();
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
k._lastTap = Date.now() - 100;
|
||||
fire('pointerdown', 58, 500, 300);
|
||||
fire('pointerup', 58, 501, 300);
|
||||
await k.updateComplete;
|
||||
o.doubleTapStillResetsZoom = k._zoom === 1;
|
||||
o.doubleTapLeavesNoLock = k._panLock === null;
|
||||
|
||||
// ---- 5. zoomed in there is no swipe zone, and the lock says so ---------
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
const zoomed = await (async () => {
|
||||
const s0 = k._space;
|
||||
fire('pointerdown', 59, 600, 300);
|
||||
fire('pointermove', 59, 540, 302);
|
||||
const lock = k._panLock;
|
||||
fire('pointermove', 59, 480, 305);
|
||||
fire('pointerup', 59, 480, 305);
|
||||
await k.updateComplete;
|
||||
return { lock, same: k._space === s0 };
|
||||
})();
|
||||
o.zoomedHorizontalLocksPan = zoomed.lock === 'pan';
|
||||
o.zoomedHorizontalKeepsTheFloor = zoomed.same;
|
||||
|
||||
k.remove();
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,103 @@
|
||||
// HP-1460-03: позиции — отдельное состояние. В v1.46.0 событие layout_updated
|
||||
// научилась слушать статическая карточка, а полная — нет, поэтому две полные
|
||||
// карточки рядом расходились до перезагрузки. Проверяем и обратное: приход
|
||||
// чужой ревизии не должен затирать перетаскивание, которое ещё не улетело.
|
||||
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 base = c.hass.callWS;
|
||||
|
||||
// общий «сервер»: layout с ревизией и подписчики на событие
|
||||
let rev = 5;
|
||||
let layout = { dev_a: { x: 10, y: 10 }, dev_b: { x: 20, y: 20 } };
|
||||
const subs = [];
|
||||
let gets = 0;
|
||||
const hass = { ...c.hass,
|
||||
callWS: async (m) => {
|
||||
if (m.type === 'houseplan/layout/get') { gets++; return { layout: JSON.parse(JSON.stringify(layout)), rev }; }
|
||||
if (m.type === 'houseplan/layout/update') {
|
||||
layout = { ...layout, [m.device_id]: m.pos }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
return { ok: true, rev };
|
||||
}
|
||||
return base(m);
|
||||
},
|
||||
connection: { subscribeEvents: async (cb, ev) => {
|
||||
if (ev === 'houseplan_layout_updated') { subs.push(cb); return () => {}; }
|
||||
return () => {};
|
||||
} },
|
||||
};
|
||||
c.hass = hass;
|
||||
c._serverStorage = true;
|
||||
c._layout = JSON.parse(JSON.stringify(layout));
|
||||
c._layoutRev = rev;
|
||||
c._unsubLayout = await hass.connection.subscribeEvents(
|
||||
(e) => c._onLayoutEvent(Number(e?.data?.rev ?? -1)),
|
||||
'houseplan_layout_updated',
|
||||
);
|
||||
out.subscribed = subs.length === 1;
|
||||
|
||||
// 1) чужая карточка подвинула иконку — наша обязана подхватить без перезагрузки
|
||||
layout = { ...layout, dev_a: { x: 77, y: 88 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.adoptedRemoteMove = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 77, y: 88 });
|
||||
out.revFollowed = c._layoutRev === rev;
|
||||
|
||||
// 2) собственная запись не вызывает лишнего перечитывания
|
||||
const before = gets;
|
||||
c._layout = { ...c._layout, dev_b: { x: 31, y: 32 } };
|
||||
c._dirtyPos.add('dev_b');
|
||||
c._persistLayout();
|
||||
c._persistLayout.flush();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.ownWriteNoReload = gets === before;
|
||||
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
|
||||
|
||||
// 3) НАСТОЯЩЕЕ перетаскивание: debounce запланирован, запись задержана, а
|
||||
// layout/get отвечает мгновенно. Именно этот порядок и терял позицию:
|
||||
// flush() внутри перечитывания опустошал _dirtyPos ДО снятия снимка.
|
||||
let releaseUpdate;
|
||||
const updateGate = new Promise((r) => { releaseUpdate = r; });
|
||||
let delayUpdate = true;
|
||||
const plain = hass.callWS;
|
||||
c.hass = { ...hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/layout/update' && delayUpdate) {
|
||||
delayUpdate = false;
|
||||
await updateGate;
|
||||
}
|
||||
return plain(m);
|
||||
} };
|
||||
|
||||
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
|
||||
c._dirtyPos.add('dev_a');
|
||||
c._persistLayout(); // debounce запланирован, не сброшен вручную
|
||||
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
out.dragKeptWhileWriteInFlight = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
|
||||
|
||||
releaseUpdate();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.serverAgrees = JSON.stringify(layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.sentPosDrained = c._sentPos.size === 0;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
subscribed: true,
|
||||
adoptedRemoteMove: true,
|
||||
revFollowed: true,
|
||||
ownWriteNoReload: true,
|
||||
ownWriteKept: true,
|
||||
dragKeptWhileWriteInFlight: true,
|
||||
remoteChangeApplied: true,
|
||||
localDragSurvived: true,
|
||||
serverAgrees: true,
|
||||
sentPosDrained: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,65 @@
|
||||
// HP-1503-01: один и тот же повреждённый store обязан рендериться одинаково в
|
||||
// ОБЕИХ карточках. Полная карточка строила модель рукописной копией
|
||||
// spaceModels и прошла мимо safeViewBox/normRect: статическая показывала
|
||||
// восстановленный план, полная — viewBox="0 0 0 0" и rect с отрицательной
|
||||
// шириной. Вектор аудита исполняется через обе модели и оба DOM-дерева.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
|
||||
// ровно вектор из отчёта: нулевой viewport + отрицательный legacy-rect
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.plan_url = null; f1.plan_aspect = null;
|
||||
f1.view_box = [0, 0, 0, 0];
|
||||
f1.rooms = [{ id: 'r1', name: 'R', area: 'living_room', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }];
|
||||
f1.settings = { ...(f1.settings || {}), show_borders: true };
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
return { ok: true };
|
||||
} };
|
||||
|
||||
main._serverCfg = cfg;
|
||||
main._cfgEpoch++;
|
||||
main._view = null;
|
||||
main.requestUpdate(); await main.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
|
||||
// модель полной карточки: fallback на весь холст + нормализованный rect
|
||||
const m = main._spaceModel('f1');
|
||||
out.fullVbFallsBack = JSON.stringify(m.vb) === JSON.stringify([0, 0, 1000, 1000]);
|
||||
const r = m.rooms[0];
|
||||
out.fullRectNormalised =
|
||||
Math.round(r.x) === 400 && Math.round(r.y) === 400
|
||||
&& Math.round(r.w) === 200 && Math.round(r.h) === 300;
|
||||
|
||||
// DOM полной карточки: конечный положительный viewBox, никаких минусов в rect
|
||||
const svg = (main.shadowRoot || main.renderRoot).querySelector('.stage svg');
|
||||
const vbAttr = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
out.fullDomViewBoxSane = vbAttr.length === 4 && vbAttr[2] > 0 && vbAttr[3] > 0;
|
||||
const rect = (main.shadowRoot || main.renderRoot).querySelector('.stage svg rect.room, .stage svg .room rect, .stage svg rect[width]');
|
||||
out.fullDomRectSane = !rect || (Number(rect.getAttribute('width')) >= 0 && Number(rect.getAttribute('height')) >= 0);
|
||||
|
||||
// статическая карточка того же store — паритет
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('svg') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r2) => setTimeout(r2, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
out.staticDomViewBoxSane = svb.length === 4 && svb[2] > 0 && svb[3] > 0;
|
||||
out.parity = JSON.stringify(vbAttr.length === 4 && svb.length === 4
|
||||
? [vbAttr[2] > 0, vbAttr[3] > 0] : null) === JSON.stringify([svb[2] > 0, svb[3] > 0]);
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,74 @@
|
||||
// Правило владельца (2026-08-06): жёлтая подложка всегда означает «работает
|
||||
// сейчас». В glow-режиме световое пятно ДОПОЛНЯЕТ её, но не заменяет. RGB
|
||||
// лампы по-прежнему красит только пятно/эффект, не значок и не рамку.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const setFill = async (mode) => {
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1.settings = { ...(f1.settings || {}), fill_mode: mode };
|
||||
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
};
|
||||
const lamp = c._devices.find((x) => x.id === 'd_lamp');
|
||||
const kettle = c._devices.find((x) => x.id === 'd_kettle'); // розетка/чайник
|
||||
// зажигаем лампу с RGB и розетку
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'on',
|
||||
attributes: { ...(c.hass.states[lamp.primary]?.attributes || {}), rgb_color: [255, 120, 40] } },
|
||||
[kettle.primary]: { ...c.hass.states[kettle.primary], state: 'on' } } };
|
||||
const devEl = (id) => {
|
||||
// надёжнее по индексу в списке devs: ищем по позиции через _pos
|
||||
const d = c._devices.find((x) => x.id === id);
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(d);
|
||||
const left = (((p.x - v.x) / v.w) * 100).toFixed(0);
|
||||
return [...sr().querySelectorAll('.dev')].find((e) => Math.abs(parseFloat(e.style.left) - left) < 1.5);
|
||||
};
|
||||
|
||||
// --- режим light: горящая RGB-лампа ЖЁЛТАЯ, без rgb-класса --------------
|
||||
await setFill('light');
|
||||
let le = devEl('d_lamp');
|
||||
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- режим glow: и лампа, и розетка жёлтые; пятно остаётся --------------
|
||||
await setFill('glow');
|
||||
le = devEl('d_lamp');
|
||||
const ke = devEl('d_kettle');
|
||||
o.litLampYellowInGlow = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.glowSpotPresent = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
o.socketStaysYellowInGlow = !!ke && ke.classList.contains('on');
|
||||
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- редакторы используют ту же жёлтую подложку независимо от слоя glow --
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
le = devEl('d_lamp');
|
||||
o.devicesEditorLampYellow = !!le && le.classList.contains('on');
|
||||
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
|
||||
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
|
||||
// удовлетворяла бы поиску «любой .dev.on», пряча регресс самой лампы)
|
||||
le = devEl('d_lamp');
|
||||
o.planEditorLampYellow = !!le && le.classList.contains('on'); // слой скрыт — жёлтый вернулся
|
||||
o.planEditorKettleStillOn = !!devEl('d_kettle')?.classList.contains('on');
|
||||
o.planEditorNoGlow = !sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// --- выключенная лампа тёмная в обоих режимах ---------------------------
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'off' } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
le = devEl('d_lamp');
|
||||
o.offLampDarkInGlow = !!le && !le.classList.contains('on');
|
||||
await setFill('light');
|
||||
le = devEl('d_lamp');
|
||||
o.offLampDarkInLightMode = !!le && !le.classList.contains('on');
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -30,5 +30,5 @@ const res = await page.evaluate(async () => {
|
||||
out.explicitInfoWins = calls.length === n2 && !!c._infoCard;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Живой текст в декоре (docs/LIVE-TEXT.md): все связи с HA живут прямо в
|
||||
// тексте как {entity} / {entity:attribute}. Один блок может смешивать обычный
|
||||
// текст и несколько переменных; выбор значения вставляет токен в позицию
|
||||
// курсора. Старые entity/attr/unit читаются, но первое сохранение мигрирует их.
|
||||
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
/** текст фигуры в DOM, склеенный из строк (tspan) */
|
||||
const dom = (id) => {
|
||||
const el = sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
if (!el) return null;
|
||||
return [...el.querySelectorAll('tspan')].map((t) => t.textContent).join('\n');
|
||||
};
|
||||
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
|
||||
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
|
||||
/** подменить одно состояние — ровно так, как HA присылает новый hass */
|
||||
const push = async (id, st) => {
|
||||
const states = { ...c.hass.states };
|
||||
if (st === null) delete states[id]; else states[id] = st;
|
||||
c.hass = { ...c.hass, states };
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
c._curSpaceCfg.decor = [];
|
||||
|
||||
// ---------- 1. несколько inline-переменных и обычная надпись -------------
|
||||
c._decorTextDialog = { x: 0.3, y: 0.3,
|
||||
text: 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}',
|
||||
color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const live = c._decorList.find((x) => x.kind === 'text');
|
||||
out.onlyTextStored = !('entity' in live) && !('attr' in live) && !('unit' in live);
|
||||
out.templateStoredRaw = live.text ===
|
||||
'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
|
||||
out.multipleValuesRendered = dom(live.id) === 'Бак 22.4 °C; LQI 154';
|
||||
|
||||
c._decorTextDialog = { x: 0.6, y: 0.3, text: 'Сауна', color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const plain = c._decorList.find((x) => x.text === 'Сауна');
|
||||
out.plainUntouched = dom(plain.id) === 'Сауна' && !('entity' in plain);
|
||||
|
||||
// ---------- 2. значение живое: новый hass — новый текст -------------------
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.9',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C; LQI 154';
|
||||
out.plainStillUntouched = dom(plain.id) === 'Сауна';
|
||||
// как отдаёт HA: ни округления, ни переформатирования
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.94781',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.noRounding = dom(live.id) === 'Бак 23.94781 °C; LQI 154';
|
||||
|
||||
// ---------- 3. мёртвая сущность — прочерк, шаблон остаётся ---------------
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unavailable',
|
||||
attributes: { unit_of_measurement: '°C' } });
|
||||
out.deadIsDash = dom(live.id) === 'Бак —; LQI —';
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unknown', attributes: {} });
|
||||
out.unknownIsDash = dom(live.id) === 'Бак —; LQI —';
|
||||
await push('sensor.living_temp', null);
|
||||
out.missingIsDash = dom(live.id) === 'Бак —; LQI —';
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.backAlive = dom(live.id) === 'Бак 22.4 °C; LQI 154';
|
||||
|
||||
// ---------- 4. picker вставляет полный токен ровно в позицию курсора ------
|
||||
c._decorTextDialog = { x: 0.45, y: 0.45, text: 'До после', color: '#112233',
|
||||
pickerEntity: 'sensor.living_temp' };
|
||||
c._decorTextSelection = { start: 3, end: 3 };
|
||||
c._decorInsertLiveVariable('linkquality'); await c.updateComplete;
|
||||
out.insertedAtCaret = c._decorTextDialog.text ===
|
||||
'До {sensor.living_temp:linkquality}после';
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const inserted = c._decorList.find((x) => x.text?.startsWith('До '));
|
||||
out.insertedRenders = dom(inserted.id) === 'До 154после';
|
||||
|
||||
// ---------- 5. ручная dotted-форма и отсутствующий атрибут ---------------
|
||||
c._decorTextDialog = { x: 0.55, y: 0.55,
|
||||
text: '{sensor.living_temp.linkquality} / {sensor.living_temp:nope}',
|
||||
color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const manual = c._decorList.find((x) => x.text?.includes(':nope'));
|
||||
out.manualTokensWork = dom(manual.id) === '154 / —';
|
||||
|
||||
// ---------- 6. старая связка мигрирует при первом редактировании ----------
|
||||
const legacy = { id: 'legacy_live', kind: 'text', x: 0.7, y: 0.7,
|
||||
text: 'Старый {}', color: '#112233', entity: 'sensor.living_temp' };
|
||||
c._curSpaceCfg.decor = [...c._decorList, legacy];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyStillRenders = dom(legacy.id) === 'Старый 22.4 °C';
|
||||
open(legacy); await c.updateComplete;
|
||||
out.legacyOpensAsToken = c._decorTextDialog?.text ===
|
||||
'Старый {sensor.living_temp}';
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const migrated = c._decorList.find((x) => x.id === legacy.id);
|
||||
out.legacyMigrated = !('entity' in migrated)
|
||||
&& migrated.text === 'Старый {sensor.living_temp}'
|
||||
&& dom(legacy.id) === 'Старый 22.4 °C';
|
||||
|
||||
// Explicit legacy units and attribute names outside the inline grammar are
|
||||
// not silently destroyed. They stay in the compatible representation until
|
||||
// the user explicitly replaces the binding.
|
||||
const legacyUnit = { id: 'legacy_unit', kind: 'text', x: 0.75, y: 0.75,
|
||||
text: 'Бак {}', color: '#112233', entity: 'sensor.living_temp', unit: 'K' };
|
||||
c._curSpaceCfg.decor = [...c._decorList, legacyUnit]; c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyUnitRenders = dom(legacyUnit.id) === 'Бак 22.4 K';
|
||||
open(legacyUnit); await c.updateComplete;
|
||||
out.legacyUnitIsNotLossyToken = c._decorTextDialog?.preserveLegacy === true
|
||||
&& c._decorTextDialog?.text === 'Бак {}';
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const keptUnit = c._decorList.find((x) => x.id === legacyUnit.id);
|
||||
out.legacyUnitSurvivesSave = keptUnit?.unit === 'K'
|
||||
&& keptUnit?.entity === 'sensor.living_temp' && dom(legacyUnit.id) === 'Бак 22.4 K';
|
||||
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154, 'bad attr': 7 } });
|
||||
const legacyAttr = { id: 'legacy_attr', kind: 'text', x: 0.8, y: 0.8,
|
||||
text: 'Атрибут {}', color: '#112233', entity: 'sensor.living_temp', attr: 'bad attr' };
|
||||
c._curSpaceCfg.decor = [...c._decorList, legacyAttr]; c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyOddAttrRenders = dom(legacyAttr.id) === 'Атрибут 7';
|
||||
open(legacyAttr); await c.updateComplete;
|
||||
out.legacyOddAttrPreserved = c._decorTextDialog?.preserveLegacy === true;
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const keptAttr = c._decorList.find((x) => x.id === legacyAttr.id);
|
||||
out.legacyOddAttrSurvivesSave = keptAttr?.attr === 'bad attr'
|
||||
&& keptAttr?.entity === 'sensor.living_temp' && dom(legacyAttr.id) === 'Атрибут 7';
|
||||
|
||||
c._decorTextDialog = { x: 0.2, y: 0.2, text: 'x', color: '#112233',
|
||||
pickerEntity: 'sensor.living_temp' };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.pickerHidesUnrepresentableAttr = ![...sr().querySelectorAll('select.namein option')]
|
||||
.some((o) => o.value === 'bad attr');
|
||||
c._decorTextDialog = null;
|
||||
|
||||
// ---------- 7. просмотр и киоск рисуют то же самое ------------------------
|
||||
const editorText = dom(live.id);
|
||||
c._setMode('view'); await c.updateComplete; await sleep(60);
|
||||
out.sameInViewMode = dom(live.id) === editorText
|
||||
&& editorText === 'Бак 22.4 °C; LQI 154';
|
||||
|
||||
const kiosk = document.createElement('houseplan-card');
|
||||
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true });
|
||||
kiosk.hass = c.hass;
|
||||
document.body.appendChild(kiosk);
|
||||
await kiosk.updateComplete; await sleep(400); await kiosk.updateComplete;
|
||||
const kr = kiosk.shadowRoot || kiosk.renderRoot;
|
||||
const kEl = kr.querySelector(`.decorlayer text.dtext[data-id="${live.id}"]`);
|
||||
out.sameInKiosk = !!kEl
|
||||
&& [...kEl.querySelectorAll('tspan')].map((t) => t.textContent).join('\n') === editorText;
|
||||
kiosk.remove();
|
||||
|
||||
// ---------- 8. round-trip хранит только текстовый шаблон ------------------
|
||||
const sent = [];
|
||||
const ws = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return ws(m); } };
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorSaveText();
|
||||
await sleep(900);
|
||||
const set = sent.filter((m) => m.type === 'houseplan/config/set').pop();
|
||||
const shape = set && (set.config.spaces.find((s) => s.id === c._space).decor || [])
|
||||
.find((x) => x.id === live.id);
|
||||
out.roundTrip = !!shape && !('entity' in shape) && !('attr' in shape) && !('unit' in shape)
|
||||
&& shape.text === 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,10 +1,11 @@
|
||||
import { launch } from './serve.mjs';
|
||||
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 calls = [];
|
||||
window.confirm = () => true; // review CR-1: unlocking now confirms
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => calls.push([d, s, data.entity_id]) };
|
||||
await c.updateComplete;
|
||||
// добавить дверь с замком на f1
|
||||
@@ -47,5 +48,5 @@ const res = await page.evaluate(async () => {
|
||||
}
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// review CR-1: exercise EVERY actuation path and prove locks/alarms are safe
|
||||
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 calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push(`${d}.${s}:${data.entity_id}`); return Promise.resolve(); },
|
||||
states: { ...c.hass.states,
|
||||
'lock.front_door': { state: 'locked', attributes: { friendly_name: 'Front door' } },
|
||||
'alarm_control_panel.home': { state: 'armed_away', attributes: {} } } };
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
const lockCalls = () => calls.filter((x) => x.includes('lock.') || x.includes('alarm_control_panel.'));
|
||||
// 1) тап по значку устройства с замком
|
||||
const lockDev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('lock.'))) || c._devices[0];
|
||||
const fake = { ...lockDev, primary: 'lock.front_door', tapAction: 'toggle',
|
||||
marker: { ...(lockDev.marker || {}), tap_action: 'toggle' } };
|
||||
c._clickDevice(new MouseEvent('click'), fake);
|
||||
out.iconTapSafe = lockCalls().length === 0;
|
||||
// 2) controls[] с замком внутри
|
||||
const withControls = { ...fake, tapAction: 'toggle',
|
||||
marker: { controls: ['lock.front_door', 'alarm_control_panel.home'], tap_action: 'toggle' } };
|
||||
c._clickDevice(new MouseEvent('click'), withControls);
|
||||
out.controlsSafe = lockCalls().length === 0;
|
||||
// 3) карточка устройства: замок отдаётся в more-info, а не тумблером
|
||||
const kinds = c._cardEntities({ ...fake, entities: ['lock.front_door', 'alarm_control_panel.home'] });
|
||||
out.cardNoToggleForLocks = kinds.every((k) => k.kind !== 'toggle');
|
||||
c._cardToggle('lock.front_door');
|
||||
c._cardToggle('alarm_control_panel.home');
|
||||
out.cardToggleRefuses = lockCalls().length === 0;
|
||||
// 4) кнопка в карточке двери — единственная разрешённая поверхность, и спрашивает подтверждение
|
||||
let asked = null;
|
||||
window.confirm = (msg) => { asked = msg; return false; };
|
||||
c._lockAction('lock.front_door', 'unlock');
|
||||
out.unlockAsksConfirm = asked !== null && lockCalls().length === 0;
|
||||
window.confirm = () => true;
|
||||
c._lockAction('lock.front_door', 'unlock');
|
||||
out.unlockAfterConfirm = calls.at(-1) === 'lock.unlock:lock.front_door';
|
||||
// запирание не спрашивает
|
||||
asked = null;
|
||||
window.confirm = (m) => { asked = m; return true; };
|
||||
c._lockAction('lock.front_door', 'lock');
|
||||
out.lockNoConfirm = asked === null && calls.at(-1) === 'lock.lock:lock.front_door';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -38,9 +38,8 @@ const res = await page.evaluate(async () => {
|
||||
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
|
||||
const center = c._roomCenter(room);
|
||||
const vpos = c._layout[vid];
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * 1000 - center[1]) < 1;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -20,5 +20,5 @@ const res = await page.evaluate(async () => {
|
||||
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res));
|
||||
await browser.close();
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
|
||||
import { launch } from './serve.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
@@ -9,7 +9,7 @@ const restore = () => page.evaluate((s) => {
|
||||
}, snap);
|
||||
// norm→render helper mirrors what _markupClick passes to handlers
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
return [nx * 1000, ny * 1000]; // the canvas is square (v1.48.0)
|
||||
}, [nx, ny]);
|
||||
const S = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
@@ -61,13 +61,42 @@ out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
|
||||
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
|
||||
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._areaSel=''; c._saveRoom();});
|
||||
s = await S();
|
||||
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
|
||||
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
|
||||
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
|
||||
await restore();
|
||||
|
||||
// AUD-159B7-01: one open span crossing the Split point belongs to two room
|
||||
// pairs afterwards. The card transaction must persist both pieces and derive
|
||||
// light-zone connectivity for both children, not keep only the first half.
|
||||
const spanSplit = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((x) => x.id === 'f1');
|
||||
sp.rooms = [
|
||||
{ id: 'left_top', name: 'Left top', area: null, poly: [[0, 0], [.5, 0], [.5, .2], [0, .2]] },
|
||||
{ id: 'left_bottom', name: 'Left bottom', area: null, poly: [[0, .2], [.5, .2], [.5, .4], [0, .4]] },
|
||||
{ id: 'right', name: 'Right', area: null, poly: [[.5, 0], [1, 0], [1, .4], [.5, .4]] },
|
||||
];
|
||||
sp.open_spans = [{ a: [.5, .1], b: [.5, .3] }];
|
||||
c._commitOpenSpans();
|
||||
const pieces = (sp.open_spans || []).map((e) =>
|
||||
[Math.min(e.a[1], e.b[1]), Math.max(e.a[1], e.b[1])]);
|
||||
const links = Object.fromEntries(sp.rooms.map((r) => [r.id, r.open_to || []]));
|
||||
return { pieces, links };
|
||||
});
|
||||
out.splitKeepsOpenPieces = spanSplit.pieces.length === 2
|
||||
&& Math.abs(spanSplit.pieces[0][0] - .1) < 1e-9
|
||||
&& Math.abs(spanSplit.pieces[0][1] - .2) < 1e-9
|
||||
&& Math.abs(spanSplit.pieces[1][0] - .2) < 1e-9
|
||||
&& Math.abs(spanSplit.pieces[1][1] - .3) < 1e-9;
|
||||
out.splitSyncsBothSides = spanSplit.links.left_top?.includes('right')
|
||||
&& spanSplit.links.left_bottom?.includes('right')
|
||||
&& spanSplit.links.right?.includes('left_top')
|
||||
&& spanSplit.links.right?.includes('left_bottom');
|
||||
await restore();
|
||||
|
||||
// SPLIT along a wall → refused (both pts on top wall of rg)
|
||||
await page.evaluate(()=>{const c=window.__card; c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
|
||||
{id:'rg2', name:'G2', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
|
||||
@@ -79,5 +108,9 @@ await page.evaluate((p)=>window.__card._splitClick(p), await R(0.4,0.0625));
|
||||
s = await S();
|
||||
out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
|
||||
|
||||
console.log(JSON.stringify(out,null,1));
|
||||
await browser.close();
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(out, {
|
||||
"mergeRooms": 3,
|
||||
"newRoom": "Cabinet",
|
||||
});
|
||||
await finish(browser, out);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user