mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
Compare commits
151
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,10 @@ 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.
|
||||
@@ -47,8 +49,8 @@ jobs:
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build a FRESH bundle for the smokes
|
||||
# the committed demo/srv/assets copy is a snapshot; testing it would
|
||||
# report green about code that no longer exists (audit T2)
|
||||
# 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: |
|
||||
|
||||
@@ -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`.
|
||||
@@ -5,6 +5,7 @@
|
||||
[](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
|
||||
@@ -16,23 +17,48 @@ right on your Lovelace dashboard.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 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; smart
|
||||
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.
|
||||
|
||||
@@ -58,16 +84,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:
|
||||
|
||||
@@ -76,11 +113,17 @@ Key advantages in short:
|
||||
- **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, media is playing, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -162,7 +205,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 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 always square; an image keeps its own proportions and is centred inside it.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -254,6 +297,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
|
||||
|
||||
+58
-15
@@ -3,8 +3,11 @@
|
||||
[](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)**
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
@@ -14,23 +17,46 @@
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
> **[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** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
@@ -57,16 +83,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
|
||||
на всех планшетах.
|
||||
|
||||
Ключевые преимущества коротко:
|
||||
|
||||
@@ -75,11 +111,14 @@ House Plan показывает ваш умный дом так, как он в
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
|
||||
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
|
||||
Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
@@ -163,7 +202,7 @@ title: План дома
|
||||
|
||||

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

|
||||
|
||||
@@ -206,7 +245,7 @@ title: План дома
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти скрытые устройства, откройте редактор устройств и нажмите **«Показать скрытые»**: они отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||
@@ -257,6 +296,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)**.
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
@@ -289,7 +332,7 @@ title: План дома
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто автоматически — откройте редактор устройств, нажмите **«Показать скрытые»**, откройте его диалог, нажмите **«Показать»**, затем **«Сохранить»**.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -50,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))
|
||||
@@ -117,6 +126,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
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()
|
||||
}
|
||||
@@ -125,14 +138,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
lay_rev = int(lay_stored.get("rev", 0))
|
||||
if merged != pending: # 1. the durable intent, before anything moves
|
||||
await data.store.async_save(
|
||||
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
{**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({"layout": layout, "rev": lay_rev + 1})
|
||||
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)
|
||||
)
|
||||
@@ -140,6 +153,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
# 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.
|
||||
@@ -194,6 +246,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
|
||||
|
||||
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 —
|
||||
|
||||
@@ -24,5 +24,8 @@ def may_write(hass: HomeAssistant, user) -> bool:
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
# 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}),
|
||||
|
||||
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.53.0"
|
||||
VERSION = "1.60.2-beta.1"
|
||||
|
||||
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
@@ -64,6 +64,16 @@ def migrate_space(space: dict[str, Any]) -> bool:
|
||||
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
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.53.0"
|
||||
"version": "1.60.2-beta.1"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -68,6 +68,15 @@ 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
|
||||
@@ -90,16 +99,22 @@ MAX_URL = 2000
|
||||
# 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))
|
||||
|
||||
# Positions are normalised to the canvas (0..1). Allow generous slack for an
|
||||
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
|
||||
# used to pass, and a single stored 1e100 stretched every client's view of the
|
||||
# space until the plan was invisible (HP-1500-03).
|
||||
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
# 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"): _COORD, vol.Required("y"): _COORD},
|
||||
@@ -107,19 +122,26 @@ POS_SCHEMA = vol.Schema(
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
# Geometry is normalised to the canvas (0..1). ±4 is generous slack for a
|
||||
# vertex nudged past an edge, not an envelope for arbitrary magnitudes: any
|
||||
# finite float used to pass here, and a single 1e100 room vertex stretched the
|
||||
# frame until the whole space was unviewable for every client (HP-1501-01) —
|
||||
# the exact failure HP-1500-03 closed for layout positions, one schema over.
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
# 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.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
|
||||
# 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):
|
||||
@@ -131,6 +153,45 @@ def _view_box(value):
|
||||
|
||||
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:
|
||||
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
|
||||
@@ -168,31 +229,92 @@ ROOM_SCHEMA = vol.All(
|
||||
),
|
||||
_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,
|
||||
@@ -200,20 +322,176 @@ DECOR_SCHEMA = vol.Any(
|
||||
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),
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
@@ -225,6 +503,28 @@ SPACE_SCHEMA = vol.Schema(
|
||||
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)),
|
||||
@@ -236,7 +536,9 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
# 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,
|
||||
@@ -246,6 +548,26 @@ 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.
|
||||
@@ -255,34 +577,64 @@ SPACE_SCHEMA = vol.Schema(
|
||||
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,
|
||||
# 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", None),
|
||||
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),
|
||||
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
|
||||
# asserts every option the editor offers is accepted here (issue #3)
|
||||
# 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", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, 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),
|
||||
@@ -300,6 +652,13 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): 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,
|
||||
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(
|
||||
|
||||
@@ -7,6 +7,7 @@ import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -16,6 +17,7 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
@@ -35,17 +37,51 @@ from .validation import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_OPTIMIZE_BACKUP = "optimize_backup"
|
||||
_OPTIMIZE_PENDING = "optimize_pending"
|
||||
|
||||
|
||||
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
|
||||
"""An optimization can be undone only before any later plan edit."""
|
||||
backup = layout_data.get(_OPTIMIZE_BACKUP)
|
||||
if not isinstance(backup, dict):
|
||||
return False
|
||||
try:
|
||||
return (
|
||||
int(backup.get("after_config_rev", -1)) == int(config_data.get("rev", 0))
|
||||
and int(backup.get("after_layout_rev", -1)) == int(layout_data.get("rev", 0))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
|
||||
"""Free a snapshot made stale by a later ordinary config edit."""
|
||||
data = await rt.store.async_load() or {}
|
||||
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
|
||||
return
|
||||
await rt.store.async_save({
|
||||
**{
|
||||
k: v for k, v in data.items()
|
||||
if k not in (_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_delete)
|
||||
websocket_api.async_register_command(hass, ws_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
@@ -75,6 +111,36 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
# ---------------- layout ----------------
|
||||
|
||||
|
||||
def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Drop positions which a deleted marker can no longer own.
|
||||
|
||||
HA device ids may be layout-only because auto-discovered devices need no
|
||||
marker entry. Virtual ids are different: every live virtual marker is
|
||||
explicit, so a missing `v_*` owner is stale data. The prefix itself is
|
||||
only a legacy naming convention, however; an explicit real marker remains
|
||||
authoritative even when its id happens to begin with `v_`.
|
||||
"""
|
||||
markers = config.get("markers") or []
|
||||
removed_ids = {
|
||||
str(m.get("id")) for m in markers if m.get("removed") is True
|
||||
}
|
||||
virtual_ids = {
|
||||
str(m.get("id")) for m in markers
|
||||
if m.get("removed") is not True and m.get("binding") == "virtual"
|
||||
}
|
||||
explicit_live_ids = {
|
||||
str(m.get("id")) for m in markers
|
||||
if m.get("removed") is not True and m.get("binding") != "virtual"
|
||||
}
|
||||
return {
|
||||
marker_id: pos for marker_id, pos in layout.items()
|
||||
if marker_id not in removed_ids
|
||||
and (not marker_id.startswith("v_")
|
||||
or marker_id in virtual_ids
|
||||
or marker_id in explicit_live_ids)
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
@@ -83,8 +149,13 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
connection.send_result(
|
||||
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
|
||||
msg["id"], {
|
||||
"layout": data.get("layout", {}),
|
||||
"rev": int(data.get("rev", 0)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -111,6 +182,7 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
data = await rt.store.async_load() or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
@@ -118,9 +190,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||
)
|
||||
return
|
||||
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": msg["layout"], "rev": new_rev})
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
|
||||
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
|
||||
"layout": layout, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
@@ -142,6 +216,39 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
# A stale browser may still finish a drag after another client deleted
|
||||
# the marker. Its tombstone is the server-side authority: acknowledge
|
||||
# but ignore the late point so re-adding starts without a zombie
|
||||
# position.
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
config = config_data.get("config") or {}
|
||||
markers = config.get("markers") or []
|
||||
deleted = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
|
||||
for m in markers
|
||||
)
|
||||
live_virtual = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
|
||||
and m.get("binding") == "virtual"
|
||||
for m in markers
|
||||
)
|
||||
live_explicit = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
|
||||
for m in markers
|
||||
)
|
||||
orphan_virtual = (
|
||||
msg["device_id"].startswith("v_")
|
||||
and not live_virtual
|
||||
and not live_explicit
|
||||
)
|
||||
if deleted or orphan_virtual:
|
||||
data = await rt.store.async_load() or {}
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"ignored": "removed" if deleted else "missing_virtual",
|
||||
"rev": int(data.get("rev", 0)),
|
||||
})
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
@@ -149,7 +256,8 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
|
||||
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
|
||||
"layout": layout, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
@@ -546,7 +654,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
|
||||
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
|
||||
"layout": layout, "rev": new_rev})
|
||||
if new_rev is not None:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
@@ -559,13 +668,27 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Return the configuration and its revision."""
|
||||
"""Return the configuration, its revision, and whether this user may write.
|
||||
|
||||
`can_write` is the single source of truth for the card's editor chrome
|
||||
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
|
||||
check that drifted from the integration option.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"config": config,
|
||||
"rev": data.get("rev", 0),
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -677,6 +800,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
try:
|
||||
await _discard_optimizer_snapshot(rt)
|
||||
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
|
||||
_LOGGER.exception("House Plan: discarding stale optimization backup failed")
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1).
|
||||
# It is best-effort housekeeping behind an already durable write — a
|
||||
@@ -692,6 +819,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded files failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
@@ -701,6 +829,184 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
# ---------------- whole-plan maintenance ----------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/optimize",
|
||||
vol.Required("config"): CONFIG_SCHEMA,
|
||||
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Atomically-intended replacement of config+layout with one-deep undo.
|
||||
|
||||
Home Assistant stores are separate files, so a literal cross-file
|
||||
transaction is impossible. Persisting the target as an intent before
|
||||
either half changes makes a crash resumable during the next setup; the UI
|
||||
only receives success once both halves are durable.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may optimize plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
size = len(json.dumps(msg["config"], separators=(",", ":")))
|
||||
if size > MAX_CONFIG_BYTES:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Plan changed elsewhere (config {config_rev}, layout {layout_rev})",
|
||||
)
|
||||
return
|
||||
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
msg["config"],
|
||||
config_data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
connection.send_error(
|
||||
msg["id"], "missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = {
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
pending = {
|
||||
"config": msg["config"],
|
||||
"layout": msg["layout"],
|
||||
"clear_backup": False,
|
||||
}
|
||||
layout_meta = {
|
||||
k: v for k, v in layout_data.items()
|
||||
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
|
||||
}
|
||||
|
||||
# Intent first. A setup-time finisher completes whichever half a crash
|
||||
# interrupted; until then the visible layout/revision remain unchanged.
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"rev": layout_rev,
|
||||
_OPTIMIZE_BACKUP: backup,
|
||||
_OPTIMIZE_PENDING: pending,
|
||||
})
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": msg["layout"],
|
||||
"rev": new_layout_rev,
|
||||
_OPTIMIZE_BACKUP: backup,
|
||||
})
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"can_undo": True,
|
||||
})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/optimize_undo",
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Restore the snapshot, but never overwrite edits made after optimization."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may undo optimization")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
|
||||
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
|
||||
return
|
||||
if not _optimizer_backup_is_current(config_data, layout_data):
|
||||
connection.send_error(
|
||||
msg["id"], "no_backup",
|
||||
"The optimization backup is unavailable or a later edit made it stale",
|
||||
)
|
||||
return
|
||||
|
||||
backup = layout_data[_OPTIMIZE_BACKUP]
|
||||
restored_config = backup.get("config") or DEFAULT_CONFIG
|
||||
restored_layout = backup.get("layout") or {}
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
pending = {
|
||||
"config": restored_config,
|
||||
"layout": restored_layout,
|
||||
"clear_backup": True,
|
||||
}
|
||||
layout_meta = {
|
||||
k: v for k, v in layout_data.items()
|
||||
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
|
||||
}
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"rev": layout_rev,
|
||||
_OPTIMIZE_BACKUP: backup,
|
||||
_OPTIMIZE_PENDING: pending,
|
||||
})
|
||||
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": restored_layout,
|
||||
"rev": new_layout_rev,
|
||||
})
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"can_undo": False,
|
||||
})
|
||||
|
||||
|
||||
# ---------------- plan uploads ----------------
|
||||
|
||||
|
||||
@@ -767,3 +1073,37 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
connection.send_error(msg["id"], err.reason, err.detail)
|
||||
return
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
|
||||
def _refresh_trail_recorder(hass: HomeAssistant) -> None:
|
||||
"""Markers changed — the trail recorder must re-resolve what it watches."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
if rec:
|
||||
hass.async_create_task(rec.async_refresh())
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
|
||||
"""Current + previous cleanup runs per marker, raw robot coordinates."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/trail/delete",
|
||||
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=256)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
|
||||
"""Permanently forget one deleted marker's current and previous runs."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete trails")
|
||||
return
|
||||
if _runtime(hass, connection, msg["id"]) is None:
|
||||
return
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
removed = await rec.async_delete(msg["marker_id"]) if rec else False
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
@@ -56,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);
|
||||
@@ -21,14 +21,27 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
|
||||
// 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.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > 900 || pt[1] > 900;
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
@@ -12,8 +14,16 @@ const res = await page.evaluate(async () => {
|
||||
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));
|
||||
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('.dialog .body');
|
||||
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')];
|
||||
|
||||
@@ -45,6 +45,19 @@ 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;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -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);
|
||||
+40
-1
@@ -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,6 +57,43 @@ 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;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// 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;
|
||||
c.hass = { ...c.hass, 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,208 @@
|
||||
// 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;
|
||||
c.hass = { ...c.hass, 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);
|
||||
+184
-4
@@ -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,114 @@ 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;
|
||||
c._decorShapeDialog = { ...c._decorShapeDialog, color: '#123456', widthCm: 6.5 };
|
||||
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
|
||||
&& c._decorShapeDialog === 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 +249,6 @@ const res = await page.evaluate(async () => {
|
||||
out.deleteKey = c._decorList.length === n1 - 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
// значение плашки зафиксировано числом: 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,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);
|
||||
@@ -19,20 +19,110 @@ 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 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._path = savedPath;
|
||||
c._roomDialog = savedRoomDialog;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
|
||||
@@ -3,6 +3,45 @@ 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;
|
||||
|
||||
@@ -50,7 +50,7 @@ const res = await page.evaluate(async () => {
|
||||
// 5) превью есть и в диалоге пространства
|
||||
c._openSpaceDialog('edit', spId); await c.updateComplete;
|
||||
out.spacePreview = !!sr().querySelector('.cardpreview');
|
||||
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
|
||||
out.spaceSlider = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('space.card_font'));
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -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, checkAll, finish } 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,14 +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;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"alignDelta": 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
|
||||
// (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, checkAll, finish } 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,10 +31,25 @@ const res = await page.evaluate(async () => {
|
||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||
return out;
|
||||
});
|
||||
// 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": 11,
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
|
||||
"rows": 16, // 11 цветов (включая wall_fill) + радиус свечения + фон + погода (docs/SUN.md)
|
||||
// + «Оптимизировать планы» (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,
|
||||
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
+54
-12
@@ -49,11 +49,11 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
// тумблер локальный: конфиг не трогается
|
||||
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.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
|
||||
o.deleteForAuto = !!sr().querySelector('hp-dialog .btn.danger');
|
||||
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
|
||||
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
@@ -62,13 +62,50 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
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: 'Тест', binding: 'virtual' };
|
||||
c._markerDialog = { ...c._markerDialog, name: 'delete-one-a', binding: 'virtual' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const virt = c._devices.find((x) => x.virtual);
|
||||
c._openMarkerDialog(virt); await c.updateComplete;
|
||||
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
|
||||
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;
|
||||
}));
|
||||
@@ -100,7 +137,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
|
||||
// --- Legacy ripple → icon+activity; ghost keeps icon and has no effect -----
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
@@ -114,11 +151,10 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
|
||||
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('.ripple'));
|
||||
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;
|
||||
@@ -175,8 +211,14 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
|
||||
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
|
||||
// статичная карточка рендерит от полного квадрата 0..1000
|
||||
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
|
||||
// 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);
|
||||
|
||||
@@ -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 = 'openwall';
|
||||
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) => /проём|проем|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);
|
||||
@@ -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);
|
||||
|
||||
@@ -35,4 +35,60 @@ const out = await page.evaluate(async () => {
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
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));
|
||||
|
||||
@@ -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));
|
||||
@@ -47,12 +47,13 @@ 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;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -51,7 +51,7 @@ const res = await page.evaluate(async () => {
|
||||
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
|
||||
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
|
||||
c._kioskDialog = true; await c.updateComplete;
|
||||
out.dialogRenders = !!sr().querySelector('.dialog input[type="range"]');
|
||||
out.dialogRenders = !!sr().querySelector('hp-dialog input[type="range"]');
|
||||
c._kioskDialog = false;
|
||||
// 8) карусель: тик двигает пространство, пауза после касания работает
|
||||
const cs = c._space;
|
||||
|
||||
@@ -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);
|
||||
@@ -1,8 +1,6 @@
|
||||
// Правило владельца (2026-07-29): у источников света подложка в glow-режиме
|
||||
// всегда стандартная — индикатор включения это ПЯТНО; в остальных режимах
|
||||
// горящий источник жёлтый, как греющая термоголовка. RGB лампы красит только
|
||||
// пятно (и фолбэк цвета пульсации); окраска иконки/рамки убрана. Морф иконки
|
||||
// остаётся везде. Розетка в glow-режиме остаётся жёлтой — она не источник.
|
||||
// Правило владельца (2026-08-06): жёлтая подложка всегда означает «работает
|
||||
// сейчас». В glow-режиме световое пятно ДОПОЛНЯЕТ её, но не заменяет. RGB
|
||||
// лампы по-прежнему красит только пятно/эффект, не значок и не рамку.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
@@ -39,21 +37,19 @@ const out = await page.evaluate(async () => {
|
||||
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- режим glow: лампа ТЁМНАЯ (индикатор — пятно), розетка жёлтая -------
|
||||
// --- режим glow: и лампа, и розетка жёлтые; пятно остаётся --------------
|
||||
await setFill('glow');
|
||||
le = devEl('d_lamp');
|
||||
const ke = devEl('d_kettle');
|
||||
o.litLampDarkInGlow = !!le && !le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
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;
|
||||
|
||||
// --- HP-1520-01: в редакторах, где glow-слой скрыт, индикатор возвращается
|
||||
// (лампа горит; в devices-редакторе слой рисуется — там лампа тёмная,
|
||||
// в plan-редакторе слоя нет — там класс on должен вернуться)
|
||||
// --- редакторы используют ту же жёлтую подложку независимо от слоя glow --
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
le = devEl('d_lamp');
|
||||
o.devicesEditorLampDark = !!le && !le.classList.contains('on');
|
||||
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 и
|
||||
|
||||
@@ -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);
|
||||
@@ -68,6 +68,35 @@ 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]]});
|
||||
|
||||
@@ -12,8 +12,11 @@ const st = () => page.evaluate(() => {
|
||||
active: sr.querySelector('.modetab.active')?.textContent.trim(),
|
||||
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
|
||||
gears: sr.querySelectorAll('.tabedit').length,
|
||||
markupBar: !!sr.querySelector('.editbar'),
|
||||
stageClass: sr.querySelector('.stage').className,
|
||||
// The last bar stays mounted for the exit animation; only an open chrome
|
||||
// is an active/visible editor bar.
|
||||
markupBar: !!sr.querySelector('.editorchrome.open .editbar'),
|
||||
// hpsettle is the transient post-boot grace (AUD-1552-02), not a mode class
|
||||
stageClass: sr.querySelector('.stage').className.replace(/ ?\bhpsettle\b/, ''),
|
||||
};
|
||||
});
|
||||
// 1) старт: view, чистая шапка
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Правило владельца (2026-08-01, вариант «б»): «движение = разовая вспышка
|
||||
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
|
||||
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
|
||||
// off→on получает activity-event — три последовательные волны
|
||||
// (hp-activity-event, ~3.3 c), после чего эффект снимается, ДАЖЕ если сущность всё ещё
|
||||
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
|
||||
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
|
||||
// activity-presence — статичное кольцо без анимации (opacity 0.4). Жёлтая
|
||||
// заливка .dev.on в обоих случаях не участвует; ghost — без кольца
|
||||
// (призрак — конфигурация, не статус).
|
||||
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;
|
||||
// Fresh-card baseline: the marker is built while motion is off and no
|
||||
// unrelated hass tick is allowed to establish runtime later by accident.
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state: 'off',
|
||||
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
c._activityRt.clear();
|
||||
// Activity is an explicit presentation: opt this fixture into it.
|
||||
const motionDevice = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
|
||||
c._serverCfg.markers = (c._serverCfg.markers || [])
|
||||
.filter((m) => m.binding !== 'device:' + motionDevice.bindingRef);
|
||||
c._serverCfg.markers.push({
|
||||
id: motionDevice.id,
|
||||
binding: 'device:' + motionDevice.bindingRef,
|
||||
display: 'icon_ripple',
|
||||
});
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const setMotion = async (state, device_class) => {
|
||||
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, linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// --- первое событие сразу после build не теряется как baseline ----------
|
||||
await setMotion('on', 'motion');
|
||||
out.firstTripAfterBuildFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
await setMotion('off', 'motion');
|
||||
c._activityRt.clear(); c._syncActivityRuntime(); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// --- motion off→on: event, 3 волны, подложка НЕ жёлтая -------------------
|
||||
await setMotion('off', 'motion');
|
||||
out.offNoFlash = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
|
||||
await setMotion('on', 'motion');
|
||||
const flashEl = sr().querySelector('.dev.activity-event');
|
||||
out.motionTripFlashes = !!flashEl;
|
||||
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
|
||||
const waves = flashEl ? [...flashEl.querySelectorAll('.activity-ring.event i')] : [];
|
||||
const firstWave = waves[0] ? getComputedStyle(waves[0]) : null;
|
||||
out.flashAnimated = !!firstWave && firstWave.animationName.startsWith('hp-activity-event');
|
||||
out.flashFinite = waves.length === 3
|
||||
&& waves.every((w) => getComputedStyle(w).animationIterationCount === '1');
|
||||
// подложка нейтральная — как у обычного .dev, не как у .dev.on
|
||||
const neutral = [...sr().querySelectorAll('.dev')].find((e) =>
|
||||
!e.classList.contains('activity-event') && !e.classList.contains('activity-presence')
|
||||
&& !e.classList.contains('on') && !e.classList.contains('open')
|
||||
&& !e.classList.contains('alarm') && !e.classList.contains('unavail') && !e.classList.contains('valonly'));
|
||||
const bg = flashEl ? getComputedStyle(flashEl).backgroundColor : '';
|
||||
out.flashBgNeutral = !!neutral && bg === getComputedStyle(neutral).backgroundColor;
|
||||
out.flashBgNotYellow = bg !== '' && bg !== 'rgb(255, 212, 92)'; // --hp-on
|
||||
|
||||
// --- КЛЮЧЕВОЙ ассерт: вспышка гаснет, хотя state всё ещё on -------------
|
||||
// (cool-down датчика — не движение: класс обязан уйти сам по таймеру)
|
||||
await sleep(3600);
|
||||
await c.updateComplete;
|
||||
out.stateStillOn = c.hass.states['binary_sensor.hall_motion'].state === 'on';
|
||||
out.flashEndsWhileOn = sr().querySelectorAll('.dev.activity-event').length === 0;
|
||||
|
||||
// --- повторный цикл off→on: новая вспышка --------------------------------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
out.retripFlashesAgain = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
|
||||
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
|
||||
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс event
|
||||
// не снимался и animation-name не менялся — браузер продолжал старый
|
||||
// timeline, и после его конца (base opacity 0) второй trip был невидим.
|
||||
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
|
||||
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
|
||||
.find((a) => a.animationName && a.animationName.startsWith('hp-activity-event') && a.playState === 'running');
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №1
|
||||
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
await sleep(1200); // середина первой вспышки (< 3.3s)
|
||||
const a1 = senseAnim(sr().querySelector('.dev.activity-event'));
|
||||
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
|
||||
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
const a2 = senseAnim(sr().querySelector('.dev.activity-event'));
|
||||
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
|
||||
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
|
||||
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
|
||||
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
|
||||
await sleep(2300);
|
||||
const el3 = sr().querySelector('.dev.activity-event');
|
||||
const a3 = senseAnim(el3);
|
||||
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
|
||||
out.rapidRingVisibleAfterFirstWindow = !!el3
|
||||
&& [...el3.querySelectorAll('.activity-ring.event i')]
|
||||
.some((w) => parseFloat(getComputedStyle(w).opacity) > 0.03);
|
||||
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
|
||||
await sleep(1400); await c.updateComplete;
|
||||
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.activity-event').length === 0;
|
||||
|
||||
// --- occupancy: статичное кольцо presence, без анимации -----------------
|
||||
await setMotion('on', 'occupancy');
|
||||
const holdEl = sr().querySelector('.dev.activity-presence');
|
||||
out.occupancyHolds = !!holdEl;
|
||||
const holdRing = holdEl?.querySelector('.activity-ring.presence i:first-child');
|
||||
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
|
||||
out.holdNotAnimated = !!hAfter && hAfter.animationName === 'none';
|
||||
out.holdRingFaint = !!hAfter && Math.abs(parseFloat(hAfter.opacity) - 0.4) < 0.01;
|
||||
out.holdBgNeutral = !!holdEl && !!neutral
|
||||
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
|
||||
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('activity-event');
|
||||
await setMotion('off', 'occupancy');
|
||||
out.occupancyOffClears = sr().querySelectorAll('.dev.activity-presence').length === 0;
|
||||
|
||||
// --- presence аналогично -------------------------------------------------
|
||||
await setMotion('on', 'presence');
|
||||
out.presenceHolds = sr().querySelectorAll('.dev.activity-presence').length === 1;
|
||||
// и это именно sense-состояние, не тревога и не «открыто»
|
||||
out.noAlarmNoOpen = !sr().querySelector('.dev.activity-presence.alarm') && !sr().querySelector('.dev.activity-presence.open');
|
||||
|
||||
// --- смена effective source синхронно снимает старую вспышку ------------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
const liveMarker = c._serverCfg.markers.find((m) => m.id === motionDevice.id);
|
||||
liveMarker.controls = ['switch.kettle'];
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
out.rebindDropsOldFlashWithoutHassTick = !sr().querySelector('.dev.activity-event');
|
||||
delete liveMarker.controls;
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// --- ghost не рисует ничего (призрак — конфигурация, не статус) ---------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
const d = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
|
||||
c._serverCfg.markers = (c._serverCfg.markers || [])
|
||||
.filter((m) => m.binding !== 'device:' + d.bindingRef);
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, display: 'icon_ripple', hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
out.hiddenNotInView = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
|
||||
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
const ghost = sr().querySelector('.dev.ghost');
|
||||
out.ghostNoSense = !!ghost && !ghost.classList.contains('activity-event') && !ghost.classList.contains('activity-presence')
|
||||
&& !ghost.querySelector('.activity-ring');
|
||||
return out;
|
||||
});
|
||||
checkAll(res, {});
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,277 @@
|
||||
// Opening drag rulers (owner 2026-08-03): while a door/window is dragged along
|
||||
// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
|
||||
// along the wall), and when the opening's center hits the wall's center a
|
||||
// perpendicular dashed tick appears and the center magnet-snaps (Shift cannot
|
||||
// opt out). The wall is ONE room's edge (owner: "only the wall of one room") —
|
||||
// r2's collinear top edge never merges into r1's, so the ruler runs 40..550
|
||||
// (center 295), not the old merged 40..960. Real pointer events; the numbers
|
||||
// are checked against the geometry: cell = 5 cm, grid pitch = 1000/240, so
|
||||
// cm = units * 1.2 and r1's top wall (510 u) is 6.12 m.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
// 80-unit door on r1's top wall (y = 0.14), off-center
|
||||
sp.openings = [{ id: 'op1', type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08 }];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
await page.waitForTimeout(220); // editor chrome transition changes stage coordinates
|
||||
await settle();
|
||||
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const svg = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const badges = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel.opshoulder')].map((el) => el.textContent.trim()).sort());
|
||||
const nums = async () => (await badges()).map((t) => parseFloat(t));
|
||||
const tick = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opcentertick').length);
|
||||
const near = (a, b, tol) => Math.abs(a - b) <= tol;
|
||||
|
||||
// ---------- drag to an off-center spot: two live badges, no tick ----------
|
||||
const [sx, sy] = await screenPt(200, 140);
|
||||
const [t1x] = await screenPt(460, 140);
|
||||
await page.mouse.move(sx, sy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(t1x, sy, { steps: 5 });
|
||||
await settle();
|
||||
// center 460 -> edges 420/500; shoulders measured on r1's OWN edge 40..550:
|
||||
// 420-40=380 u (4.56 m) and 550-500=50 u (0.60 m) — the old merged build
|
||||
// showed 960-500=460 u (5.52 m) on the right
|
||||
{
|
||||
const [a, b] = await nums();
|
||||
check('two_badges_visible', (await badges()).length, 2);
|
||||
check('badge_right_0_60', near(a, 0.6, 0.04));
|
||||
check('badge_left_4_56', near(b, 4.56, 0.04));
|
||||
// exact invariant, immune to pointer rounding: shoulders + opening = wall
|
||||
check('badges_sum_to_wall', near(a + b, 6.12 - 0.96, 0.021));
|
||||
}
|
||||
check('no_tick_off_center', await tick(), 0);
|
||||
|
||||
// ---------- live update on the way ----------
|
||||
const [t2x] = await screenPt(360, 140);
|
||||
await page.mouse.move(t2x, sy, { steps: 3 });
|
||||
await settle();
|
||||
// center 360 -> shoulders 320-40=280 u (3.36 m) and 550-400=150 u (1.80 m)
|
||||
{
|
||||
const [a, b] = await nums();
|
||||
check('badge_right_updates_1_80', near(a, 1.8, 0.04));
|
||||
check('badge_left_updates_3_36', near(b, 3.36, 0.04));
|
||||
}
|
||||
|
||||
// ---------- near the OWN edge center (40+550)/2 = 295: tick + magnet ----------
|
||||
const [t3x] = await screenPt(293.7, 140);
|
||||
await page.mouse.move(t3x, sy, { steps: 3 });
|
||||
await settle();
|
||||
check('tick_at_center', await tick(), 1);
|
||||
check('magnet_snaps_center', near(await page.evaluate(() =>
|
||||
window.__card._curSpaceCfg.openings[0].x), 0.295, 1e-6));
|
||||
// both shoulders equal after the snap: (510 - 80) / 2 = 215 u -> 2.58 m
|
||||
check('badges_equal_at_center', await badges(), ['2.58 m', '2.58 m']);
|
||||
// the tick is perpendicular to the wall (horizontal wall -> a vertical dash)
|
||||
const geom = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opcentertick');
|
||||
if (!el) return null;
|
||||
const dashed = getComputedStyle(el).strokeDasharray;
|
||||
return { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
|
||||
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2'),
|
||||
dashed: dashed !== 'none' && dashed !== '' };
|
||||
});
|
||||
check('tick_perpendicular_at_wall_center', !!geom
|
||||
&& near(geom.x1, 295, 1e-6) && near(geom.x2, 295, 1e-6) && near(geom.y2 - geom.y1, 30, 1e-6));
|
||||
check('tick_dashed', !!geom && geom.dashed);
|
||||
|
||||
// ---------- release: everything disappears, the snapped x is committed ----------
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
check('badges_gone_after_drop', (await badges()).length, 0);
|
||||
check('tick_gone_after_drop', await tick(), 0);
|
||||
check('committed_x_center', near(await page.evaluate(() =>
|
||||
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
|
||||
|
||||
// ---------- Shift cannot disable the magnet (badges stay) ----
|
||||
// park the opening off-center first: a drag that returns to within 3 px of its
|
||||
// own starting point is filtered by the tap threshold, so the Shift approach
|
||||
// must start away from the center
|
||||
const [cx0] = await screenPt(295, 140);
|
||||
await page.mouse.move(cx0, sy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(t2x, sy, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
await page.keyboard.down('Shift');
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(t3x, sy, { steps: 3 });
|
||||
await settle();
|
||||
check('tick_with_shift', await tick(), 1);
|
||||
const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
|
||||
check('magnet_with_shift', near(freeX, 0.295, 1e-6));
|
||||
check('badges_still_there_with_shift', (await badges()).length, 2);
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up('Shift');
|
||||
await settle();
|
||||
check('badges_gone_final', (await badges()).length, 0);
|
||||
|
||||
// ---------- opening on a SHARED wall of two rooms: the own room's edge ----------
|
||||
// staggered fragments on the boundary x=550: r1's right edge 140..460 (its
|
||||
// room is made the short one), r2's left edge 140..580. The snap resolves to
|
||||
// r1's edge (nearest, first in roomEdges order on the tie), so the shoulders
|
||||
// and the center come from 140..460 — the old build measured the merged
|
||||
// 140..580 union
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.rooms = [
|
||||
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.46], [0.04, 0.46]] },
|
||||
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.58], [0.55, 0.58]] },
|
||||
];
|
||||
sp.openings = [{ id: 'op1', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await settle();
|
||||
const [vx0, vy0] = await screenPt(550, 300);
|
||||
const [, vy1] = await screenPt(550, 360);
|
||||
await page.mouse.move(vx0, vy0);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(vx0, vy1, { steps: 3 });
|
||||
await settle();
|
||||
// center 360 -> edges 320/400; r1's edge 140..460: 320-140=180 u (2.16 m) and
|
||||
// 460-400=60 u (0.72 m); the merged build showed 580-400=180 u (2.16 m) below
|
||||
{
|
||||
const [a, b] = await nums();
|
||||
check('shared_wall_two_badges', (await badges()).length, 2);
|
||||
check('shared_wall_bottom_0_72', near(a, 0.72, 0.04));
|
||||
check('shared_wall_top_2_16', near(b, 2.16, 0.04));
|
||||
}
|
||||
check('shared_wall_no_tick_off_center', await tick(), 0);
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
// approach the OWN edge center (140+460)/2 = 300 — NOT the merged 360
|
||||
const [, vy2] = await screenPt(550, 301.3);
|
||||
await page.mouse.move(vx0, vy1);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(vx0, vy2, { steps: 3 });
|
||||
await settle();
|
||||
check('shared_wall_tick_at_own_center', await tick(), 1);
|
||||
check('shared_wall_magnet_y_0_3', near(await page.evaluate(() =>
|
||||
window.__card._curSpaceCfg.openings[0].y), 0.3, 1e-6));
|
||||
// equal shoulders on the own edge: (320 - 80) / 2 = 120 u -> 1.44 m
|
||||
check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
|
||||
// ---------- PLACING a new opening: the same rulers (owner 2026-08-03) -------
|
||||
// Moving along a wall with the Opening tool must show exactly what dragging an
|
||||
// existing opening shows: a badge on each shoulder of the WOULD-BE opening, a
|
||||
// perpendicular tick + magnet at the wall centre, Shift opting out — and the
|
||||
// whole lot vanishing the moment the opening is placed. Default length is
|
||||
// 90 cm = 75 units, so the shoulders are measured from the edges c +- 37.5.
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.rooms = [
|
||||
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] },
|
||||
];
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await settle();
|
||||
const ghosts = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opghost').length);
|
||||
|
||||
// hover off-centre on r1's top wall (own edge 40..550, y = 140)
|
||||
{
|
||||
const [hx, hy] = await screenPt(460, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_ghost_shown', await ghosts(), 1);
|
||||
check('place_two_badges', (await badges()).length, 2);
|
||||
// edges 422.5 / 497.5 -> 382.5 u (4.59 m) left, 52.5 u (0.63 m) right
|
||||
const [a, b] = await nums();
|
||||
check('place_badge_right_0_63', near(a, 0.63, 0.05));
|
||||
check('place_badge_left_4_59', near(b, 4.59, 0.05));
|
||||
check('place_no_tick_off_center', await tick(), 0);
|
||||
}
|
||||
|
||||
// live update on the way — the badges follow the cursor
|
||||
{
|
||||
const [hx, hy] = await screenPt(200, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
// edges 162.5 / 237.5 -> 122.5 u (1.47 m) left, 312.5 u (3.75 m) right
|
||||
const [a, b] = await nums();
|
||||
check('place_badge_updates_1_47', near(a, 1.47, 0.05));
|
||||
check('place_badge_updates_3_75', near(b, 3.75, 0.05));
|
||||
}
|
||||
|
||||
// near the wall centre (40+550)/2 = 295: tick appears and the magnet bites
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_tick_at_center', await tick(), 1);
|
||||
// equal shoulders after the magnet: (510 - 75) / 2 = 217.5 u -> 2.61 m
|
||||
check('place_badges_equal_at_center', await badges(), ['2.61 m', '2.61 m']);
|
||||
const g = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opcentertick');
|
||||
return el && { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
|
||||
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2') };
|
||||
});
|
||||
check('place_tick_perpendicular', !!g && near(g.x1, 295, 1e-6) && near(g.x2, 295, 1e-6)
|
||||
&& near(g.y2 - g.y1, 30, 1e-6));
|
||||
// the ghost itself already sits on the magnetised centre
|
||||
const gh = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opghost');
|
||||
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
|
||||
});
|
||||
check('place_ghost_magnetised', near(gh, 295, 1e-6));
|
||||
}
|
||||
|
||||
// Shift cannot opt out of the magnet, the badges stay
|
||||
{
|
||||
await page.keyboard.down('Shift');
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_tick_with_shift', await tick(), 1);
|
||||
check('place_badges_with_shift', (await badges()).length, 2);
|
||||
const gh = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opghost');
|
||||
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
|
||||
});
|
||||
check('place_ghost_magnetised_with_shift', near(gh, 295, 1e-6));
|
||||
await page.keyboard.up('Shift');
|
||||
}
|
||||
|
||||
// the click places it AT THE MAGNET and clears every hint
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.click(hx, hy);
|
||||
await settle();
|
||||
check('place_dialog_opened', await page.evaluate(() => !!window.__card._openingDialog), true);
|
||||
check('place_dialog_x_magnetised', near(await page.evaluate(() => window.__card._openingDialog.x), 295, 1e-6));
|
||||
check('place_badges_gone', (await badges()).length, 0);
|
||||
check('place_tick_gone', await tick(), 0);
|
||||
check('place_ghost_gone', await ghosts(), 0);
|
||||
// …and confirming the dialog writes the opening at the magnetised point
|
||||
await page.evaluate(() => { const c = window.__card; c._saveOpening(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
check('place_committed_x_center', near(await page.evaluate(() =>
|
||||
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
|
||||
}
|
||||
|
||||
await finish(browser, { done: true });
|
||||
+31
-19
@@ -5,17 +5,17 @@ const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
|
||||
const H = 1000; // square canvas
|
||||
c._openWallClick([550, 0.25 * H]);
|
||||
// Shared wall r1|r2 at x=550, y from 140..460. Two-click open full shared stretch.
|
||||
c._openWallClick([550, 140]);
|
||||
c._openWallClick([550, 460]);
|
||||
await c.updateComplete;
|
||||
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
|
||||
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
|
||||
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
|
||||
// пунктир отрисован
|
||||
out.hasSpans = Array.isArray(c._curSpaceCfg.open_spans) && c._curSpaceCfg.open_spans.length > 0;
|
||||
out.dashes = sr().querySelectorAll('.openwall').length > 0;
|
||||
out.hotClass = !!sr().querySelector('.openwalls.hot');
|
||||
// в Просмотре: сплошной штрих комнаты снят, контур без выреза + пунктир ПОВЕРХ glow
|
||||
// View: trimmed outlines + dash
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
|
||||
out.trimmedOutline = sr().querySelectorAll('.room-outline').length >= 2;
|
||||
@@ -24,30 +24,31 @@ const res = await page.evaluate(async () => {
|
||||
const gi = order.indexOf('glowlayer');
|
||||
const oi = order.indexOf('openwalls');
|
||||
out.dashAboveGlow = gi === -1 || oi > gi;
|
||||
// в редакторе плана: пунктир виден, штрих комнат вырезан, контур синий
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
out.planDashes = sr().querySelectorAll('.openwall').length > 0;
|
||||
out.planNoedge = sr().querySelectorAll('.room.noedge').length >= 2;
|
||||
out.planBlueOutline = sr().querySelectorAll('.room-outline.outlined').length >= 2;
|
||||
// производные стены (.seg) не проходят сквозь открытый участок x=550, y≈0.25H
|
||||
const midY = 0.25 * H;
|
||||
const midY = 300;
|
||||
out.planSegCut = ![...sr().querySelectorAll('line.seg')].some((l) => {
|
||||
const x1 = +l.getAttribute('x1'), x2 = +l.getAttribute('x2');
|
||||
const y1 = +l.getAttribute('y1'), y2 = +l.getAttribute('y2');
|
||||
return Math.abs(x1 - 550) < 0.5 && Math.abs(x2 - 550) < 0.5
|
||||
&& Math.min(y1, y2) < midY && Math.max(y1, y2) > midY;
|
||||
});
|
||||
c._tool = 'openwall'; await c.updateComplete;
|
||||
// повторный клик закрывает
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
c._tool = 'openwall';
|
||||
c._openWallClick([550, 300]); await c.updateComplete;
|
||||
out.openToolCannotClose = (c._curSpaceCfg.open_spans || []).length > 0;
|
||||
c._tool = 'closewall'; await c.updateComplete;
|
||||
// Closing is a separate, single-purpose action (UX-02).
|
||||
c._closeWallClick([550, 300]); await c.updateComplete;
|
||||
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
|
||||
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
|
||||
// клик мимо стен — тост, без изменений
|
||||
c._openWallClick([100, 100]);
|
||||
c._closeWallClick([100, 100]);
|
||||
out.missToast = !!c._toast;
|
||||
// снова открыть для glow-теста
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
// glow: свет из r1 заливает и r2 (clip содержит оба полигона)
|
||||
// reopen for glow
|
||||
c._openWallClick([550, 140]);
|
||||
c._openWallClick([550, 460]);
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
@@ -57,17 +58,28 @@ const res = await page.evaluate(async () => {
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
|
||||
// транзитивность: r2↔r3 тоже открыть → clip получит 3 полигона
|
||||
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
|
||||
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
|
||||
if (rr3) {
|
||||
rr2.open_to = [...(rr2.open_to || []), 'r3'];
|
||||
rr3.open_to = [...(rr3.open_to || []), 'r2'];
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
// legacy open_to without spans still expands on read for cuts — force spans for r2-r3
|
||||
c._openWallClick = c._openWallClick.bind(c);
|
||||
c._setMode('plan'); c._tool = 'openwall';
|
||||
// shared r2|r3 at y=460, x 550..960
|
||||
c._openWallClick([700, 460]);
|
||||
c._openWallClick([900, 460]);
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
|
||||
} else out.transitive = 'no r3';
|
||||
// outer wall refuses open
|
||||
c._setMode('plan'); c._tool = 'openwall';
|
||||
c._toast = null;
|
||||
c._openWallClick([40, 300]);
|
||||
out.outerToast = !!(c._toast && String(c._toast).length);
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
checkAll(res, { outerToast: true });
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -6,30 +6,34 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
const H = 1000; // square canvas
|
||||
// 1) без наведения: курсор default, превью нет
|
||||
const H = 1000;
|
||||
// 1) idle: crosshair (draw-like), no preview
|
||||
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
out.idleCursor = getComputedStyle(stage()).cursor === 'crosshair';
|
||||
out.noPreview = !sr().querySelector('.openwall-preview');
|
||||
// 2) наведение на общую стену r1|r2 (x=550): pointer + янтарное превью
|
||||
// 2) hover shared solid wall: still NO full-edge preview (click target must stay clear)
|
||||
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
|
||||
out.hotCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
const prev = sr().querySelectorAll('.openwall-preview');
|
||||
out.previewShown = prev.length > 0;
|
||||
out.previewAmber = prev.length ? !prev[0].classList.contains('willclose') : null;
|
||||
// превью лежит на стене x=550
|
||||
out.previewOnWall = prev.length ? Math.abs(Number(prev[0].getAttribute('x1')) - 550) < 0.5 : null;
|
||||
// 3) наведение в центр комнаты: снова default, превью исчезло
|
||||
c._cursorPt = [300, 150]; c.requestUpdate(); await c.updateComplete;
|
||||
out.missCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
out.missNoPreview = !sr().querySelector('.openwall-preview');
|
||||
// 4) открыть границу → навести снова: превью красное сплошное (клик закроет)
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
|
||||
const prev2 = sr().querySelector('.openwall-preview');
|
||||
out.willclose = prev2?.classList.contains('willclose') === true;
|
||||
// прибрать
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
out.hoverNoFullEdge = !sr().querySelector('.openwall-preview');
|
||||
out.hoverCursor = getComputedStyle(stage()).cursor === 'crosshair';
|
||||
// 3) Opening and closing are separate tools: openwall does not advertise a
|
||||
// close action, while closewall highlights the exact virtual stretch.
|
||||
c._openWallClick([550, 140]);
|
||||
c._openWallClick([550, 460]);
|
||||
await c.updateComplete;
|
||||
c._cursorPt = [550, 300]; c.requestUpdate(); await c.updateComplete;
|
||||
out.openToolDoesNotOfferClose = !sr().querySelector('.openwall-preview');
|
||||
c._tool = 'closewall'; c.requestUpdate(); await c.updateComplete;
|
||||
const prev = sr().querySelector('.openwall-preview');
|
||||
out.closeToolWillClose = prev?.classList.contains('willclose') === true;
|
||||
// 4) with anchor set: rubber-band preview appears
|
||||
c._closeWallClick([550, 300]);
|
||||
await c.updateComplete;
|
||||
c._tool = 'openwall';
|
||||
c._openWallClick([550, 200]);
|
||||
c._cursorPt = [550, 400]; c.requestUpdate(); await c.updateComplete;
|
||||
const band = sr().querySelector('.openwall-preview');
|
||||
out.anchorPreview = !!band && !band.classList.contains('willclose');
|
||||
c._openWallAnchor = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Panning at ANY zoom (owner's report 2026-08-04: «добавь возможность таскать
|
||||
// план при любом масштабе, а не только при более 100%»).
|
||||
//
|
||||
// Until this fix `_stagePointerMove` moved the view only while `_zoom > 1`:
|
||||
// the rule was inherited from the old bounded canvas, where a plan smaller
|
||||
// than the scene had nowhere to go. The infinite canvas removed that edge —
|
||||
// there is always somewhere to go, and an editor extending a plan outwards
|
||||
// needs exactly that. So a drag on empty scene now pans at 100 %, at 50 % and
|
||||
// at 800 %, in view mode and in every editor; how far you may walk is decided
|
||||
// by `_clampView` (PAN_SLACK: about a screen past the content) and not by the
|
||||
// zoom. The tools keep the pointer they already owned — a resize handle, a
|
||||
// device badge, an opening — and the kiosk keeps its floor swipe.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// ---- a drag on empty scene pans, at every zoom and in every mode ---------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
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 });
|
||||
let pid = 100;
|
||||
/** Drag 80x60 px across the middle of the scene; did the view follow? */
|
||||
const drag = async (zoom) => {
|
||||
// always start from the plan in the middle: the clamp is a real limit, and
|
||||
// a dozen drags in a row would otherwise walk into it
|
||||
const vb = c._baseVb();
|
||||
c._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
await c.updateComplete;
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const id = ++pid;
|
||||
pd(id, 300, 300);
|
||||
pm(id, 340, 330);
|
||||
pm(id, 380, 360);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
pu(id, 380, 360);
|
||||
// dragging down-right pulls the viewport up-left, and the zoom is untouched
|
||||
return Math.abs(v1.x - v0.x) > 1 && Math.abs(v1.y - v0.y) > 1
|
||||
&& v1.x < v0.x && v1.y < v0.y && Math.abs(c._zoom - zoom) < 1e-9;
|
||||
};
|
||||
const inMode = async (name, setup) => {
|
||||
await setup();
|
||||
await c.updateComplete;
|
||||
o['pan_' + name + '_at100'] = await drag(1);
|
||||
o['pan_' + name + '_at50'] = await drag(0.5);
|
||||
o['pan_' + name + '_at33'] = await drag(1 / 3);
|
||||
};
|
||||
|
||||
await inMode('view', async () => { c._setMode('view'); });
|
||||
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'closewall', 'delroom']) {
|
||||
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
|
||||
}
|
||||
await inMode('devices', async () => { c._setMode('devices'); });
|
||||
await inMode('decor', async () => { c._setMode('decor'); c._decorTool = 'select'; });
|
||||
c._setMode('view');
|
||||
c._applyView(1);
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- 400 % still pans (the behaviour that already worked) ----------------
|
||||
out.panStillWorksZoomedIn = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('view'); c._applyView(4); await c.updateComplete;
|
||||
const v0 = { ...c._view };
|
||||
c._stagePointerDown({ pointerId: 7, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 7, clientX: 380, clientY: 360 });
|
||||
const moved = Math.abs(c._view.x - v0.x) > 1;
|
||||
c._stagePointerUp({ pointerId: 7, clientX: 380, clientY: 360 });
|
||||
c._applyView(1);
|
||||
return moved;
|
||||
});
|
||||
|
||||
// ---- the clamp: you may walk about a screen off, never further ------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
c._setMode('view');
|
||||
const vb = c._baseVb();
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); // at 100 % the view IS fitView
|
||||
await c.updateComplete;
|
||||
const fit = { ...c._view };
|
||||
c._stagePointerDown({ pointerId: 8, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 8, clientX: -40000, clientY: 400 }); // shove it into the void
|
||||
const far = { ...c._view };
|
||||
c._stagePointerUp({ pointerId: 8, clientX: -40000, clientY: 400 });
|
||||
o.panLeavesTheContent = far.x > fit.x + fit.w * 0.5;
|
||||
o.panStopsAtTheSlack = far.x <= fit.x + fit.w + 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
o.homeArrowOffersTheWayBack = !!sr.querySelector('.homearrow');
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// the same at 50 %: the screen holds more, the walk is still finite
|
||||
const fit2 = { ...c._view };
|
||||
c._applyView(0.5, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
|
||||
c._stagePointerDown({ pointerId: 9, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 9, clientX: -40000, clientY: 400 });
|
||||
const far2 = { ...c._view };
|
||||
c._stagePointerUp({ pointerId: 9, clientX: -40000, clientY: 400 });
|
||||
o.panStopsAtTheSlackZoomedOut = far2.x <= fit2.x + fit2.w + 1 && far2.x > fit2.x;
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- the tools still own their pointer ------------------------------------
|
||||
const ev = (type, id, x, y) => ({ type, id, x, y });
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const fire = (el, type, id, x, y) => el.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
|
||||
// a) resize handle: the wall moves, the view does not
|
||||
c._setMode('plan'); c._tool = 'resize'; c._applyView(1); await c.updateComplete;
|
||||
const h = sr.querySelector('.rszhandle');
|
||||
o.resizeHandlePresent = !!h;
|
||||
if (h) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = h.getBoundingClientRect();
|
||||
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.resizeHandleTakesThePointer = !!c._rszDrag && c._panStart === null;
|
||||
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
o.resizeHandleResizes = !!c._rszDrag && c._rszDrag.moved === true;
|
||||
c._rszCancelDrag?.();
|
||||
fire(h, 'pointerup', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
}
|
||||
c._rszSel = null;
|
||||
|
||||
// b) a device badge in the devices editor drags the DEVICE
|
||||
c._setMode('devices'); c._applyView(1); await c.updateComplete;
|
||||
const dev = sr.querySelector('.devlayer .dev');
|
||||
o.devicePresent = !!dev;
|
||||
if (dev) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = dev.getBoundingClientRect();
|
||||
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.deviceTakesThePointer = !!c._drag && c._panStart === null;
|
||||
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
o.deviceActuallyMoved = !!c._drag && c._drag.moved === true;
|
||||
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
// _pointerUp releases _drag on the next macrotask, and _stagePointerDown
|
||||
// refuses to start a gesture while a device drag is live
|
||||
await new Promise((r2) => setTimeout(r2, 20));
|
||||
o.deviceDragReleased = c._drag === null;
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// c) an opening drags along its wall, the view stays put
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].openings = [{ id: 'o_pan', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.1 }];
|
||||
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
|
||||
c._setMode('plan'); c._tool = 'draw'; c._applyView(1);
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const op = sr.querySelector('.op-hit');
|
||||
o.openingPresent = !!op;
|
||||
if (op) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = op.getBoundingClientRect();
|
||||
fire(op, 'pointerdown', 23, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.openingTakesThePointer = !!c._opDrag && c._panStart === null;
|
||||
fire(op, 'pointermove', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.openingDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
fire(op, 'pointerup', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// d) a drawing tool is still a drawing tool: two fingers pinch, they do not pan
|
||||
c._setMode('plan'); c._tool = 'draw'; c._applyView(1); await c.updateComplete;
|
||||
const z0 = c._zoom;
|
||||
c._stagePointerDown({ pointerId: 31, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerDown({ pointerId: 32, clientX: 400, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 31, clientX: 250, clientY: 300 });
|
||||
c._stagePointerMove({ pointerId: 32, clientX: 450, clientY: 300 });
|
||||
o.pinchStillZooms = c._zoom > z0 * 1.5;
|
||||
c._stagePointerUp({ pointerId: 31, clientX: 250, clientY: 300 });
|
||||
c._stagePointerUp({ pointerId: 32, clientX: 450, clientY: 300 });
|
||||
c._path = [];
|
||||
c._setMode('view'); c._applyView(1); await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- the kiosk keeps its floor swipe ---------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c0 = window.__card;
|
||||
const o = {};
|
||||
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,
|
||||
}));
|
||||
k._applyView(1); await k.updateComplete;
|
||||
const s0 = k._space;
|
||||
const v0 = { ...k._viewOr(k._baseVb()) };
|
||||
// a REAL swipe: down, several moves, up — the moves are what used to be
|
||||
// impossible to add to smoke_kiosk without hijacking the gesture
|
||||
fire('pointerdown', 41, 600, 300);
|
||||
fire('pointermove', 41, 540, 302);
|
||||
fire('pointermove', 41, 480, 305);
|
||||
fire('pointermove', 41, 450, 305);
|
||||
const midPan = Math.abs(k._viewOr(k._baseVb()).x - v0.x);
|
||||
fire('pointerup', 41, 450, 305);
|
||||
await k.updateComplete;
|
||||
o.kioskSwipeStillSwitchesFloors = k._space !== s0;
|
||||
o.kioskHorizontalDragIsNotAPan = midPan < 1;
|
||||
// …and the plan is not left hanging half-panned after the switch
|
||||
o.kioskViewCenteredAfterSwipe = Math.abs(k._viewOr(k._baseVb()).x - v0.x) < 2000;
|
||||
// a VERTICAL drag is nobody's swipe, so it pans like everywhere else
|
||||
const s1 = k._space;
|
||||
const v1 = { ...k._viewOr(k._baseVb()) };
|
||||
fire('pointerdown', 42, 450, 200);
|
||||
fire('pointermove', 42, 452, 260);
|
||||
fire('pointermove', 42, 454, 330);
|
||||
const panned = Math.abs(k._viewOr(k._baseVb()).y - v1.y) > 1;
|
||||
fire('pointerup', 42, 454, 330);
|
||||
await k.updateComplete;
|
||||
o.kioskVerticalDragPans = panned;
|
||||
o.kioskVerticalDragKeepsFloor = k._space === s1;
|
||||
// zoomed in, a horizontal drag pans (no swipe up there — unchanged rule)
|
||||
k._applyView(2); await k.updateComplete;
|
||||
const v2 = { ...k._view };
|
||||
const s2 = k._space;
|
||||
fire('pointerdown', 43, 600, 300);
|
||||
fire('pointermove', 43, 540, 302);
|
||||
fire('pointermove', 43, 480, 305);
|
||||
const pannedZoomed = Math.abs(k._view.x - v2.x) > 1;
|
||||
fire('pointerup', 43, 480, 305);
|
||||
await k.updateComplete;
|
||||
o.kioskZoomedHorizontalPans = pannedZoomed;
|
||||
o.kioskZoomedDoesNotSwipe = k._space === s2;
|
||||
k.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,106 @@
|
||||
// «Уже загруженные» — прокручиваемый список превью внутри диалога пространства.
|
||||
// Проверяем НЕ наличие узлов в DOM, а ГЕОМЕТРИЮ: scrollable-контейнер как
|
||||
// flex-item в .dialog .body (flex column) схлопывается до бордера (min-height
|
||||
// auto = 0 при overflow != visible), и владелец видит тонкую полоску вместо
|
||||
// сетки миниатюр. Ровно этот баг уже ловили в .candlist (v1.53.1) — смок там
|
||||
// смотрел только на DOM и пропустил его.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: 'desktop', width: 900, height: 1000 },
|
||||
{ name: 'mobile', width: 390, height: 780 },
|
||||
];
|
||||
|
||||
const out = {};
|
||||
for (const vp of VIEWPORTS) {
|
||||
const { page, browser } = await launch({ width: vp.width, height: vp.height }, 1);
|
||||
const res = await page.evaluate(async (mode) => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const base = c.hass.callWS;
|
||||
const mk = (i) => ({
|
||||
name: `plan${i}.png`,
|
||||
url: `/api/houseplan/content/plans/_/plan${i}.png`,
|
||||
size: 100000 + i * 1000,
|
||||
modified: i,
|
||||
used_by: [],
|
||||
});
|
||||
let serverPlans = [mk(1), mk(2), mk(3), mk(4), mk(5), mk(6)];
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
const urls = {}; for (const p of m.paths) urls[p] = p; return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
const openPicker = async () => {
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file' };
|
||||
await c.updateComplete;
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(() => r()));
|
||||
};
|
||||
const geom = (tag) => {
|
||||
const box = sr().querySelector('.savedplans');
|
||||
const rows = [...sr().querySelectorAll('.savedplan')];
|
||||
o[tag + 'Rows'] = rows.length;
|
||||
if (!box || !rows.length) { o[tag + 'BoxTall'] = false; o[tag + 'FirstVisible'] = false; return; }
|
||||
const b = box.getBoundingClientRect();
|
||||
const r0 = rows[0].getBoundingClientRect();
|
||||
o[tag + 'BoxH'] = Math.round(b.height);
|
||||
// 2-3 ряда миниатюр: ряд ~40px + отступы
|
||||
o[tag + 'BoxTall'] = b.height >= 100;
|
||||
// первая миниатюра целиком внутри контейнера и не нулевая
|
||||
o[tag + 'FirstVisible'] = r0.height >= 32 && r0.top >= b.top - 1 && r0.bottom <= b.bottom + 1;
|
||||
// при 6 планах список не влезает целиком — должна быть прокрутка
|
||||
o[tag + 'Scrolls'] = box.scrollHeight - box.clientHeight > 8;
|
||||
box.scrollTop = 9999;
|
||||
o[tag + 'ScrollWorks'] = box.scrollTop > 8;
|
||||
box.scrollTop = 0;
|
||||
};
|
||||
|
||||
// 1. диалог создания пространства
|
||||
c._openSpaceDialog('create');
|
||||
await c.updateComplete;
|
||||
await openPicker();
|
||||
geom('create');
|
||||
|
||||
// 2. диалог настроек существующего пространства
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
c._openSpaceDialog('edit', 'f1');
|
||||
await c.updateComplete;
|
||||
await openPicker();
|
||||
geom('edit');
|
||||
|
||||
// 3. пусто: аккуратная надпись, а не схлопнутая полоска
|
||||
serverPlans = [];
|
||||
c._spaceDialog = { ...c._spaceDialog, pickSaved: false };
|
||||
await c.updateComplete;
|
||||
await openPicker();
|
||||
const empty = sr().querySelector('.savedplans');
|
||||
o.emptyHasText = !!empty && empty.textContent.trim().length > 0;
|
||||
o.emptyTall = !!empty && empty.getBoundingClientRect().height >= 24;
|
||||
o.mode = mode;
|
||||
return o;
|
||||
}, vp.name);
|
||||
for (const [k, v] of Object.entries(res)) out[vp.name + '.' + k] = v;
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 1));
|
||||
checkAll(out, {
|
||||
'desktop.createRows': 6,
|
||||
'desktop.editRows': 6,
|
||||
'desktop.createBoxH': out['desktop.createBoxH'],
|
||||
'desktop.editBoxH': out['desktop.editBoxH'],
|
||||
'desktop.mode': 'desktop',
|
||||
'mobile.createRows': 6,
|
||||
'mobile.editRows': 6,
|
||||
'mobile.createBoxH': out['mobile.createBoxH'],
|
||||
'mobile.editBoxH': out['mobile.editBoxH'],
|
||||
'mobile.mode': 'mobile',
|
||||
});
|
||||
await finish(null);
|
||||
@@ -0,0 +1,146 @@
|
||||
// HP-1552: residual jump on first open in NORMAL (non-kiosk) mode. The stage is
|
||||
// calc(100dvh - _hdrH) and _hdrH is measured from HA's chrome, which loads
|
||||
// AFTER the card's first paint — so the plan painted, then jumped when the
|
||||
// panels landed. The fix hides the plan behind a boot veil (pulsing house)
|
||||
// until the height reads the same twice in a row (or ~600 ms). This smoke
|
||||
// simulates the late HA panel: a spacer above the card grows 0 → 56 px at
|
||||
// t=300 ms. Assertions: the veil is shown, and there is NOT A SINGLE frame
|
||||
// where the plan is visible at a non-final stage height. Kiosk: no veil ever.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
// ---------- phase 1: normal mode, HA panel lands late ----------
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.append(spacer, c);
|
||||
document.body.appendChild(wrap);
|
||||
|
||||
const frames = [];
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let grown = false;
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const t = performance.now() - t0;
|
||||
if (!grown && t >= 300) {
|
||||
grown = true; // the "HA panel" arrives: everything above the card grows
|
||||
spacer.style.height = '56px';
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
const stage = sr().querySelector('.stage');
|
||||
if (stage) {
|
||||
const veil = sr().querySelector('.bootveil');
|
||||
const wrapEl = stage.querySelector(':scope > .zoomwrap');
|
||||
const planHidden =
|
||||
!wrapEl ||
|
||||
getComputedStyle(wrapEl).visibility === 'hidden' ||
|
||||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
|
||||
frames.push({
|
||||
t: Math.round(t),
|
||||
planVisible: !planHidden,
|
||||
veil: !!veil,
|
||||
veilBg: veil ? getComputedStyle(veil).backgroundColor : null,
|
||||
house: !!(veil && veil.querySelector('.boothouse path')),
|
||||
stageH: stage.clientHeight,
|
||||
});
|
||||
} else {
|
||||
frames.push(null);
|
||||
}
|
||||
if (t < 1400) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
|
||||
const seen = frames.filter(Boolean);
|
||||
const finalH = seen[seen.length - 1].stageH;
|
||||
out.sampledFrames = seen.length > 40;
|
||||
out.veilShownWhileSettling = seen.some((f) => f.veil && !f.planVisible && f.house);
|
||||
out.veilIsDark = (() => {
|
||||
const f = seen.find((x) => x.veilBg);
|
||||
const m = f && f.veilBg.match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
return !!m && +m[1] + +m[2] + +m[3] < 300; // dark, not white/transparent
|
||||
})();
|
||||
// THE assertion: no frame may show the plan at a stale (pre-panel) height
|
||||
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
|
||||
out.noVisibleFrameAtStaleHeight = bad.length === 0
|
||||
? true
|
||||
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms — the jump is visible`;
|
||||
const shown = seen.filter((f) => f.planVisible);
|
||||
out.planShownAfterSettle = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
|
||||
out.veilGoneInTheEnd = !seen[seen.length - 1].veil;
|
||||
out.heightReactedToPanel = finalH !== seen[0].stageH; // the simulated panel actually moved the stage
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
|
||||
// ---------- phase 2: kiosk never shows the veil ----------
|
||||
const kiosk = 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.__card.hass;
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:700px;z-index:99';
|
||||
document.body.appendChild(c);
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
let veilEver = false;
|
||||
let planHiddenEver = false;
|
||||
const t0 = performance.now();
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const stage = sr().querySelector('.stage');
|
||||
if (stage) {
|
||||
if (sr().querySelector('.bootveil')) veilEver = true;
|
||||
const wrapEl = stage.querySelector(':scope > .zoomwrap');
|
||||
if (wrapEl && getComputedStyle(wrapEl).visibility === 'hidden') planHiddenEver = true;
|
||||
}
|
||||
if (performance.now() - t0 < 900) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
out.kioskNoVeil = !veilEver;
|
||||
out.kioskPlanNeverHidden = !planHiddenEver;
|
||||
c.remove();
|
||||
return out;
|
||||
});
|
||||
check('kioskNoVeil', kiosk.kioskNoVeil);
|
||||
check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
|
||||
|
||||
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const reduced = await page.evaluate(async () => {
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99';
|
||||
document.body.appendChild(c);
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let anim = 'missed';
|
||||
await new Promise((done) => {
|
||||
const tick = () => {
|
||||
const house = sr().querySelector('.bootveil .boothouse');
|
||||
if (house) { anim = getComputedStyle(house).animationName; done(); return; }
|
||||
if (performance.now() - t0 < 500) requestAnimationFrame(tick);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
c.remove();
|
||||
return { anim };
|
||||
});
|
||||
check('reducedMotionStaticHouse', reduced.anim, 'none');
|
||||
|
||||
await finish(browser, { ...res, ...kiosk, ...reduced });
|
||||
@@ -0,0 +1,201 @@
|
||||
// AUD-1552-01 + AUD-1552-02: adversarial lifecycle regressions for the
|
||||
// first-open boot veil (HP-1552). Both scenarios FAIL on v1.55.2.
|
||||
//
|
||||
// A. disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
|
||||
// switch remounts the card). v1.55.2 cleared the boot timer on disconnect
|
||||
// but kept its truthy id, so the reconnect never restarted the watcher and
|
||||
// the plan stayed hidden FOREVER. Now the veil lifecycle restarts from
|
||||
// connectedCallback: the veil must lift no later than the hard cap.
|
||||
// B. layout shifts before, between and after the old poll ticks (150, 300,
|
||||
// 450, 590 ms). v1.55.2 revealed the plan after two equal reads at
|
||||
// 200/400 ms, so a panel landing at 450+ ms jumped on a VISIBLE plan. Now
|
||||
// the veil holds for the full protective window with trailing quiescence:
|
||||
// not a single frame may show the plan at a non-final stage height.
|
||||
// C. a shift AFTER the reveal (slower than the cap) must glide, not snap:
|
||||
// for a short grace the stage height transitions and the plan refits
|
||||
// along with it (no transition at all on v1.55.2 — instant jump).
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
// ---------- A: detach before the first tick, reattach, veil must lift ----------
|
||||
const rec = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.appendChild(c);
|
||||
document.body.appendChild(wrap);
|
||||
await sleep(50); // the first boot tick has not fired yet
|
||||
out.bootingWhenDetached = c._booting === true;
|
||||
c.remove(); // disconnect: timers die here
|
||||
await sleep(30);
|
||||
wrap.appendChild(c); // reconnect — the veil lifecycle must restart
|
||||
const t0 = performance.now();
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
while (c._booting && performance.now() - t0 < 2000) await sleep(50);
|
||||
out.revealMs = Math.round(performance.now() - t0);
|
||||
out.bootSettledAfterReconnect = c._booting === false;
|
||||
out.settledWithinCap = performance.now() - t0 < 1500; // hard cap 1200 + slack
|
||||
await sleep(300); // let the fade finish
|
||||
const stage = sr().querySelector('.stage');
|
||||
const wrapEl = stage && stage.querySelector(':scope > .zoomwrap');
|
||||
out.planVisibleAfterReconnect = !!wrapEl && getComputedStyle(wrapEl).visibility === 'visible';
|
||||
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
|
||||
out.veilGone = !sr().querySelector('.bootveil');
|
||||
|
||||
// A detach during resume/nav motion kills their timers. The transient state
|
||||
// must die with the timers, otherwise the same element comes back veiled or
|
||||
// permanently clipped by nav-enter.
|
||||
c._resumeSettling = true;
|
||||
c._navMotion = 'enter';
|
||||
c._slide = 'left';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c.remove();
|
||||
out.detachClearsResumeAndNav = c._resumeSettling === false
|
||||
&& c._navMotion === '' && c._slide === '';
|
||||
wrap.appendChild(c);
|
||||
await c.updateComplete;
|
||||
const stageAfterMotionReattach = sr().querySelector('.stage');
|
||||
out.reattachHasNoStuckMotionClasses = !!stageAfterMotionReattach
|
||||
&& !stageAfterMotionReattach.classList.contains('hpnav')
|
||||
&& !stageAfterMotionReattach.classList.contains('hpresume');
|
||||
|
||||
// A2: a second detach DURING the fade must not leave a zombie veil either
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c2 = document.createElement('houseplan-card');
|
||||
c2.setConfig({ type: 'custom:houseplan-card' });
|
||||
c2.hass = window.__card.hass;
|
||||
wrap.appendChild(c2);
|
||||
const t1 = performance.now();
|
||||
while (c2._booting && performance.now() - t1 < 2000) await sleep(50);
|
||||
// _bootFading is true for ~220 ms after the reveal — detach right inside it
|
||||
c2.remove();
|
||||
await sleep(30);
|
||||
wrap.appendChild(c2);
|
||||
await sleep(400);
|
||||
const sr2 = c2.shadowRoot || c2.renderRoot;
|
||||
out.fadeVeilGoneAfterReattach = !sr2.querySelector('.bootveil') && c2._bootFading === false;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(rec)) check(k, v, k === 'revealMs' ? rec.revealMs : true);
|
||||
|
||||
// ---------- B: shifts at 150/300/450/590 ms — never a visible stale frame ----------
|
||||
const shifts = [150, 300, 450, 590];
|
||||
const outB = {};
|
||||
for (const shiftMs of shifts) {
|
||||
const res = await page.evaluate(async (shiftAt) => {
|
||||
const out = {};
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.append(spacer, c);
|
||||
document.body.appendChild(wrap);
|
||||
const frames = [];
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let grown = false;
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const t = performance.now() - t0;
|
||||
if (!grown && t >= shiftAt) {
|
||||
grown = true; // the "HA panel" arrives
|
||||
spacer.style.height = '56px';
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
const stage = sr().querySelector('.stage');
|
||||
if (stage) {
|
||||
const veil = sr().querySelector('.bootveil');
|
||||
const wrapEl = stage.querySelector(':scope > .zoomwrap');
|
||||
const planHidden =
|
||||
!wrapEl ||
|
||||
getComputedStyle(wrapEl).visibility === 'hidden' ||
|
||||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
|
||||
frames.push({ t: Math.round(t), planVisible: !planHidden, stageH: stage.clientHeight });
|
||||
}
|
||||
if (t < 1900) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
const seen = frames;
|
||||
const finalH = seen[seen.length - 1].stageH;
|
||||
// THE assertion: no frame may show the plan at a stale (pre-panel) height
|
||||
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
|
||||
out.noStaleFrame = bad.length === 0
|
||||
? true
|
||||
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms`;
|
||||
const shown = seen.filter((f) => f.planVisible);
|
||||
out.planShown = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
|
||||
out.heightReacted = finalH !== seen[0].stageH;
|
||||
wrap.remove();
|
||||
return out;
|
||||
}, shiftMs);
|
||||
outB[`shift${shiftMs}_noStaleFrame`] = res.noStaleFrame;
|
||||
outB[`shift${shiftMs}_planShown`] = res.planShown;
|
||||
outB[`shift${shiftMs}_heightReacted`] = res.heightReacted;
|
||||
check(`shift${shiftMs}_noStaleFrame`, res.noStaleFrame);
|
||||
check(`shift${shiftMs}_planShown`, res.planShown);
|
||||
check(`shift${shiftMs}_heightReacted`, res.heightReacted);
|
||||
}
|
||||
|
||||
// ---------- C: shift AFTER the reveal glides instead of snapping ----------
|
||||
const late = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.append(spacer, c);
|
||||
document.body.appendChild(wrap);
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
while (c._booting && performance.now() - t0 < 2500) await sleep(25);
|
||||
out.revealed = c._booting === false;
|
||||
await sleep(120); // clearly after the reveal, still inside the soft grace
|
||||
const stage = sr().querySelector('.stage');
|
||||
const h0 = stage.clientHeight;
|
||||
spacer.style.height = '56px'; // VERY late panel — slower than the cap
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
const heights = [];
|
||||
await new Promise((done) => {
|
||||
const t1 = performance.now();
|
||||
const sample = () => {
|
||||
heights.push(stage.clientHeight);
|
||||
if (performance.now() - t1 < 700) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
const hEnd = heights[heights.length - 1];
|
||||
out.heightMoved = Math.abs(hEnd - h0) > 30;
|
||||
// the glide: intermediate frames strictly between the endpoints
|
||||
const mid = heights.filter((h) => Math.abs(h - h0) > 4 && Math.abs(h - hEnd) > 4);
|
||||
out.lateShiftGlides = mid.length >= 3
|
||||
? true
|
||||
: `only ${mid.length} intermediate frame(s): ${h0} -> ${hEnd} snapped`;
|
||||
out.settledAtFinal = Math.abs(hEnd - (h0 - 56)) <= 2;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
check('late_revealed', late.revealed);
|
||||
check('late_heightMoved', late.heightMoved);
|
||||
check('late_lateShiftGlides', late.lateShiftGlides);
|
||||
check('late_settledAtFinal', late.settledAtFinal);
|
||||
|
||||
await finish(browser, { ...rec, ...outB, ...late });
|
||||
@@ -47,6 +47,29 @@ const res = await page.evaluate(async () => {
|
||||
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
|
||||
out.overrideRespected = out.staticCardRoom0 === 'transparent';
|
||||
|
||||
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами» — a miter join
|
||||
// spikes far past a sharp corner (and bevels it flat once past the miter
|
||||
// limit). Room borders join ROUND now, and BOTH renderers share the rule:
|
||||
// the styles live in one cardStyles, and this is the smoke that keeps it so.
|
||||
const joins = (root) => [...root.querySelectorAll('.room')]
|
||||
.map((el) => getComputedStyle(el).strokeLinejoin);
|
||||
const fullJoins = joins(main.shadowRoot || main.renderRoot);
|
||||
const staticJoins = joins(card.renderRoot);
|
||||
out.fullCardBordersRound = fullJoins.length > 0 && fullJoins.every((j) => j === 'round');
|
||||
out.staticCardBordersRound = staticJoins.length > 0 && staticJoins.every((j) => j === 'round');
|
||||
// the trimmed outline of a room with open boundaries is a set of separate
|
||||
// subpaths: its CAPS are the corners, so they must be round as well
|
||||
const f1cfg = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1cfg.rooms[0].open_to = [f1cfg.rooms[1].id];
|
||||
main._cfgEpoch++;
|
||||
main.requestUpdate(); await main.updateComplete;
|
||||
const outlines = [...(main.shadowRoot || main.renderRoot).querySelectorAll('.room-outline')];
|
||||
out.trimmedOutlineDrawn = outlines.length > 0;
|
||||
out.trimmedOutlineRound = outlines.every((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return cs.strokeLinejoin === 'round' && cs.strokeLinecap === 'round';
|
||||
});
|
||||
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
|
||||
+10
-11
@@ -3,38 +3,37 @@ const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
// счётчики вызовов тяжёлой геометрии
|
||||
// AUD-159B6-06: the old hook `_computeOpenPairs` is gone. The observable
|
||||
// contract that still matters is the MODEL memo — a hass tick that changes
|
||||
// no geometry must not rebuild spaces (audit L1). Open-pair work is cheap
|
||||
// and may run once per paint; we only refuse a storm (>2× renders).
|
||||
let pairsCalls = 0, buildCalls = 0;
|
||||
const origPairs = c._computeOpenPairs.bind(c);
|
||||
c._computeOpenPairs = (...a) => { pairsCalls++; return origPairs(...a); };
|
||||
const origPairs = c._openPairs.bind(c);
|
||||
c._openPairs = (...a) => { pairsCalls++; return origPairs(...a); };
|
||||
const origBuild = c._buildModel.bind(c);
|
||||
c._buildModel = (...a) => { buildCalls++; return origBuild(...a); };
|
||||
// открыть границу, чтобы pairs было что считать
|
||||
const sp = c._curSpaceCfg;
|
||||
const r1 = sp.rooms[0], r2 = sp.rooms[1];
|
||||
r1.open_to = [r2.id]; r2.open_to = [r1.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
pairsCalls = 0; buildCalls = 0;
|
||||
// 10 «пушей состояния» от HA без изменения конфига
|
||||
for (let i = 0; i < 10; i++) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } };
|
||||
await c.updateComplete;
|
||||
}
|
||||
out.pairsPerRender = pairsCalls; // должно быть 0: кэш живёт между рендерами
|
||||
out.pairsBounded = pairsCalls <= 20;
|
||||
out.modelBuildsPer10Renders = buildCalls;
|
||||
// правка конфига обязана инвалидировать кэш
|
||||
const before = pairsCalls;
|
||||
const r3 = sp.rooms[2] || sp.rooms[0];
|
||||
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.invalidatesOnEdit = pairsCalls > before;
|
||||
// геометрия по-прежнему правильная: пунктир на месте
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true };
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"pairsPerRender": 0,
|
||||
"modelBuildsPer10Renders": 0,
|
||||
modelBuildsPer10Renders: 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// The v1.55.0 resize audit (HP-1550-01/-03/-04), numeric regressions:
|
||||
// 01 — a debounced config write pending from a PREVIOUS edit must never carry
|
||||
// the live resize preview to the server; Esc leaves server === snapshot;
|
||||
// a normal pointerup produces exactly one write;
|
||||
// 03 — pointercancel aborts the drag (no commit, no undo step, no write),
|
||||
// for wall handles and corner handles alike; lostpointercapture after
|
||||
// a cancel must not double-fire;
|
||||
// 04 — a door at the exact midpoint of a wall must not shadow the resize
|
||||
// handle: the handle wins the hit test, the wall drags WITH the door,
|
||||
// and no opening-edit drag starts in the resize tool.
|
||||
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._rszSel = null; c._rszDrag = null; c._rszLive = null;
|
||||
c._geometryHistory.clear();
|
||||
if ('_rszPreview' in c) c._rszPreview = null;
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
const enter = async (tool) => {
|
||||
const changed = await page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
const changed = !c._markup;
|
||||
if (changed) c._setMode('plan');
|
||||
c._tool = t; c._rszSel = null; c.requestUpdate();
|
||||
return Promise.resolve(c.updateComplete).then(() => changed);
|
||||
}, tool);
|
||||
if (changed) await page.waitForTimeout(220);
|
||||
};
|
||||
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const svg = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
// geometry the SERVER would get (the mutable config _writeConfig reads)
|
||||
const roomSrv = (id) => page.evaluate((id) => {
|
||||
const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id);
|
||||
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
|
||||
}, id);
|
||||
// geometry AS RENDERED (includes the live preview overlay when a drag is on)
|
||||
const roomLive = (id) => page.evaluate((id) => {
|
||||
const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id);
|
||||
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
|
||||
}, id);
|
||||
|
||||
// spy on every config write with a deep copy taken AT CALL TIME — exactly what
|
||||
// the backend would persist (a later fix of the mutable object must not hide it)
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
window.__writes = [];
|
||||
const orig = c.hass.callWS.bind(c.hass);
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') window.__writes.push(JSON.parse(JSON.stringify(m.config)));
|
||||
return orig(m);
|
||||
} };
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
const writes = () => page.evaluate(() => window.__writes.length);
|
||||
const writeWallAt = (i, x) => page.evaluate(([i, x]) => {
|
||||
const w = window.__writes[i];
|
||||
const r1 = w.spaces.find((s) => s.id === 'f1').rooms.find((r) => r.id === 'r1');
|
||||
return r1.poly.some((p) => Math.abs(p[0] - x) < 0.011);
|
||||
}, [i, x]);
|
||||
|
||||
// ================= HP-1550-01: pending write vs live preview =================
|
||||
await enter('resize');
|
||||
await settle();
|
||||
// precompute every screen point so drag 2 starts well inside the 500 ms window
|
||||
const [h550x, h550y] = await screenPt(550, 360);
|
||||
const [h600x] = await screenPt(600, 360);
|
||||
const [h650x] = await screenPt(650, 360);
|
||||
const [h700x] = await screenPt(700, 360);
|
||||
// drag 1 commits r1's right wall 0.55 → 0.60: a write enters the debounce queue
|
||||
await page.mouse.move(h550x, h550y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(h600x, h550y, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
// drag 2 starts IMMEDIATELY (pending save still queued): preview 0.60 → 0.70, HOLD
|
||||
await page.mouse.move(h600x, h550y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(h700x, h550y, { steps: 3 });
|
||||
await settle();
|
||||
check('01_committed_wall', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 0.011);
|
||||
check('01_preview_live', Math.abs((await roomLive('r1'))[1][0] - 0.70) < 0.011);
|
||||
// … the queued write fires while the handle is still held (>500 ms)
|
||||
await page.waitForTimeout(800);
|
||||
check('01_pending_write_fired', (await writes()) >= 1);
|
||||
// Esc abandons the drag
|
||||
await page.keyboard.press('Escape');
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800); // any stray debounced write would have fired
|
||||
const nw = await writes();
|
||||
for (let i = 0; i < nw; i++) {
|
||||
check(`01_write_${i}_no_preview_coords`, await writeWallAt(i, 0.70), false);
|
||||
check(`01_write_${i}_has_committed`, await writeWallAt(i, 0.60), true);
|
||||
}
|
||||
check('01_server_geom_is_snapshot', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 1e-6);
|
||||
check('01_local_geom_is_snapshot', Math.abs((await roomLive('r1'))[1][0] - 0.60) < 1e-6);
|
||||
// a normal pointerup still produces EXACTLY ONE write
|
||||
{
|
||||
const before = await writes();
|
||||
await page.mouse.move(h600x, h550y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(h650x, h550y, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
check('01_single_write_per_commit', (await writes()) - before, 1);
|
||||
check('01_commit_reached_server', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ================= HP-1550-03: pointercancel = abort, not commit =============
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
const w0 = await writes();
|
||||
const [ax, ay] = await screenPt(550, 360);
|
||||
const [bx] = await screenPt(650, 360);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, ay, { steps: 3 });
|
||||
await settle();
|
||||
check('03_preview_moved', Math.abs((await roomLive('r1'))[1][0] - 0.65) < 0.011);
|
||||
// the system interrupts the stream (app switch, palm rejection)
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const pid = c._rszDrag.pid;
|
||||
const el = c.renderRoot.querySelector('.rszhandle');
|
||||
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true }));
|
||||
// the lostpointercapture that follows a cancel must not double-fire
|
||||
el.dispatchEvent(new PointerEvent('lostpointercapture', { pointerId: pid, bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
check('03_snapshot_back_live', Math.abs((await roomLive('r1'))[1][0] - 0.55) < 1e-6);
|
||||
check('03_server_untouched', Math.abs((await roomSrv('r1'))[1][0] - 0.55) < 1e-6);
|
||||
check('03_undo_empty', await page.evaluate(() => window.__card._geometryHistory.size), 0);
|
||||
check('03_drag_cleared', await page.evaluate(() => window.__card._rszDrag === null));
|
||||
await page.waitForTimeout(800);
|
||||
check('03_no_pending_write', (await writes()) - w0, 0);
|
||||
await page.mouse.up();
|
||||
}
|
||||
await restore();
|
||||
// the corner (scale) handle takes the same abort path
|
||||
await enter('resize');
|
||||
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
{
|
||||
const w0 = await writes();
|
||||
const [ax, ay] = await screenPt(550, 860);
|
||||
const [bx, by] = await screenPt(448, 804); // ≈ k=0.8 about (40,580)
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
check('03_corner_preview', Math.abs((await roomLive('r4'))[2][0] - 0.55) > 0.02);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const pid = c._rszDrag.pid;
|
||||
const el = c.renderRoot.querySelector('.rszcorner') || c.renderRoot.querySelector('.rszhandle');
|
||||
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
check('03_corner_snapshot_back', Math.abs((await roomLive('r4'))[2][0] - 0.55) < 1e-6);
|
||||
check('03_corner_undo_empty', await page.evaluate(() => window.__card._geometryHistory.size), 0);
|
||||
await page.waitForTimeout(800);
|
||||
check('03_corner_no_write', (await writes()) - w0, 0);
|
||||
await page.mouse.up();
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ============ HP-1550-04: a door mid-wall must not shadow the handle =========
|
||||
await enter('resize');
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
// exactly at the midpoint of r1's right wall (y 0.14..0.58 → 0.36)
|
||||
sp.openings = [{ id: 'opm', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await settle();
|
||||
{
|
||||
const [hx, hy] = await screenPt(550, 360);
|
||||
const [tx] = await screenPt(650, 360);
|
||||
// the REAL hit test: the topmost interactive element must be the handle
|
||||
const top = await page.evaluate(([x, y]) => {
|
||||
const el = window.__card.shadowRoot.elementFromPoint(x, y);
|
||||
return el ? el.getAttribute('class') || '' : '';
|
||||
}, [hx, hy]);
|
||||
check('04_handle_wins_hit_test', String(top).includes('rszhandle'));
|
||||
await page.mouse.move(hx, hy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(tx, hy, { steps: 4 });
|
||||
await settle();
|
||||
check('04_no_opening_drag', await page.evaluate(() => !window.__card._opDrag));
|
||||
check('04_wall_drag_started', await page.evaluate(() => !!window.__card._rszDrag));
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
check('04_wall_moved', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011);
|
||||
const op = await page.evaluate(() => window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0]);
|
||||
check('04_door_travelled_with_wall', Math.abs(op.x - 0.65) < 0.011);
|
||||
}
|
||||
await restore();
|
||||
|
||||
await finish(browser, { done: true });
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Regression: resize a shared wall whose middle is virtual and whose solid
|
||||
* remainders carry thickness. The live overlay, commit and Undo must move one
|
||||
* geometry transaction. Virtual drawing paints above the real wall body in
|
||||
* editors, but below it in View so thick-wall jambs mask the dash ends.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
|
||||
|
||||
sp().settings = { ...(sp().settings || {}), show_borders: true };
|
||||
delete sp().walls;
|
||||
delete sp().open_spans;
|
||||
delete sp().openings;
|
||||
for (const r of sp().rooms || []) delete r.open_to;
|
||||
|
||||
c._setMode('plan');
|
||||
c._tool = 'wallthick';
|
||||
await upd();
|
||||
c._wallThickClick([550, 250]);
|
||||
await upd();
|
||||
if (c._wallDialog) {
|
||||
c._wallDialog = { ...c._wallDialog, value: '20' };
|
||||
c._wallThickApply(false);
|
||||
}
|
||||
await upd();
|
||||
out.realBodyReady = !!sr().querySelector('.wallbodies');
|
||||
|
||||
// The two-click rubber band used to be below .wallbodies and disappeared
|
||||
// exactly where it crossed the real T receiver.
|
||||
c._tool = 'openwall';
|
||||
c._openWallClick([550, 200]);
|
||||
c._cursorPt = [550, 300];
|
||||
await upd();
|
||||
const bodyGroup = sr().querySelector('.wallbodies');
|
||||
const virtualGroup = sr().querySelector('.openwalls');
|
||||
out.virtualPreviewDrawn = !!sr().querySelector('.openwall-preview');
|
||||
out.virtualPreviewAboveReal = !!bodyGroup && !!virtualGroup
|
||||
&& !!(bodyGroup.compareDocumentPosition(virtualGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
c._openWallClick([550, 300]);
|
||||
c._cursorPt = null;
|
||||
await upd();
|
||||
out.partialSpanCreated = (sp().open_spans || []).length === 1;
|
||||
const editorBody = sr().querySelector('.wallbodies');
|
||||
const editorVirtual = sr().querySelector('.openwalls');
|
||||
out.savedVirtualAboveRealInEditor = !!editorBody && !!editorVirtual
|
||||
&& !!(editorBody.compareDocumentPosition(editorVirtual) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
const viewBody = sr().querySelector('.wallbodies');
|
||||
const viewVirtual = sr().querySelector('.openwalls');
|
||||
out.savedVirtualBelowRealInView = !!viewBody && !!viewVirtual
|
||||
&& !!(viewVirtual.compareDocumentPosition(viewBody) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
c._setMode('decor');
|
||||
await upd();
|
||||
const decorBody = sr().querySelector('.wallbodies');
|
||||
const decorVirtual = sr().querySelector('.openwalls');
|
||||
out.realWallsFadeBehindDecor = !!decorBody
|
||||
&& getComputedStyle(decorBody).opacity === '0.35';
|
||||
out.virtualWallsFadeBehindDecor = !!decorVirtual
|
||||
&& getComputedStyle(decorVirtual).opacity === '0.35';
|
||||
c._setMode('plan');
|
||||
await upd();
|
||||
out.atomicThicknessBefore = c._intervalCm([550, 145, 550, 195]) === 20
|
||||
&& c._intervalCm([550, 205, 550, 295]) === 0
|
||||
&& c._intervalCm([550, 305, 550, 455]) === 20;
|
||||
|
||||
const before = JSON.stringify({
|
||||
rooms: sp().rooms,
|
||||
walls: sp().walls,
|
||||
open_spans: sp().open_spans,
|
||||
});
|
||||
c._tool = 'resize';
|
||||
await upd();
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const toScreen = (x, y) => {
|
||||
const r = stage().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 pev = (type, target, x, y) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
pointerId: 77, clientX, clientY, button: 0, isPrimary: true,
|
||||
}));
|
||||
};
|
||||
const handle = [...sr().querySelectorAll('.rszhandle:not(.rszcorner)')].find((h) => {
|
||||
const x = +h.getAttribute('cx'), y = +h.getAttribute('cy');
|
||||
return Math.abs(x - 550) < c._gridPitch * 2 && y > 140 && y < 460;
|
||||
});
|
||||
out.resizeHandleFound = !!handle;
|
||||
if (handle) {
|
||||
const x = +handle.getAttribute('cx');
|
||||
const y = +handle.getAttribute('cy');
|
||||
const step = c._gridPitch;
|
||||
pev('pointerdown', handle, x, y);
|
||||
pev('pointermove', handle, x + step, y);
|
||||
await c.updateComplete;
|
||||
const liveCut = c._openCuts()[0];
|
||||
const liveX = liveCut?.[0];
|
||||
out.liveSpanMoves = Number.isFinite(liveX) && Math.abs(liveX - (550 + step)) < 0.1;
|
||||
out.liveThicknessStays = c._intervalCm([liveX, 145, liveX, 195]) === 20
|
||||
&& c._intervalCm([liveX, 205, liveX, 295]) === 0
|
||||
&& c._intervalCm([liveX, 305, liveX, 455]) === 20;
|
||||
out.liveBodyStays = !!sr().querySelector('.wallbodies');
|
||||
|
||||
pev('pointerup', handle, x + step, y);
|
||||
await upd();
|
||||
const finalCut = c._openCuts()[0];
|
||||
const finalX = finalCut?.[0];
|
||||
out.commitSpanMoves = Number.isFinite(finalX) && Math.abs(finalX - (550 + step)) < 0.1;
|
||||
out.commitThicknessStays = c._intervalCm([finalX, 145, finalX, 195]) === 20
|
||||
&& c._intervalCm([finalX, 205, finalX, 295]) === 0
|
||||
&& c._intervalCm([finalX, 305, finalX, 455]) === 20;
|
||||
out.commitKeepsAtomicKeys = (sp().walls || []).filter((w) => w.cm === 20).length >= 2;
|
||||
|
||||
c._undoGeometry();
|
||||
await upd();
|
||||
out.undoRestoresAll = JSON.stringify({
|
||||
rooms: sp().rooms,
|
||||
walls: sp().walls,
|
||||
open_spans: sp().open_spans,
|
||||
}) === before;
|
||||
}
|
||||
|
||||
// Canonical storage: legacy/transactional fragments on one room pair become
|
||||
// one selectable span, and closing it restores the uniformly thick parent
|
||||
// wall instead of leaving atomic debris in `walls`.
|
||||
const restoredCut = c._openCuts()[0];
|
||||
if (restoredCut) {
|
||||
const mx = (restoredCut[0] + restoredCut[2]) / 2;
|
||||
const my = (restoredCut[1] + restoredCut[3]) / 2;
|
||||
c._persistOpenCuts([
|
||||
[restoredCut[0], restoredCut[1], mx, my],
|
||||
[mx, my, restoredCut[2], restoredCut[3]],
|
||||
]);
|
||||
await upd();
|
||||
out.adjacentSpansPersistAsOne = (sp().open_spans || []).length === 1
|
||||
&& c._openCuts().length === 1;
|
||||
c._closeOpenSpan(c._openCuts()[0]);
|
||||
await upd();
|
||||
out.closeRejoinsUniformWall = !sp().open_spans
|
||||
&& (sp().walls || []).length === 1
|
||||
&& c._intervalCm(restoredCut) === 20;
|
||||
} else {
|
||||
out.adjacentSpansPersistAsOne = false;
|
||||
out.closeRejoinsUniformWall = false;
|
||||
}
|
||||
|
||||
// The screenshot topology: the two real arms belong to different room
|
||||
// contours and only meet at the endpoint of B↔C's virtual boundary. Their
|
||||
// per-room butt caps must be completed into one clean outer mitre.
|
||||
sp().rooms = [
|
||||
{ id: 'ta', name: 'A', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
|
||||
{ id: 'tb', name: 'B', poly: [[0.5, 0.5], [0.9, 0.5], [0.9, 0.9], [0.5, 0.9]] },
|
||||
{ id: 'tc', name: 'C', poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
|
||||
];
|
||||
delete sp().walls;
|
||||
delete sp().open_spans;
|
||||
c._tool = 'wallthick';
|
||||
await upd();
|
||||
for (const p of [[300, 500], [500, 700]]) {
|
||||
c._wallThickClick(p);
|
||||
await upd();
|
||||
if (c._wallDialog) {
|
||||
c._wallDialog = { ...c._wallDialog, value: '20' };
|
||||
c._wallThickApply(false);
|
||||
await upd();
|
||||
}
|
||||
}
|
||||
sp().open_spans = [{ a: [0.5, 0.5], b: [0.9, 0.5] }];
|
||||
sp().rooms.find((r) => r.id === 'tb').open_to = ['tc'];
|
||||
sp().rooms.find((r) => r.id === 'tc').open_to = ['tb'];
|
||||
await upd();
|
||||
const tPath = sr().querySelector('.wallbody')?.getAttribute('d') || '';
|
||||
const values = (tPath.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
|
||||
const half = (20 / c._cellCm) * c._gridPitch / 2;
|
||||
out.virtualTJunctionMitred = values.some((x, i) => i % 2 === 0
|
||||
&& Math.abs(x - (500 + half)) < 1e-6
|
||||
&& Math.abs(values[i + 1] - (500 - half)) < 1e-6);
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -9,10 +9,10 @@ const res = await page.evaluate(async () => {
|
||||
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
|
||||
await c.updateComplete;
|
||||
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
|
||||
// живёт только в glow-пятне и в фолбэке цвета пульсации
|
||||
// живёт только в glow-пятне и в фолбэке цвета activity-эффекта
|
||||
out.rgbClassGone = !sr().querySelector('.dev.rgb');
|
||||
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
|
||||
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
|
||||
// фолбэк activity сохраняет цвет свечения: маркер icon_ripple на лампе
|
||||
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
|
||||
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// Room resize tool (docs/RESIZE.md): wall drag with shared walls (T-junction),
|
||||
// live badges, stops (neighbour min size, opening anchor), the corner scale
|
||||
// frame, Esc-cancel and one-step undo. Vertex positions are checked NUMERICALLY.
|
||||
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._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszPreview = null;
|
||||
c._geometryHistory.clear();
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
const enter = async (tool) => {
|
||||
const changed = await page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
const changed = !c._markup;
|
||||
if (changed) c._setMode('plan');
|
||||
c._tool = t; c._rszSel = null; c.requestUpdate();
|
||||
return Promise.resolve(c.updateComplete).then(() => changed);
|
||||
}, tool);
|
||||
if (changed) await page.waitForTimeout(220);
|
||||
};
|
||||
|
||||
const handleCount = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length);
|
||||
const roomPolyN = (id) => page.evaluate((id) => {
|
||||
const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id);
|
||||
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
|
||||
}, id);
|
||||
// mid-drag geometry AS RENDERED: HP-1550-01 moved the live preview out of
|
||||
// _serverCfg into the _rszPreview overlay (_curSpaceCfg serves it to renders)
|
||||
const roomPolyLive = (id) => page.evaluate((id) => {
|
||||
const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id);
|
||||
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
|
||||
}, id);
|
||||
const openingOf = (id) => page.evaluate((id) =>
|
||||
(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings || []).find((o) => o.id === id) || null, id);
|
||||
// screen position of a render-unit point (the svg viewBox fills the stage exactly)
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const svg = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
|
||||
const labels = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel')].map((el) => el.textContent.trim()));
|
||||
const polyClose = (name, got, want, tol = 1e-4) => {
|
||||
check(name + '.len', got?.length, want.length);
|
||||
if (got?.length !== want.length) return;
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
check(`${name}[${i}]`, Math.hypot(got[i][0] - want[i][0], got[i][1] - want[i][1]) <= tol, true);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- no handles outside the resize tool ----------
|
||||
await enter('draw');
|
||||
check('draw_tool_no_handles', await handleCount(), 0);
|
||||
await enter('opening');
|
||||
check('opening_tool_no_handles', await handleCount(), 0);
|
||||
|
||||
// ---------- handles appear in the resize tool ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
check('resize_handles_16', await handleCount(), 16); // 4 rooms × 4 walls
|
||||
|
||||
// ---------- wall drag: T-stack r1|r2+r3, opening rides along, live badges ----------
|
||||
// opening op2 sits ON the shared wall x=0.55 → it must travel with it
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [{ id: 'op2', type: 'door', x: 0.55, y: 0.30, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
});
|
||||
await settle();
|
||||
const [hx, hy] = await screenPt(550, 360); // r1 right-wall handle (mid y of 140..580)
|
||||
const [tx] = await screenPt(650, 360); // +100 render units
|
||||
await page.mouse.move(hx, hy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((hx + tx) / 2, hy, { steps: 4 });
|
||||
await settle();
|
||||
const mid = await labels();
|
||||
check('drag_badges_visible', mid.length >= 6); // 3 wall lengths + 3 areas (r1, r2, r3)
|
||||
check('drag_badges_have_area', mid.filter((t) => t.includes('m²')).length >= 3);
|
||||
check('drag_badges_have_len', mid.some((t) => /\d\.\d\d m$/.test(t)));
|
||||
const midR1 = await roomPolyLive('r1');
|
||||
check('preview_moves_r1', Math.abs(midR1[1][0] - 0.6) < 0.011); // halfway ≈ 0.60 (snapped)
|
||||
// HP-1550-01: the preview must NOT leak into the config a queued write reads
|
||||
const midSrv = await roomPolyN('r1');
|
||||
check('preview_not_in_servercfg', Math.abs(midSrv[1][0] - 0.55) < 1e-9);
|
||||
await page.mouse.move(tx, hy, { steps: 4 });
|
||||
await settle();
|
||||
const end = await labels();
|
||||
check('drag_badges_change', JSON.stringify(mid) !== JSON.stringify(end));
|
||||
check('area_updates_live', mid.find((t) => t.includes('m²')) !== end.find((t) => t.includes('m²')));
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
// committed geometry: r1 grew, r2 translated, r3 became L-shaped with 6 vertices
|
||||
polyClose('r1_after', await roomPolyN('r1'), [[0.04, 0.14], [0.65, 0.14], [0.65, 0.58], [0.04, 0.58]]);
|
||||
polyClose('r2_after', await roomPolyN('r2'), [[0.65, 0.14], [0.96, 0.14], [0.96, 0.46], [0.65, 0.46]]);
|
||||
polyClose('r3_after', await roomPolyN('r3'),
|
||||
[[0.65, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86], [0.55, 0.58], [0.65, 0.58]]);
|
||||
const op2 = await openingOf('op2');
|
||||
check('opening_travels', Math.abs(op2.x - 0.65) < 1e-4 && Math.abs(op2.y - 0.30) < 1e-6);
|
||||
|
||||
// ---------- undo: one release = one step back ----------
|
||||
await page.keyboard.press('Control+z');
|
||||
await settle();
|
||||
polyClose('undo_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
|
||||
polyClose('undo_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
|
||||
polyClose('undo_r3', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
|
||||
const op2b = await openingOf('op2');
|
||||
check('undo_opening_back', Math.abs(op2b.x - 0.55) < 1e-9);
|
||||
await restore();
|
||||
|
||||
// ---------- stop: the shrinking neighbour keeps ~30 cm ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(550, 360);
|
||||
const [bx] = await screenPt(1050, 360); // far past r2/r3 (right walls at 0.96)
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, ay, { steps: 6 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r1 = await roomPolyN('r1');
|
||||
const r2 = await roomPolyN('r2');
|
||||
const width2 = (r2[1][0] - r2[0][0]) * 1000;
|
||||
check('neighbour_min_stop', Math.abs(r1[1][0] - 0.93333) < 0.005); // clamped, NOT 1.05
|
||||
check('neighbour_min_width', width2 >= 25 - 1e-6 && width2 <= 25 + 1000 / 240 + 1e-6);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- stop: an opening anchors the shortening wall ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
// a door on r1's LEFT wall (x=0.04), spans y 0.16..0.24. Dragging r1's TOP
|
||||
// wall down shortens that wall from above — it must stop at the door edge.
|
||||
// (No collinear neighbour wall can adopt this door — a real «упор».)
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [{ id: 'op1', type: 'door', x: 0.04, y: 0.20, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
});
|
||||
await settle();
|
||||
const [ax, ay] = await screenPt(295, 140); // r1 top-wall handle
|
||||
const [, by] = await screenPt(295, 300); // try to push far past the door top (y=160)
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(ax, by, { steps: 5 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r1 = await roomPolyN('r1');
|
||||
const topY = r1[0][1] * 1000;
|
||||
check('opening_stop', topY <= 160 + 1e-6); // never below the door top
|
||||
check('opening_stop_close', topY >= 160 - 1000 / 240 - 1e-6); // and clamped right at it
|
||||
const op1 = await openingOf('op1');
|
||||
check('anchored_opening_static', Math.abs(op1.y - 0.2) < 1e-9 && Math.abs(op1.x - 0.04) < 1e-9);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- the scale frame: proportional, neighbours stay, stops work ----------
|
||||
await enter('resize');
|
||||
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
check('frame_corners', await page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszcorner').length), 4);
|
||||
check('frame_present', await page.evaluate(() => !!window.__card.renderRoot.querySelector('.rszframe')));
|
||||
{
|
||||
// shrink about the top-left corner (40,580): drag br (550,860) toward it
|
||||
const [ax, ay] = await screenPt(550, 860);
|
||||
const [bx, by] = await screenPt(448, 804); // ≈ k=0.8
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 5 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r4 = await roomPolyN('r4');
|
||||
const base = [[0.04, 0.58], [0.55, 0.58], [0.55, 0.86], [0.04, 0.86]];
|
||||
// ALL vertices scale by the SAME factor about the fixed corner (0.04, 0.58)
|
||||
const ks = base.map((b, i) => {
|
||||
const dx = b[0] - 0.04, dy = b[1] - 0.58;
|
||||
if (Math.abs(dx) > 1e-9) return (r4[i][0] - 0.04) / dx;
|
||||
if (Math.abs(dy) > 1e-9) return (r4[i][1] - 0.58) / dy;
|
||||
return null;
|
||||
}).filter((k) => k != null);
|
||||
const kAvg = ks.reduce((a, b) => a + b, 0) / ks.length;
|
||||
check('scale_shrinks', kAvg > 0.7 && kAvg < 0.9);
|
||||
check('scale_proportional', ks.every((k) => Math.abs(k - kAvg) < 1e-3));
|
||||
const ky = base.map((b, i) => (Math.abs(b[1] - 0.58) > 1e-9 ? (r4[i][1] - 0.58) / (b[1] - 0.58) : null)).filter((k) => k != null);
|
||||
check('scale_uniform_xy', ky.every((k) => Math.abs(k - kAvg) < 1e-3));
|
||||
// neighbours did NOT move (scale never drags them — docs/RESIZE.md exception)
|
||||
polyClose('scale_r1_static', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
|
||||
polyClose('scale_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
|
||||
}
|
||||
await restore();
|
||||
await enter('resize');
|
||||
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
{
|
||||
// growing about the top-left corner immediately runs into r3 → the scale stops at ≈1
|
||||
const [ax, ay] = await screenPt(550, 860);
|
||||
const [bx, by] = await screenPt(805, 1000); // ≈ k=1.5
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 5 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const r4 = await roomPolyN('r4');
|
||||
check('scale_neighbour_stop', Math.abs(r4[1][0] - 0.55) < 0.005 && Math.abs(r4[2][1] - 0.86) < 0.005);
|
||||
polyClose('scale_stop_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- Esc cancels the current drag ----------
|
||||
await enter('resize');
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(550, 360);
|
||||
const [bx] = await screenPt(700, 360);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, ay, { steps: 4 });
|
||||
await settle();
|
||||
const midPoly = await roomPolyLive('r1');
|
||||
check('esc_preview_moved', Math.abs(midPoly[1][0] - 0.70) < 0.011);
|
||||
await page.keyboard.press('Escape');
|
||||
await settle();
|
||||
polyClose('esc_restores_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
|
||||
polyClose('esc_restores_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
|
||||
check('esc_no_badges', (await labels()).length, 0);
|
||||
await page.mouse.up();
|
||||
}
|
||||
await restore();
|
||||
|
||||
// ---------- leaving the tool removes every handle ----------
|
||||
await enter('merge');
|
||||
check('merge_tool_no_handles', await handleCount(), 0);
|
||||
await page.evaluate(() => { window.__card._setMode('devices'); });
|
||||
await settle();
|
||||
check('devices_mode_no_handles', await handleCount(), 0);
|
||||
|
||||
await finish(browser, { done: true });
|
||||
@@ -43,8 +43,8 @@ const res = await page.evaluate(async () => {
|
||||
// 5) создание новой комнаты: в диалоге есть секция настроек
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
|
||||
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
|
||||
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
|
||||
out.createHasSection = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
|
||||
out.createHasInherit = [...sr().querySelectorAll('hp-dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
|
||||
@@ -35,7 +35,7 @@ const res = await page.evaluate(async () => {
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
|
||||
out.saveBlockedWithoutPlan = !!sr().querySelector('hp-dialog .btn.on[disabled]');
|
||||
|
||||
// открываем список сохранённых
|
||||
await c._toggleServerPlans();
|
||||
@@ -54,7 +54,7 @@ const res = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
|
||||
out.listClosed = !c._spaceDialog.pickSaved;
|
||||
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
out.saveEnabledAfterPick = !sr().querySelector('hp-dialog .btn.on[disabled]');
|
||||
|
||||
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
|
||||
// на несуществующий файл (HP-1470-02)
|
||||
|
||||
@@ -58,4 +58,33 @@ const out = await page.evaluate(async () => {
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
checkAll(out);
|
||||
|
||||
// Шаг угла в диалоге устройства — 5°, а не 10° (владелец, 2026-08-03):
|
||||
// значок часто надо выровнять по стене, которая не лежит на сетке в 10°.
|
||||
const out3 = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.space === 'f1');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
// строка «размер · угол»: два ползунка, второй — угол
|
||||
const rows = [...sr().querySelectorAll('hp-dialog .colorrow')];
|
||||
const row = rows.find((r) => r.textContent.includes('°'));
|
||||
const ctl = [...row.querySelectorAll('ha-slider, input[type=range]')];
|
||||
const stepOf = (el) => Number(el.step ?? el.getAttribute('step'));
|
||||
const maxOf = (el) => Number(el.max ?? el.getAttribute('max'));
|
||||
o.angleStepIs5 = ctl.length === 2 && stepOf(ctl[1]) === 5;
|
||||
o.angleReaches355 = maxOf(ctl[1]) === 355;
|
||||
o.sizeStepUnchanged = stepOf(ctl[0]) === 0.1;
|
||||
// и значение реально ложится на 5°
|
||||
ctl[1].value = 35;
|
||||
ctl[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
o.angle35Applied = c._markerDialog.angle === 35;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out3));
|
||||
|
||||
@@ -39,12 +39,13 @@ const res = await page.evaluate(async () => {
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
|
||||
await c.updateComplete;
|
||||
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
out.saveEnabled = !sr().querySelector('hp-dialog .btn.on[disabled]');
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
|
||||
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
|
||||
out.atticSettings = attic?.settings;
|
||||
out.atticNoPlan = attic ? attic.plan_url === null : null;
|
||||
out.atticOpensPlanEditor = c._space === attic?.id && c._mode === 'plan' && c._tool === 'draw';
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
@@ -56,6 +57,8 @@ checkAll(res, {
|
||||
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
|
||||
"lqiFills": 0,
|
||||
"atticSquare": true,
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
|
||||
// Existing spaces with an absent fill_mode still resolve to 'none'.
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Хуки для card-mod (docs/STYLING-HOOKS.md): на каждом значимом объекте плана
|
||||
// есть стабильные data-атрибуты, и по документированным селекторам элементы
|
||||
// НАХОДЯТСЯ через querySelector — ровно так, как их будет искать чужой CSS.
|
||||
// 1) устройство: data-hp="device" + data-id + data-entity + data-area;
|
||||
// 2) комната: data-hp="room" + data-id + data-area;
|
||||
// 3) подпись комнаты: data-hp="room-label" + data-id (и в SVG, и в HTML-карточке);
|
||||
// 4) проём: data-hp="opening" + data-id + data-kind (door/window);
|
||||
// 5) декор: data-hp="decor" + data-id + data-kind (line/rect/ellipse/text);
|
||||
// 6) вкладка пространства: data-hp="space-tab" + data-id;
|
||||
// 7) «нет значения — нет атрибута»: у виртуального маркера нет data-entity,
|
||||
// у комнаты без area нет data-area (никаких "undefined" в DOM);
|
||||
// 8) те же хуки в статичной карточке houseplan-space-card.
|
||||
// ПАДАЕТ на сборке до этих правок: data-hp нет вообще, а data-id есть только
|
||||
// у текстовой надписи декора.
|
||||
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 q = (sel) => sr().querySelector(sel);
|
||||
const qa = (sel) => [...sr().querySelectorAll(sel)];
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ============ 1. устройства ============
|
||||
const devs = qa('[data-hp="device"]');
|
||||
out.deviceHookFindsEveryMarker = devs.length > 0 && devs.length === qa('.dev').length;
|
||||
out.deviceKeepsItsClass = devs.every((e) => e.classList.contains('dev'));
|
||||
out.deviceHasId = devs.every((e) => !!e.getAttribute('data-id'));
|
||||
// id — тот же, что у устройства в модели карточки, а не порядковый номер в DOM
|
||||
const modelIds = new Set(c._devices.filter((d) => d.space === c._space && !d.hidden).map((d) => d.id));
|
||||
out.deviceIdIsTheConfigId = devs.every((e) => modelIds.has(e.getAttribute('data-id')));
|
||||
// селектор из документации находит конкретное устройство по СУЩНОСТИ
|
||||
const lock = q('[data-hp="device"][data-entity="lock.front_door"]');
|
||||
out.deviceFoundByEntity = !!lock;
|
||||
out.deviceEntityMatchesPrimary = !!lock
|
||||
&& c._devices.find((d) => d.id === lock.getAttribute('data-id'))?.primary === 'lock.front_door';
|
||||
out.deviceHasArea = lock?.getAttribute('data-area') === 'hallway';
|
||||
// и по ЗОНЕ: все маркеры кухни разом
|
||||
out.devicesFoundByArea = qa('[data-hp="device"][data-area="kitchen"]').length > 0;
|
||||
out.noUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
|
||||
|
||||
// ============ 2. комнаты ============
|
||||
const rooms = qa('[data-hp="room"]');
|
||||
out.roomHookFindsEveryRoom = rooms.length === c._curSpaceCfg.rooms.length;
|
||||
out.roomKeepsItsClass = rooms.every((e) => e.classList.contains('room'));
|
||||
const kitchen = q('[data-hp="room"][data-area="kitchen"]');
|
||||
out.roomFoundByArea = !!kitchen && kitchen.getAttribute('data-id') === 'r2';
|
||||
out.roomFoundById = !!q('[data-hp="room"][data-id="r3"]');
|
||||
out.roomIsAnSvgShape = !!kitchen && ['polygon', 'rect', 'path'].includes(kitchen.tagName.toLowerCase());
|
||||
|
||||
// ============ 3. подписи комнат ============
|
||||
// одно имя — две разные реализации: <text> в SVG на плане без подложки и
|
||||
// HTML-карточка, когда включены «имена на плане». Хук на обеих один и тот же.
|
||||
const disp = c._curSpaceCfg.settings || (c._curSpaceCfg.settings = {});
|
||||
const wasNames = disp.show_names;
|
||||
const planUrl = c._curSpaceCfg.plan_url;
|
||||
const redraw = async () => {
|
||||
c._cfgEpoch++; c._regSignature = ''; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
};
|
||||
delete c._curSpaceCfg.plan_url; disp.show_names = false; await redraw();
|
||||
const svgLabels = qa('text.rlabel[data-hp="room-label"]');
|
||||
out.svgRoomLabelHooked = svgLabels.length > 0
|
||||
&& svgLabels.every((e) => !!e.getAttribute('data-id'));
|
||||
out.svgRoomLabelFoundById = !!q('text[data-hp="room-label"][data-id="r1"]');
|
||||
out.svgRoomLabelHasArea = q('text[data-hp="room-label"][data-id="r1"]')?.getAttribute('data-area') === 'living_room';
|
||||
c._curSpaceCfg.plan_url = planUrl; disp.show_names = true; await redraw();
|
||||
const htmlLabel = q('div.roomlabel[data-hp="room-label"][data-id="r2"]');
|
||||
out.htmlRoomLabelHooked = !!htmlLabel;
|
||||
out.htmlRoomLabelHasArea = htmlLabel?.getAttribute('data-area') === 'kitchen';
|
||||
disp.show_names = wasNames; await redraw();
|
||||
|
||||
// ============ 4. проёмы ============
|
||||
c._curSpaceCfg.openings = [
|
||||
{ id: 'op_d', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.09 },
|
||||
{ id: 'op_w', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.12 },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
out.openingHookFindsBoth = qa('[data-hp="opening"]').length === 2;
|
||||
const door = q('[data-hp="opening"][data-kind="door"]');
|
||||
const win = q('[data-hp="opening"][data-kind="window"]');
|
||||
out.openingKindDoor = door?.getAttribute('data-id') === 'op_d';
|
||||
out.openingKindWindow = win?.getAttribute('data-id') === 'op_w';
|
||||
out.openingKeepsItsClass = !!door && door.classList.contains('opening');
|
||||
|
||||
// ============ 5. декор ============
|
||||
c._curSpaceCfg.decor = [
|
||||
{ id: 'dc_l', kind: 'line', x1: 0.1, y1: 0.9, x2: 0.4, y2: 0.9, color: '#123456', width: 3 },
|
||||
{ id: 'dc_r', kind: 'rect', x: 0.1, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
|
||||
{ id: 'dc_e', kind: 'ellipse', x: 0.3, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
|
||||
{ id: 'dc_t', kind: 'text', x: 0.5, y: 0.92, text: 'Подпись', color: '#123456' },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
out.decorHookFindsEveryShape = qa('[data-hp="decor"]').length === 4;
|
||||
out.decorKindLine = q('[data-hp="decor"][data-kind="line"]')?.getAttribute('data-id') === 'dc_l';
|
||||
out.decorKindRect = q('[data-hp="decor"][data-kind="rect"]')?.getAttribute('data-id') === 'dc_r';
|
||||
out.decorKindEllipse = q('[data-hp="decor"][data-kind="ellipse"]')?.getAttribute('data-id') === 'dc_e';
|
||||
out.decorKindText = q('[data-hp="decor"][data-kind="text"]')?.getAttribute('data-id') === 'dc_t';
|
||||
out.decorKeepsItsClasses = qa('[data-hp="decor"]').length === 4
|
||||
&& qa('[data-hp="decor"]').every((e) => e.classList.contains('dshape'))
|
||||
&& !!q('[data-hp="decor"][data-kind="text"]')?.classList.contains('dtext');
|
||||
// старый селектор текстовой надписи (его уже использует smoke_decor_text) цел
|
||||
out.legacyDtextSelectorStillWorks = !!q('text.dtext[data-id="dc_t"]');
|
||||
|
||||
// ============ 6. вкладки пространств ============
|
||||
const tabs = qa('[data-hp="space-tab"]');
|
||||
out.spaceTabHookFindsEveryFloor = tabs.length === c._model.length;
|
||||
out.spaceTabHasId = tabs.map((e) => e.getAttribute('data-id')).join(',') === c._model.map((s) => s.id).join(',');
|
||||
out.spaceTabKeepsItsClass = tabs.every((e) => e.classList.contains('tab'));
|
||||
// кнопка «+» вкладкой пространства не является
|
||||
out.addButtonIsNotASpaceTab = !q('.tabadd[data-hp]');
|
||||
|
||||
// ============ 7. «нет значения — нет атрибута» ============
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'm_virt', binding: 'virtual', space: c._space, name: 'Вентиль' },
|
||||
] };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const virt = q('[data-hp="device"][data-id="m_virt"]');
|
||||
out.virtualMarkerRendered = !!virt;
|
||||
out.virtualHasNoEntityAttr = !!virt && !virt.hasAttribute('data-entity');
|
||||
out.virtualHasNoUndefinedText = !!virt && virt.getAttribute('data-entity') === null;
|
||||
// комната без HA-области (подкомната): data-hp есть, data-area нет.
|
||||
// Она рисуется только когда включены границы — это её обычное условие показа.
|
||||
c._curSpaceCfg.rooms = [...c._curSpaceCfg.rooms,
|
||||
{ id: 'r_sub', name: 'Кладовка', area: null, poly: [[0.06, 0.6], [0.2, 0.6], [0.2, 0.7], [0.06, 0.7]] }];
|
||||
disp.show_borders = true;
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
const sub = q('[data-hp="room"][data-id="r_sub"]');
|
||||
out.roomWithoutAreaRendered = !!sub;
|
||||
out.roomWithoutAreaHasNoAreaAttr = !!sub && !sub.hasAttribute('data-area');
|
||||
out.stillNoUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
|
||||
|
||||
// ============ 8. ограничение shadow DOM (docs/STYLING-HOOKS.md §5) =========
|
||||
// хост ha-icon доступен селектору, его внутренности — нет: именно поэтому
|
||||
// хуки висят на НАШИХ обёртках
|
||||
const anyIcon = q('[data-hp="device"] ha-icon');
|
||||
out.iconHostReachable = !!anyIcon;
|
||||
out.iconInternalsAreBehindItsOwnRoot = !!anyIcon && !!anyIcon.shadowRoot
|
||||
&& !sr().querySelector('[data-hp="device"] ha-icon svg');
|
||||
return out;
|
||||
});
|
||||
checkAll(res, { iconInternalsAreBehindItsOwnRoot: !!res.iconInternalsAreBehindItsOwnRoot });
|
||||
|
||||
// ============ 9. те же хуки в статичной карточке ============
|
||||
const stat = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
// своя копия конфига с включёнными именами: у статичной карточки подпись
|
||||
// комнаты — это HTML-плашка, и она должна нести тот же хук
|
||||
const base = window.__card.hass;
|
||||
const cfg = JSON.parse(JSON.stringify(window.__card._serverCfg));
|
||||
cfg.spaces[0].settings = { ...(cfg.spaces[0].settings || {}), show_names: true };
|
||||
el.hass = { ...base, callWS: async (m) => (m.type === 'houseplan/config/get'
|
||||
? { config: cfg, rev: 9 } : base.callWS(m)) };
|
||||
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 rr = el.renderRoot;
|
||||
out.staticRoomsHooked = rr.querySelectorAll('[data-hp="room"]').length > 0;
|
||||
out.staticRoomFoundByArea = !!rr.querySelector('[data-hp="room"][data-area="kitchen"]');
|
||||
out.staticRoomFoundById = !!rr.querySelector('[data-hp="room"][data-id="r1"]');
|
||||
out.staticDevicesHooked = rr.querySelectorAll('[data-hp="device"]').length > 0;
|
||||
out.staticDeviceFoundByEntity = !!rr.querySelector('[data-hp="device"][data-entity="lock.front_door"]');
|
||||
out.staticNoUndefined = !rr.innerHTML.includes('="undefined"');
|
||||
out.staticRoomLabelHooked = rr.querySelectorAll('div.roomlabel[data-hp="room-label"]').length > 0;
|
||||
out.staticRoomLabelFoundByArea = !!rr.querySelector('[data-hp="room-label"][data-area="bedroom"]');
|
||||
// а вот проёмов и декора она не рисует вовсе (§5) — и это не регрессия
|
||||
out.staticDrawsNoOpenings = rr.querySelectorAll('[data-hp="opening"]').length === 0;
|
||||
out.staticDrawsNoDecor = rr.querySelectorAll('[data-hp="decor"]').length === 0;
|
||||
return out;
|
||||
});
|
||||
checkAll(stat);
|
||||
await finish(browser, { ...res, ...stat });
|
||||
@@ -0,0 +1,347 @@
|
||||
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
|
||||
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
|
||||
// day/night background vs static, per-space inheritance, cloud fading,
|
||||
// memoisation across hass ticks, editors stay clean.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = 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 setSun = async (azimuth, elevation) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation },
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const setWeather = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'weather.home': {
|
||||
entity_id: 'weather.home', state, attributes: {},
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const cfg = () => c._serverCfg;
|
||||
// DEV-B701-01: local mutations bump _cfgEpoch (what _saveConfig() does
|
||||
// synchronously). The old helper bumped _cfgRev — a server ack that the
|
||||
// production save path does NOT produce until the debounced write lands —
|
||||
// and thereby masked the stale sun-ray cache.
|
||||
const touchCfg = () => { c._cfgEpoch++; };
|
||||
const litIds = () => (c._sunRaysCache?.rays || []).map((r) => r.openingId).sort();
|
||||
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
const stageStyle = () => sr().querySelector('.stage').getAttribute('style') || '';
|
||||
|
||||
// ---- the test plan: windows on all four OUTER walls + one interior ----
|
||||
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 }, // top of r1
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 }, // right of r3
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 }, // bottom of r4
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }, // left of r1
|
||||
{ id: 'wI', type: 'window', x: 0.55, y: 0.30, angle: 90, length: 0.08 }, // r1|r2 wall
|
||||
{ id: 'dS', type: 'door', x: 0.20, y: 0.86, angle: 0, length: 0.1 }, // doors never glow
|
||||
];
|
||||
touchCfg();
|
||||
|
||||
// 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
|
||||
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.silentWithoutNorth = domPolys().length === 0;
|
||||
out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
|
||||
|
||||
// 2) compass set → morning east sun lights ONLY the east window
|
||||
cfg().settings.north_deg = 0;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.morningEast = JSON.stringify(litIds()) === '["wE"]';
|
||||
out.morningDomDrawn = domPolys().length > 0;
|
||||
|
||||
// 3) noon south → only the south window, wedge shorter than the morning one
|
||||
const eastLen = c._sunRaysCache?.rays?.[0]?.len ?? 0;
|
||||
await setSun(180, 60);
|
||||
out.noonSouth = JSON.stringify(litIds()) === '["wS"]';
|
||||
out.noonShorter = (c._sunRaysCache?.rays?.[0]?.len ?? 1e9) < eastLen;
|
||||
|
||||
// 4) evening west → only the west window; wedge stays inside its room r1
|
||||
await setSun(270, 4);
|
||||
out.eveningWest = JSON.stringify(litIds()) === '["wW"]';
|
||||
const ray = c._sunRaysCache?.rays?.[0] || { polys: [] };
|
||||
out.wedgeClippedToRoom = ray.polys.length > 0 && ray.polys.every((poly) => poly.every(([x, y]) =>
|
||||
x >= 40 - 0.5 && x <= 550 + 0.5 && y >= 140 - 0.5 && y <= 580 + 0.5));
|
||||
|
||||
// 5) night → nothing
|
||||
await setSun(90, -10);
|
||||
out.nightEmpty = domPolys().length === 0;
|
||||
|
||||
// 6) rotated compass: the same morning east sun now lights the NORTH window
|
||||
cfg().settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
|
||||
cfg().settings.north_deg = 0;
|
||||
touchCfg();
|
||||
|
||||
// 7) memoisation: an unrelated hass tick must NOT recompute the wedges
|
||||
await setSun(90, 5);
|
||||
const cacheBefore = c._sunRaysCache;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; // plain tick, sun unchanged
|
||||
await upd();
|
||||
out.memoSurvivesTick = c._sunRaysCache === cacheBefore;
|
||||
await setSun(91, 5);
|
||||
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
|
||||
|
||||
// 8) day/night background: daynight breathes, static does not
|
||||
await setSun(180, 60);
|
||||
out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
await setSun(180, 60);
|
||||
out.daynightNoonBg = stageStyle().includes('background:#ffffff');
|
||||
out.daynightClass = !!sr().querySelector('.stage.daynight');
|
||||
await setSun(180, -20);
|
||||
out.daynightNightBg = stageStyle().includes('background:#070c14');
|
||||
out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
cfg().settings.bg_mode = 'static';
|
||||
touchCfg();
|
||||
await setSun(180, -20);
|
||||
out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
|
||||
delete cfg().settings.bg_mode;
|
||||
touchCfg();
|
||||
|
||||
// 9) per-space inheritance: the space override wins over the global flag
|
||||
sp.settings = { ...(sp.settings || {}), sun_rays: false };
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.spaceOverridesOff = domPolys().length === 0;
|
||||
delete sp.settings.sun_rays;
|
||||
// and a per-space compass override redirects the light
|
||||
sp.settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
|
||||
delete sp.settings.north_deg;
|
||||
touchCfg();
|
||||
|
||||
// 10) clouds: rain kills the wedges, cloudy only fades them
|
||||
cfg().settings.weather_entity = 'weather.home';
|
||||
touchCfg();
|
||||
await setWeather('pouring');
|
||||
await setSun(90, 5);
|
||||
out.rainKillsRays = domPolys().length === 0;
|
||||
await setWeather('cloudy');
|
||||
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|
||||
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.3 * 0.4) < 1e-6;
|
||||
await setWeather('sunny');
|
||||
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
|
||||
delete cfg().settings.weather_entity;
|
||||
touchCfg();
|
||||
|
||||
// 11) feature off: without the flag nothing is drawn even with sun+north
|
||||
delete cfg().settings.sun_rays;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.offDrawsNothing = domPolys().length === 0;
|
||||
cfg().settings.sun_rays = true;
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
|
||||
// 12) editors stay clean: no wedges, no day/night canvas
|
||||
await setSun(90, 5);
|
||||
c._setMode('plan');
|
||||
await upd();
|
||||
out.editorNoRays = domPolys().length === 0;
|
||||
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
out.viewRaysBack = domPolys().length > 0;
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
// 13) the general-settings dialog: a REAL drag on the compass dial sets degrees
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._openSettingsDialog();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
|
||||
});
|
||||
const box = await page.evaluate(() => {
|
||||
const r = window.__card.shadowRoot.querySelector('.compass').getBoundingClientRect();
|
||||
return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
|
||||
});
|
||||
await page.mouse.move(box.cx + 45, box.cy); // on the ring, due right = 90°
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.cx, box.cy + 45); // drag on to due down = 180°
|
||||
await page.mouse.up();
|
||||
const dlg = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot;
|
||||
const out = {
|
||||
compassDragSets180: c._settingsDialog?.northDeg === 180,
|
||||
dialogHasModeSelect: !!sr.querySelector('hp-dialog select.areasel'),
|
||||
dialogHasRaysToggle: [...sr.querySelectorAll('hp-dialog .srcrow input[type=checkbox]')].length > 0,
|
||||
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
|
||||
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
|
||||
};
|
||||
c._settingsDialog = null;
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, dlg);
|
||||
|
||||
// 14) DEV-B701-01 regression: the PRODUCTION local-save path (no touchCfg, no
|
||||
// fake rev/epoch bump from the test) must move the wedge. A REAL pointer drag
|
||||
// of the east window ends in _opPointerUp -> _saveConfig(); the WS write is
|
||||
// debounced 500 ms, so at check time _cfgRev is still the boot revision — the
|
||||
// ray must follow the window anyway, and again after the late server ack.
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const svgEl = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svgEl.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const rayInfo = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const rays = c._sunRaysCache?.rays || [];
|
||||
const r = rays.find((q) => q.openingId === 'wE');
|
||||
return {
|
||||
ids: rays.map((q) => q.openingId).sort(),
|
||||
midY: r ? (r.a[1] + r.b[1]) / 2 : NaN,
|
||||
minX: r ? Math.min(...r.polys.flat().map((p) => p[0])) : NaN,
|
||||
rev: c._cfgRev,
|
||||
wEy: c._serverCfg.spaces.find((s) => s.id === 'f1').openings.find((o) => o.id === 'wE').y,
|
||||
};
|
||||
});
|
||||
|
||||
// baseline: morning east sun, only wE glows, window sits at y = 600
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 90, elevation: 5 },
|
||||
} } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
});
|
||||
const base = await rayInfo();
|
||||
check('b701_baseline_east_only', base.ids, ['wE']);
|
||||
check('b701_baseline_mid_600', Math.abs(base.midY - 600) < 1, true);
|
||||
|
||||
// the real drag: enter the opening editor and pull wE down the east wall
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(220); // stage coordinates settle with editor chrome
|
||||
await settle();
|
||||
const [dx, dy] = await screenPt(960, 600);
|
||||
const [, dty] = await screenPt(960, 700);
|
||||
await page.mouse.move(dx, dy);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(dx, dty, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
|
||||
// leave the editor IMMEDIATELY — well inside the 500 ms debounce window
|
||||
const after = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('view');
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return true;
|
||||
});
|
||||
check('b701_left_editor', after);
|
||||
const moved = await rayInfo();
|
||||
check('b701_window_really_moved', Math.abs(moved.wEy * 1000 - 700) < 15, true);
|
||||
check('b701_rev_still_boot', moved.rev, base.rev); // the write has NOT landed yet
|
||||
check('b701_ray_follows_window', Math.abs(moved.midY - moved.wEy * 1000) < 1, true);
|
||||
check('b701_ray_not_stale', Math.abs(moved.midY - 600) > 50, true);
|
||||
|
||||
// same defect class for ROOM geometry: r3 shrinks to a thin strip along its
|
||||
// east wall through the real mutation endpoint (_saveConfig, still no ack).
|
||||
// The wedge reaches ~188 units into the room (minX ~772), so the new west
|
||||
// boundary at x = 900 MUST cut it — a stale clip keeps points near x = 772.
|
||||
const clipped = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.rooms.find((r) => r.id === 'r3').poly = [[0.90, 0.46], [0.96, 0.46], [0.96, 0.86], [0.90, 0.86]];
|
||||
c._saveConfig(); // the one true local-save entry point
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return true;
|
||||
});
|
||||
check('b701_room_mutated', clipped);
|
||||
const reclipped = await rayInfo();
|
||||
check('b701_room_rev_still_boot', reclipped.rev, base.rev);
|
||||
check('b701_room_reclips_ray', reclipped.minX >= 900 - 1, true);
|
||||
|
||||
// let the debounced write land: the ray must survive the late server ack
|
||||
await page.waitForTimeout(800);
|
||||
const acked = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return true;
|
||||
});
|
||||
check('b701_acked', acked);
|
||||
const final = await rayInfo();
|
||||
check('b701_write_landed', final.rev !== base.rev, true);
|
||||
check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true);
|
||||
|
||||
// 15) the 3° threshold + the 2 s dissolve (owner 2026-08-03). Runs LAST and in
|
||||
// its own evaluate: it deliberately waits out a two-second fade, and the b701
|
||||
// regression above measures a debounced config write against wall-clock time.
|
||||
const thr = 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 setSun = async (azimuth, elevation) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation },
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
const layer = () => sr().querySelector('.sunlayer');
|
||||
const stopAlpha = () => Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
|
||||
// the old build ramped the alpha in over the first ~2°; now the layer is
|
||||
// either absent or at FULL strength, and crossing 3° fades the whole LAYER
|
||||
// (never the geometry) in or out over exactly two seconds
|
||||
await setSun(90, 10);
|
||||
out.aboveThresholdDrawn = domPolys().length > 0 && !layer().classList.contains('out');
|
||||
{
|
||||
const cs = getComputedStyle(layer());
|
||||
out.fadeInTakesTwoSeconds = cs.animationName === 'hp-sunfade-in' && cs.animationDuration === '2s';
|
||||
}
|
||||
out.fullAlphaWellAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
|
||||
await setSun(90, 3.2);
|
||||
out.fullAlphaJustAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
|
||||
|
||||
// below 3°: the layer stays for exactly the dissolve, then leaves the DOM
|
||||
await setSun(90, 2);
|
||||
{
|
||||
const dying = layer();
|
||||
const cs = dying && getComputedStyle(dying);
|
||||
out.belowThresholdDissolves = !!dying && dying.classList.contains('out')
|
||||
&& cs.animationName === 'hp-sunfade-out' && cs.animationDuration === '2s';
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2300));
|
||||
await upd();
|
||||
out.belowThresholdGoneAfterFade = !layer() && domPolys().length === 0;
|
||||
|
||||
// a sun that never crossed the threshold draws nothing at all — no ghost
|
||||
await setSun(90, 1);
|
||||
out.coldStartBelowThresholdEmpty = domPolys().length === 0 && !layer();
|
||||
await setSun(90, 5);
|
||||
out.backAboveThreshold = domPolys().length > 0 && !layer().classList.contains('out');
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, thr);
|
||||
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,71 @@
|
||||
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
|
||||
// после обновления страницы». The day/night sky must follow sun.sun on a plain
|
||||
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
|
||||
// background of the stage (what the eye sees, not the style attribute) must
|
||||
// actually arrive at the sky colour (docs/SUN.md).
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
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 cfg = c._serverCfg;
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
|
||||
cfg.spaces.find((s) => s.id === 'f1').openings = [
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
c._cfgEpoch++;
|
||||
// NO requestUpdate anywhere below: production only ever assigns `hass`.
|
||||
const setSun = async (az, el) => {
|
||||
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 },
|
||||
} } };
|
||||
await c.updateComplete;
|
||||
// one paint so the sky lands on screen, not only in the attribute
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
};
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const comp = () => getComputedStyle(stage()).backgroundColor;
|
||||
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
|
||||
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
|
||||
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
|
||||
|
||||
await setSun(180, 40);
|
||||
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
|
||||
out.dayNoDim = dim().includes('brightness(1.000)');
|
||||
out.dayRays = polys() > 0;
|
||||
|
||||
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
|
||||
await setSun(180, -10);
|
||||
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
|
||||
out.nightDimmed = dim().includes('brightness(0.900');
|
||||
out.nightNoRays = polys() === 0;
|
||||
|
||||
// ...and back, again on a plain tick
|
||||
await setSun(180, 40);
|
||||
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
|
||||
out.backToDayRays = polys() > 0;
|
||||
|
||||
// a small step still moves the sky (0.1° granularity of the model)...
|
||||
await setSun(180, 20);
|
||||
const twenty = comp();
|
||||
await setSun(180, 12);
|
||||
out.smallStepMovesSky = comp() !== twenty;
|
||||
|
||||
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
|
||||
// GLIDE: the catch-up jump is for a card that was not watching, never for the
|
||||
// day/night breathing itself (docs/SUN.md).
|
||||
await setSun(180, 12.6);
|
||||
const target = stage().getAttribute('style').split('background:')[1];
|
||||
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
|
||||
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
|
||||
|
||||
// the tab came back from the background: the sky catches up at once
|
||||
await setSun(180, 25); // 12° of sun happened while we were not painting
|
||||
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
|
||||
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,243 @@
|
||||
// THE RIM (owner 2026-08-04): «тонкая (1px) чёрная граница по бокам
|
||||
// светящегося сектора, которая также плавно уходит в ноль вместе с самим
|
||||
// градиентом». The owner rejected the «shade instead of light» model of
|
||||
// legacy/docs/SUN-CONTRAST.md and asked for this one line instead — because a wedge
|
||||
// of added luminance simply cannot read on white paper, while its BOUNDARY
|
||||
// can (docs/SUN.md, «The rim»).
|
||||
//
|
||||
// Everything this smoke pins down:
|
||||
// * exactly TWO rim lines per wedge, on the two SIDE edges — the ones that
|
||||
// run from the ends of the glass along the ray. Never the glass edge,
|
||||
// never the far edge; the coordinates are checked against the wedge's own
|
||||
// vertices, so a rim drawn around the whole polygon fails here;
|
||||
// * the stroke is `url(#hp-sunrim-N)`, a gradient with BLACK stops on the
|
||||
// SAME axis and the SAME offsets as the fill's `hp-sun-N`, monotone,
|
||||
// dead from 85 % on — the rim cannot outlive the light it outlines;
|
||||
// * `vector-effect: non-scaling-stroke` at width 1 — one screen pixel at
|
||||
// any zoom;
|
||||
// * no rim below the 3° threshold (after the 2 s layer fade) and none in an
|
||||
// editor;
|
||||
// * and the point of the whole change: on WHITE paper the pixels along the
|
||||
// wedge's side are measurably darker than the paper on either hand.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // no 2 s layer fade in the way
|
||||
|
||||
const RAY_FADE_MS = 2000;
|
||||
|
||||
const res = await page.evaluate(async (fadeMs) => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
// r1 is [[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]] — a west window
|
||||
// at y = 0.30 casts a wedge that stays well inside it, so the unclipped
|
||||
// side edges are the whole story; the second window is deliberately on a
|
||||
// wall the same sun cannot reach, to prove no rim appears without a wedge.
|
||||
sp.openings = [
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, 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 white paper under the wedge
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
|
||||
c._cfgEpoch++;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
|
||||
await upd();
|
||||
};
|
||||
const rimsOf = () => [...sr().querySelectorAll('.sunlayer line.sunrim')];
|
||||
const gradOf = (id) => sr().querySelector('linearGradient[id="' + id + '"]');
|
||||
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => ({
|
||||
off: parseFloat(s.getAttribute('offset')),
|
||||
color: s.getAttribute('stop-color'),
|
||||
a: Number(s.getAttribute('stop-opacity')),
|
||||
}));
|
||||
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
|
||||
|
||||
await setSun(270, 60); // a western sun, square into the west window
|
||||
const rays = c._sunRaysCache.rays;
|
||||
out.oneWedgeDrawn = rays.length === 1;
|
||||
out.wedgeIsUnclipped = rays.length === 1 && rays[0].polys.length === 1
|
||||
&& rays[0].polys[0].length === 4;
|
||||
|
||||
// ---- 1) exactly two rim lines, on the two SIDE edges -------------------
|
||||
const rims = rimsOf();
|
||||
out.rimDrawn = rims.length > 0;
|
||||
out.twoRimsPerWedge = rims.length === rays.length * 2;
|
||||
out.rimStrokeIsAGradient = rims.length > 0
|
||||
&& rims.every((l) => /^url\(#hp-sunrim-\d+\)$/.test(l.getAttribute('stroke') || ''));
|
||||
out.rimIsOnePixelAtAnyZoom = rims.length > 0 && rims.every((l) =>
|
||||
l.getAttribute('vector-effect') === 'non-scaling-stroke'
|
||||
&& Number(l.getAttribute('stroke-width')) === 1);
|
||||
|
||||
// every rim line must BE a side edge: its two ends are vertices of the
|
||||
// wedge polygon, it runs along `dir`, and it starts at an end of the glass
|
||||
out.rimSidesMatchTheWedge = true;
|
||||
out.rimIsNeverTheGlassOrTheFarEdge = true;
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
const r = rays[i];
|
||||
const mine = rims.filter((l) => l.getAttribute('stroke') === `url(#hp-sunrim-${i})`);
|
||||
if (mine.length !== 2) { out.rimSidesMatchTheWedge = false; continue; }
|
||||
const verts = r.polys.flat();
|
||||
const isVertex = (p) => verts.some((v) => near(v[0], p[0], 1e-3) && near(v[1], p[1], 1e-3));
|
||||
const ends = mine.map((l) => [
|
||||
[+l.getAttribute('x1'), +l.getAttribute('y1')],
|
||||
[+l.getAttribute('x2'), +l.getAttribute('y2')],
|
||||
]);
|
||||
// the four expected corners: the glass ends and where their rays stop
|
||||
const far = (s) => [s[0] + r.dir[0] * r.len, s[1] + r.dir[1] * r.len];
|
||||
const want = [[r.a, far(r.a)], [r.b, far(r.b)]];
|
||||
for (const [src, tip] of want) {
|
||||
const hit = ends.some(([p, q]) =>
|
||||
(near(p[0], src[0], 1e-3) && near(p[1], src[1], 1e-3)
|
||||
&& near(q[0], tip[0], 1e-3) && near(q[1], tip[1], 1e-3))
|
||||
|| (near(q[0], src[0], 1e-3) && near(q[1], src[1], 1e-3)
|
||||
&& near(p[0], tip[0], 1e-3) && near(p[1], tip[1], 1e-3)));
|
||||
if (!hit) out.rimSidesMatchTheWedge = false;
|
||||
}
|
||||
for (const [p, q] of ends) {
|
||||
if (!isVertex(p) || !isVertex(q)) out.rimSidesMatchTheWedge = false;
|
||||
// parallel to the ray (the glass edge and the far edge are not)
|
||||
const dx = q[0] - p[0], dy = q[1] - p[1];
|
||||
const L = Math.hypot(dx, dy) || 1;
|
||||
if (Math.abs((dx / L) * r.dir[1] - (dy / L) * r.dir[0]) > 1e-6)
|
||||
out.rimIsNeverTheGlassOrTheFarEdge = false;
|
||||
// and it must not be the glass: both ends on the a-b line would mean it
|
||||
const onGlass = (z) => Math.abs((z[0] - r.a[0]) * r.dir[0] + (z[1] - r.a[1]) * r.dir[1]) < 1e-6;
|
||||
if (onGlass(p) && onGlass(q)) out.rimIsNeverTheGlassOrTheFarEdge = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2) the rim gradient: same axis, same curve, black ------------------
|
||||
const fills = [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
|
||||
out.rimGradientBlack = true;
|
||||
out.rimGradientSharesTheAxis = true;
|
||||
out.rimGradientSharesTheCurve = true;
|
||||
out.rimGradientMonotone = true;
|
||||
out.rimGradientDeadAt85 = true;
|
||||
out.rimGradientBrightAtTheGlass = true;
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
const rim = gradOf(`hp-sunrim-${i}`);
|
||||
const fill = fills[i];
|
||||
if (!rim || !fill) { out.rimGradientBlack = false; continue; }
|
||||
for (const k of ['x1', 'y1', 'x2', 'y2']) {
|
||||
if (!near(+rim.getAttribute(k), +fill.getAttribute(k), 1e-9)) out.rimGradientSharesTheAxis = false;
|
||||
}
|
||||
const rs = stopsOf(rim);
|
||||
const fs = stopsOf(fill);
|
||||
if (rs.length !== fs.length) out.rimGradientSharesTheCurve = false;
|
||||
const peak = rs[0].a;
|
||||
rs.forEach((s, j) => {
|
||||
const black = /^#0{3,8}$/i.test(s.color || '') || /rgb\(0,\s*0,\s*0\)/.test(s.color || '');
|
||||
if (!black) out.rimGradientBlack = false;
|
||||
if (!fs[j] || !near(s.off, fs[j].off, 1e-9)) out.rimGradientSharesTheCurve = false;
|
||||
// the same normalised curve as the fill, only scaled to its own peak
|
||||
if (fs[j] && !near(s.a / (peak || 1), fs[j].a / (fs[0].a || 1), 1e-3))
|
||||
out.rimGradientSharesTheCurve = false;
|
||||
if (j > 0 && s.a > rs[j - 1].a + 1e-9) out.rimGradientMonotone = false;
|
||||
if (s.off >= 85 - 1e-9 && s.a !== 0) out.rimGradientDeadAt85 = false;
|
||||
if (s.off < 85 - 1e-9 && !(s.a > 0)) out.rimGradientDeadAt85 = false;
|
||||
});
|
||||
if (!(peak >= 0.3 && peak <= 0.55)) out.rimGradientBrightAtTheGlass = false;
|
||||
}
|
||||
|
||||
// ---- 3) screen coords for the pixel probe, off the real geometry --------
|
||||
// sample ACROSS the a-side edge at 22 % of the reach, where both the fill
|
||||
// and the rim are still strong
|
||||
const svgEl = sr().querySelector('.sunlayer')?.ownerSVGElement || sr().querySelector('.stage svg');
|
||||
const ctm = svgEl.getScreenCTM();
|
||||
const toScr = (x, y) => {
|
||||
const q = svgEl.createSVGPoint(); q.x = x; q.y = y;
|
||||
const p = q.matrixTransform(ctm); return [p.x, p.y];
|
||||
};
|
||||
if (rays.length) {
|
||||
const r = rays[0];
|
||||
const u = 0.22 * r.len;
|
||||
const on = toScr(r.a[0] + r.dir[0] * u, r.a[1] + r.dir[1] * u);
|
||||
// one render unit INTO the wedge (toward b) → the screen-space normal
|
||||
const inw = [r.b[0] - r.a[0], r.b[1] - r.a[1]];
|
||||
const iL = Math.hypot(inw[0], inw[1]) || 1;
|
||||
const q = toScr(r.a[0] + r.dir[0] * u + (inw[0] / iL) * 10, r.a[1] + r.dir[1] * u + (inw[1] / iL) * 10);
|
||||
const vx = q[0] - on[0], vy = q[1] - on[1];
|
||||
const vL = Math.hypot(vx, vy) || 1;
|
||||
out.probe = { on, perp: [vx / vL, vy / vL], dpr: window.devicePixelRatio };
|
||||
}
|
||||
|
||||
// ---- 4) below the threshold and in an editor there is no rim -----------
|
||||
await setSun(270, 2); // under RAY_ELEVATION_MIN
|
||||
await new Promise((rs) => setTimeout(rs, fadeMs + 300)); // let the layer leave
|
||||
await upd();
|
||||
out.noRimBelowThreshold = rimsOf().length === 0;
|
||||
out.noWedgeBelowThreshold = sr().querySelectorAll('.sunlayer polygon').length === 0;
|
||||
|
||||
await setSun(270, 60);
|
||||
out.rimIsBackAboveThreshold = rimsOf().length === 2;
|
||||
c._mode = 'plan';
|
||||
await upd();
|
||||
out.noRimInTheEditor = rimsOf().length === 0;
|
||||
c._mode = 'view';
|
||||
await upd();
|
||||
out.rimIsBackInViewMode = rimsOf().length === 2;
|
||||
|
||||
// ---- 5) a fully overcast sky takes the rim with the light --------------
|
||||
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.noRimInTheRain = rimsOf().length === 0;
|
||||
delete cfg.settings.weather_entity;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.rimIsBackInTheSun = rimsOf().length === 2;
|
||||
return out;
|
||||
}, RAY_FADE_MS);
|
||||
|
||||
// ---- 6) the whole point: on WHITE paper the side of the wedge is DARK ----
|
||||
const probe = res.probe;
|
||||
delete res.probe;
|
||||
if (probe) {
|
||||
const shot = (await page.screenshot()).toString('base64');
|
||||
const px = await page.evaluate(async ({ shot, probe }) => {
|
||||
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 d = probe.dpr || 1;
|
||||
const lum = (t) => {
|
||||
const x = Math.round((probe.on[0] + probe.perp[0] * t) * d);
|
||||
const y = Math.round((probe.on[1] + probe.perp[1] * t) * d);
|
||||
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
};
|
||||
// t > 0 goes INTO the wedge, t < 0 onto the untouched paper beside it
|
||||
const band = [];
|
||||
for (let t = -2.5; t <= 2.5; t += 0.25) band.push(lum(t));
|
||||
const darkest = Math.min(...band);
|
||||
const inside = Math.min(lum(7), lum(9), lum(11)); // lit floor, still bright
|
||||
const outside = Math.min(lum(-7), lum(-9), lum(-11)); // plain white paper
|
||||
return {
|
||||
paperIsLight: outside > 200,
|
||||
wedgeInteriorIsLight: inside > 200,
|
||||
rimIsDarkOnTheEdge: darkest < Math.min(inside, outside) - 25,
|
||||
rimProfile: [Math.round(darkest), Math.round(inside), Math.round(outside)],
|
||||
};
|
||||
}, { shot, probe });
|
||||
res.rimProfile = px.rimProfile;
|
||||
delete px.rimProfile;
|
||||
Object.assign(res, px);
|
||||
} else {
|
||||
res.paperIsLight = false;
|
||||
res.wedgeInteriorIsLight = false;
|
||||
res.rimIsDarkOnTheEdge = false;
|
||||
}
|
||||
const profile = res.rimProfile;
|
||||
delete res.rimProfile;
|
||||
console.log('rim/inside/outside luminance:', JSON.stringify(profile));
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,216 @@
|
||||
// Owner 2026-08-04, on the first attempt: «с лучами солнца ты сделал фигню —
|
||||
// не надо размывать их боковые грани». A shaft of sunlight has HARD sides; it
|
||||
// fades only with distance, along the ray, from the glass inward. So:
|
||||
// * the reach is still 70 % of the v1.56 curve;
|
||||
// * the gradient still spans the FULL wedge and is dead from RAY_FADE_END;
|
||||
// * the sides are SHARP — no blur filter on the wedge, none defined at all;
|
||||
// * and because the sides are sharp, the wedge must end ON an iso-alpha line
|
||||
// of that gradient: nothing is ever drawn past its end, so the old bright
|
||||
// kerb (a far edge parallel to the wall, cut while still lit) cannot come
|
||||
// back at an oblique sun.
|
||||
// DEV-EB173-01 turned the last of those into a contract of its own: the fade
|
||||
// runs along the wall's INWARD NORMAL, not along the ray. For parallel rays
|
||||
// the distance travelled from the glass is an affine function of the point, so
|
||||
// its iso-alpha lines are parallel to the WALL — which is where an honest
|
||||
// parallelogram puts its far edge. All three invariants then hold at once:
|
||||
// peak alpha across the whole pane, the same fade distance along every ray,
|
||||
// and a far edge exactly on the gradient's end. The auditor's own grazing
|
||||
// repro is re-run below with his numbers.
|
||||
// The "light never crosses a wall" clip is asserted in demo/smoke_sun.mjs
|
||||
// (wedgeClippedToRoom) — the polygons arrive from computeSunRays() already
|
||||
// intersected with the room, which is why no clip-path is needed here.
|
||||
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 cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
|
||||
c._cfgEpoch++;
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
};
|
||||
const grads = () => [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
|
||||
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => [
|
||||
parseFloat(s.getAttribute('offset')), Number(s.getAttribute('stop-opacity'))]);
|
||||
// everything below is measured off the DOM gradient, exactly like the audit
|
||||
// probe: axis, the offset a point lands on, and the alpha there
|
||||
const axisOf = (g) => {
|
||||
const x1 = +g.getAttribute('x1'), y1 = +g.getAttribute('y1');
|
||||
const x2 = +g.getAttribute('x2'), y2 = +g.getAttribute('y2');
|
||||
const len = Math.hypot(x2 - x1, y2 - y1);
|
||||
return { x1, y1, dx: x2 - x1, dy: y2 - y1, len, ux: (x2 - x1) / len, uy: (y2 - y1) / len };
|
||||
};
|
||||
const offsetOf = (g, p) => {
|
||||
const a = axisOf(g);
|
||||
return ((p[0] - a.x1) * a.dx + (p[1] - a.y1) * a.dy) / (a.len * a.len);
|
||||
};
|
||||
// a bundle without the normal-axis fade must FAIL these by name, not blow up
|
||||
const nrm = (r) => r.normal || [NaN, NaN];
|
||||
const dep = (r) => (r.depth === undefined ? NaN : r.depth);
|
||||
const alphaAt = (g, off) => {
|
||||
const st = stopsOf(g).map(([o, a]) => [o / 100, a]);
|
||||
if (off <= st[0][0]) return st[0][1];
|
||||
for (let i = 1; i < st.length; i++) {
|
||||
if (off <= st[i][0]) {
|
||||
const t = (off - st[i - 1][0]) / (st[i][0] - st[i - 1][0] || 1);
|
||||
return st[i - 1][1] + t * (st[i][1] - st[i - 1][1]);
|
||||
}
|
||||
}
|
||||
return st[st.length - 1][1];
|
||||
};
|
||||
|
||||
// ---- 1) 30 % shorter: the wedge reach in window lengths ----------------
|
||||
await setSun(270, 5); // low western sun into the west window
|
||||
const oldK = (e) => 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
|
||||
const winLen = 0.08 * 1000;
|
||||
const low = c._sunRaysCache.rays[0].len;
|
||||
out.lowSunIs70Percent = Math.abs(low - oldK(5) * 0.7 * winLen) < 1e-6;
|
||||
await setSun(180, 60);
|
||||
const high = c._sunRaysCache.rays[0].len;
|
||||
out.highSunIs70Percent = Math.abs(high - oldK(60) * 0.7 * winLen) < 1e-6;
|
||||
out.lowStillReachesFurther = low > high;
|
||||
|
||||
// ---- 2) the shaft always dissolves BEFORE its own far edge -------------
|
||||
await setSun(270, 5);
|
||||
const gs = grads();
|
||||
out.gradientsDrawn = gs.length > 0;
|
||||
// the axis is the wall's inward normal, `len · cos(incidence)` long — the
|
||||
// perpendicular depth a ray reaches after running the FULL wedge length
|
||||
out.gradientRunsAlongTheWallNormal = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
const a = axisOf(g);
|
||||
return Math.abs(a.ux - nrm(r)[0]) < 1e-6 && Math.abs(a.uy - nrm(r)[1]) < 1e-6;
|
||||
});
|
||||
out.gradientSpansWholeWedge = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
|
||||
return Math.abs(axisOf(g).len - r.len * cos) < 1e-6
|
||||
&& Math.abs(dep(r) - r.len * cos) < 1e-9
|
||||
&& stopsOf(g).length > 2;
|
||||
});
|
||||
// the ONLY thing that matters about that axis: a point `source + dir·u`
|
||||
// lands on offset `u / len`, whichever ray it rode in on
|
||||
out.offsetIsDistanceAlongTheRay = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
return [0, 0.3, 0.85, 1].every((u) => [r.a, r.b].every((src) => {
|
||||
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
|
||||
return Math.abs(offsetOf(g, p) - u) < 1e-6;
|
||||
}));
|
||||
});
|
||||
out.deadWellBeforeTheEnd = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
const firstZero = st.find(([, a]) => a === 0);
|
||||
return !!firstZero && firstZero[0] <= 85.001; // %
|
||||
});
|
||||
out.lastStopIsZero = gs.every((g) => stopsOf(g).slice(-1)[0][1] === 0);
|
||||
out.brightAtTheGlass = gs.every((g) => stopsOf(g)[0][1] > 0.2);
|
||||
out.neverBrightensInward = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
return st.every(([, a], i) => i === 0 || a <= st[i - 1][1] + 1e-9);
|
||||
});
|
||||
|
||||
// ---- 3) SHARP sides: no blur on the wedge, and none defined anywhere ----
|
||||
const wedges = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
// walk the polygon and its ancestors up to and including .sunlayer — the
|
||||
// day/night `brightness` filter lives further up, on the zoomwrap, and is
|
||||
// none of this test's business
|
||||
const blurredChain = (el) => {
|
||||
for (let n = el; n; n = n.parentElement) {
|
||||
const attr = n.getAttribute && n.getAttribute('filter');
|
||||
if (attr && attr !== 'none') return true;
|
||||
const cs = getComputedStyle(n).filter;
|
||||
if (cs && cs !== 'none' && cs !== '') return true;
|
||||
if (n.classList && n.classList.contains('sunlayer')) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
out.wedgesDrawn = wedges().length > 0;
|
||||
out.everyWedgeHasSharpSides = wedges().length > 0 && wedges().every((p) => !blurredChain(p));
|
||||
out.noSoftFilterDefined = sr().querySelectorAll('filter[id^=hp-sunsoft-]').length === 0;
|
||||
out.noGaussianBlurAtAll = sr().querySelectorAll('feGaussianBlur').length === 0;
|
||||
|
||||
// ---- 4) an OBLIQUE sun: the shaft still dies of its gradient -----------
|
||||
// The sides are hard again, so the only thing that may end the wedge is the
|
||||
// gradient. That holds ONLY if the far edge is square to the RAY: with the
|
||||
// old wall-parallel edge one far corner sat at offset ~0.7 (low sun) or
|
||||
// ~0.11 (high sun) — i.e. still lit — which is exactly the bright kerb.
|
||||
// offset ALONG THE GRADIENT, i.e. depth under the wall over `len · cos`
|
||||
const tOf = (r, p) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return ((p[0] - mx) * nrm(r)[0] + (p[1] - my) * nrm(r)[1]) / dep(r);
|
||||
};
|
||||
const skew = (r) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return Math.abs((r.a[0] - mx) * r.dir[0] + (r.a[1] - my) * r.dir[1]) / r.len;
|
||||
};
|
||||
out.obliqueChecked = [];
|
||||
out.sunIsReallyOblique = true;
|
||||
out.nothingDrawnPastTheGradient = true;
|
||||
for (const [az, el] of [[230, 8], [225, 55]]) {
|
||||
await setSun(az, el);
|
||||
const rays = c._sunRaysCache.rays;
|
||||
out.obliqueChecked.push(rays.length);
|
||||
if (!rays.length) { out.sunIsReallyOblique = false; continue; }
|
||||
if (!rays.some((r) => skew(r) > 1e-3)) out.sunIsReallyOblique = false;
|
||||
for (const r of rays) {
|
||||
for (const poly of r.polys) {
|
||||
for (const p of poly) if (tOf(r, p) > 1 + 1e-6) out.nothingDrawnPastTheGradient = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.obliqueSunHasWedges = out.obliqueChecked.every((n) => n > 0);
|
||||
delete out.obliqueChecked;
|
||||
|
||||
// ---- 5) DEV-EB173-01: the auditor's own grazing repro ------------------
|
||||
// West window 80 render units long, elevation 90 (nominal reach 0.56 · 80 =
|
||||
// 44.8, i.e. 70 % of the old 64), azimuth 190 — the light enters the glass
|
||||
// and travels 10° off the wall's own direction. The probe on the broken
|
||||
// build read sides 5.408 / 84.192 (ratio 15.57) and source offsets ±0.879
|
||||
// with opacity 0 at one end of the pane.
|
||||
sp.openings = [{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++;
|
||||
await setSun(190, 90);
|
||||
const gr = c._sunRaysCache.rays;
|
||||
out.grazingRayDrawn = gr.length === 1;
|
||||
if (gr.length === 1) {
|
||||
const r = gr[0];
|
||||
const g = grads()[0];
|
||||
out.grazingIsReallyGrazing = Math.abs(r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1] - 0.17365) < 1e-4;
|
||||
out.grazingLengthIs70Percent = Math.abs(r.len - 0.7 * (0.8 * 80)) < 1e-6
|
||||
&& Math.abs(r.len - 44.8) < 1e-6;
|
||||
// both sides of the shaft, measured off the DRAWN polygon: the depth of a
|
||||
// vertex divided by cos is how far its ray ran
|
||||
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
|
||||
const ran = (p) => ((p[0] - r.a[0]) * nrm(r)[0] + (p[1] - r.a[1]) * nrm(r)[1]) / cos;
|
||||
const far = r.polys[0].map(ran).filter((u) => u > 1e-6);
|
||||
out.grazingHasTwoFarCorners = far.length === 2;
|
||||
out.grazingSidesEqualWithin1Percent = far.length === 2
|
||||
&& Math.abs(far[0] - far[1]) <= 0.01 * r.len;
|
||||
out.grazingBothSidesAreTheNominalLength = far.every((u) => Math.abs(u - r.len) <= 0.01 * r.len);
|
||||
// the whole pane of glass at peak alpha (was 0 at one end)
|
||||
const peak = stopsOf(g)[0][1];
|
||||
out.grazingGlassAtOffsetZero = [r.a, r.b].every((p) => Math.abs(offsetOf(g, p)) < 1e-6);
|
||||
out.grazingGlassAtPeakAlpha = [r.a, r.b].every((p) => Math.abs(alphaAt(g, offsetOf(g, p)) - peak) < 1e-9);
|
||||
out.grazingPeakIsTheRealPeak = peak > 0.2;
|
||||
// and nothing is drawn past the gradient
|
||||
out.grazingInsideTheGradient = r.polys.every((poly) =>
|
||||
poly.every((p) => offsetOf(g, p) >= -1e-6 && offsetOf(g, p) <= 1 + 1e-6));
|
||||
}
|
||||
// a sun 2° off the wall's plane (cos 0.035 < RAY_MIN_COS) casts nothing
|
||||
await setSun(182, 90);
|
||||
out.sunAlongTheWallCastsNothing = c._sunRaysCache.rays.length === 0;
|
||||
await setSun(186, 90);
|
||||
out.sunJustClearOfTheWallStillCasts = c._sunRaysCache.rays.length === 1;
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -10,7 +10,7 @@ const res = await page.evaluate(async () => {
|
||||
// берём заведомо не-световое устройство
|
||||
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
|
||||
const sel = [...sr().querySelectorAll('hp-dialog select')].find((s) =>
|
||||
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
|
||||
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
|
||||
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
|
||||
|
||||
+11
-3
@@ -19,17 +19,25 @@ const out = await page.evaluate(async () => {
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(d); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'run' }; await c.updateComplete;
|
||||
o.pickerShown = !!sr().querySelector('.dialog .candlist');
|
||||
// ВИДИМОСТЬ, не просто наличие в DOM: список — flex-item со скроллом и
|
||||
// однажды схлопнулся в полоску 1px при 26 кандидатах внутри (репорт
|
||||
// пользователя 2026-07-30, поймано только замером высоты)
|
||||
const listEl = () => sr().querySelector('hp-dialog .candlist');
|
||||
o.pickerShown = !!listEl() && listEl().getBoundingClientRect().height > 30;
|
||||
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
|
||||
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
|
||||
const cands = [...sr().querySelectorAll('hp-dialog .cand')].map((x) => x.textContent);
|
||||
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
|
||||
// и найденная строка реально нарисована, а не сплющена
|
||||
const row = sr().querySelector('hp-dialog .cand');
|
||||
o.resultRowVisible = !!row && row.getBoundingClientRect().height > 10
|
||||
&& row.getBoundingClientRect().bottom <= listEl().getBoundingClientRect().bottom + 1;
|
||||
// сохранение без цели блокируется
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
o.saveBlockedWithoutTarget = !!c._markerDialog;
|
||||
// выбираем цель + подтверждение, сохраняем
|
||||
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
|
||||
await c.updateComplete;
|
||||
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
|
||||
o.confirmCheckboxShown = [...sr().querySelectorAll('hp-dialog .srcrow')].length >= 2;
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
|
||||
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
|
||||
|
||||
@@ -32,7 +32,7 @@ checkAll(res, {
|
||||
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
|
||||
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
|
||||
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
|
||||
"dialogTempFields": 3,
|
||||
"dialogHiddenWhenNone": 1,
|
||||
"dialogTempFields": 4, // масштаб + мин/макс + компас пространства (docs/SUN.md)
|
||||
"dialogHiddenWhenNone": 2, // мин/макс скрыты; масштаб и компас остаются
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
+11
-4
@@ -20,13 +20,19 @@ const res = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
out.tipTemp = c._tip?.temp;
|
||||
out.tipHasTempLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя температура');
|
||||
// _roomArea consumes the rendered room geometry (0..1000), not the stored
|
||||
// normalized config polygon (0..1).
|
||||
const expectedArea = c._roomArea(c._spaceModel().rooms[0]);
|
||||
out.tipHasArea = !!expectedArea && c._tip?.meta === c._t('tip.area', { value: expectedArea });
|
||||
out.tipHasAreaLine = (sr().querySelector('.tip')?.textContent || '').includes(expectedArea);
|
||||
c._tip = null;
|
||||
// диалог: радио заливки, компактные поля, ширина
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
|
||||
out.tempInputs = sr().querySelectorAll('.temprange .tempin').length;
|
||||
out.dialogWide = !!sr().querySelector('.dialog.wide .srcrow');
|
||||
out.dialogWidth = Math.round(sr().querySelector('.dialog').getBoundingClientRect().width);
|
||||
const hpDialog = sr().querySelector('hp-dialog');
|
||||
out.dialogWide = !!hpDialog?.hasAttribute('wide') && !!hpDialog.querySelector('.srcrow');
|
||||
out.dialogWidth = Math.round(hpDialog.shadowRoot.querySelector('.surface').getBoundingClientRect().width);
|
||||
// NaN-защита: пустой ввод не ломает границы
|
||||
const before = c._spaceDialog.tempMax;
|
||||
const fakeEv = { target: { value: '' } };
|
||||
@@ -39,13 +45,14 @@ const res = await page.evaluate(async () => {
|
||||
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
|
||||
// ассертов (портируемость, ревью 2026-07-27)
|
||||
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
// Geometry values were re-baselined for the shared border-box hp-dialog in
|
||||
// v1.59.2; the usable wide-dialog width remains 500 px.
|
||||
checkAll(res, {
|
||||
"filledClass": 1,
|
||||
"unfilled": 3,
|
||||
"tipTemp": 22.4,
|
||||
"fillRadios": 5,
|
||||
"tempInputs": 2,
|
||||
"dialogWidth": 502,
|
||||
"dialogWidth": 500,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Live vacuum P1 (docs/VACUUM.md): puck, trail, calibration, hidden rules.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// a vacuum device bound to space f1, dock placed by layout, plus a source
|
||||
// camera whose coords live in "robot mm": target = 0.02*x+? — we calibrate
|
||||
// via the 6-number matrix directly (0.5 scale: robot 0..2000 -> canvas 0..1000)
|
||||
const M = [0.5, 0, 0, 0, 0.5, 0];
|
||||
const mkAttrs = (x, y, extra) => ({ vacuum_position: { x, y, a: 45 }, map_name: 'm1', ...extra });
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(600, 800) },
|
||||
} };
|
||||
const cfg = c._serverCfg;
|
||||
cfg.markers = cfg.markers || [];
|
||||
cfg.markers.push({ id: 'e_vacuum_robo', binding: 'entity:vacuum.robo', space: 'f1',
|
||||
vacuum: { source: 'camera.robo_map', calibration: { m1: M } } });
|
||||
c._layout['e_vacuum_robo'] = { s: 'f1', x: 0.1, y: 0.1 };
|
||||
c._regSignature = ''; c._setMode('view'); await c.updateComplete;
|
||||
|
||||
o.dockedNoPuck = !sr().querySelector('.vacpuck');
|
||||
o.baseMarkerThere = !!sr().querySelector('.dev');
|
||||
|
||||
// start cleaning -> puck appears at transformed coords, base marker stays
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 50));
|
||||
let puck = sr().querySelector('.vacpuck');
|
||||
o.puckAppears = !!puck;
|
||||
o.puckRound = puck && getComputedStyle(puck).borderRadius === '50%';
|
||||
// owner 2026-07-31: the base badge, but round and 20% smaller — same plate
|
||||
// vs a NEUTRAL badge: the robot's own base is yellow while cleaning
|
||||
const devEl = sr().querySelector('.dev:not(.on)');
|
||||
const pcs = getComputedStyle(puck), dcs = getComputedStyle(devEl);
|
||||
o.puckPlateMatchesDev = pcs.backgroundColor === dcs.backgroundColor
|
||||
&& pcs.borderTopColor === dcs.borderTopColor;
|
||||
o.puck80pct = Math.abs(puck.getBoundingClientRect().width
|
||||
- devEl.getBoundingClientRect().width * 0.8) < 2;
|
||||
o.noWedge = !sr().querySelector('.vacwedge'); // owner 2026-07-31: no heading arrow
|
||||
// the glyph is DEAD centre (owner report: it floated on the text baseline)
|
||||
const pr = puck.getBoundingClientRect();
|
||||
const ir = sr().querySelector('.vacpuck ha-icon').getBoundingClientRect();
|
||||
o.iconCentred = Math.abs(ir.left + ir.width / 2 - (pr.left + pr.width / 2)) < 0.75
|
||||
&& Math.abs(ir.top + ir.height / 2 - (pr.top + pr.height / 2)) < 0.75;
|
||||
o.baseStaysDuringCleaning = !!sr().querySelector('.dev');
|
||||
|
||||
// drive: two more points -> trail polyline grows; puck moves
|
||||
const p1 = puck.getBoundingClientRect();
|
||||
for (const [x, y] of [[900, 800], [900, 1100]]) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(x, y) } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
const trail = sr().querySelector('.vactrail polyline');
|
||||
// the trail LAGS one point behind the puck: after 3 telemetry points only
|
||||
// the first two are drawn — a segment never outruns the icon (owner)
|
||||
o.trailDrawn = !!trail && trail.getAttribute('points').split(' ').length === 2;
|
||||
o.trailScaledByMatrix = !!trail && trail.getAttribute('points').startsWith('300.0,400.0');
|
||||
o.trailBehindPuck = !!trail && !trail.getAttribute('points').includes('450.0,550.0');
|
||||
// a zoom/view change TELEPORTS the puck instead of gliding across the plan
|
||||
c._applyView(1.35); c.requestUpdate(); await c.updateComplete;
|
||||
o.zoomTeleports = sr().querySelector('.vacpuck').classList.contains('jump');
|
||||
c._applyView(1); c.requestUpdate(); await c.updateComplete;
|
||||
// casing pair: dark halo + light core with identical geometry — the trail
|
||||
// must survive any room fill underneath (owner 2026-07-31)
|
||||
const tcase = sr().querySelector('.vactrail .case');
|
||||
const tcore = sr().querySelector('.vactrail .core');
|
||||
o.trailCasing = !!tcase && !!tcore
|
||||
&& tcase.getAttribute('points') === tcore.getAttribute('points')
|
||||
&& parseFloat(getComputedStyle(tcase).strokeWidth) > parseFloat(getComputedStyle(tcore).strokeWidth)
|
||||
&& getComputedStyle(tcase).stroke.includes('0, 0, 0')
|
||||
&& getComputedStyle(tcore).stroke.includes('255, 255, 255');
|
||||
|
||||
|
||||
// trail off via marker option
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = false;
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.trailToggleOff = !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = null;
|
||||
|
||||
// dock -> puck dissolves, trail lingers (linger window)
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } } } };
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
|
||||
o.puckGoneWhenDocked = !sr().querySelector('.vacpuck');
|
||||
// default mode 'cleaning': the trail HIDES the moment the run ends (owner)
|
||||
o.trailHiddenAfterDock = !sr().querySelector('.vactrail polyline');
|
||||
|
||||
// hidden marker renders neither puck nor trail
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = true;
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.hiddenNoPuck = !sr().querySelector('.vacpuck') && !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = false;
|
||||
|
||||
// no calibration for the active map -> no puck (graceful degradation)
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(900, 1100, { map_name: 'm2' }) } } };
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
|
||||
|
||||
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'always';
|
||||
c._vacSrvTrails = { e_vacuum_robo: {
|
||||
current: { map_id: 'm1', started: 1, ended: null,
|
||||
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
|
||||
previous: { map_id: 'm1', started: 0, ended: 1,
|
||||
points: [[600, 1300], [800, 1300], [800, 1500]] },
|
||||
} };
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 950, y: 1150, a: 0 }, map_name: 'm1' } } } };
|
||||
await c.updateComplete;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
|
||||
const prevG = sr().querySelector('.vactrail g.prev');
|
||||
o.srvPrevRunShown = !!prevG && prevG.querySelectorAll('polyline').length === 2
|
||||
&& prevG.querySelector('.case').getAttribute('points').split(' ').length === 3;
|
||||
const curLines = [...sr().querySelectorAll('.vactrail > polyline.case, .vactrail g:not(.prev) polyline.case')];
|
||||
const cur = curLines.find((x) => !x.closest('g.prev'));
|
||||
// 4 server points, the live tail trimmed while moving -> 3 rendered
|
||||
o.srvCurTrimmed = !!cur && cur.getAttribute('points').split(' ').length === 3;
|
||||
o.srvPrevFaded = !!prevG && getComputedStyle(prevG).opacity === '0.4';
|
||||
// the growing tip is glued to the puck by the rAF sampler
|
||||
const tip = sr().querySelector('line.tip');
|
||||
o.tipExists = !!tip;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const puckNow = sr().querySelector('.vacpuck').getBoundingClientRect();
|
||||
const svgT = sr().querySelector('.vactrail');
|
||||
const vbT = svgT.viewBox.baseVal; const sbT = svgT.getBoundingClientRect();
|
||||
const tx = sbT.left + ((parseFloat(tip.getAttribute('x2')) - vbT.x) / vbT.width) * sbT.width;
|
||||
const ty = sbT.top + ((parseFloat(tip.getAttribute('y2')) - vbT.y) / vbT.height) * sbT.height;
|
||||
o.tipGluedToPuck = Math.hypot(tx - (puckNow.left + puckNow.width / 2), ty - (puckNow.top + puckNow.height / 2)) < 12;
|
||||
// 'never' kills every line including the previous run
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'never';
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.neverHidesAll = !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = null;
|
||||
c._vacSrvTrails = {};
|
||||
|
||||
// ---- the fit panel (drag + corner-stretch) end to end ----
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
const dev = c._devices.find((x) => x.id === 'e_vacuum_robo');
|
||||
o.fitDevFound = !!dev;
|
||||
// map m2 with rooms (bboxes) and a parked robot
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 500, y: 500, a: 0 }, map_name: 'm2',
|
||||
rooms: { 1: { name: 'Кухня', x0: 0, y0: 0, x1: 1000, y1: 800 },
|
||||
2: { name: 'Зал', x0: 0, y0: 800, x1: 1000, y1: 2000 } } } } } };
|
||||
await c.updateComplete;
|
||||
c._vacStartFit(dev); await c.updateComplete;
|
||||
o.fitOverlay = !!sr().querySelector('.vacfit');
|
||||
o.fitGhostRooms = sr().querySelectorAll('.vacfit polygon').length === 2;
|
||||
o.fitLabels = [...sr().querySelectorAll('.vacfit text')].map((t) => t.textContent.trim()).join('|') === 'Кухня|Зал';
|
||||
o.fitHandles = sr().querySelectorAll('.vacfithandle').length === 4;
|
||||
// REAL hit-tests: what would an actual click land on? Synthetic dispatch
|
||||
// bypasses pointer-events, and that let the untouchable overlay pass once.
|
||||
const hitAt = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
let n = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
while (n && n.shadowRoot) {
|
||||
const deeper = n.shadowRoot.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
if (!deeper || deeper === n) break;
|
||||
n = deeper;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
o.fitHandleHittable = hitAt(sr().querySelector('.vacfithandle'))?.classList?.contains('vacfithandle') === true;
|
||||
o.fitGhostHittable = !!hitAt(sr().querySelector('.vacfit polygon'))?.closest?.('.vacfit');
|
||||
o.fitBar = !!sr().querySelector('.vaccalbar');
|
||||
o.fitMirrorDefault = c._vacFit.p.mir === true;
|
||||
// drag: the ghost centre must follow; rotate: the centre must NOT move
|
||||
const before = { ...c._vacFit.p };
|
||||
const svgEl = sr().querySelector('.vacfit');
|
||||
const sb2 = svgEl.getBoundingClientRect();
|
||||
const fire = (type, x, y, target) => (target || svgEl).dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, pointerId: 7, clientX: x, clientY: y }));
|
||||
fire('pointerdown', sb2.left + sb2.width * 0.5, sb2.top + sb2.height * 0.5);
|
||||
fire('pointermove', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
|
||||
fire('pointerup', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
|
||||
await c.updateComplete;
|
||||
o.fitDragMoves = c._vacFit.p.ox !== before.ox && c._vacFit.p.oy !== before.oy;
|
||||
const centreBefore = (() => { const t = c._vacFit.p; const m = [t.s * (t.mir ? -1 : 1), 0, t.ox, 0, t.s, t.oy];
|
||||
return [m[0] * 500 + m[2], m[4] * 1000 + m[5]]; })();
|
||||
c._vacFitTurn({ rot: 90 }); await c.updateComplete;
|
||||
o.fitRotateKeepsCentre = c._vacFit.p.rot === 90;
|
||||
// corner-stretch scales
|
||||
const s0 = c._vacFit.p.s;
|
||||
const handle = sr().querySelector('.vacfithandle');
|
||||
const hb = handle.getBoundingClientRect();
|
||||
fire('pointerdown', hb.left + hb.width / 2, hb.top + hb.height / 2, handle);
|
||||
fire('pointermove', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
|
||||
fire('pointerup', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
|
||||
await c.updateComplete;
|
||||
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
|
||||
// save -> matrix stored for m2, panel closed, puck appears while cleaning
|
||||
c._vacFitSave(); await c.updateComplete;
|
||||
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
|
||||
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
|
||||
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
|
||||
o.oldWizardGone = typeof c._vacCalClick === 'undefined' && typeof c._vacStartWizard === 'undefined';
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 501, y: 501, a: 0 }, map_name: 'm2',
|
||||
rooms: {} } } } };
|
||||
await c.updateComplete;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
|
||||
o.puckAfterFit = !!sr().querySelector('.vacpuck');
|
||||
|
||||
return o;
|
||||
});
|
||||
|
||||
checkAll(out, {
|
||||
dockedNoPuck: true, baseMarkerThere: true, puckAppears: true, puckRound: true,
|
||||
puckPlateMatchesDev: true, puck80pct: true,
|
||||
noWedge: true, iconCentred: true, baseStaysDuringCleaning: true, trailDrawn: true,
|
||||
trailScaledByMatrix: true, trailBehindPuck: true, zoomTeleports: true,
|
||||
trailCasing: true, trailToggleOff: true,
|
||||
puckGoneWhenDocked: true, trailHiddenAfterDock: true, hiddenNoPuck: true,
|
||||
unknownMapNoPuck: true,
|
||||
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
|
||||
tipExists: true, tipGluedToPuck: true, neverHidesAll: true,
|
||||
fitDevFound: true, fitOverlay: true, fitGhostRooms: true, fitLabels: true,
|
||||
fitHandles: true, fitHandleHittable: true, fitGhostHittable: true, fitBar: true, fitMirrorDefault: true, fitDragMoves: true,
|
||||
fitRotateKeepsCentre: true, fitCornerScales: true, fitSavedMatrix: true,
|
||||
fitClosed: true, oldWizardGone: true, puckAfterFit: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,108 @@
|
||||
// v1.54.0 audit regressions (HP-1540-01/-04/-06): the FIRST-USE path.
|
||||
// An auto-discovered vacuum has NO marker in the config — cfg.markers stays
|
||||
// empty here, unlike smoke_vacuum.mjs which always pushes one by hand (the
|
||||
// audit called that gap out explicitly). Every vacuum action must materialise
|
||||
// the marker and actually persist; plan rooms are legacy RECTANGLES.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
|
||||
// legacy rectangle rooms (x/y/w/h, HP-1540-04) in the garden space where
|
||||
// the auto-discovered vacuum.mower already lives
|
||||
const g = cfg.spaces.find((s) => s.id === 'garden');
|
||||
g.rooms = [
|
||||
{ id: 'ga', name: 'Кухня', area: 'garden', x: 0.05, y: 0.10, w: 0.25, h: 0.30 },
|
||||
{ id: 'gb', name: 'Зал', x: 0.40, y: 0.10, w: 0.25, h: 0.30 },
|
||||
{ id: 'gc', name: 'Спальня', x: 0.05, y: 0.50, w: 0.25, h: 0.30 },
|
||||
];
|
||||
// robot rooms: same centres in "robot mm" (scale 4), map_index is a
|
||||
// NUMERIC ZERO — the cross-runtime zero-map contract (HP-1540-02)
|
||||
const robotRooms = {
|
||||
1: { name: 'Кухня', x0: 300, y0: 600, x1: 1100, y1: 1400 },
|
||||
2: { name: 'Зал', x0: 1700, y0: 600, x1: 2500, y1: 1400 },
|
||||
3: { name: 'Спальня', x0: 300, y0: 2200, x1: 1100, y1: 3000 },
|
||||
};
|
||||
const camAttrs = { vacuum_position: { x: 700, y: 1000, a: 0 }, map_index: 0, rooms: robotRooms };
|
||||
c.hass = { ...c.hass,
|
||||
entities: { ...c.hass.entities,
|
||||
'camera.mower_map': { entity_id: 'camera.mower_map', device_id: 'd_mower', platform: 'demo' } },
|
||||
states: { ...c.hass.states,
|
||||
'camera.mower_map': { state: 'idle', attributes: camAttrs } } };
|
||||
c._regSignature = '';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// spy on config writes: a success toast is only honest after one of these
|
||||
const writes = [];
|
||||
const realWS = c.hass.callWS;
|
||||
c.hass.callWS = async (m) => { if (m.type === 'houseplan/config/set') writes.push(m); return realWS(m); };
|
||||
|
||||
const dev = c._devices.find((x) => x.id === 'd_mower');
|
||||
o.devFound = !!dev;
|
||||
o.freshNoMarker = (cfg.markers || []).length === 0 && !dev.marker;
|
||||
|
||||
// ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ----
|
||||
c._vacAutoCalibrate(dev);
|
||||
await c.updateComplete;
|
||||
const m1 = (cfg.markers || []).find((x) => x.id === 'd_mower');
|
||||
o.markerMaterialised = !!m1 && m1.binding === 'device:d_mower';
|
||||
const cal0 = m1?.vacuum?.calibration?.['0'];
|
||||
o.rectRoomsCalibrated = Array.isArray(cal0) && cal0.length === 6 && cal0.every(Number.isFinite);
|
||||
o.sourceStored = m1?.vacuum?.source === 'camera.mower_map';
|
||||
// the matrix really maps robot mm → canvas: centre of «Кухня» → (175, 250)
|
||||
const ap = (mm, x, y) => [mm[0] * x + mm[1] * y + mm[2], mm[3] * x + mm[4] * y + mm[5]];
|
||||
const hit = cal0 ? ap(cal0, 700, 1000) : [0, 0];
|
||||
o.matrixMapsRooms = Math.hypot(hit[0] - 175, hit[1] - 250) < 2;
|
||||
o.successToastShown = (c._toast || '').startsWith('Done: bound via 3 rooms');
|
||||
await new Promise((r) => setTimeout(r, 700)); // debounced _saveConfig
|
||||
o.configWritePersisted = writes.length >= 1
|
||||
&& !!writes[writes.length - 1].config.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'];
|
||||
|
||||
// ---- manual fit from scratch (HP-1540-01) ----
|
||||
cfg.markers = [];
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
const dev2 = c._devices.find((x) => x.id === 'd_mower');
|
||||
o.fitFreshNoMarker = !dev2.marker;
|
||||
c._vacStartFit(dev2); await c.updateComplete;
|
||||
c._vacFitSave(); await c.updateComplete;
|
||||
const m2 = (cfg.markers || []).find((x) => x.id === 'd_mower');
|
||||
const calFit = m2?.vacuum?.calibration?.['0'];
|
||||
o.fitMaterialises = !!m2 && Array.isArray(calFit) && calFit.length === 6;
|
||||
o.fitToastAfterSave = c._toast === c._t('vac.cal_done');
|
||||
|
||||
// ---- live checkbox + trail select from scratch (HP-1540-01) ----
|
||||
cfg.markers = [];
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
|
||||
const liveBox = sr().querySelector('.vacbox input[type=checkbox]');
|
||||
o.vacSectionShown = !!liveBox;
|
||||
if (liveBox) { liveBox.click(); await c.updateComplete; }
|
||||
const m3 = (cfg.markers || []).find((x) => x.id === 'd_mower');
|
||||
o.liveToggleMaterialises = !!m3 && m3.vacuum?.live === false;
|
||||
const sel = sr().querySelector('.vacbox select');
|
||||
if (sel) { sel.value = 'always'; sel.dispatchEvent(new Event('change')); await c.updateComplete; }
|
||||
o.trailModePersists = m3?.vacuum?.trail_mode === 'always';
|
||||
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
|
||||
|
||||
// ---- error copy never points at the removed point calibration (HP-1540-06) ----
|
||||
const toasts = ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.autocal_res_warn']
|
||||
.map((k) => c._t(k)).join(' | ');
|
||||
o.noPointCalibrationCopy = !/point|точк/i.test(toasts);
|
||||
// and the real no-rooms path shows the reworded toast
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } };
|
||||
await c.updateComplete;
|
||||
c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
|
||||
o.noRoomsToastReworded = c._toast === c._t('vac.autocal_no_rooms') && !/point|точк/i.test(c._toast);
|
||||
|
||||
c.hass.callWS = realWS;
|
||||
return o;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,126 @@
|
||||
// Значения форматирует HOME ASSISTANT (docs/STYLING-HOOKS.md §6, docs/LIVE-TEXT.md §2.1).
|
||||
// Везде, где карточка печатает состояние ОДНОЙ сущности, она зовёт
|
||||
// hass.formatEntityState (и hass.formatEntityAttributeValue для атрибута) —
|
||||
// значит, работают display_precision, локальный разделитель дробной части и
|
||||
// переводы состояний, а единица не дублируется.
|
||||
// 1) бейдж display:'value' — «68,4 %» вместо сырых «68.42 %»;
|
||||
// 2) живой текст декора — то же, плюс перевод 'on' → «Включено»;
|
||||
// 3) своя единица заменяет единицу форматтера, а не приписывается к ней;
|
||||
// 4) атрибут идёт через СВОЙ форматтер, а не через форматтер состояния;
|
||||
// 5) старый HA без форматтера — прежнее поведение слово в слово;
|
||||
// 6) плашки °/% остаются нашими: это производное значение, а не состояние.
|
||||
// ПАДАЕТ на сборке до этих правок: бейдж и живой текст печатают сырое
|
||||
// состояние, 'on' остаётся 'on', а своя единица приписывается к чужой.
|
||||
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 base = window.__mkHass();
|
||||
|
||||
// ---- сущности стенда: датчик с «лишними» знаками и выключатель ----
|
||||
const EXTRA = {
|
||||
'sensor.tank': { entity_id: 'sensor.tank', state: '68.42',
|
||||
attributes: { friendly_name: 'Бак', unit_of_measurement: '%' } },
|
||||
'switch.pump': { entity_id: 'switch.pump', state: 'on',
|
||||
attributes: { friendly_name: 'Насос' } },
|
||||
'climate.hall': { entity_id: 'climate.hall', state: 'heat',
|
||||
attributes: { friendly_name: 'Котёл', current_temperature: 21.53, unit_of_measurement: '°C' } },
|
||||
};
|
||||
// Стаб ровно того, что делает HA: display_precision = 1, русская локаль
|
||||
// (запятая), перевод состояний и единица ВНУТРИ результата.
|
||||
const haFormat = (st) => {
|
||||
if (st.state === 'on') return 'Включено';
|
||||
if (st.state === 'off') return 'Выключено';
|
||||
if (st.state === 'heat') return 'Нагрев';
|
||||
if (st.state === 'locked') return 'Заперто';
|
||||
const n = Number(st.state);
|
||||
if (!Number.isFinite(n)) return st.state;
|
||||
const txt = n.toFixed(1).replace('.', ',');
|
||||
const u = st.attributes?.unit_of_measurement;
|
||||
return u ? `${txt} ${u}` : txt;
|
||||
};
|
||||
const haAttr = (st, a) => String(st.attributes?.[a]).replace('.', ',');
|
||||
const mk = (withFormatter) => {
|
||||
const h = {
|
||||
...base,
|
||||
states: { ...base.states, ...EXTRA },
|
||||
entities: { ...base.entities,
|
||||
'sensor.tank': { entity_id: 'sensor.tank', platform: 'demo' },
|
||||
'switch.pump': { entity_id: 'switch.pump', platform: 'demo' },
|
||||
'climate.hall': { entity_id: 'climate.hall', platform: 'demo' } },
|
||||
};
|
||||
if (withFormatter) { h.formatEntityState = haFormat; h.formatEntityAttributeValue = haAttr; }
|
||||
else { delete h.formatEntityState; delete h.formatEntityAttributeValue; }
|
||||
return h;
|
||||
};
|
||||
const apply = async (withFormatter) => {
|
||||
c.hass = mk(withFormatter);
|
||||
c._regSignature = ''; c._cfgEpoch++; c._maybeRebuildDevices(); c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
};
|
||||
|
||||
// ================= 1. бейдж «значение вместо значка» =================
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'm_tank', binding: 'entity:sensor.tank', space: c._space, display: 'value' },
|
||||
] };
|
||||
await apply(true);
|
||||
const badge = () => sr().querySelector('[data-hp="device"][data-id="m_tank"] .valtext');
|
||||
out.badgeRendered = !!badge();
|
||||
out.badgeIsHaFormatted = badge()?.textContent.trim() === '68,4 %';
|
||||
out.badgeUnitNotDoubled = !/%\s*%/.test(badge()?.textContent || '');
|
||||
// старый HA без форматтера — ровно прежняя строка
|
||||
await apply(false);
|
||||
out.badgeFallsBackToRawState = badge()?.textContent.trim() === '68.42 %';
|
||||
await apply(true);
|
||||
|
||||
// ================= 2. живой текст декора =================
|
||||
c._curSpaceCfg.decor = [
|
||||
{ id: 'lt_val', kind: 'text', x: 0.2, y: 0.92, text: 'Бак {}', color: '#123456', entity: 'sensor.tank' },
|
||||
{ id: 'lt_unit', kind: 'text', x: 0.5, y: 0.92, text: '{}', color: '#123456', entity: 'sensor.tank', unit: 'проц.' },
|
||||
{ id: 'lt_state', kind: 'text', x: 0.8, y: 0.92, text: 'Насос: {}', color: '#123456', entity: 'switch.pump' },
|
||||
{ id: 'lt_attr', kind: 'text', x: 0.2, y: 0.96, text: '{}', color: '#123456', entity: 'climate.hall', attr: 'current_temperature' },
|
||||
{ id: 'lt_attru', kind: 'text', x: 0.5, y: 0.96, text: '{}', color: '#123456', entity: 'climate.hall', attr: 'current_temperature', unit: '°C' },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
const txt = (id) => sr().querySelector(`text.dtext[data-id="${id}"]`)?.textContent.trim();
|
||||
out.liveTextIsHaFormatted = txt('lt_val') === 'Бак 68,4 %';
|
||||
out.liveTextUnitNotDoubled = txt('lt_val') === 'Бак 68,4 %' && !/%\s*%/.test(txt('lt_val'));
|
||||
out.liveTextOwnUnitReplacesTheFormatters = txt('lt_unit') === '68,4 проц.';
|
||||
out.liveTextStateIsTranslated = txt('lt_state') === 'Насос: Включено';
|
||||
// атрибут идёт через СВОЙ форматтер: иначе тут было бы «Нагрев»
|
||||
out.attrUsesTheAttributeFormatter = txt('lt_attr') === '21,53';
|
||||
out.attrDoesNotInheritTheEntityUnit = txt('lt_attr') === '21,53';
|
||||
out.attrTakesAnExplicitUnit = txt('lt_attru') === '21,53 °C';
|
||||
// без форматтера — прежнее поведение слово в слово
|
||||
await apply(false);
|
||||
out.liveTextFallsBackToRawState = txt('lt_val') === 'Бак 68.42 %';
|
||||
out.liveTextFallbackTranslatesNothing = txt('lt_state') === 'Насос: on';
|
||||
out.attrFallsBackToTheRawAttribute = txt('lt_attr') === '21.53';
|
||||
await apply(true);
|
||||
|
||||
// ================= 3. инфо-карточка =================
|
||||
const dev = c._devices.find((d) => d.primary === 'lock.front_door');
|
||||
c._infoCard = dev; c.requestUpdate(); await c.updateComplete;
|
||||
const rowVals = [...sr().querySelectorAll('.entlist .entrow')]
|
||||
.map((r) => r.querySelector('.entbtn, .ev')?.textContent.trim());
|
||||
out.infoCardUsesTheFormatter = rowVals.includes('Заперто');
|
||||
out.infoCardPrintsNoRawState = !rowVals.includes('locked');
|
||||
c._infoCard = null; c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// ================= 4. плашки °/% остались нашими =================
|
||||
// термометр в режиме «значение» по-прежнему показывает компактное «22.4°»:
|
||||
// это ПРОИЗВОДНОЕ показание (среднее по датчикам зоны / атрибут климата),
|
||||
// а не состояние одной сущности, и весь план читается как один прибор
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'd_temp', binding: 'device:d_temp', display: 'value' },
|
||||
] };
|
||||
await apply(true);
|
||||
const tempBadge = [...sr().querySelectorAll('.dev.valonly .valtext')].map((e) => e.textContent.trim());
|
||||
out.tempPlateKeepsItsCompactForm = tempBadge.includes('22.4°');
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user