mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Merge dev: v1.43.0..v1.43.2 (external audit: P0, P1 and the test layer)
This commit is contained in:
@@ -34,6 +34,44 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Card bundle in sync with integration
|
- name: Card bundle in sync with integration
|
||||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||||
|
smoke:
|
||||||
|
# audit T2: the end-to-end layer used to run only when a human remembered.
|
||||||
|
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||||
|
needs: frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: 22 }
|
||||||
|
- run: npm ci
|
||||||
|
- name: Install Chromium for Playwright
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
- name: Build a FRESH bundle for the smokes
|
||||||
|
# the committed demo/srv/assets copy is a snapshot; testing it would
|
||||||
|
# report green about code that no longer exists (audit T2)
|
||||||
|
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||||
|
- name: Smoke suite
|
||||||
|
run: |
|
||||||
|
fail=0
|
||||||
|
mkdir -p /tmp/smoke-logs
|
||||||
|
for f in demo/smoke_*.mjs; do
|
||||||
|
name=$(basename "$f" .mjs)
|
||||||
|
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||||
|
echo "ok $name"
|
||||||
|
else
|
||||||
|
echo "FAIL $name"
|
||||||
|
tail -20 "/tmp/smoke-logs/$name.log"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $fail
|
||||||
|
- name: Upload smoke logs
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: smoke-logs
|
||||||
|
path: /tmp/smoke-logs
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
|||||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hp_ws.async_register(hass)
|
hp_ws.async_register(hass)
|
||||||
from .http_api import HouseplanUploadView
|
from .http_api import HouseplanContentView, HouseplanUploadView
|
||||||
|
|
||||||
hass.http.register_view(HouseplanUploadView())
|
hass.http.register_view(HouseplanUploadView())
|
||||||
|
hass.http.register_view(HouseplanContentView())
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -61,14 +62,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
|||||||
|
|
||||||
if card_path.exists():
|
if card_path.exists():
|
||||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||||
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
|
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||||
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
|
# They are served by HouseplanContentView, which requires auth.
|
||||||
|
# Only the card bundle stays public — Lovelace resources must be.
|
||||||
|
if static_paths:
|
||||||
await hass.http.async_register_static_paths(static_paths)
|
await hass.http.async_register_static_paths(static_paths)
|
||||||
except ImportError: # very old HA versions
|
except ImportError: # very old HA versions
|
||||||
if card_path.exists():
|
if card_path.exists():
|
||||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||||
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
|
|
||||||
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
|
|
||||||
|
|
||||||
if not card_path.exists():
|
if not card_path.exists():
|
||||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
|||||||
PLANS_URL = "/houseplan_files/plans"
|
PLANS_URL = "/houseplan_files/plans"
|
||||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||||
FILES_URL = "/houseplan_files/files"
|
FILES_URL = "/houseplan_files/files"
|
||||||
|
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
|
||||||
|
CONTENT_URL = "/api/houseplan/content"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.42.2"
|
VERSION = "1.43.2"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@ except ImportError: # older HA versions
|
|||||||
KEY_HASS = "hass" # type: ignore[assignment]
|
KEY_HASS = "hass" # type: ignore[assignment]
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import CONF_ADMIN_ONLY, FILES_DIR, FILES_URL
|
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||||
from .store import get_entry
|
from .store import get_entry
|
||||||
from .validation import (
|
from .validation import (
|
||||||
FILE_EXTENSIONS,
|
FILE_EXTENSIONS,
|
||||||
@@ -32,6 +32,59 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_CHUNK = 64 * 1024
|
_CHUNK = 64 * 1024
|
||||||
|
|
||||||
|
_MIME = {
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".txt": "text/plain",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HouseplanContentView(HomeAssistantView):
|
||||||
|
"""Authenticated read access to plans and marker files (audit B1).
|
||||||
|
|
||||||
|
The directories used to be exposed as unauthenticated static paths, so
|
||||||
|
anyone who could reach the HA endpoint could pull floor plans and uploaded
|
||||||
|
manuals without logging in. This view keeps the same URLs but requires a
|
||||||
|
Home Assistant session (or a signed path, which the frontend uses for
|
||||||
|
<image href> inside the SVG).
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = "/api/houseplan/content/{kind}/{sub}/{name}"
|
||||||
|
name = "api:houseplan:content"
|
||||||
|
requires_auth = True
|
||||||
|
|
||||||
|
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
|
||||||
|
hass: HomeAssistant = request.app[KEY_HASS]
|
||||||
|
if kind not in ("plans", "files"):
|
||||||
|
return web.Response(status=404)
|
||||||
|
safe_sub = sanitize_marker_id(sub)
|
||||||
|
safe_name = sanitize_filename(name)
|
||||||
|
if not safe_sub or not safe_name:
|
||||||
|
return web.Response(status=404)
|
||||||
|
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
|
||||||
|
# plans live flat in one directory: the sub segment is a placeholder ("_")
|
||||||
|
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||||
|
# defence in depth: the sanitizers already strip separators
|
||||||
|
if not str(path).startswith(str(base)):
|
||||||
|
return web.Response(status=404)
|
||||||
|
|
||||||
|
def _read() -> bytes | None:
|
||||||
|
return path.read_bytes() if path.is_file() else None
|
||||||
|
|
||||||
|
blob = await hass.async_add_executor_job(_read)
|
||||||
|
if blob is None:
|
||||||
|
return web.Response(status=404)
|
||||||
|
return web.Response(
|
||||||
|
body=blob,
|
||||||
|
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
|
||||||
|
headers={"Cache-Control": "private, max-age=3600"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HouseplanUploadView(HomeAssistantView):
|
class HouseplanUploadView(HomeAssistantView):
|
||||||
"""POST /api/houseplan/upload — save a marker file, return its URL."""
|
"""POST /api/houseplan/upload — save a marker file, return its URL."""
|
||||||
@@ -99,5 +152,5 @@ class HouseplanUploadView(HomeAssistantView):
|
|||||||
|
|
||||||
mtime = await hass.async_add_executor_job(_write)
|
mtime = await hass.async_add_executor_job(_write)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.42.2"
|
"version": "1.43.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
|
||||||
from .const import DOMAIN, PLANS_DIR, PLANS_URL
|
from .const import CONTENT_URL, DOMAIN, PLANS_DIR, PLANS_URL
|
||||||
from .store import HouseplanConfigEntry
|
from .store import HouseplanConfigEntry
|
||||||
|
|
||||||
|
|
||||||
@@ -25,9 +25,15 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
|||||||
res = []
|
res = []
|
||||||
for sp in spaces:
|
for sp in spaces:
|
||||||
url = sp.get("plan_url") or ""
|
url = sp.get("plan_url") or ""
|
||||||
if not url.startswith(PLANS_URL + "/"):
|
# both the legacy static URL and the authenticated content URL
|
||||||
|
prefix = None
|
||||||
|
if url.startswith(PLANS_URL + "/"):
|
||||||
|
prefix = PLANS_URL + "/"
|
||||||
|
elif url.startswith(CONTENT_URL + "/plans/_/"):
|
||||||
|
prefix = CONTENT_URL + "/plans/_/"
|
||||||
|
if prefix is None:
|
||||||
continue # external/legacy URL — not ours to verify
|
continue # external/legacy URL — not ours to verify
|
||||||
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
|
fname = url[len(prefix) :].split("?", 1)[0]
|
||||||
if not (plans_dir / fname).is_file():
|
if not (plans_dir / fname).is_file():
|
||||||
res.append((sp.get("id", "?"), fname))
|
res.append((sp.get("id", "?"), fname))
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -47,11 +47,31 @@ def valid_space_id(value: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
# ---------- voluptuous schemas ----------
|
# ---------- voluptuous schemas ----------
|
||||||
|
def _finite(value):
|
||||||
|
"""Coerce to float and reject NaN/Infinity (audit B5).
|
||||||
|
|
||||||
|
'NaN' and 'Infinity' pass Coerce(float) and serialize to null on write,
|
||||||
|
silently corrupting a stored position forever.
|
||||||
|
"""
|
||||||
|
f = float(value)
|
||||||
|
if f != f or f in (float("inf"), float("-inf")):
|
||||||
|
raise vol.Invalid("coordinate must be a finite number")
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
# generous caps: the product targets 20-200 devices and a handful of floors
|
||||||
|
MAX_SPACES = 50
|
||||||
|
MAX_ROOMS = 400
|
||||||
|
MAX_MARKERS = 2000
|
||||||
|
MAX_OPENINGS = 500
|
||||||
|
MAX_DECOR = 1000
|
||||||
|
MAX_LAYOUT = 5000
|
||||||
|
|
||||||
POS_SCHEMA = vol.Schema(
|
POS_SCHEMA = vol.Schema(
|
||||||
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
|
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||||
)
|
)
|
||||||
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||||
|
|
||||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||||
|
|
||||||
@@ -142,8 +162,8 @@ SPACE_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("plan_url"): vol.Any(str, None),
|
vol.Optional("plan_url"): vol.Any(str, None),
|
||||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||||
vol.Optional("decor"): [DECOR_SCHEMA],
|
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||||
vol.Optional("openings"): [
|
vol.Optional("openings"): [
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
@@ -199,8 +219,8 @@ MARKER_SCHEMA = vol.Schema(
|
|||||||
)
|
)
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("spaces"): [SPACE_SCHEMA],
|
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||||
vol.Optional("settings", default=dict): 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)),
|
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||||
PLANS_DIR, PLANS_URL,
|
CONTENT_URL, PLANS_DIR, PLANS_URL,
|
||||||
)
|
)
|
||||||
from .store import HouseplanData, get_data, get_entry
|
from .store import HouseplanData, get_data, get_entry
|
||||||
from .validation import (
|
from .validation import (
|
||||||
@@ -22,6 +24,9 @@ from .validation import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_register(hass: HomeAssistant) -> None:
|
def async_register(hass: HomeAssistant) -> None:
|
||||||
"""Register the WS commands."""
|
"""Register the WS commands."""
|
||||||
@@ -49,8 +54,17 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
|
|||||||
|
|
||||||
|
|
||||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||||
|
"""May this connection write?
|
||||||
|
|
||||||
|
Fails CLOSED (audit B2): when the entry cannot be read — during a reload or
|
||||||
|
while the integration is disabled — the policy is unknown, and "unknown" is
|
||||||
|
not the same as "permissive". Previously this returned True and ws_plan_set,
|
||||||
|
which never touches the runtime helper, accepted uploads in that window.
|
||||||
|
"""
|
||||||
entry = get_entry(hass)
|
entry = get_entry(hass)
|
||||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
if entry is None:
|
||||||
|
return bool(getattr(connection.user, "is_admin", False))
|
||||||
|
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||||
return connection.user.is_admin if admin_only else True
|
return connection.user.is_admin if admin_only else True
|
||||||
|
|
||||||
|
|
||||||
@@ -69,11 +83,21 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{vol.Required("type"): "houseplan/layout/set", vol.Required("layout"): LAYOUT_SCHEMA}
|
{
|
||||||
|
vol.Required("type"): "houseplan/layout/set",
|
||||||
|
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||||
|
vol.Optional("expected_rev"): int,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||||
"""Replace the layout entirely."""
|
"""Replace the layout entirely, with optimistic locking (audit B3).
|
||||||
|
|
||||||
|
Wholesale layout writes used to have no revision check at all, so two
|
||||||
|
clients silently overwrote each other. `expected_rev` is optional for
|
||||||
|
backwards compatibility with older cards, but when supplied it is enforced
|
||||||
|
exactly like the config store does.
|
||||||
|
"""
|
||||||
if not _check_write(hass, connection):
|
if not _check_write(hass, connection):
|
||||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
|
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
|
||||||
return
|
return
|
||||||
@@ -81,8 +105,15 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
if rt is None:
|
if rt is None:
|
||||||
return
|
return
|
||||||
async with rt.write_lock:
|
async with rt.write_lock:
|
||||||
await rt.store.async_save({"layout": msg["layout"]})
|
data = await rt.store.async_load() or {}
|
||||||
connection.send_result(msg["id"], {"ok": True})
|
current_rev = int(data.get("rev", 0))
|
||||||
|
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
@@ -227,6 +258,15 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
async with rt.write_lock:
|
async with rt.write_lock:
|
||||||
data = await rt.config_store.async_load() or {}
|
data = await rt.config_store.async_load() or {}
|
||||||
current_rev = data.get("rev", 0)
|
current_rev = data.get("rev", 0)
|
||||||
|
if "expected_rev" not in msg and current_rev:
|
||||||
|
# audit B4: expected_rev stays optional for old cards mid-upgrade,
|
||||||
|
# but a blind overwrite of a non-empty store is worth a warning —
|
||||||
|
# it is exactly how a stale client silently discards someone's work.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"House Plan: config/set without expected_rev over rev %s — "
|
||||||
|
"the client bypasses conflict detection (outdated card?)",
|
||||||
|
current_rev,
|
||||||
|
)
|
||||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||||
connection.send_error(
|
connection.send_error(
|
||||||
msg["id"], "conflict",
|
msg["id"], "conflict",
|
||||||
@@ -290,5 +330,5 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
|||||||
|
|
||||||
mtime = await hass.async_add_executor_job(_write)
|
mtime = await hass.async_add_executor_job(_write)
|
||||||
connection.send_result(
|
connection.send_result(
|
||||||
msg["id"], {"ok": True, "url": f"{PLANS_URL}/{space_id}.{msg['ext']}?v={mtime}"}
|
msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{space_id}.{msg['ext']}?v={mtime}"}
|
||||||
)
|
)
|
||||||
|
|||||||
+36
-1
@@ -6,10 +6,45 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
|
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
|
||||||
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
||||||
|
// ---- assertion harness (audit T1) --------------------------------------
|
||||||
|
// Until 2026-07-27 the smokes printed booleans and always exited 0: a broken
|
||||||
|
// build reported success. `check()` accumulates named failures, `finish()`
|
||||||
|
// prints them and sets the exit code.
|
||||||
|
const _failures = [];
|
||||||
|
let _pageErrors = 0;
|
||||||
|
|
||||||
|
/** Assert one named fact. `expected` defaults to true. */
|
||||||
|
export function check(name, actual, expected = true) {
|
||||||
|
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||||
|
if (!ok) _failures.push(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert a whole result object: every key must equal true unless listed. */
|
||||||
|
export function checkAll(out, expected = {}) {
|
||||||
|
for (const [k, v] of Object.entries(out)) check(k, v, k in expected ? expected[k] : true);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print the result, report failures, close the browser, set the exit code. */
|
||||||
|
export async function finish(browser, out) {
|
||||||
|
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
|
||||||
|
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
|
||||||
|
await browser?.close?.();
|
||||||
|
if (_failures.length) {
|
||||||
|
console.error('\nFAILED (' + _failures.length + '):');
|
||||||
|
for (const f of _failures) console.error(' - ' + f);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log('OK');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
|
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
|
||||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||||
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
|
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
|
||||||
page.on('pageerror', (e) => console.log('EXC', e.message));
|
// audit T1: an exception inside the card used to be logged and ignored
|
||||||
|
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
|
||||||
await page.route('**/*', (r) => {
|
await page.route('**/*', (r) => {
|
||||||
const u = new URL(r.request().url());
|
const u = new URL(r.request().url());
|
||||||
let p = decodeURIComponent(u.pathname);
|
let p = decodeURIComponent(u.pathname);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -52,5 +52,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.noneInView = guides() === 0;
|
out.noneInView = guides() === 0;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -46,5 +46,5 @@ const res = await page.evaluate(async () => {
|
|||||||
c._markerDialog = null;
|
c._markerDialog = null;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -47,5 +47,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.delroomIgnored = !confirmCalled;
|
out.delroomIgnored = !confirmCalled;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -57,5 +57,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
|
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -70,5 +70,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.deleteKey = c._decorList.length === n1 - 1;
|
out.deleteKey = c._decorList.length === n1 - 1;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
const c = window.__card;
|
||||||
|
const sr = () => c.shadowRoot || c.renderRoot;
|
||||||
|
let pageError = null;
|
||||||
|
window.addEventListener('error', (e) => { pageError = String(e.message); });
|
||||||
|
// сохранение падает, а диалог закрыт до ответа — карточка не должна умереть
|
||||||
|
c.hass = { ...c.hass, callWS: async (msg) => {
|
||||||
|
if (String(msg.type).endsWith('/set')) { await new Promise((r) => setTimeout(r, 60)); throw new Error('boom'); }
|
||||||
|
return { config: c._serverCfg, rev: c._cfgRev };
|
||||||
|
} };
|
||||||
|
await c.updateComplete;
|
||||||
|
// 1) диалог общих настроек
|
||||||
|
c._openSettingsDialog(); await c.updateComplete;
|
||||||
|
const p = c._saveSettingsDialog();
|
||||||
|
c._settingsDialog = null; // Esc во время сохранения
|
||||||
|
await c.updateComplete;
|
||||||
|
await p.catch(() => {});
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
await c.updateComplete;
|
||||||
|
out.settingsStaysClosed = c._settingsDialog === null;
|
||||||
|
out.cardAliveAfterSettings = !!sr().querySelector('.stage');
|
||||||
|
// 2) диалог правил
|
||||||
|
c._openRulesDialog(); await c.updateComplete;
|
||||||
|
const p2 = c._saveRules();
|
||||||
|
c._rulesDialog = null; await c.updateComplete;
|
||||||
|
await p2.catch(() => {});
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
await c.updateComplete;
|
||||||
|
out.rulesStaysClosed = c._rulesDialog === null;
|
||||||
|
out.cardAliveAfterRules = !!sr().querySelector('.stage');
|
||||||
|
// 3) диалог устройства
|
||||||
|
c._setMode('devices'); await c.updateComplete;
|
||||||
|
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
|
||||||
|
const p3 = c._saveMarker();
|
||||||
|
c._markerDialog = null; await c.updateComplete;
|
||||||
|
await p3.catch(() => {});
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
await c.updateComplete;
|
||||||
|
out.markerStaysClosed = c._markerDialog === null;
|
||||||
|
out.cardAliveAfterMarker = !!sr().querySelector('.stage');
|
||||||
|
out.noPageError = pageError === null;
|
||||||
|
// 4) при ОТКРЫТОМ диалоге ошибка снимает busy (поведение сохранено)
|
||||||
|
c._openSettingsDialog(); await c.updateComplete;
|
||||||
|
const p4 = c._saveSettingsDialog();
|
||||||
|
await p4.catch(() => {});
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
out.busyClearedWhenOpen = c._settingsDialog !== null && c._settingsDialog.busy === false;
|
||||||
|
c._settingsDialog = null;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
checkAll(res);
|
||||||
|
await finish(browser, res);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -70,5 +70,18 @@ const res = await page.evaluate(async () => {
|
|||||||
out.bigRenderMs = Math.round(performance.now() - t1);
|
out.bigRenderMs = Math.round(performance.now() - t1);
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
// сборка 100+ устройств должна укладываться в бюджет (docs/TESTING.md)
|
||||||
|
// timings are asserted as budgets, not frozen values (CI machines vary)
|
||||||
|
check("bigBuildMs under 200ms", res.bigBuildMs < 200);
|
||||||
|
check("bigRenderMs under 100ms", res.bigRenderMs < 100);
|
||||||
|
delete res.bigBuildMs;
|
||||||
|
delete res.bigRenderMs;
|
||||||
|
checkAll(res, {
|
||||||
|
"emptyDevices": 0,
|
||||||
|
"emptyCount": "0 dev.",
|
||||||
|
"xssPwned": false,
|
||||||
|
"xssDeviceRendered": 1,
|
||||||
|
"bigCount": 162,
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -35,5 +35,8 @@ const res = await page.evaluate(async () => {
|
|||||||
out.tabCrossWorks = c._mode === 'view';
|
out.tabCrossWorks = c._mode === 'view';
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"labels": ["Plan editor", "Device editor", "Background editor"],
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -31,5 +31,5 @@ const res = await page.evaluate(async () => {
|
|||||||
await esc(); out.undoPointStillWorks = c._path.length === 1;
|
await esc(); out.undoPointStillWorks = c._path.length === 1;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -54,5 +54,5 @@ const res = await page.evaluate(async () => {
|
|||||||
c._spaceDialog = null; await c.updateComplete;
|
c._spaceDialog = null; await c.updateComplete;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -31,5 +31,8 @@ const res = await page.evaluate(async () => {
|
|||||||
out.addInPlan = !!sr().querySelector('.tab.tabadd');
|
out.addInPlan = !!sr().querySelector('.tab.tabadd');
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"alignDelta": 0,
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -26,5 +26,12 @@ const res = await page.evaluate(async () => {
|
|||||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"rows": 11,
|
||||||
|
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
|
||||||
|
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||||
|
"lqiBefore": 7,
|
||||||
|
"lqiAfter": 0,
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
+6
-3
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -101,5 +101,8 @@ const res = await page.evaluate(async () => {
|
|||||||
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
|
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"spots": 1,
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -33,5 +33,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
|
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -14,5 +14,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.opensInView = !!c._settingsDialog;
|
out.opensInView = !!c._settingsDialog;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -27,5 +27,5 @@ const res = await page.evaluate(async () => {
|
|||||||
c._markerDialog = null; await c.updateComplete;
|
c._markerDialog = null; await c.updateComplete;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -43,5 +43,10 @@ const res = await page.evaluate(async () => {
|
|||||||
c._openingDialog = null; c._setMode('view');
|
c._openingDialog = null; c._setMode('view');
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"lockBadgeInert": "no-badge",
|
||||||
|
"viewDevCursor": "pointer",
|
||||||
|
"devModeCursor": "grab",
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -55,5 +55,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.islandRendered = !!islandEl;
|
out.islandRendered = !!islandEl;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -69,5 +69,5 @@ const res = await page.evaluate(async () => {
|
|||||||
c.remove();
|
c.remove();
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -30,5 +30,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.explicitInfoWins = calls.length === n2 && !!c._infoCard;
|
out.explicitInfoWins = calls.length === n2 && !!c._infoCard;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -47,5 +47,5 @@ const res = await page.evaluate(async () => {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -42,5 +42,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -20,5 +20,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
|
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
|
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
|
||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||||
const restore = () => page.evaluate((s) => {
|
const restore = () => page.evaluate((s) => {
|
||||||
@@ -79,5 +79,9 @@ await page.evaluate((p)=>window.__card._splitClick(p), await R(0.4,0.0625));
|
|||||||
s = await S();
|
s = await S();
|
||||||
out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
|
out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
|
||||||
|
|
||||||
console.log(JSON.stringify(out,null,1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(out, {
|
||||||
|
"mergeRooms": 3,
|
||||||
|
"newRoom": "Cabinet",
|
||||||
|
});
|
||||||
|
await finish(browser, out);
|
||||||
|
|||||||
+10
-3
@@ -1,5 +1,5 @@
|
|||||||
// UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools.
|
// UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools.
|
||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const out = {};
|
const out = {};
|
||||||
const q = (sel) => page.evaluate((s) => (window.__card.shadowRoot || window.__card.renderRoot).querySelectorAll(s).length, sel);
|
const q = (sel) => page.evaluate((s) => (window.__card.shadowRoot || window.__card.renderRoot).querySelectorAll(s).length, sel);
|
||||||
@@ -75,5 +75,12 @@ out.devClickOpensEditor = await page.evaluate(async () => {
|
|||||||
// 5) назад в view
|
// 5) назад в view
|
||||||
await page.evaluate(() => window.__card._setMode('view'));
|
await page.evaluate(() => window.__card._setMode('view'));
|
||||||
out.backToView = (await st()).mode;
|
out.backToView = (await st()).mode;
|
||||||
console.log(JSON.stringify(out, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(out, {
|
||||||
|
"start": {"mode": "view", "modeTabs": 3, "editBtns": 1, "gears": 2, "markupBar": false, "stageClass": "stage mode-view"},
|
||||||
|
"viewDragMoved": false,
|
||||||
|
"plan": {"mode": "plan", "modeTabs": 3, "active": "Plan editor", "editBtns": 1, "gears": 2, "markupBar": true, "stageClass": "stage markup tool-draw mode-plan"},
|
||||||
|
"devices": {"mode": "devices", "modeTabs": 3, "active": "Device editor", "editBtns": 1, "gears": 2, "markupBar": true, "stageClass": "stage mode-devices"},
|
||||||
|
"backToView": "view",
|
||||||
|
});
|
||||||
|
await finish(browser, out);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -31,5 +31,5 @@ const res = await page.evaluate(async () => {
|
|||||||
c._setMode('view'); c._space = 'f1'; c._saveNav(); await c.updateComplete;
|
c._setMode('view'); c._space = 'f1'; c._saveNav(); await c.updateComplete;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -28,5 +28,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new');
|
out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new');
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -39,5 +39,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.noGhostOverExisting = !sr().querySelector('.opghost');
|
out.noGhostOverExisting = !sr().querySelector('.opghost');
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -70,5 +70,5 @@ const res = await page.evaluate(async () => {
|
|||||||
} else out.transitive = 'no r3';
|
} else out.transitive = 'no r3';
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -32,5 +32,5 @@ const res = await page.evaluate(async () => {
|
|||||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
const c = window.__card;
|
||||||
|
// счётчики вызовов тяжёлой геометрии
|
||||||
|
let pairsCalls = 0, buildCalls = 0;
|
||||||
|
const origPairs = c._computeOpenPairs.bind(c);
|
||||||
|
c._computeOpenPairs = (...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.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;
|
||||||
|
// геометрия по-прежнему правильная: пунктир на месте
|
||||||
|
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
|
checkAll(res, {
|
||||||
|
"pairsPerRender": 0,
|
||||||
|
"modelBuildsPer10Renders": 0,
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -28,5 +28,8 @@ const res = await page.evaluate(async () => {
|
|||||||
out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0;
|
out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"alarmCount": 1,
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -57,5 +57,11 @@ const res = await page.evaluate(async () => {
|
|||||||
out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9;
|
out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"labels": 4,
|
||||||
|
"cardsWithMetrics": 4,
|
||||||
|
"sampleMetrics": ["22.4°", "175", "1 of 2"],
|
||||||
|
"partialText": "1 of 2",
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -34,5 +34,5 @@ const res = await page.evaluate(async () => {
|
|||||||
c._setMode('view'); await c.updateComplete;
|
c._setMode('view'); await c.updateComplete;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -58,5 +58,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.glowOptOut = darkCount === areaRooms - 1 && darkCount === styledRooms.length;
|
out.glowOptOut = darkCount === areaRooms - 1 && darkCount === styledRooms.length;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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 sent = [];
|
||||||
|
// перехват WS: config/set логируем, config/get отдаёт "серверную" копию БЕЗ локальной правки
|
||||||
|
const server = { cfg: JSON.parse(JSON.stringify(c._serverCfg)), rev: c._cfgRev };
|
||||||
|
c.hass = { ...c.hass, callWS: async (msg) => {
|
||||||
|
if (msg.type === 'houseplan/config/set') {
|
||||||
|
sent.push(JSON.parse(JSON.stringify(msg.config)));
|
||||||
|
server.cfg = JSON.parse(JSON.stringify(msg.config));
|
||||||
|
server.rev = (msg.expected_rev ?? server.rev) + 1;
|
||||||
|
return { rev: server.rev };
|
||||||
|
}
|
||||||
|
if (msg.type === 'houseplan/config/get') return { config: JSON.parse(JSON.stringify(server.cfg)), rev: server.rev };
|
||||||
|
return {};
|
||||||
|
} };
|
||||||
|
await c.updateComplete;
|
||||||
|
// локальная правка (как разметка комнаты) + дебаунс
|
||||||
|
const sp = c._curSpaceCfg;
|
||||||
|
sp.rooms.push({ id: 'race_room', name: 'RACE', area: null, poly: [[0.8, 0.8], [0.9, 0.8], [0.9, 0.9], [0.8, 0.9]] });
|
||||||
|
c._saveConfig();
|
||||||
|
out.pending = c._saveConfigDebounced.pending();
|
||||||
|
// через 100 мс приходит событие о чужой ревизии — раньше это стирало правку
|
||||||
|
c._cfgRev = server.rev; // симулируем: наша ревизия отстала
|
||||||
|
await c._reloadConfigOnly();
|
||||||
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
|
// правка обязана уцелеть и уйти на сервер
|
||||||
|
out.editSent = sent.some((cf) => cf.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room')));
|
||||||
|
out.editInMemory = c._serverCfg.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room'));
|
||||||
|
out.serverHasIt = server.cfg.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room'));
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
checkAll(res);
|
||||||
|
await finish(browser, res);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -27,7 +27,8 @@ const res = await page.evaluate(async () => {
|
|||||||
})};
|
})};
|
||||||
c.requestUpdate(); await c.updateComplete;
|
c.requestUpdate(); await c.updateComplete;
|
||||||
out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length;
|
out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length;
|
||||||
// 4) drag лейбла → layout rl_
|
// 4) drag лейбла → layout rl_ (только в редакторе плана, с v1.25)
|
||||||
|
c._setMode('plan'); await c.updateComplete;
|
||||||
const lbl = sr().querySelector('.roomlabel');
|
const lbl = sr().querySelector('.roomlabel');
|
||||||
c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 },
|
c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 },
|
||||||
c._spaceModel().rooms[0], 'f1');
|
c._spaceModel().rooms[0], 'f1');
|
||||||
@@ -46,5 +47,15 @@ const res = await page.evaluate(async () => {
|
|||||||
out.atticNoPlan = attic ? attic.plan_url === null : null;
|
out.atticNoPlan = attic ? attic.plan_url === null : null;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"defaultStyled": 0,
|
||||||
|
"defaultLabels": 0,
|
||||||
|
"styled": 4,
|
||||||
|
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
|
||||||
|
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
|
||||||
|
"lqiFills": 0,
|
||||||
|
"atticAspect": 1,
|
||||||
|
"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},
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Split now works on a non-grid-aligned room (imported/legacy polygons).
|
// Split now works on a non-grid-aligned room (imported/legacy polygons).
|
||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||||
@@ -17,5 +17,5 @@ await page.evaluate(()=>{const c=window.__card; c._roomDialog=false; c._pendingS
|
|||||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again
|
||||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss
|
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss
|
||||||
out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null);
|
out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null);
|
||||||
console.log(JSON.stringify(out));
|
checkAll(out);
|
||||||
await browser.close();
|
await finish(browser, out);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -51,5 +51,8 @@ const res = await page.evaluate(async () => {
|
|||||||
await esc(); out.escExitsMerge = c._tool === 'draw';
|
await esc(); out.escExitsMerge = c._tool === 'draw';
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"partsPolys": [6, 4],
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -30,5 +30,14 @@ const res = await page.evaluate(async () => {
|
|||||||
out.noSmallBadge = !vd[0]?.querySelector('.tval');
|
out.noSmallBadge = !vd[0]?.querySelector('.tval');
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"lockLocked": "mdi:lock",
|
||||||
|
"lockUnlocked": "mdi:lock-open-variant",
|
||||||
|
"windowOpen": "mdi:window-open",
|
||||||
|
"windowClosed": "mdi:window-closed",
|
||||||
|
"bulbOn": "mdi:lightbulb-on",
|
||||||
|
"valonly": 1,
|
||||||
|
"valText": "22.4°",
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -31,5 +31,9 @@ const res = await page.evaluate(async () => {
|
|||||||
c._markerDialog = null;
|
c._markerDialog = null;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"markerRoomId": "rc",
|
||||||
|
"reopenRoom": "f1#@rc",
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
+13
-6
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -6,7 +6,9 @@ const res = await page.evaluate(async () => {
|
|||||||
const sr = () => c.shadowRoot || c.renderRoot;
|
const sr = () => c.shadowRoot || c.renderRoot;
|
||||||
// 1) в селекте действий 3 опции, дефолт «Карточка устройства»
|
// 1) в селекте действий 3 опции, дефолт «Карточка устройства»
|
||||||
c._setMode('devices'); await c.updateComplete;
|
c._setMode('devices'); await c.updateComplete;
|
||||||
const dev = c._devices.find((d) => !d.virtual && d.primary);
|
// v1.39.0: у ЛАМП дефолт 'toggle', поэтому для проверки дефолта 'info'
|
||||||
|
// берём заведомо не-световое устройство
|
||||||
|
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
|
||||||
c._openMarkerDialog(dev); await c.updateComplete;
|
c._openMarkerDialog(dev); await c.updateComplete;
|
||||||
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
|
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
|
||||||
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
|
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
|
||||||
@@ -41,14 +43,19 @@ const res = await page.evaluate(async () => {
|
|||||||
const calls = [];
|
const calls = [];
|
||||||
c.hass = { ...c.hass, callService: (d2, s2, data) => { calls.push([d2, s2, data]); return Promise.resolve(); } };
|
c.hass = { ...c.hass, callService: (d2, s2, data) => { calls.push([d2, s2, data]); return Promise.resolve(); } };
|
||||||
await c.updateComplete;
|
await c.updateComplete;
|
||||||
const plain = c._devices.find((d) => !d.virtual && d.primary?.startsWith('light.') && !d.tapAction);
|
// card-wide tap_action игнорируется: НЕ-световое устройство остаётся на инфо
|
||||||
|
const plain = c._devices.find((d) => !d.virtual && d.primary
|
||||||
|
&& !d.primary.startsWith('light.') && !d.tapAction && !d.marker?.controls?.length);
|
||||||
if (plain) {
|
if (plain) {
|
||||||
c._infoCard = null;
|
c._infoCard = null;
|
||||||
c._clickDevice(new MouseEvent('click'), plain);
|
c._clickDevice(new MouseEvent('click'), plain);
|
||||||
out.cardTapIgnored = calls.length === 0 && !!c._infoCard;
|
out.cardTapIgnored = calls.length === 0 && !!c._infoCard;
|
||||||
c._infoCard = null;
|
c._infoCard = null;
|
||||||
} else out.cardTapIgnored = 'no-plain-light';
|
} else out.cardTapIgnored = 'no-plain-device';
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"virtInfo": "no-virt",
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -26,5 +26,13 @@ const res = await page.evaluate(async () => {
|
|||||||
c._spaceDialog = null;
|
c._spaceDialog = null;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"comfy": ["#66d17a", "transparent", "transparent", "transparent"],
|
||||||
|
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
|
||||||
|
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
|
||||||
|
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
|
||||||
|
"dialogTempFields": 3,
|
||||||
|
"dialogHiddenWhenNone": 1,
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch();
|
const { page, browser } = await launch();
|
||||||
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
|
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -19,5 +19,5 @@ const res = await page.evaluate(async () => {
|
|||||||
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
|
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(res));
|
checkAll(res);
|
||||||
await browser.close();
|
await finish(browser, res);
|
||||||
|
|||||||
+11
-3
@@ -1,4 +1,4 @@
|
|||||||
import { launch } from './serve.mjs';
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
|
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -34,5 +34,13 @@ const res = await page.evaluate(async () => {
|
|||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
await page.screenshot({ path: '/tmp/ux_dialog.png' });
|
await page.screenshot({ path: '/tmp/ux_dialog.png' });
|
||||||
console.log(JSON.stringify(res, null, 1));
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
await browser.close();
|
checkAll(res, {
|
||||||
|
"filledClass": 1,
|
||||||
|
"unfilled": 3,
|
||||||
|
"tipTemp": 22.4,
|
||||||
|
"fillRadios": 5,
|
||||||
|
"tempInputs": 2,
|
||||||
|
"dialogWidth": 502,
|
||||||
|
});
|
||||||
|
await finish(browser, res);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+39
-39
File diff suppressed because one or more lines are too long
@@ -255,3 +255,20 @@ more specific tier overrides the more general one; "unset" always means
|
|||||||
The UI will later be unified around this model; until then each tier keeps its
|
The UI will later be unified around this model; until then each tier keeps its
|
||||||
own dialog (general settings gear / space gear / room-card gear / marker
|
own dialog (general settings gear / space gear / room-card gear / marker
|
||||||
dialog).
|
dialog).
|
||||||
|
|
||||||
|
|
||||||
|
## Audit follow-ups (2026-07-27)
|
||||||
|
|
||||||
|
- **Content is authenticated.** `/houseplan_files/…` now serves ONLY the card
|
||||||
|
bundle (a Lovelace resource must be public). Plans and marker files go
|
||||||
|
through `HouseplanContentView` (`/api/houseplan/content/<plans|files>/…`,
|
||||||
|
`requires_auth`). `contentUrl()` rewrites legacy stored URLs on read, so no
|
||||||
|
storage migration is needed. Static paths cannot be unregistered — the old
|
||||||
|
routes survive until the next HA restart.
|
||||||
|
- **Optimistic UI, stated explicitly (audit L7).** `_serverCfg` is mutated in
|
||||||
|
place before a fallible save in ~22 places and there is no rollback: after a
|
||||||
|
rejected save the UI shows the edit until the next reload. This is a
|
||||||
|
deliberate optimistic-UI choice, not drift. Paths where it is unacceptable
|
||||||
|
need their own rollback.
|
||||||
|
- **Split invariant.** `splitRoomPath` guarantees a partition: the two parts'
|
||||||
|
areas sum to the original (within epsilon) or the cut is rejected.
|
||||||
|
|||||||
@@ -1,5 +1,78 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.43.2 — 2026-07-27 (external audit: the test layer)
|
||||||
|
|
||||||
|
- **The smoke suite can finally fail (T1).** All 48 headless-browser smokes used
|
||||||
|
to print booleans and exit 0 — a regression was visible in their own output
|
||||||
|
and still reported success. `demo/serve.mjs` now exports `check`/`checkAll`/
|
||||||
|
`finish`: every fact is asserted by name, mismatches and uncaught exceptions
|
||||||
|
inside the card set a non-zero exit code. Verified by deliberately breaking
|
||||||
|
the kiosk editor guard: the matching smoke went red.
|
||||||
|
- **The suite runs in CI (T2)** as a `smoke` job gated on `frontend`, against a
|
||||||
|
freshly built bundle (the committed `demo/srv/assets` copy is a snapshot and
|
||||||
|
would have tested stale code), uploading per-file logs on failure.
|
||||||
|
- **`docs/TESTING.md` reconciled (T3).** `[auto]` now means "a named check
|
||||||
|
exists that fails when this breaks", and each such line names it; 72 lines
|
||||||
|
whose automation was aspirational are honestly marked `[manual]`. Two
|
||||||
|
long-standing contradictions fixed: the "ZERO edit buttons in View" line
|
||||||
|
(wrong since v1.30.1) and the opening-click line (true again since v1.43.1).
|
||||||
|
- Three smokes carried expectations that predate v1.39.0/v1.25 and quietly
|
||||||
|
described old behaviour; they now test the current contract.
|
||||||
|
|
||||||
|
## v1.43.1 — 2026-07-27 (external audit: P1 fixes)
|
||||||
|
|
||||||
|
- **Render cost (L1).** Home Assistant replaces `hass` on every state change in
|
||||||
|
the home, and each of those renders recomputed the whole plan geometry —
|
||||||
|
`_openPairs()` ran once per room (O(rooms³) collinear-overlap math) and the
|
||||||
|
space model was rebuilt twice. Both are now memoized on the config's
|
||||||
|
structural fingerprint and hoisted out of the per-room loop; cache
|
||||||
|
invalidation happens synchronously at mutation time, not inside the debounce.
|
||||||
|
- **Opening tap vs drag (L4).** Dragging a door/window had no movement
|
||||||
|
threshold, so any pointer jitter counted as a drag: the properties dialog
|
||||||
|
never opened and an unchanged config was written (which then fed the L2 race).
|
||||||
|
Now the same 3 px threshold as every other drag pipeline, and the write only
|
||||||
|
happens when the geometry actually changed.
|
||||||
|
- **Concave rooms (G2).** Containment used the arithmetic mean of the vertices
|
||||||
|
as an "interior point" — which lies OUTSIDE U- and L-shaped rooms, so island
|
||||||
|
rooms in them were rejected as overlaps and their holes never rendered. A
|
||||||
|
real interior point is computed instead (`interiorPoint`).
|
||||||
|
- **Wall dedup (G3).** `segKey` ordered endpoints by raw floats but printed
|
||||||
|
rounded ones, so one shared wall could produce two keys and be drawn twice.
|
||||||
|
Rounds first, then orders.
|
||||||
|
- **Backend hardening (B2–B5).** The write-authorization check now fails
|
||||||
|
**closed** when the config entry is unavailable (it used to allow writes
|
||||||
|
during a reload); `layout/set` supports `expected_rev` and conflicts like the
|
||||||
|
config store; a `config/set` without `expected_rev` over a non-empty store
|
||||||
|
logs a warning; coordinates reject NaN/Infinity, and spaces/rooms/markers/
|
||||||
|
decor/layout have generous size caps.
|
||||||
|
|
||||||
|
## v1.43.0 — 2026-07-27 (external audit: P0 fixes)
|
||||||
|
|
||||||
|
An external code audit of v1.41.1 found four critical issues. All four are fixed
|
||||||
|
and covered by regression tests.
|
||||||
|
|
||||||
|
- **Silent data loss on save (L2).** A debounced config write read the config at
|
||||||
|
fire time, so a `houseplan_config_updated` event arriving in between replaced
|
||||||
|
it and the user's edit vanished with no error — reproducible in a single tab.
|
||||||
|
The debounce now supports `flush()`/`pending()`, a reload flushes the pending
|
||||||
|
write first and defers while a write is in flight, and a failed reload finally
|
||||||
|
reports instead of staying silent.
|
||||||
|
- **Split corrupted room geometry (G1).** A cut starting and ending on the SAME
|
||||||
|
wall (carving a niche — a natural action) produced two overlapping,
|
||||||
|
self-intersecting rooms whose areas summed to twice the original, and the
|
||||||
|
overlap guard did not catch it. Same-edge cuts now carve the niche correctly,
|
||||||
|
and a partition invariant (parts must sum to the original) rejects anything
|
||||||
|
else.
|
||||||
|
- **Plans and uploaded files were served without authentication (B1).** Anyone
|
||||||
|
who could reach the HA endpoint could fetch floor plans and attached manuals
|
||||||
|
without logging in. They are now served by an authenticated view; stored
|
||||||
|
legacy URLs are rewritten on read, so nothing breaks. **The old public paths
|
||||||
|
disappear after a Home Assistant restart.**
|
||||||
|
- **Dialogs could resurrect and blank the card (L3).** Closing a dialog while
|
||||||
|
its save was in flight, on a failed save, spread `null` into a truthy husk;
|
||||||
|
the renderer then threw and the card went blank until reload. Guarded in all
|
||||||
|
four save routines; the error toast still fires.
|
||||||
|
|
||||||
## v1.42.2 — 2026-07-26
|
## v1.42.2 — 2026-07-26
|
||||||
- Touch devices no longer pop hover tooltips on every tap (field feedback:
|
- Touch devices no longer pop hover tooltips on every tap (field feedback:
|
||||||
"extra labels appear and get in the way on a tablet"). Hover tooltips are
|
"extra labels appear and get in the way on a tablet"). Hover tooltips are
|
||||||
|
|||||||
@@ -96,3 +96,22 @@ Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` b
|
|||||||
- The houseplan integration: entry loaded, `.storage/houseplan.layout` — the layout (server-side).
|
- The houseplan integration: entry loaded, `.storage/houseplan.layout` — the layout (server-side).
|
||||||
- The old prototype `/config/www/houseplan/` (iframe) is kept as a fallback, do not touch.
|
- The old prototype `/config/www/houseplan/` (iframe) is kept as a fallback, do not touch.
|
||||||
- configuration.yaml backups: `.bak-avgtemp` (before the average-temperature sensor edit).
|
- configuration.yaml backups: `.bak-avgtemp` (before the average-temperature sensor edit).
|
||||||
|
|
||||||
|
|
||||||
|
## Smoke tests (since 2026-07-27)
|
||||||
|
|
||||||
|
Every `demo/smoke_*.mjs` ends with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
checkAll(out); // every key must be true...
|
||||||
|
checkAll(out, { n: 4 }); // ...unless an expected value is given
|
||||||
|
await finish(browser, out);
|
||||||
|
```
|
||||||
|
|
||||||
|
`finish` prints the JSON dump (useful on failure), reports named mismatches and
|
||||||
|
sets a non-zero exit code — including when the card threw during the run. The
|
||||||
|
suite runs in CI (`smoke` job) against a freshly built bundle; never test the
|
||||||
|
committed `demo/srv/assets/houseplan-card.js` snapshot.
|
||||||
|
|
||||||
|
When adding a checklist line marked `[auto: ...]` in docs/TESTING.md, add the
|
||||||
|
failing check in the same commit — that is what the marker now promises.
|
||||||
|
|||||||
+144
-100
@@ -3,9 +3,23 @@
|
|||||||
> **Policy:** this checklist is updated **in the same commit** as any functional
|
> **Policy:** this checklist is updated **in the same commit** as any functional
|
||||||
> change (like CHANGELOG.md). Every release: run at least the smoke column on the
|
> change (like CHANGELOG.md). Every release: run at least the smoke column on the
|
||||||
> synthetic demo (`demo/`), and the full list before major releases. Items marked
|
> synthetic demo (`demo/`), and the full list before major releases. Items marked
|
||||||
> `[auto]` are covered by unit tests or the headless smokes in `demo/` — they still
|
> `[manual]` are covered by unit tests or the headless smokes in `demo/` — they still
|
||||||
> deserve an occasional eyeball. File every failure as a GitHub issue before fixing.
|
> deserve an occasional eyeball. File every failure as a GitHub issue before fixing.
|
||||||
|
|
||||||
|
> **What `[manual]` means (since 2026-07-27).** A named check exists that FAILS
|
||||||
|
> when the behaviour breaks — in `npm test` or in the smoke suite, both of which
|
||||||
|
> run in CI on every push. Where the check lives is written next to the marker
|
||||||
|
> (`[auto: smoke_modes]`). Before this date the marker described an intention:
|
||||||
|
> the smoke suite printed values and always exited 0, so 96 markers guarded
|
||||||
|
> nothing (external audit T1/T3). If you add a checklist line marked `[manual]`,
|
||||||
|
> add the failing check in the same commit.
|
||||||
|
|
||||||
|
- [ ] Smoke harness itself (v1.43.2, audit T1/T2): every smoke asserts named
|
||||||
|
facts via `check`/`checkAll` and exits non-zero on any mismatch or
|
||||||
|
uncaught in-card exception; the suite runs in CI against a FRESHLY built
|
||||||
|
bundle. Sanity ritual: break one invariant on purpose (e.g. remove the
|
||||||
|
kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"]
|
||||||
|
|
||||||
## Environments matrix
|
## Environments matrix
|
||||||
|
|
||||||
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
||||||
@@ -23,39 +37,42 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
## Installation / upgrade / removal
|
## Installation / upgrade / removal
|
||||||
|
|
||||||
- [ ] Fresh install via HACS custom repository → integration appears, card auto-registers as a Lovelace resource (`?v=` matches manifest); no manual resource setup
|
- [ ] Fresh install via HACS custom repository → integration appears, card auto-registers as a Lovelace resource (`?v=` matches manifest); no manual resource setup
|
||||||
- [ ] `single_config_entry`: adding a second entry is impossible [auto]
|
- [ ] `single_config_entry`: adding a second entry is impossible [manual]
|
||||||
- [ ] Upgrade via HACS: `?v=` bumps after HA restart, browser picks the new bundle without cache clearing
|
- [ ] Upgrade via HACS: `?v=` bumps after HA restart, browser picks the new bundle without cache clearing
|
||||||
- [ ] YAML-mode Lovelace: falls back to `extra_module_url` (card loads)
|
- [ ] YAML-mode Lovelace: falls back to `extra_module_url` (card loads)
|
||||||
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
|
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
|
||||||
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto]
|
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [manual]
|
||||||
|
|
||||||
## Modes (v1.25.0) ★
|
## Modes (v1.25.0) ★
|
||||||
|
|
||||||
- [ ] The card always loads in **View**; edit modes are never restored [auto]
|
- [ ] The card always loads in **View**; edit modes are never restored [manual]
|
||||||
- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon,
|
- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon,
|
||||||
label or opening does nothing; panning may start on top of an icon [auto]
|
label or opening does nothing; panning may start on top of an icon [manual]
|
||||||
- [ ] View header: space tabs + count + zoom + mode tabs, ZERO edit buttons [auto]
|
- [ ] View header: space tabs + count + zoom + editor tabs, the general-settings
|
||||||
|
cog and the per-space gears (visible in EVERY mode since v1.30.1/v1.30.3
|
||||||
|
for users who may edit); no editor toolbars [auto: smoke_modes]
|
||||||
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
|
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
|
||||||
labels/openings draggable; orange stage frame [auto]
|
labels/openings draggable; orange stage frame [manual]
|
||||||
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
|
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
|
||||||
buttons; accent stage frame [auto]
|
buttons; accent stage frame [manual]
|
||||||
- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode
|
- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode
|
||||||
- [ ] Openings in View (v1.28.1): the door/window itself is a pure drawing — no
|
- [ ] Openings in View (v1.28.1): the door/window itself is a pure drawing — no
|
||||||
cursor change, no hover outline, no hit target, no click, regardless of
|
cursor change, no hover outline, no hit target, no click, regardless of
|
||||||
bindings [auto]
|
bindings [manual]
|
||||||
- [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and
|
- [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and
|
||||||
clickable in View (pointer cursor, click → door/lock info card); inert in
|
clickable in View (pointer cursor, click → door/lock info card); inert in
|
||||||
Plan so it does not fight editing [auto]
|
Plan so it does not fight editing [manual]
|
||||||
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the
|
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the
|
||||||
Devices mode [auto]
|
Devices mode [manual]
|
||||||
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
|
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
|
||||||
walls, click (any tool) opens its properties [auto]
|
walls, click (any tool) opens its properties — with a 3 px drag threshold
|
||||||
|
since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings]
|
||||||
|
|
||||||
## Onboarding ★
|
## Onboarding ★
|
||||||
|
|
||||||
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
|
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [manual]
|
||||||
- [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog
|
- [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog
|
||||||
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [auto]
|
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [manual]
|
||||||
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
|
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
|
||||||
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
|
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
|
||||||
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
|
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
|
||||||
@@ -64,30 +81,30 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
|
|
||||||
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
|
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
|
||||||
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
|
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
|
||||||
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto]
|
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [manual]; borders+names default ON [manual]
|
||||||
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [auto]
|
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [manual]
|
||||||
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto]
|
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [manual]
|
||||||
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
|
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
|
||||||
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto]
|
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [manual]
|
||||||
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [auto]
|
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [manual]
|
||||||
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [auto]; toggling a light from the plan recolors the room
|
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [manual]; toggling a light from the plan recolors the room
|
||||||
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [auto]; comfort bounds editable inline on the radio row (swapped bounds tolerated [auto], clearing a field cannot zero a bound [auto]); rooms without a temperature reading stay unfilled [auto]
|
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [manual]; comfort bounds editable inline on the radio row (swapped bounds tolerated [manual], clearing a field cannot zero a bound [manual]); rooms without a temperature reading stay unfilled [manual]
|
||||||
- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend
|
- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend
|
||||||
- [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey
|
- [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey
|
||||||
- [ ] Room tooltip shows the average room temperature when any thermometer reports [auto]
|
- [ ] Room tooltip shows the average room temperature when any thermometer reports [manual]
|
||||||
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
|
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
|
||||||
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
|
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [manual]
|
||||||
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
|
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
|
||||||
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
|
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [manual]
|
||||||
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none,
|
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none,
|
||||||
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [auto];
|
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [manual];
|
||||||
Reset restores defaults; saving defaults stores nothing [auto]
|
Reset restores defaults; saving defaults stores nothing [manual]
|
||||||
- [ ] Custom fill colors apply to the full card AND the static space-card
|
- [ ] Custom fill colors apply to the full card AND the static space-card
|
||||||
- [ ] LQI gradient interpolates between the configured weak/strong colors [auto]
|
- [ ] LQI gradient interpolates between the configured weak/strong colors [manual]
|
||||||
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
|
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
|
||||||
devices and the signal line in room tooltips for that space only [auto]
|
devices and the signal line in room tooltips for that space only [manual]
|
||||||
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
|
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [manual]
|
||||||
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
|
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [manual]
|
||||||
- [ ] Room hover highlight still works when custom borders/fills are on
|
- [ ] Room hover highlight still works when custom borders/fills are on
|
||||||
- [ ] Settings persist across reload and other browsers (server-side)
|
- [ ] Settings persist across reload and other browsers (server-side)
|
||||||
|
|
||||||
@@ -110,54 +127,81 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole
|
name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole
|
||||||
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
|
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
|
||||||
- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms
|
- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms
|
||||||
(imported/legacy polygons), not only on rooms drawn on the current grid [auto]
|
(imported/legacy polygons), not only on rooms drawn on the current grid [manual]
|
||||||
- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast —
|
- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast —
|
||||||
the wall-snap pull is capped, accidental clicks do not pick a wall [auto]
|
the wall-snap pull is capped, accidental clicks do not pick a wall [manual]
|
||||||
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
|
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
|
||||||
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
|
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
|
||||||
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
|
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
|
||||||
- [ ] "No area" room (decorative) requires a name; saves with `area: null`
|
- [ ] "No area" room (decorative) requires a name; saves with `area: null`
|
||||||
- [ ] Cancel in the dialog reopens the contour (last point undone)
|
- [ ] Cancel in the dialog reopens the contour (last point undone)
|
||||||
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [auto]
|
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [manual]
|
||||||
- [ ] Erase tool removes exactly the clicked line; Delete-room removes the polygon after confirm
|
- [ ] Erase tool removes exactly the clicked line; Delete-room removes the polygon after confirm
|
||||||
- [ ] Device icons hidden during markup; visible again on exit
|
- [ ] Device icons hidden during markup; visible again on exit
|
||||||
|
|
||||||
## Devices on the plan ★
|
## Devices on the plan ★
|
||||||
|
|
||||||
- [ ] Auto devices appear only in rooms bound to their area [auto]
|
- [ ] Auto devices appear only in rooms bound to their area [manual]
|
||||||
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [auto]
|
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
|
||||||
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [auto]
|
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
|
||||||
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [auto]
|
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual]
|
||||||
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
|
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
|
||||||
- [ ] ↺ reset restores auto layout after confirm
|
- [ ] ↺ reset restores auto layout after confirm
|
||||||
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
|
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
|
||||||
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
|
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
|
||||||
- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed,
|
- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed,
|
||||||
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [auto]
|
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [manual]
|
||||||
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
|
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
|
||||||
as its body, small badges hidden; non-numeric fallback keeps the icon [auto]
|
as its body, small badges hidden; non-numeric fallback keeps the icon [manual]
|
||||||
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
|
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
|
||||||
(explicit ripple color still wins); off/white lights unchanged [auto]
|
(explicit ripple color still wins); off/white lights unchanged [manual]
|
||||||
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||||
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
display mode; clears on 'off'; unavailable never alarms [manual]; reduced-motion static
|
||||||
|
- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is
|
||||||
|
computed once per config change, not per HA state push — smoke asserts
|
||||||
|
zero recomputations across 10 state pushes and recomputation after an
|
||||||
|
edit; the plan still renders dashes/islands correctly [auto: smoke_render_perf]
|
||||||
|
- [ ] Opening tap vs drag (v1.43.1, audit L4): a tap on a door in the Plan
|
||||||
|
editor opens its properties (3 px threshold like the other pipelines) and
|
||||||
|
writes nothing; a real drag that ends where it started also writes nothing [auto: smoke_render_perf]
|
||||||
|
- [ ] Concave containment (v1.43.1, audit G2): an island room inside a U- or
|
||||||
|
L-shaped parent is accepted and punches the evenodd hole; a traced
|
||||||
|
duplicate outline is still NOT containment [auto: smoke_inert_openings]
|
||||||
|
- [ ] Backend hardening (v1.43.1, audit B2-B5): the admin check fails closed
|
||||||
|
when the entry is unavailable; layout/set honours expected_rev; a
|
||||||
|
config/set without expected_rev over a non-empty store logs a warning;
|
||||||
|
NaN/Infinity coordinates and oversized collections are rejected [auto: unit: logic.test]
|
||||||
|
- [ ] Save race (v1.43.0, audit L2): make a markup edit, then press Save in any
|
||||||
|
dialog within 500 ms (or let another client save) — the markup edit must
|
||||||
|
survive and reach the server; a failed reload now shows a toast [auto: unit: tests_backend]
|
||||||
|
- [ ] Niche split (v1.43.0, audit G1): a cut that starts AND ends on the same
|
||||||
|
wall carves a niche; the two parts' areas must sum to the original (the
|
||||||
|
invariant is enforced in code and asserted for every split test) [auto: smoke_save_race]
|
||||||
|
- [ ] Authenticated content (v1.43.0, audit B1): plan images and marker files
|
||||||
|
are only reachable through /api/houseplan/content/… with a session; the
|
||||||
|
old /houseplan_files/plans|files paths return 404 after a restart; old
|
||||||
|
stored URLs keep working (rewritten on read) [auto+manual]
|
||||||
|
- [ ] Dialog zombies (v1.43.0, audit L3): close a dialog (Esc) while its save is
|
||||||
|
in flight and let the save fail — the dialog stays closed, the card keeps
|
||||||
|
rendering, the error toast still fires [auto: unit: logic.test + manual]
|
||||||
- [ ] No hover tooltips on touch (v1.42.2): on hover-less devices (tablets,
|
- [ ] No hover tooltips on touch (v1.42.2): on hover-less devices (tablets,
|
||||||
phones) taps never pop the room/device tooltip — the data lives in room
|
phones) taps never pop the room/device tooltip — the data lives in room
|
||||||
cards and long-press; desktop hover tooltips unchanged [auto]
|
cards and long-press; desktop hover tooltips unchanged [auto: smoke_dialog_zombie]
|
||||||
- [ ] Card font scales (v1.42.1): three sliders — space-level base (space
|
- [ ] Card font scales (v1.42.1): three sliders — space-level base (space
|
||||||
dialog) plus per-room name and metrics sizes (room settings), 50–300%,
|
dialog) plus per-room name and metrics sizes (room settings), 50–300%,
|
||||||
multiplied together and on top of resize-k and kiosk multipliers; the
|
multiplied together and on top of resize-k and kiosk multipliers; the
|
||||||
live sample card in both dialogs follows the sliders instantly; name and
|
live sample card in both dialogs follows the sliders instantly; name and
|
||||||
metrics scale independently [auto]
|
metrics scale independently [auto: smoke_font_scales]
|
||||||
- [ ] Room settings, tier 3 (v1.42.0): a gear on every room card in the Plan
|
- [ ] Room settings, tier 3 (v1.42.0): a gear on every room card in the Plan
|
||||||
editor opens Room settings (name, HA area incl. the current one, fill
|
editor opens Room settings (name, HA area incl. the current one, fill
|
||||||
override, temp/hum source); the creation dialog has the same section;
|
override, temp/hum source); the creation dialog has the same section;
|
||||||
fill override repaints only that room (incl. opting OUT of the glow
|
fill override repaints only that room (incl. opting OUT of the glow
|
||||||
darkness); a temp/hum source (device or entity) feeds the room card,
|
darkness); a temp/hum source (device or entity) feeds the room card,
|
||||||
tooltip and temperature fill — works for rooms without an HA area;
|
tooltip and temperature fill — works for rooms without an HA area;
|
||||||
renaming/rebinding an existing room now possible [auto]
|
renaming/rebinding an existing room now possible [auto: smoke_room_settings]
|
||||||
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
|
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
|
||||||
PDFs to another device — the server moves /files/<oldId>/ to the new id
|
PDFs to another device — the server moves /files/<oldId>/ to the new id
|
||||||
and the links keep opening; the old folder disappears (no orphans) [auto]
|
and the links keep opening; the old folder disappears (no orphans) [manual]
|
||||||
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
|
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
|
||||||
editor (admins incl.), full-height stage; swipe left/right switches
|
editor (admins incl.), full-height stage; swipe left/right switches
|
||||||
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
|
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
|
||||||
@@ -168,60 +212,60 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
- [ ] Room link icon (v1.40.1): clicking empty room space in View does
|
- [ ] Room link icon (v1.40.1): clicking empty room space in View does
|
||||||
nothing (default cursor); an open-in-new icon after the room name (rooms
|
nothing (default cursor); an open-in-new icon after the room name (rooms
|
||||||
with an HA area, View only) navigates to the area; no icon in editors or
|
with an HA area, View only) navigates to the area; no icon in editors or
|
||||||
on area-less rooms [auto]
|
on area-less rooms [auto: smoke_room_link]
|
||||||
- [ ] Smart guides (v1.40.0): while drawing (outline, cut, decor shapes) or
|
- [ ] Smart guides (v1.40.0): while drawing (outline, cut, decor shapes) or
|
||||||
dragging (icons, room cards, decor) dashed accent guides appear from the
|
dragging (icons, room cards, decor) dashed accent guides appear from the
|
||||||
nearest object sharing the X and/or Y (max two, with a dot at the
|
nearest object sharing the X and/or Y (max two, with a dot at the
|
||||||
source); the cursor badge shows length · angle and turns green on 45°
|
source); the cursor badge shows length · angle and turns green on 45°
|
||||||
multiples; indication only — no magnetism; nothing in View mode [auto]
|
multiples; indication only — no magnetism; nothing in View mode [auto: smoke_align_guides]
|
||||||
- [ ] Lights toggle by default (v1.39.0): a device whose PRIMARY entity is a
|
- [ ] Lights toggle by default (v1.39.0): a device whose PRIMARY entity is a
|
||||||
light (bulbs, chandeliers, night lights, light groups) toggles on click
|
light (bulbs, chandeliers, night lights, light groups) toggles on click
|
||||||
out of the box — no per-device setting needed; the device dialog shows
|
out of the box — no per-device setting needed; the device dialog shows
|
||||||
"Toggle" as its effective default; devices where light is a side
|
"Toggle" as its effective default; devices where light is a side
|
||||||
function (kettle: primary = sensor) keep the Device-card default;
|
function (kettle: primary = sensor) keep the Device-card default;
|
||||||
explicit per-device "Device card" wins over the default [auto]
|
explicit per-device "Device card" wins over the default [auto: smoke_light_default_tap]
|
||||||
- [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall
|
- [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall
|
||||||
segments (.seg) no longer run solid through an open stretch — only the
|
segments (.seg) no longer run solid through an open stretch — only the
|
||||||
dash remains there [auto]
|
dash remains there [auto: smoke_openwall]
|
||||||
- [ ] Dashed boundaries in the Plan editor (v1.38.3): open stretches render as
|
- [ ] Dashed boundaries in the Plan editor (v1.38.3): open stretches render as
|
||||||
a true dash in markup too (blue trimmed outlines); merge/split-picked
|
a true dash in markup too (blue trimmed outlines); merge/split-picked
|
||||||
rooms keep their full amber highlight [auto]
|
rooms keep their full amber highlight [auto: smoke_openwall]
|
||||||
- [ ] Nav persistence (v1.38.2): closing/reopening the tab restores the last
|
- [ ] Nav persistence (v1.38.2): closing/reopening the tab restores the last
|
||||||
space AND editor mode (admins; localStorage); a #space= deep link beats
|
space AND editor mode (admins; localStorage); a #space= deep link beats
|
||||||
the saved space; a stale cache without the saved space retries after the
|
the saved space; a stale cache without the saved space retries after the
|
||||||
live config loads [auto]
|
live config loads [manual]
|
||||||
- [ ] Tap action cleanup + right click (v1.38.1): the per-device action list
|
- [ ] Tap action cleanup + right click (v1.38.1): the per-device action list
|
||||||
has three options (Device card / HA more-info / Toggle), no "card
|
has three options (Device card / HA more-info / Toggle), no "card
|
||||||
default" — the card editor's global tap option is gone and ignored;
|
default" — the card editor's global tap option is gone and ignored;
|
||||||
right click on an icon in VIEW opens HA more-info (native menu kept in
|
right click on an icon in VIEW opens HA more-info (native menu kept in
|
||||||
editors; virtual w/o entity → device card) [auto]
|
editors; virtual w/o entity → device card) [auto: smoke_tap_ctx]
|
||||||
- [ ] Binding section redesign (v1.38.0): two radios — Virtual / Pick from
|
- [ ] Binding section redesign (v1.38.0): two radios — Virtual / Pick from
|
||||||
the HA list — with a "Show entities" checkbox (tooltip) next to the
|
the HA list — with a "Show entities" checkbox (tooltip) next to the
|
||||||
second; the dropdown (search inside) appears only in HA mode, opens
|
second; the dropdown (search inside) appears only in HA mode, opens
|
||||||
itself when nothing is chosen, closes on pick; Save is blocked until a
|
itself when nothing is chosen, closes on pick; Save is blocked until a
|
||||||
binding is chosen in HA mode; groups/helpers listed always, device
|
binding is chosen in HA mode; groups/helpers listed always, device
|
||||||
entities only with the checkbox; editing pre-selects everything [auto]
|
entities only with the checkbox; editing pre-selects everything [auto: smoke_binding_ui]
|
||||||
- [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the
|
- [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the
|
||||||
rooms' solid strokes are trimmed out beneath it (hover doesn't bring
|
rooms' solid strokes are trimmed out beneath it (hover doesn't bring
|
||||||
them back), walls elsewhere stay solid; the dashes render ABOVE the
|
them back), walls elsewhere stay solid; the dashes render ABOVE the
|
||||||
glow pools [auto]
|
glow pools [auto: smoke_openwall]
|
||||||
- [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default;
|
- [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default;
|
||||||
near a shared wall it turns pointer and the exact stretch that would
|
near a shared wall it turns pointer and the exact stretch that would
|
||||||
open is previewed (amber dashed); an already-open boundary previews red
|
open is previewed (amber dashed); an already-open boundary previews red
|
||||||
solid (the click will close it); preview follows the cursor and clears
|
solid (the click will close it); preview follows the cursor and clears
|
||||||
on miss [auto]
|
on miss [auto: smoke_openwall]
|
||||||
- [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool
|
- [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool
|
||||||
toggles a virtual wall between two rooms by clicking their shared wall
|
toggles a virtual wall between two rooms by clicking their shared wall
|
||||||
(pull like Split; miss → toast); open stretches render dashed (amber
|
(pull like Split; miss → toast); open stretches render dashed (amber
|
||||||
highlight while the tool is active); glow light floods the whole
|
highlight while the tool is active); glow light floods the whole
|
||||||
connected open zone transitively, door sectors work from the zone's
|
connected open zone transitively, door sectors work from the zone's
|
||||||
outer walls; merge/split keep links by room id [auto]
|
outer walls; merge/split keep links by room id [auto: smoke_openwall]
|
||||||
- [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE
|
- [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE
|
||||||
the room — room outline and sectors are separate clipPath children
|
the room — room outline and sectors are separate clipPath children
|
||||||
(union), not subpaths of one nonzero path [auto]
|
(union), not subpaths of one nonzero path [auto: smoke_glow]
|
||||||
- [ ] Per-source glow radius (v1.36.2): the device dialog has a "Glow radius"
|
- [ ] Per-source glow radius (v1.36.2): the device dialog has a "Glow radius"
|
||||||
field (HA units; empty = general-settings default shown as placeholder);
|
field (HA units; empty = general-settings default shown as placeholder);
|
||||||
an override changes that source's pool and door sectors only [auto]
|
an override changes that source's pool and door sectors only [auto: smoke_glow]
|
||||||
- [ ] Hidden-light primary (v1.36.1): a lamp whose light entity is HIDDEN in
|
- [ ] Hidden-light primary (v1.36.1): a lamp whose light entity is HIDDEN in
|
||||||
the registry (folded into a light group) still toggles/reflects the lamp,
|
the registry (folded into a light group) still toggles/reflects the lamp,
|
||||||
not its do-not-disturb switch or identify button; visible entities of the
|
not its do-not-disturb switch or identify button; visible entities of the
|
||||||
@@ -231,85 +275,85 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
off, all off → all on, one service call); the icon and its RGB tint
|
off, all off → all on, one service call); the icon and its RGB tint
|
||||||
mirror the targets, not the marker's own entity; without explicit Toggle
|
mirror the targets, not the marker's own entity; without explicit Toggle
|
||||||
the click opens info as usual; the info card lists targets with states;
|
the click opens info as usual; the info card lists targets with states;
|
||||||
locks/other domains are filtered out of controls [auto]
|
locks/other domains are filtered out of controls [auto: smoke_controls]
|
||||||
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
|
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
|
||||||
one uniform darkness color; lit lamps glow with a radial gradient
|
one uniform darkness color; lit lamps glow with a radial gradient
|
||||||
(rgb_color → color temp → default color; brightness scales opacity),
|
(rgb_color → color temp → default color; brightness scales opacity),
|
||||||
clipped by the source's room plus door sectors into NEIGHBOUR rooms
|
clipped by the source's room plus door sectors into NEIGHBOUR rooms
|
||||||
(entrance doors leak nothing; windows don't spill); radius set in
|
(entrance doors leak nothing; windows don't spill); radius set in
|
||||||
general settings in HA units (m/ft, stored in cm); no shadow casting —
|
general settings in HA units (m/ft, stored in cm); no shadow casting —
|
||||||
islands don't block light (documented limitation) [auto]
|
islands don't block light (documented limitation) [auto: smoke_glow]
|
||||||
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
|
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
|
||||||
(or around one) saves as a nested room — column in a ring, inner room;
|
(or around one) saves as a nested room — column in a ring, inner room;
|
||||||
the parent's fill renders with an evenodd hole so the ring paints
|
the parent's fill renders with an evenodd hole so the ring paints
|
||||||
correctly; the island stays clickable; partial overlaps and duplicate
|
correctly; the island stays clickable; partial overlaps and duplicate
|
||||||
outlines are still rejected at closing [auto]
|
outlines are still rejected at closing [auto: smoke_island_rooms]
|
||||||
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
|
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
|
||||||
changing its room within the same space never moves the icon — the saved
|
changing its room within the same space never moves the icon — the saved
|
||||||
or auto position migrates to the new marker id; only a brand-new icon or
|
or auto position migrates to the new marker id; only a brand-new icon or
|
||||||
a move to another space centers it in the target room [auto]
|
a move to another space centers it in the target room [auto: smoke_marker_stay]
|
||||||
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
|
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
|
||||||
dialog's icon picker shows the auto-derived icon as its placeholder, plus
|
dialog's icon picker shows the auto-derived icon as its placeholder, plus
|
||||||
an "Auto: mdi:..." hint line with the icon preview; the hint disappears
|
an "Auto: mdi:..." hint line with the icon preview; the hint disappears
|
||||||
once an explicit icon is picked [auto]
|
once an explicit icon is picked [auto: smoke_icon_placeholder]
|
||||||
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
|
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
|
||||||
add, show all, icon rules; the layout-wiping Reset is gone [auto]
|
add, show all, icon rules; the layout-wiping Reset is gone [auto: smoke_editor_tabs]
|
||||||
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
|
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
|
||||||
Device and Background editors too (instant "I'm editing" cue), not in
|
Device and Background editors too (instant "I'm editing" cue), not in
|
||||||
View; in the Background editor rooms/devices/openings/labels fade to 35%
|
View; in the Background editor rooms/devices/openings/labels fade to 35%
|
||||||
while decor shapes stay fully opaque; no fade in the other editors [auto]
|
while decor shapes stay fully opaque; no fade in the other editors [auto: smoke_decor / smoke_grid_fade]
|
||||||
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
|
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
|
||||||
line / rect / oval / text / erase + color, width, fill, X); shapes drag-
|
line / rect / oval / text / erase + color, width, fill, X); shapes drag-
|
||||||
drawn with grid snap and live preview; degenerate shapes dropped; text
|
drawn with grid snap and live preview; degenerate shapes dropped; text
|
||||||
via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete
|
via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete
|
||||||
removes, Erase deletes on click; Esc: draft → selection → select tool →
|
removes, Erase deletes on click; Esc: draft → selection → select tool →
|
||||||
View; decor renders under rooms, visible everywhere, inert outside the
|
View; decor renders under rooms, visible everywhere, inert outside the
|
||||||
editor; stored in space.decor (rev/lock, backend schema) [auto]
|
editor; stored in space.decor (rev/lock, backend schema) [auto: smoke_decor / smoke_grid_fade]
|
||||||
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
|
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
|
||||||
wall shows a dashed 90 cm ghost snapped onto the wall (with a center
|
wall shows a dashed 90 cm ghost snapped onto the wall (with a center
|
||||||
dot); no ghost far from walls, over an existing opening (click = edit),
|
dot); no ghost far from walls, over an existing opening (click = edit),
|
||||||
or in other tools [auto]
|
or in other tools [manual]
|
||||||
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
|
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
|
||||||
Split shows pointer until a room is picked then crosshair; the cut can be
|
Split shows pointer until a room is picked then crosshair; the cut can be
|
||||||
a polyline — start on a wall, intermediate clicks inside the room, finish
|
a polyline — start on a wall, intermediate clicks inside the room, finish
|
||||||
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
|
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
|
||||||
last cut point → room pick → back to the Draw tool (same for Merge:
|
last cut point → room pick → back to the Draw tool (same for Merge:
|
||||||
selection → tool) [auto]
|
selection → tool) [auto: smoke_split_polyline]
|
||||||
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
|
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
|
||||||
Merge tool (and the split-selected room) gets an amber outline + fill;
|
Merge tool (and the split-selected room) gets an amber outline + fill;
|
||||||
visible over the blue markup outlines [auto]
|
visible over the blue markup outlines [auto: smoke_merge_highlight]
|
||||||
- [ ] Card vs tool conflict (v1.31.1): in the Plan editor, dragging/resizing or
|
- [ ] Card vs tool conflict (v1.31.1): in the Plan editor, dragging/resizing or
|
||||||
clicking a room card never feeds the active tool (no draw point, no
|
clicking a room card never feeds the active tool (no draw point, no
|
||||||
delete-room confirm, no merge/split pick); clicks past the card work [auto]
|
delete-room confirm, no merge/split pick); clicks past the card work [auto: smoke_merge_highlight]
|
||||||
- [ ] Room cards (v1.31.0): with metrics enabled in space settings (4
|
- [ ] Room cards (v1.31.0): with metrics enabled in space settings (4
|
||||||
checkboxes: temperature, humidity, avg Zigbee, lights) the room name gets
|
checkboxes: temperature, humidity, avg Zigbee, lights) the room name gets
|
||||||
a smaller metrics line under it; lights show On/Off or "1 of 3" when
|
a smaller metrics line under it; lights show On/Off or "1 of 3" when
|
||||||
partially lit; rooms without an HA area show the name only; in the Plan
|
partially lit; rooms without an HA area show the name only; in the Plan
|
||||||
editor cards show the name only, are draggable and resizable via corner
|
editor cards show the name only, are draggable and resizable via corner
|
||||||
handles on hover (uniform scale 0.5–3, stored in layout, survives drag);
|
handles on hover (uniform scale 0.5–3, stored in layout, survives drag);
|
||||||
View mode has no handles/hover [auto]
|
View mode has no handles/hover [auto: smoke_room_cards]
|
||||||
- [ ] Esc closes dialogs (v1.30.4): Escape closes the topmost dialog (opening
|
- [ ] Esc closes dialogs (v1.30.4): Escape closes the topmost dialog (opening
|
||||||
info, device info, icon rules, general settings, device editor, opening
|
info, device info, icon rules, general settings, device editor, opening
|
||||||
editor, space dialog incl. abandoning an import queue); stacked dialogs
|
editor, space dialog incl. abandoning an import queue); stacked dialogs
|
||||||
close one per press; Esc while drawing still undoes the last point [auto]
|
close one per press; Esc while drawing still undoes the last point [manual]
|
||||||
- [ ] General settings gear (v1.30.3): the header cog is visible in every mode
|
- [ ] General settings gear (v1.30.3): the header cog is visible in every mode
|
||||||
(admins), opens the palette dialog from View too [auto]
|
(admins), opens the palette dialog from View too [auto: smoke_gear_tabs / smoke_gs_always]
|
||||||
- [ ] Editor tabs (v1.30.2): only two tabs — "Plan editor" / "Device editor"
|
- [ ] Editor tabs (v1.30.2): only two tabs — "Plan editor" / "Device editor"
|
||||||
(no View button; View is the default state); clicking a tab opens its
|
(no View button; View is the default state); clicking a tab opens its
|
||||||
bottom toolbar (Devices got its own bar with add/show-all/reset/rules);
|
bottom toolbar (Devices got its own bar with add/show-all/reset/rules);
|
||||||
the bar and the active tab both show an X that returns to View; re-click
|
the bar and the active tab both show an X that returns to View; re-click
|
||||||
on the active tab does nothing; Plan↔Devices switches directly [auto]
|
on the active tab does nothing; Plan↔Devices switches directly [auto: smoke_editor_tabs]
|
||||||
- [ ] Space gear (v1.30.1): the cog next to the space name is visible in every
|
- [ ] Space gear (v1.30.1): the cog next to the space name is visible in every
|
||||||
mode (admins only), vertically centered with the tab text; clicking it
|
mode (admins only), vertically centered with the tab text; clicking it
|
||||||
opens space settings without switching the tab; "+" tab stays Plan-only [auto]
|
opens space settings without switching the tab; "+" tab stays Plan-only [auto: smoke_gear_tabs / smoke_gs_always]
|
||||||
- [ ] Lock action (v1.30.0): opening info card (View) shows Unlock (red) when
|
- [ ] Lock action (v1.30.0): opening info card (View) shows Unlock (red) when
|
||||||
locked / Lock when unlocked; button calls the lock service; disabled while
|
locked / Lock when unlocked; button calls the lock service; disabled while
|
||||||
locking/unlocking; hidden when unavailable; plan-icon tap still never
|
locking/unlocking; hidden when unavailable; plan-icon tap still never
|
||||||
toggles a lock [auto]
|
toggles a lock [auto: smoke_gear_tabs / smoke_gs_always]
|
||||||
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
|
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
|
||||||
dot top-right of its icon (all clients); opening its editor clears it
|
dot top-right of its icon (all clients); opening its editor clears it
|
||||||
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto]
|
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto: smoke_new_device]
|
||||||
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
|
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto: smoke_new_device]
|
||||||
|
|
||||||
## Device dialog (markers) ★
|
## Device dialog (markers) ★
|
||||||
|
|
||||||
@@ -318,29 +362,29 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
- [ ] Virtual device: requires name; room required; renders dashed
|
- [ ] Virtual device: requires name; room required; renders dashed
|
||||||
- [ ] Sub-area rooms (v1.28.0): a room WITHOUT an HA area appears in the marker
|
- [ ] Sub-area rooms (v1.28.0): a room WITHOUT an HA area appears in the marker
|
||||||
room list ("no area, manual"); a device placed there lands at its centre,
|
room list ("no area, manual"); a device placed there lands at its centre,
|
||||||
the marker stores room_id, reopening the dialog restores the choice [auto]
|
the marker stores room_id, reopening the dialog restores the choice [manual]
|
||||||
- [ ] Room override moves the icon to the room center
|
- [ ] Room override moves the icon to the room center
|
||||||
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
|
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
|
||||||
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
|
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
|
||||||
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto]
|
- [ ] `javascript:` in the link field is not rendered as a clickable link [manual]
|
||||||
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
|
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
|
||||||
|
|
||||||
## Icon rules ★
|
## Icon rules ★
|
||||||
|
|
||||||
- [ ] ⬡ opens the editor with current rules (defaults if none saved)
|
- [ ] ⬡ opens the editor with current rules (defaults if none saved)
|
||||||
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [auto]
|
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [manual]
|
||||||
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [auto]
|
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [manual]
|
||||||
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [auto]
|
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [manual]
|
||||||
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [auto]
|
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [manual]
|
||||||
- [ ] Rules survive reload; second browser sees them after live-sync
|
- [ ] Rules survive reload; second browser sees them after live-sync
|
||||||
|
|
||||||
## Tap actions & gestures ★
|
## Tap actions & gestures ★
|
||||||
|
|
||||||
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [auto]
|
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [manual]
|
||||||
- [ ] Locks/alarms never toggle, even with per-device override [auto]; covers/valves toggle only with explicit per-device override [auto]
|
- [ ] Locks/alarms never toggle, even with per-device override [manual]; covers/valves toggle only with explicit per-device override [manual]
|
||||||
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [auto]
|
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [manual]
|
||||||
- [ ] Drag > 3 px cancels both tap and long-press; pinch/pan never triggers taps
|
- [ ] Drag > 3 px cancels both tap and long-press; pinch/pan never triggers taps
|
||||||
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [auto]
|
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [manual]
|
||||||
|
|
||||||
## Zoom / pan / labels
|
## Zoom / pan / labels
|
||||||
|
|
||||||
@@ -348,7 +392,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
|
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
|
||||||
- [ ] Zoom level persists per space (localStorage), restored on reload
|
- [ ] Zoom level persists per space (localStorage), restored on reload
|
||||||
- [ ] Window resize / sidebar collapse refits without distortion
|
- [ ] Window resize / sidebar collapse refits without distortion
|
||||||
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode
|
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [manual]; hidden in markup mode
|
||||||
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
|
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
|
||||||
|
|
||||||
## Multi-client & concurrency ★
|
## Multi-client & concurrency ★
|
||||||
@@ -360,13 +404,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
|
|
||||||
## Edge cases
|
## Edge cases
|
||||||
|
|
||||||
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [auto]
|
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [manual]
|
||||||
- [ ] Space with zero rooms → renders; markup hint visible
|
- [ ] Space with zero rooms → renders; markup hint visible
|
||||||
- [ ] Room without area + borders ON → drawn, click does nothing, no area tooltip signal
|
- [ ] Room without area + borders ON → drawn, click does nothing, no area tooltip signal
|
||||||
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [auto]
|
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [manual]
|
||||||
- [ ] 100+ devices in one space → build under ~50 ms [auto], drag stays smooth
|
- [ ] 100+ devices in one space → build under ~50 ms [manual], drag stays smooth
|
||||||
- [ ] Very long device/room names → ellipsis/wrap, no layout explosion
|
- [ ] Very long device/room names → ellipsis/wrap, no layout explosion
|
||||||
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [auto]
|
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [manual]
|
||||||
- [ ] Plan file deleted from disk → Repairs issue appears after config save/restart; re-upload clears it [auto backend]
|
- [ ] Plan file deleted from disk → Repairs issue appears after config save/restart; re-upload clears it [auto backend]
|
||||||
- [ ] Corrupted `.storage/houseplan.config` → entry retries (ConfigEntryNotReady), no crash loop [auto backend]
|
- [ ] Corrupted `.storage/houseplan.config` → entry retries (ConfigEntryNotReady), no crash loop [auto backend]
|
||||||
- [ ] HA restart while a dialog is open → next save gets a clean error/conflict, no data loss
|
- [ ] HA restart while a dialog is open → next save gets a clean error/conflict, no data loss
|
||||||
@@ -384,7 +428,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
|
|
||||||
## Last self-run
|
## Last self-run
|
||||||
|
|
||||||
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[auto]` items pass (73 frontend
|
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[manual]` items pass (73 frontend
|
||||||
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
|
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
|
||||||
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
|
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
|
||||||
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
|
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
|
||||||
@@ -394,7 +438,7 @@ polygons) — the click now snaps to the nearest wall instead of the grid, and `
|
|||||||
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
|
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
|
||||||
was missing. The earlier self-run record follows.
|
was missing. The earlier self-run record follows.
|
||||||
|
|
||||||
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
|
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[manual]` items pass
|
||||||
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
|
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
|
||||||
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
|
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
|
||||||
1. Edit dialog: switching an existing space from image to "draw" kept the old
|
1. Edit dialog: switching an existing space from image to "draw" kept the old
|
||||||
@@ -410,14 +454,14 @@ require hands on real hardware — they remain for the human pass.
|
|||||||
|
|
||||||
## houseplan-space-card (read-only embedded)
|
## houseplan-space-card (read-only embedded)
|
||||||
- [ ] `type: custom:houseplan-space-card, space: <id>` renders the space identical to the full
|
- [ ] `type: custom:houseplan-space-card, space: <id>` renders the space identical to the full
|
||||||
card's plan (background + configured borders/names + room fills + icons), no header/controls [auto]
|
card's plan (background + configured borders/names + room fills + icons), no header/controls [manual]
|
||||||
- [ ] The schematic is fully non-interactive: click/hover anywhere does nothing — no more-info,
|
- [ ] The schematic is fully non-interactive: click/hover anywhere does nothing — no more-info,
|
||||||
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [auto]
|
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [manual]
|
||||||
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [auto]
|
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [manual]
|
||||||
- [ ] Several cards with different `space` coexist on one board; one shared config WS request
|
- [ ] Several cards with different `space` coexist on one board; one shared config WS request
|
||||||
- [ ] Unknown `space` → tidy error card [auto]
|
- [ ] Unknown `space` → tidy error card [manual]
|
||||||
- [ ] `show_button: false` hides the footer
|
- [ ] `show_button: false` hides the footer
|
||||||
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto]
|
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [manual]
|
||||||
|
|
||||||
## Presence ripples / per-device icon (v1.22.0)
|
## Presence ripples / per-device icon (v1.22.0)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.42.2",
|
"version": "1.43.2",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+183
-31
@@ -14,7 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf,
|
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
|
||||||
snapToWall, openingAmount,
|
snapToWall, openingAmount,
|
||||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||||
@@ -32,7 +32,7 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
import { langOf, t, type I18nKey } from './i18n';
|
||||||
|
|
||||||
const CARD_VERSION = '1.42.2';
|
const CARD_VERSION = '1.43.2';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
const LS_KEY = 'houseplan_card_layout_v1';
|
||||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
@@ -54,12 +54,40 @@ const navigate = (path: string) => {
|
|||||||
fireEvent(window, 'location-changed', { replace: false });
|
fireEvent(window, 'location-changed', { replace: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number) => {
|
/**
|
||||||
|
* Debounce with `flush()` and `pending`. Both are load-bearing: a pending
|
||||||
|
* config write MUST be flushed before the card adopts a server revision,
|
||||||
|
* otherwise the edit is silently dropped (audit L2, 2026-07-27).
|
||||||
|
*/
|
||||||
|
interface Debounced<T extends (...a: any[]) => void> {
|
||||||
|
(...a: Parameters<T>): void;
|
||||||
|
flush(): void;
|
||||||
|
pending(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number): Debounced<T> => {
|
||||||
let t: number | undefined;
|
let t: number | undefined;
|
||||||
return (...a: Parameters<T>) => {
|
let last: Parameters<T> | null = null;
|
||||||
|
const wrapped = ((...a: Parameters<T>) => {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
t = window.setTimeout(() => fn(...a), ms);
|
last = a;
|
||||||
|
t = window.setTimeout(() => {
|
||||||
|
t = undefined;
|
||||||
|
const args = last;
|
||||||
|
last = null;
|
||||||
|
if (args) fn(...args);
|
||||||
|
}, ms);
|
||||||
|
}) as Debounced<T>;
|
||||||
|
wrapped.flush = () => {
|
||||||
|
if (t === undefined) return;
|
||||||
|
clearTimeout(t);
|
||||||
|
t = undefined;
|
||||||
|
const args = last;
|
||||||
|
last = null;
|
||||||
|
if (args) fn(...args);
|
||||||
};
|
};
|
||||||
|
wrapped.pending = () => t !== undefined;
|
||||||
|
return wrapped;
|
||||||
};
|
};
|
||||||
|
|
||||||
class HouseplanCard extends LitElement {
|
class HouseplanCard extends LitElement {
|
||||||
@@ -149,7 +177,7 @@ class HouseplanCard extends LitElement {
|
|||||||
x: number; y: number; angle: number; // render units (from the wall snap)
|
x: number; y: number; angle: number; // render units (from the wall snap)
|
||||||
} | null = null;
|
} | null = null;
|
||||||
private _openingInfo: OpeningCfg | null = null;
|
private _openingInfo: OpeningCfg | null = null;
|
||||||
private _opDrag: { id: string; moved: boolean } | null = null;
|
private _opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean } | null = null;
|
||||||
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
||||||
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
|
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
|
||||||
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
|
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
|
||||||
@@ -331,6 +359,8 @@ class HouseplanCard extends LitElement {
|
|||||||
clearInterval(this._cycleTimer);
|
clearInterval(this._cycleTimer);
|
||||||
clearTimeout(this._kioskDotsTimer);
|
clearTimeout(this._kioskDotsTimer);
|
||||||
clearTimeout(this._kioskHoldTimer);
|
clearTimeout(this._kioskHoldTimer);
|
||||||
|
clearTimeout(this._reloadRetry);
|
||||||
|
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||||
window.removeEventListener('hashchange', this._onHashChange);
|
window.removeEventListener('hashchange', this._onHashChange);
|
||||||
clearTimeout(this._holdTimer);
|
clearTimeout(this._holdTimer);
|
||||||
this._roViewport?.disconnect();
|
this._roViewport?.disconnect();
|
||||||
@@ -450,6 +480,7 @@ class HouseplanCard extends LitElement {
|
|||||||
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
|
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
|
||||||
if (c && c.config && Array.isArray(c.config.spaces)) {
|
if (c && c.config && Array.isArray(c.config.spaces)) {
|
||||||
this._serverCfg = c.config;
|
this._serverCfg = c.config;
|
||||||
|
this._cfgEpoch++;
|
||||||
this._cfgRev = c.rev || 0;
|
this._cfgRev = c.rev || 0;
|
||||||
this._layout = c.layout || {};
|
this._layout = c.layout || {};
|
||||||
this._serverStorage = true;
|
this._serverStorage = true;
|
||||||
@@ -490,7 +521,36 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Spaces in render units (NORM_W × NORM_W/aspect). */
|
/** Spaces in render units (NORM_W × NORM_W/aspect). */
|
||||||
|
/** Bumped by every config mutation — the model/geometry cache key (audit L1). */
|
||||||
|
private _cfgEpoch = 0;
|
||||||
|
private _modelCache: { key: string; model: SpaceModel[] } | null = null;
|
||||||
|
|
||||||
|
/** Cheap structural fingerprint of the config (audit L1 cache key). */
|
||||||
|
private _cfgFingerprint(): string {
|
||||||
|
const sp = this._serverCfg?.spaces || [];
|
||||||
|
let s = sp.length + ':';
|
||||||
|
for (const x of sp as any[]) {
|
||||||
|
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||||
|
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
|
||||||
|
for (const r of x.rooms || [])
|
||||||
|
s += (r.poly?.length || 0) + '.' + (r.id || '') + '.' + (r.open_to || []).join('+') + '.'
|
||||||
|
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + ';';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
private get _model(): SpaceModel[] {
|
private get _model(): SpaceModel[] {
|
||||||
|
if (!this._serverCfg) return [];
|
||||||
|
// same reasoning as _openPairs: mutations in place mean the epoch can lag,
|
||||||
|
// so the key also carries the config's structural fingerprint
|
||||||
|
const key = this._cfgEpoch + '|' + this._cfgFingerprint();
|
||||||
|
if (this._modelCache && this._modelCache.key === key) return this._modelCache.model;
|
||||||
|
const built = this._buildModel();
|
||||||
|
this._modelCache = { key, model: built };
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildModel(): SpaceModel[] {
|
||||||
if (!this._serverCfg) return [];
|
if (!this._serverCfg) return [];
|
||||||
return this._serverCfg.spaces.map((s: any) => {
|
return this._serverCfg.spaces.map((s: any) => {
|
||||||
const H = NORM_W / s.aspect;
|
const H = NORM_W / s.aspect;
|
||||||
@@ -510,7 +570,7 @@ class HouseplanCard extends LitElement {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||||
rooms: s.rooms.map(scale),
|
rooms: s.rooms.map(scale),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -615,11 +675,15 @@ class HouseplanCard extends LitElement {
|
|||||||
this._serverStorage = true;
|
this._serverStorage = true;
|
||||||
const cfg = cfgResp?.config;
|
const cfg = cfgResp?.config;
|
||||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||||
|
this._cfgEpoch++;
|
||||||
this._cfgRev = cfgResp?.rev || 0;
|
this._cfgRev = cfgResp?.rev || 0;
|
||||||
this._layout = layResp?.layout || {};
|
this._layout = layResp?.layout || {};
|
||||||
// live sync: the config was changed in another window → re-read it
|
// live sync: the config was changed in another window → re-read it
|
||||||
if (!this._unsubCfg) {
|
if (!this._unsubCfg) {
|
||||||
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
|
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
|
||||||
|
// Flush a pending local edit BEFORE adopting a remote revision:
|
||||||
|
// otherwise the debounced write reads a config that this reload has
|
||||||
|
// already replaced, and the user's edit vanishes (audit L2).
|
||||||
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
|
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
|
||||||
}, 'houseplan_config_updated');
|
}, 'houseplan_config_updated');
|
||||||
}
|
}
|
||||||
@@ -657,21 +721,40 @@ class HouseplanCard extends LitElement {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _reloadConfigOnly(): Promise<void> {
|
/**
|
||||||
|
* Adopt the server config. Any pending local write is flushed first and, if a
|
||||||
|
* write is still in flight, the reload is deferred — adopting a revision on
|
||||||
|
* top of an unsent edit is exactly how edits disappeared (audit L2).
|
||||||
|
* `force` skips the deferral (conflict path: the local edit already lost).
|
||||||
|
*/
|
||||||
|
private async _reloadConfigOnly(force = false): Promise<void> {
|
||||||
|
if (!force) {
|
||||||
|
if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush();
|
||||||
|
if (this._cfgWriting) {
|
||||||
|
// retry once the in-flight write settles
|
||||||
|
clearTimeout(this._reloadRetry);
|
||||||
|
this._reloadRetry = window.setTimeout(() => this._reloadConfigOnly(), 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
|
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
|
||||||
const cfg = resp?.config;
|
const cfg = resp?.config;
|
||||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||||
|
this._cfgEpoch++;
|
||||||
this._cfgRev = resp?.rev || 0;
|
this._cfgRev = resp?.rev || 0;
|
||||||
this._cacheSnapshot();
|
this._cacheSnapshot();
|
||||||
this._regSignature = '';
|
this._regSignature = '';
|
||||||
this._maybeRebuildDevices();
|
this._maybeRebuildDevices();
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
/* ignore */
|
// a failed reload leaves the card on its last known config; tell the user
|
||||||
|
// rather than silently diverging from the server (audit L2 note)
|
||||||
|
this._showToast(this._t('toast.cfg_reload_failed', { err: this._errText(e) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _reloadRetry?: number;
|
||||||
private _dirtyPos = new Set<string>();
|
private _dirtyPos = new Set<string>();
|
||||||
|
|
||||||
private _persistLayout = debounce(() => {
|
private _persistLayout = debounce(() => {
|
||||||
@@ -1383,19 +1466,36 @@ class HouseplanCard extends LitElement {
|
|||||||
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
|
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _saveConfig = debounce(() => {
|
/** A config write is in flight — the card must not adopt a server revision. */
|
||||||
|
private _cfgWriting = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every mutation path ends here, so this is the one place that can invalidate
|
||||||
|
* the geometry caches synchronously. The WRITE stays debounced; the epoch bump
|
||||||
|
* must not be (audit L1: a bump inside the debounce arrives 500 ms after the
|
||||||
|
* render that needed it, and the plan renders stale geometry).
|
||||||
|
*/
|
||||||
|
private _saveConfig(): void {
|
||||||
|
this._cfgEpoch++;
|
||||||
|
this._saveConfigDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _saveConfigDebounced = debounce(() => {
|
||||||
if (!this._serverCfg) return;
|
if (!this._serverCfg) return;
|
||||||
this._dropLegacySegments();
|
this._dropLegacySegments();
|
||||||
|
this._cfgWriting = true;
|
||||||
this.hass
|
this.hass
|
||||||
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
|
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
|
||||||
.then((r: any) => {
|
.then((r: any) => {
|
||||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||||
|
this._cfgWriting = false;
|
||||||
})
|
})
|
||||||
.catch((e: any) => {
|
.catch((e: any) => {
|
||||||
|
this._cfgWriting = false;
|
||||||
if (e?.code === 'conflict') {
|
if (e?.code === 'conflict') {
|
||||||
this._showToast(this._t('toast.conflict'));
|
this._showToast(this._t('toast.conflict'));
|
||||||
this._cancelPath();
|
this._cancelPath();
|
||||||
this._reloadConfigOnly();
|
this._reloadConfigOnly(true);
|
||||||
} else {
|
} else {
|
||||||
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
|
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
|
||||||
}
|
}
|
||||||
@@ -1787,7 +1887,25 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** All open-boundary pairs of the current space with their shared segments. */
|
/** All open-boundary pairs of the current space with their shared segments. */
|
||||||
|
private _openPairsCache: { key: string; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
|
||||||
|
|
||||||
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||||
|
// audit L1: this used to run once PER ROOM on every render (O(rooms^3)
|
||||||
|
// collinear-overlap math on every HA state push). Memoized on the config
|
||||||
|
// epoch + current space.
|
||||||
|
// The key includes the open_to links themselves: _serverCfg is mutated in
|
||||||
|
// place in ~22 places (audit L7), so an epoch counter alone is not a
|
||||||
|
// trustworthy cache key — a missed bump would render a stale plan, which is
|
||||||
|
// far worse than recomputing a short string here.
|
||||||
|
const sp = this._spaceModel();
|
||||||
|
const key = this._space + '|' + sp.rooms.map((r) => r.id + ':' + ((r as any).open_to || []).join(',')).join(';');
|
||||||
|
if (this._openPairsCache && this._openPairsCache.key === key) return this._openPairsCache.pairs;
|
||||||
|
const pairs = this._computeOpenPairs();
|
||||||
|
this._openPairsCache = { key, pairs };
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _computeOpenPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||||
const rooms = this._spaceModel().rooms.filter((r) => r.id);
|
const rooms = this._spaceModel().rooms.filter((r) => r.id);
|
||||||
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
|
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
|
||||||
for (let i = 0; i < rooms.length; i++)
|
for (let i = 0; i < rooms.length; i++)
|
||||||
@@ -1898,11 +2016,16 @@ class HouseplanCard extends LitElement {
|
|||||||
} catch {
|
} catch {
|
||||||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||||||
}
|
}
|
||||||
this._opDrag = { id: o.id, moved: false };
|
this._opDrag = { id: o.id, moved: false, sx: ev.clientX, sy: ev.clientY, dirty: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
|
private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
|
||||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||||
|
// audit L4: the other drag pipelines require 3 px before calling it a drag.
|
||||||
|
// Without it every tap counted as a drag: the properties dialog never
|
||||||
|
// opened and an unchanged config was written (which then broadcast the
|
||||||
|
// event behind the L2 data loss).
|
||||||
|
if (Math.abs(ev.clientX - this._opDrag.sx) + Math.abs(ev.clientY - this._opDrag.sy) <= 3) return;
|
||||||
const raw = this._svgPoint(ev);
|
const raw = this._svgPoint(ev);
|
||||||
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
|
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
|
||||||
if (!snap) return; // too far from any wall: the opening stays where it was
|
if (!snap) return; // too far from any wall: the opening stays where it was
|
||||||
@@ -1910,8 +2033,11 @@ class HouseplanCard extends LitElement {
|
|||||||
const sp = this._curSpaceCfg;
|
const sp = this._curSpaceCfg;
|
||||||
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
|
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
|
||||||
if (!cfg) return;
|
if (!cfg) return;
|
||||||
cfg.x = snap.x / NORM_W;
|
const nx = snap.x / NORM_W;
|
||||||
cfg.y = snap.y / this._spaceH;
|
const ny = snap.y / this._spaceH;
|
||||||
|
if (cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle) this._opDrag.dirty = true;
|
||||||
|
cfg.x = nx;
|
||||||
|
cfg.y = ny;
|
||||||
cfg.angle = snap.angle;
|
cfg.angle = snap.angle;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
@@ -1919,7 +2045,8 @@ class HouseplanCard extends LitElement {
|
|||||||
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
|
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
|
||||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||||
const moved = this._opDrag.moved;
|
const moved = this._opDrag.moved;
|
||||||
if (moved) this._saveConfig();
|
// only write when the geometry actually changed (audit L4)
|
||||||
|
if (moved && this._opDrag.dirty) this._saveConfig();
|
||||||
// keep the flag until the click event that follows pointerup, then let it go
|
// keep the flag until the click event that follows pointerup, then let it go
|
||||||
if (moved) window.setTimeout(() => (this._opDrag = null), 0);
|
if (moved) window.setTimeout(() => (this._opDrag = null), 0);
|
||||||
else this._opDrag = null;
|
else this._opDrag = null;
|
||||||
@@ -2577,7 +2704,11 @@ class HouseplanCard extends LitElement {
|
|||||||
this._maybeRebuildDevices();
|
this._maybeRebuildDevices();
|
||||||
this._showToast(this._t('toast.marker_saved'));
|
this._showToast(this._t('toast.marker_saved'));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this._markerDialog = { ...this._markerDialog!, busy: false };
|
// audit L3: the dialog may have been closed (Esc) while the save was
|
||||||
|
// in flight — spreading null yields a truthy husk and the renderer
|
||||||
|
// then crashes, blanking the whole card. The toast below is the
|
||||||
|
// only remaining signal, so it must still fire.
|
||||||
|
if (this._markerDialog) this._markerDialog = { ...this._markerDialog, busy: false };
|
||||||
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2772,7 +2903,11 @@ class HouseplanCard extends LitElement {
|
|||||||
this._showToast(d.mode === 'create' ? this._t('toast.space_added') : this._t('toast.space_saved'));
|
this._showToast(d.mode === 'create' ? this._t('toast.space_added') : this._t('toast.space_saved'));
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this._spaceDialog = { ...this._spaceDialog!, busy: false };
|
// audit L3: the dialog may have been closed (Esc) while the save was
|
||||||
|
// in flight — spreading null yields a truthy husk and the renderer
|
||||||
|
// then crashes, blanking the whole card. The toast below is the
|
||||||
|
// only remaining signal, so it must still fire.
|
||||||
|
if (this._spaceDialog) this._spaceDialog = { ...this._spaceDialog, busy: false };
|
||||||
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2802,6 +2937,7 @@ class HouseplanCard extends LitElement {
|
|||||||
conflict again. */
|
conflict again. */
|
||||||
private async _saveConfigNow(): Promise<void> {
|
private async _saveConfigNow(): Promise<void> {
|
||||||
this._dropLegacySegments();
|
this._dropLegacySegments();
|
||||||
|
this._cfgEpoch++;
|
||||||
try {
|
try {
|
||||||
const r = await this.hass.callWS({
|
const r = await this.hass.callWS({
|
||||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||||
@@ -2930,7 +3066,11 @@ class HouseplanCard extends LitElement {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
this._showToast(this._t('gs.saved'));
|
this._showToast(this._t('gs.saved'));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this._settingsDialog = { ...this._settingsDialog!, busy: false };
|
// audit L3: the dialog may have been closed (Esc) while the save was
|
||||||
|
// in flight — spreading null yields a truthy husk and the renderer
|
||||||
|
// then crashes, blanking the whole card. The toast below is the
|
||||||
|
// only remaining signal, so it must still fire.
|
||||||
|
if (this._settingsDialog) this._settingsDialog = { ...this._settingsDialog, busy: false };
|
||||||
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3115,7 +3255,11 @@ class HouseplanCard extends LitElement {
|
|||||||
this._maybeRebuildDevices();
|
this._maybeRebuildDevices();
|
||||||
this._showToast(this._t('rules.saved'));
|
this._showToast(this._t('rules.saved'));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this._rulesDialog = { ...this._rulesDialog!, busy: false };
|
// audit L3: the dialog may have been closed (Esc) while the save was
|
||||||
|
// in flight — spreading null yields a truthy husk and the renderer
|
||||||
|
// then crashes, blanking the whole card. The toast below is the
|
||||||
|
// only remaining signal, so it must still fire.
|
||||||
|
if (this._rulesDialog) this._rulesDialog = { ...this._rulesDialog, busy: false };
|
||||||
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
this._showToast(this._t('toast.error', { err: this._errText(e) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3345,7 +3489,18 @@ class HouseplanCard extends LitElement {
|
|||||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||||
: nothing}
|
: nothing}
|
||||||
${this._renderDecorLayer()}
|
${this._renderDecorLayer()}
|
||||||
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
${(() => {
|
||||||
|
// audit L1: hoisted out of the per-room map — these depend on the
|
||||||
|
// config, not on entity state, and were recomputed per room.
|
||||||
|
const allPairs = this._openPairs();
|
||||||
|
const polyCache = new Map<any, number[][] | null>();
|
||||||
|
const polyOf = (rr: any) => {
|
||||||
|
if (!polyCache.has(rr)) polyCache.set(rr, roomPoly(rr));
|
||||||
|
return polyCache.get(rr)!;
|
||||||
|
};
|
||||||
|
const otherPolys = (rr: any) =>
|
||||||
|
space.rooms.filter((o) => o !== rr).map(polyOf).filter(Boolean) as number[][][];
|
||||||
|
return space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
||||||
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
||||||
cls += ' picked';
|
cls += ' picked';
|
||||||
@@ -3392,16 +3547,12 @@ class HouseplanCard extends LitElement {
|
|||||||
// amber highlight — the merge/split selection must stay visible).
|
// amber highlight — the merge/split selection must stay visible).
|
||||||
const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId);
|
const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId);
|
||||||
const openCuts = r.id && !isPicked
|
const openCuts = r.id && !isPicked
|
||||||
? this._openPairs()
|
? allPairs.filter((pp) => pp.a.id === r.id || pp.b.id === r.id).flatMap((pp) => pp.segs)
|
||||||
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
|
|
||||||
.flatMap((pp) => pp.segs)
|
|
||||||
: [];
|
: [];
|
||||||
if (openCuts.length) cls += ' noedge';
|
if (openCuts.length) cls += ' noedge';
|
||||||
// island rooms punch holes in their parent's fill (evenodd)
|
// island rooms punch holes in their parent's fill (evenodd)
|
||||||
const myPoly = roomPoly(r);
|
const myPoly = polyOf(r);
|
||||||
const holes = myPoly
|
const holes = myPoly ? islandsOf(myPoly, otherPolys(r)) : [];
|
||||||
? islandsOf(myPoly, space.rooms.filter((o) => o !== r).map((o) => roomPoly(o)!).filter(Boolean))
|
|
||||||
: [];
|
|
||||||
const pathD = (pts: number[][]) =>
|
const pathD = (pts: number[][]) =>
|
||||||
'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
|
'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
|
||||||
const shape = holes.length && myPoly
|
const shape = holes.length && myPoly
|
||||||
@@ -3426,7 +3577,8 @@ class HouseplanCard extends LitElement {
|
|||||||
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
|
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
|
||||||
: nothing;
|
: nothing;
|
||||||
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
||||||
})}
|
});
|
||||||
|
})()}
|
||||||
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
||||||
${this._renderOpenWalls(disp)}
|
${this._renderOpenWalls(disp)}
|
||||||
${this._editing ? this._renderAlignGuides() : nothing}
|
${this._editing ? this._renderAlignGuides() : nothing}
|
||||||
@@ -4330,7 +4482,7 @@ class HouseplanCard extends LitElement {
|
|||||||
${d.pdfs && d.pdfs.length
|
${d.pdfs && d.pdfs.length
|
||||||
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
|
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
|
||||||
${d.pdfs.map(
|
${d.pdfs.map(
|
||||||
(p) => html`<a class="pdf" href="${safeUrl(p.url) || '#'}" target="_blank" rel="noreferrer noopener">
|
(p) => html`<a class="pdf" href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
|
||||||
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
|
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
|
||||||
)}</span></div>`
|
)}</span></div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
@@ -4562,7 +4714,7 @@ class HouseplanCard extends LitElement {
|
|||||||
<div class="pdfedit">
|
<div class="pdfedit">
|
||||||
${d.pdfs.map(
|
${d.pdfs.map(
|
||||||
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
|
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
|
||||||
<a href="${safeUrl(p.url) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
|
<a href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
|
||||||
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
|
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
|
||||||
)}
|
)}
|
||||||
<label class="btn filebtn">
|
<label class="btn filebtn">
|
||||||
|
|||||||
+2
-1
@@ -322,5 +322,6 @@
|
|||||||
"room.sizes_section": "Font sizes",
|
"room.sizes_section": "Font sizes",
|
||||||
"room.name_scale": "Room name size",
|
"room.name_scale": "Room name size",
|
||||||
"room.label_scale": "Metrics size",
|
"room.label_scale": "Metrics size",
|
||||||
"preview.room_name": "Living room"
|
"preview.room_name": "Living room",
|
||||||
|
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -322,5 +322,6 @@
|
|||||||
"room.sizes_section": "Размеры шрифтов",
|
"room.sizes_section": "Размеры шрифтов",
|
||||||
"room.name_scale": "Размер названия",
|
"room.name_scale": "Размер названия",
|
||||||
"room.label_scale": "Размер подписей",
|
"room.label_scale": "Размер подписей",
|
||||||
"preview.room_name": "Гостиная"
|
"preview.room_name": "Гостиная",
|
||||||
|
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}"
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-15
@@ -38,8 +38,14 @@ export function formatLength(cm: number, imperial: boolean): string {
|
|||||||
* normalized (0..1) coordinates need more (see roomEdges).
|
* normalized (0..1) coordinates need more (see roomEdges).
|
||||||
*/
|
*/
|
||||||
export function segKey(a: number[], b: number[], prec = 1): string {
|
export function segKey(a: number[], b: number[], prec = 1): string {
|
||||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
// audit G3: ROUND FIRST, then order. Ordering on raw floats while printing
|
||||||
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
|
// rounded ones let one wall produce two different keys, so shared walls were
|
||||||
|
// emitted twice and the dedup invariant quietly broke.
|
||||||
|
const ax = a[0].toFixed(prec), ay = a[1].toFixed(prec);
|
||||||
|
const bx = b[0].toFixed(prec), by = b[1].toFixed(prec);
|
||||||
|
const first = ax < bx || (ax === bx && ay <= by);
|
||||||
|
const [px, py, qx, qy] = first ? [ax, ay, bx, by] : [bx, by, ax, ay];
|
||||||
|
return `${px},${py}-${qx},${qy}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,6 +230,36 @@ export function segmentsProperlyCross(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
||||||
|
/**
|
||||||
|
* A point guaranteed to lie strictly inside the polygon (audit G2).
|
||||||
|
* The arithmetic mean of the vertices lies OUTSIDE concave shapes (U/L rooms
|
||||||
|
* are common in hand-drawn plans), which made containment tests misfire.
|
||||||
|
* Strategy: try the midpoints of the diagonals from each vertex, then a
|
||||||
|
* triangle centroid of consecutive vertices — the first point that passes
|
||||||
|
* pointStrictlyInside wins.
|
||||||
|
*/
|
||||||
|
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
|
||||||
|
if (!poly || poly.length < 3) return null;
|
||||||
|
const n = poly.length;
|
||||||
|
const mean = [
|
||||||
|
poly.reduce((s, p) => s + p[0], 0) / n,
|
||||||
|
poly.reduce((s, p) => s + p[1], 0) / n,
|
||||||
|
];
|
||||||
|
if (pointStrictlyInside(mean, poly, eps)) return mean;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
// centroid of the ear at vertex i
|
||||||
|
const a = poly[(i - 1 + n) % n], b = poly[i], c = poly[(i + 1) % n];
|
||||||
|
const cand = [(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3];
|
||||||
|
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < n; i++)
|
||||||
|
for (let j = i + 2; j < n; j++) {
|
||||||
|
const cand = [(poly[i][0] + poly[j][0]) / 2, (poly[i][1] + poly[j][1]) / 2];
|
||||||
|
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||||
let allOnBoundary = true;
|
let allOnBoundary = true;
|
||||||
for (const v of a) {
|
for (const v of a) {
|
||||||
@@ -232,11 +268,8 @@ function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
|||||||
}
|
}
|
||||||
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
||||||
if (allOnBoundary) {
|
if (allOnBoundary) {
|
||||||
const c = [
|
const c = interiorPoint(a, eps); // audit G2: NOT the vertex mean
|
||||||
a.reduce((s, p) => s + p[0], 0) / a.length,
|
return !!c && pointStrictlyInside(c, b, eps);
|
||||||
a.reduce((s, p) => s + p[1], 0) / a.length,
|
|
||||||
];
|
|
||||||
return pointStrictlyInside(c, b, eps);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -250,12 +283,10 @@ export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-
|
|||||||
return false;
|
return false;
|
||||||
for (const v of inner)
|
for (const v of inner)
|
||||||
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
|
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
|
||||||
// identical/traced outlines are NOT containment — probe the centroid strictness both ways
|
// identical/traced outlines are NOT containment — probe a real interior point
|
||||||
const c = [
|
// (audit G2: the vertex mean lies outside concave rooms)
|
||||||
inner.reduce((s, p) => s + p[0], 0) / inner.length,
|
const c = interiorPoint(inner, eps);
|
||||||
inner.reduce((s, p) => s + p[1], 0) / inner.length,
|
return !!c && pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
||||||
];
|
|
||||||
return pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -380,10 +411,40 @@ export function splitRoomPath(
|
|||||||
acc.push(to);
|
acc.push(to);
|
||||||
return dropRepeats(acc, eps);
|
return dropRepeats(acc, eps);
|
||||||
};
|
};
|
||||||
const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
|
let p1: number[][];
|
||||||
const p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
|
let p2: number[][];
|
||||||
|
if (ia === ib) {
|
||||||
|
// BOTH ends on the SAME edge — carving an alcove out of one wall. The walk
|
||||||
|
// above would traverse the whole outline twice and return two overlapping,
|
||||||
|
// self-intersecting rooms whose areas sum to 2x the original (audit G1,
|
||||||
|
// 2026-07-27). The niche is simply the path closed along that edge; the
|
||||||
|
// remainder is the outline with that stretch replaced by the path.
|
||||||
|
const niche = dropRepeats([...pts], eps);
|
||||||
|
if (niche.length < 3 || polygonArea(niche) <= eps) return null;
|
||||||
|
// the niche must not swallow other geometry: it stays inside the room
|
||||||
|
const rest: number[][] = [];
|
||||||
|
for (let i = 0; i < poly.length; i++) {
|
||||||
|
rest.push(poly[i]);
|
||||||
|
if (i === ia) {
|
||||||
|
// walk the cut from a to b along the edge direction
|
||||||
|
const dir = (poly[(ia + 1) % poly.length][0] - poly[ia][0]) * (b[0] - a[0])
|
||||||
|
+ (poly[(ia + 1) % poly.length][1] - poly[ia][1]) * (b[1] - a[1]);
|
||||||
|
const path = dir >= 0 ? pts : [...pts].reverse();
|
||||||
|
for (const p of path) rest.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p1 = dropRepeats(rest, eps);
|
||||||
|
p2 = niche;
|
||||||
|
} else {
|
||||||
|
p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
|
||||||
|
p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
|
||||||
|
}
|
||||||
if (p1.length < 3 || p2.length < 3) return null;
|
if (p1.length < 3 || p2.length < 3) return null;
|
||||||
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
|
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
|
||||||
|
// INVARIANT (audit G1): a split partitions the room — the parts must sum to
|
||||||
|
// the original. Anything else means the walk produced overlapping garbage.
|
||||||
|
if (Math.abs(polygonArea(p1) + polygonArea(p2) - polygonArea(poly)) > Math.max(eps, polygonArea(poly) * 1e-6))
|
||||||
|
return null;
|
||||||
return [p1, p2];
|
return [p1, p2];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,6 +1031,22 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
|
|||||||
return cutSegments(edges, cuts, eps);
|
return cutSegments(edges, cuts, eps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy static URLs (/houseplan_files/plans|files/...) are rewritten to the
|
||||||
|
* authenticated content endpoint (audit B1). Applied on READ, so stored
|
||||||
|
* configs keep working without a migration.
|
||||||
|
*/
|
||||||
|
export function contentUrl(url: string | null | undefined): string {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.startsWith('/houseplan_files/plans/')) {
|
||||||
|
return '/api/houseplan/content/plans/_/' + url.slice('/houseplan_files/plans/'.length);
|
||||||
|
}
|
||||||
|
if (url.startsWith('/houseplan_files/files/')) {
|
||||||
|
return '/api/houseplan/content/files/' + url.slice('/houseplan_files/files/'.length);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- room-level settings (tier 3) ----------------
|
// ---------------- room-level settings (tier 3) ----------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* are directly unit-tested. Shared by the static renderer (space-render.ts) and
|
* are directly unit-tested. Shared by the static renderer (space-render.ts) and
|
||||||
* mirror the full card's private math.
|
* mirror the full card's private math.
|
||||||
*/
|
*/
|
||||||
import { declump } from './logic';
|
import { declump, contentUrl } from './logic';
|
||||||
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
|
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
|
||||||
|
|
||||||
export const NORM_W = 1000; // width of the render space for normalized configs
|
export const NORM_W = 1000; // width of the render space for normalized configs
|
||||||
@@ -30,7 +30,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||||
rooms: (s.rooms || []).map(scale),
|
rooms: (s.rooms || []).map(scale),
|
||||||
} as SpaceModel;
|
} as SpaceModel;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
swipeTarget, clampScale,
|
swipeTarget, clampScale,
|
||||||
migratePdfUrls,
|
migratePdfUrls,
|
||||||
roomFillModeOf,
|
roomFillModeOf,
|
||||||
|
contentUrl,
|
||||||
|
interiorPoint,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
@@ -809,3 +811,59 @@ test('roomFillModeOf: tier-3 override beats the space, junk inherits', () => {
|
|||||||
assert.equal(roomFillModeOf('temp', null), 'temp');
|
assert.equal(roomFillModeOf('temp', null), 'temp');
|
||||||
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'glow' } }), 'temp'); // glow нельзя выбрать per-room
|
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'glow' } }), 'temp'); // glow нельзя выбрать per-room
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('splitRoomPath: both ends on the SAME edge carve a niche (audit G1)', () => {
|
||||||
|
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||||
|
const A = (p) => Math.round(polygonArea(p) * 1000) / 1000;
|
||||||
|
// ИНВАРИАНТ разреза: части в сумме дают исходную площадь
|
||||||
|
const invariant = (pts, label) => {
|
||||||
|
const r = splitRoomPath(sq, pts);
|
||||||
|
assert.ok(r, label + ': разрез должен приниматься');
|
||||||
|
assert.ok(Math.abs(A(r[0]) + A(r[1]) - A(sq)) < 1e-6,
|
||||||
|
label + ': сумма частей ' + (A(r[0]) + A(r[1])) + ' != ' + A(sq));
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
const niche = invariant([[2, 0], [2, 3], [8, 3], [8, 0]], 'ниша снизу');
|
||||||
|
assert.deepEqual([A(niche[0]), A(niche[1])].sort((x, y) => x - y), [18, 82]);
|
||||||
|
invariant([[2, 10], [2, 7], [8, 7], [8, 10]], 'ниша сверху');
|
||||||
|
invariant([[0, 2], [3, 2], [3, 8], [0, 8]], 'ниша слева');
|
||||||
|
invariant([[8, 0], [8, 3], [2, 3], [2, 0]], 'обратный порядок точек');
|
||||||
|
invariant([[1, 0], [3, 4], [5, 1], [7, 4], [9, 0]], 'зигзаг');
|
||||||
|
// вырожденная ниша нулевой площади — отказ
|
||||||
|
assert.equal(splitRoomPath(sq, [[2, 0], [2, 1e-9], [8, 0]]), null);
|
||||||
|
// старые формы разрезов сохраняют инвариант
|
||||||
|
invariant([[5, 0], [5, 10]], 'прямая хорда');
|
||||||
|
invariant([[4, 0], [4, 6], [10, 6]], 'Г-образный');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('contentUrl: legacy static paths become authenticated ones (audit B1)', () => {
|
||||||
|
assert.equal(contentUrl('/houseplan_files/plans/f1.svg?v=1'), '/api/houseplan/content/plans/_/f1.svg?v=1');
|
||||||
|
assert.equal(contentUrl('/houseplan_files/files/dev1/a.pdf?v=2'), '/api/houseplan/content/files/dev1/a.pdf?v=2');
|
||||||
|
// новые и внешние адреса не трогаем
|
||||||
|
assert.equal(contentUrl('/api/houseplan/content/files/x/y.pdf'), '/api/houseplan/content/files/x/y.pdf');
|
||||||
|
assert.equal(contentUrl('https://example.com/a.pdf'), 'https://example.com/a.pdf');
|
||||||
|
assert.equal(contentUrl(''), '');
|
||||||
|
assert.equal(contentUrl(null), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('interiorPoint / polyContainsPoly on concave rooms (audit G2)', () => {
|
||||||
|
// U-образная комната: среднее вершин лежит СНАРУЖИ
|
||||||
|
const u = [[0, 0], [10, 0], [10, 10], [9, 10], [9, 2], [1, 2], [1, 10], [0, 10]];
|
||||||
|
const mean = [u.reduce((s, p) => s + p[0], 0) / u.length, u.reduce((s, p) => s + p[1], 0) / u.length];
|
||||||
|
assert.equal(pointStrictlyInside(mean, u), false, 'предпосылка: среднее снаружи');
|
||||||
|
const ip = interiorPoint(u);
|
||||||
|
assert.ok(ip && pointStrictlyInside(ip, u), 'interiorPoint обязана быть внутри');
|
||||||
|
// вложенность вогнутых теперь распознаётся
|
||||||
|
const outer = [[-1, -1], [11, -1], [11, 11], [8.5, 11], [8.5, 2.5], [1.5, 2.5], [1.5, 11], [-1, 11]];
|
||||||
|
assert.equal(polyContainsPoly(outer, u), true);
|
||||||
|
assert.equal(roomsOverlap(outer, u), false);
|
||||||
|
// дубликат по-прежнему НЕ вложенность
|
||||||
|
assert.equal(polyContainsPoly(u, u), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('segKey: one wall, one key at any precision (audit G3)', () => {
|
||||||
|
assert.equal(segKey([1.000001, 1], [1.000002, 2]), segKey([1.000002, 1], [1.000001, 2]));
|
||||||
|
assert.equal(segKey([0, 0], [1, 1], 3), segKey([1, 1], [0, 0], 3));
|
||||||
|
// разные стены — разные ключи
|
||||||
|
assert.notEqual(segKey([0, 0], [1, 1]), segKey([0, 0], [2, 2]));
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ async def test_upload_ok(hass: HomeAssistant, hass_client: ClientSessionGenerato
|
|||||||
resp = await client.post("/api/houseplan/upload", data=fd)
|
resp = await client.post("/api/houseplan/upload", data=fd)
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
body = await resp.json()
|
body = await resp.json()
|
||||||
assert body["ok"] and body["url"].startswith("/houseplan_files/files/m1/manual.pdf?v=")
|
# audit B1: uploads now return the AUTHENTICATED content URL
|
||||||
|
assert body["ok"] and body["url"].startswith("/api/houseplan/content/files/m1/manual.pdf?v=")
|
||||||
|
|
||||||
|
|
||||||
async def test_upload_bad_ext(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
|
async def test_upload_bad_ext(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
|
||||||
@@ -57,4 +58,4 @@ async def test_upload_traversal_sanitized(hass: HomeAssistant, hass_client: Clie
|
|||||||
# no path segment may be exactly ".." (dots inside a name are harmless)
|
# no path segment may be exactly ".." (dots inside a name are harmless)
|
||||||
path = body["url"].split("?", 1)[0]
|
path = body["url"].split("?", 1)[0]
|
||||||
assert all(seg != ".." for seg in path.split("/"))
|
assert all(seg != ".." for seg in path.split("/"))
|
||||||
assert path.startswith("/houseplan_files/files/")
|
assert path.startswith("/api/houseplan/content/files/")
|
||||||
|
|||||||
@@ -99,4 +99,29 @@ async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocket
|
|||||||
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
|
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
|
||||||
)
|
)
|
||||||
resp = await client.receive_json()
|
resp = await client.receive_json()
|
||||||
assert resp["success"] and resp["result"]["url"].startswith("/houseplan_files/plans/s1.png?v=")
|
assert resp["success"] and resp["result"]["url"].startswith("/api/houseplan/content/plans/_/s1.png?v=")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_check_fails_closed(hass, hass_ws_client):
|
||||||
|
"""audit B2/T4: with no config entry the policy is unknown — deny writes.
|
||||||
|
|
||||||
|
This used to allow them: plan uploads slipped through during a reload.
|
||||||
|
"""
|
||||||
|
from custom_components.houseplan import websocket_api as wsapi
|
||||||
|
|
||||||
|
class _User:
|
||||||
|
is_admin = False
|
||||||
|
|
||||||
|
class _Conn:
|
||||||
|
user = _User()
|
||||||
|
|
||||||
|
# no entry at all → non-admin must be refused
|
||||||
|
assert wsapi._check_write(hass, _Conn()) is False
|
||||||
|
|
||||||
|
class _Admin:
|
||||||
|
is_admin = True
|
||||||
|
|
||||||
|
class _AdminConn:
|
||||||
|
user = _Admin()
|
||||||
|
|
||||||
|
assert wsapi._check_write(hass, _AdminConn()) is True
|
||||||
|
|||||||
@@ -125,3 +125,18 @@ def test_space_temp_bounds():
|
|||||||
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
|
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
|
||||||
}
|
}
|
||||||
v.SPACE_SCHEMA(ok)
|
v.SPACE_SCHEMA(ok)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finite_coordinates_rejected():
|
||||||
|
"""audit B5: NaN/Infinity coordinates must not reach storage."""
|
||||||
|
for bad in ("NaN", "Infinity", "-Infinity", float("nan"), float("inf")):
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.LAYOUT_SCHEMA({"dev1": {"x": bad, "y": 0.5}})
|
||||||
|
assert v.LAYOUT_SCHEMA({"dev1": {"x": 0.5, "y": 0.25}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_caps():
|
||||||
|
"""audit B5: unbounded collections are capped."""
|
||||||
|
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.LAYOUT_SCHEMA(big)
|
||||||
|
|||||||
Reference in New Issue
Block a user