This commit is contained in:
Matysh
2026-07-29 01:41:45 +03:00
19 changed files with 409 additions and 75 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.50.0"
VERSION = "1.50.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.50.0"
"version": "1.50.1"
}
+7 -1
View File
@@ -95,8 +95,14 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
# Positions are normalised to the canvas (0..1). Allow generous slack for an
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
# used to pass, and a single stored 1e100 stretched every client's view of the
# space until the plan was invisible (HP-1500-03).
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _finite, vol.Required("y"): _finite},
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
)
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
+85 -3
View File
@@ -41,6 +41,7 @@ def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_geometry_repair)
websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get)
@@ -118,7 +119,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
new_rev = current_rev + 1
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev})
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": msg["layout"], "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -147,11 +149,90 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev})
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/geometry/repair",
vol.Required("space_id"): str,
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Optional("dry_run"): bool,
vol.Optional("undo"): bool,
}
)
@websocket_api.async_response
async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Re-apply the square-canvas transform to ONE space's layout, explicitly.
For installations that hit the v1.48/v1.49 crash window: the config write
of the migration landed, the layout write did not, and the trigger fields
were already gone — markers and labels of that space are stranded in the
old coordinates with nothing able to tell (HP-1500-01). Nothing can be
detected reliably after the fact, and re-running a transform on a layout
that is already correct would corrupt it, so this NEVER runs by itself:
an administrator names the space and its old aspect, may preview with
`dry_run`, and gets a one-deep backup written in the same store write —
`undo` restores it.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may repair the layout")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
from .geometry_migration import migrate_layout
space_id = msg["space_id"]
if not valid_space_id(space_id):
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
return
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout") or {}
current_rev = int(data.get("rev", 0))
if msg.get("undo"):
backup = data.get("repair_backup")
if not isinstance(backup, dict) or backup.get("space") != space_id:
connection.send_error(msg["id"], "no_backup", "No repair backup stored for this space")
return
restored = dict(layout)
for key, pos in (backup.get("positions") or {}).items():
restored[key] = pos
new_rev = current_rev + 1
await rt.store.async_save({"layout": restored, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
"restored": len(backup.get("positions") or {})})
return
touched = {
k: dict(v) for k, v in layout.items()
if isinstance(v, dict) and str(v.get("s")) == space_id
}
preview = {k: dict(v) for k, v in touched.items()}
migrate_layout(preview, {space_id: msg["aspect"]})
if msg.get("dry_run"):
connection.send_result(msg["id"], {
"ok": True, "dry_run": True, "moved": len(preview),
"before": touched, "after": preview,
})
return
new_layout = {**layout, **preview}
new_rev = current_rev + 1
# the backup rides the same store write: either both are durable or
# neither — the deletion-shy rules of this project apply to positions
# too
await rt.store.async_save({
"layout": new_layout, "rev": new_rev,
"repair_backup": {"space": space_id, "positions": touched},
})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/files/migrate",
@@ -455,7 +536,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
if msg["device_id"] in layout:
del layout[msg["device_id"]]
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev})
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
+17
View File
@@ -61,4 +61,21 @@ out.devicesStretchFrame = await page.evaluate(() => {
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
});
// -- a card BELOW other dashboard content still gets a stage (HP-1500-02) --
out.stageSurvivesContentAbove = await page.evaluate(async () => {
const spacer = document.createElement('div');
spacer.style.height = '900px';
document.body.insertBefore(spacer, document.body.firstChild);
window.dispatchEvent(new Event('resize'));
await new Promise((r) => setTimeout(r, 120));
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const h = sr.querySelector('.stage').getBoundingClientRect().height;
spacer.remove();
window.dispatchEvent(new Event('resize'));
await new Promise((r) => setTimeout(r, 120));
// the old code billed the 900px spacer as "header" and left a 0px stage
return h > 300;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+18 -18
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -182,6 +182,17 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
**If the v1.48 migration crashed halfway** (HP-1500-01): the config write
landed, the layout write did not, and both triggers are gone — markers of that
space sit in the old coordinates and nothing in the data can prove it. The
`geom_pending` intent (v1.50.0) prevents this for any future migration, but
cannot help an install that was already stranded. There is no safe automatic
answer — re-transforming a layout that is actually correct would corrupt it —
so the fix is explicit: `houseplan/geometry/repair {space_id, aspect}`
re-applies the transform to that one space's positions. `dry_run: true`
previews, the previous positions ride the same store write as a one-deep
backup, and `undo: true` restores them. Admin-gated like every other write.
**The canvas is square, the image is not** (v1.48.0). A space used to carry an
`aspect`, and coordinates were normalised against it — x by the width, y by the
height. That made every geometric question depend on a per-space number for no
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## v1.50.1 — 2026-07-29
**From the v1.50.0 review**
- **A card below other dashboard content gets its stage back (HP-1500-02).**
The v1.50.0 height measurement used the absolute document coordinate, so a
tall card before this one was billed as "header" and the stage collapsed to
zero. The card now measures only its own chrome plus a bounded allowance for
what the dashboard keeps above it, and re-measures on window resize; the
listener is removed on teardown.
- **The content frame can no longer be degenerate or absurd (HP-1500-03).**
A lone marker in an empty space produced a zero-area viewBox — a blank
scene; a single stored coordinate like 1e100 (any finite float passed
validation) stretched the frame until the plan was a dot, for every viewer
of the space. A near-zero axis now opens up to a floor of canvas around the
marker, points far outside the canvas envelope no longer command the frame
(they still render where they are), and the server refuses layout
coordinates outside ±4 — generous slack for an icon dragged past an edge,
not an envelope for absurdity. A real thin room keeps its tight frame, and
the gate sensor slightly past the edge still counts.
- **A repair path for installs stranded by the v1.48 migration window
(HP-1500-01).** If the old migration crashed between its two writes, the
markers of a space are left in the old coordinates with nothing in the data
able to prove it — and re-transforming a correct layout would corrupt it, so
nothing automatic is safe. `houseplan/geometry/repair {space_id, aspect}` is
the explicit answer: `dry_run` previews the exact moves, the previous
positions ride the same store write as a one-deep backup, `undo` restores
them, and routine drags no longer erase that backup. The v1.50.0
`geom_pending` protocol already protects every future migration; this covers
the installs it was too late for.
## v1.50.0 — 2026-07-28
**Owner's batch**
+31
View File
@@ -6,6 +6,37 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.50.1 — 2026-07-29
**По ревью v1.50.0**
- **Карточка ниже другого контента дашборда снова получает сцену
(HP-1500-02).** Замер высоты в v1.50.0 брал абсолютную координату документа:
высокая карточка перед этой записывалась в «шапку», и сцена схлопывалась в
ноль. Теперь меряется только собственная обвязка плюс ограниченная поправка
на то, что дашборд держит сверху; перемер — по resize окна, слушатель
снимается при демонтаже.
- **Рамка содержимого больше не бывает вырожденной или абсурдной
(HP-1500-03).** Одинокий значок в пустом пространстве давал viewBox нулевой
площади — пустую сцену; одна сохранённая координата вида 1e100 (любое
конечное число проходило проверку) растягивала рамку так, что план
становился точкой — у всех зрителей пространства. Почти нулевая ось теперь
раскрывается до минимального кадра вокруг значка, точки далеко за холстом
рамкой не командуют (рисуются, где стоят), а сервер отклоняет координаты вне
±4 — щедрый запас для значка, утащенного за край, но не для абсурда. Узкая
комната сохраняет тесный кадр, датчик калитки чуть за краём по-прежнему
учитывается.
- **Путь восстановления для установок, застрявших в окне миграции v1.48
(HP-1500-01).** Если старая миграция упала между двумя записями, значки
пространства остаются в старых координатах, и по данным это недоказуемо — а
повторное преобразование правильных координат их испортит, поэтому ничего
автоматического тут быть не может. Явный ответ —
`houseplan/geometry/repair {space_id, aspect}`: `dry_run` показывает точные
перемещения, прежние позиции уезжают той же записью хранилища как резервная
копия глубиной один, `undo` их возвращает, и обычные перетаскивания эту
копию больше не затирают. Протокол `geom_pending` из v1.50.0 уже защищает
все будущие миграции; это — для тех, кому он опоздал.
## v1.50.0 — 2026-07-28
**Задачи владельца**
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.50.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.50.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.0** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.1** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
+10
View File
@@ -239,6 +239,16 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Card below other dashboard content (v1.50.1, HP-1500-02): place the card
after a tall card in a normal dashboard — the plan still gets most of the
viewport instead of a zero-height stage [auto: smoke_zoom_out]
- [ ] Frame never degenerate (v1.50.1, HP-1500-03): a space with one lone
marker opens with canvas around it, not an empty scene; an absurd stored
coordinate neither hides the plan nor is accepted by the server
[auto: unit contentBounds + backend test_layout_coordinates_are_bounded]
- [ ] Stranded migration repair (v1.50.1, HP-1500-01): geometry/repair with
dry_run previews, applies with a backup, undo restores; wrong space is
recoverable [auto: test_geometry_repair_is_explicit_previewable_and_undoable]
- [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space
with one small room opens content-fit in View; switching to the plan
editor shows the full square with room to draw a second room far away;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.50.0",
"version": "1.50.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.50.0",
"version": "1.50.1",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.50.0",
"version": "1.50.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+22 -7
View File
@@ -36,7 +36,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.50.0';
const CARD_VERSION = '1.50.1';
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_ZOOM = 'houseplan_card_zoom_v1';
@@ -246,6 +246,7 @@ class HouseplanCard extends LitElement {
private _suppressClick = false;
private _roViewport?: ResizeObserver;
private _roHdr?: ResizeObserver;
private _onWinResize?: () => void;
private _hdrH = 118; // measured px above the stage (see the observer in updated())
private _onboardingShown = false; // the auto space dialog is shown once per session
@@ -436,6 +437,10 @@ class HouseplanCard extends LitElement {
this._roViewport = undefined;
this._roHdr?.disconnect();
this._roHdr = undefined;
if (this._onWinResize) {
window.removeEventListener('resize', this._onWinResize);
this._onWinResize = undefined;
}
if (this._unsubCfg) {
this._unsubCfg();
this._unsubCfg = null;
@@ -729,15 +734,23 @@ class HouseplanCard extends LitElement {
this._roViewport = new ResizeObserver(() => this._refitView());
this._roViewport.observe(stage);
}
// The stage fills the rest of the viewport. What sits above it — the HA
// toolbar, card margins, our own header — DEPENDS ON THE MODE: the editor
// bars used to be billed against a hard-coded 118px, so entering an editor
// pushed the plan down by the difference and cut its bottom off below the
// fold. Measure where the stage actually starts instead of assuming.
// The stage fills the rest of the viewport. What sits above it inside the
// CARD depends on the mode — the editor bars used to be billed against a
// hard-coded 118px, so entering an editor pushed the plan down by the
// difference. Measure our own chrome (stage top relative to the card) and
// allow a BOUNDED amount for what the dashboard puts above us (HA's
// toolbar). The first version used the absolute document coordinate here:
// put anything tall before the card and the "header budget" swallowed the
// whole viewport, leaving a 0px stage (HP-1500-02). Content above the card
// is the dashboard's business — it scrolls; it is not header.
const hdr = this.renderRoot.querySelector('.hdr') as HTMLElement | null;
if (hdr && stage && !this._roHdr) {
const measure = () => {
const t = Math.round(stage.getBoundingClientRect().top + (window.scrollY || 0));
const card = this.renderRoot.querySelector('ha-card');
if (!card) return;
const own = stage.getBoundingClientRect().top - card.getBoundingClientRect().top;
const above = Math.min(Math.max(card.getBoundingClientRect().top, 0), 120);
const t = Math.round(own + above);
if (t >= 0 && Math.abs(t - this._hdrH) > 1) this._hdrH = t;
};
// a frame later: setting state straight from the observer callback makes
@@ -745,6 +758,8 @@ class HouseplanCard extends LitElement {
// notifications" — the render it triggers resizes the stage again
this._roHdr = new ResizeObserver(() => requestAnimationFrame(measure));
this._roHdr.observe(hdr);
this._onWinResize = () => requestAnimationFrame(measure);
window.addEventListener('resize', this._onWinResize);
measure();
}
if (stage && !this._view) this._refitView();
+21 -3
View File
@@ -86,10 +86,28 @@ export function contentBounds(
add(r.x + (r.w || 0), r.y + (r.h || 0));
}
}
// things that live outside any room still count as content — a gate sensor
// by the fence, a camera on a pole (the card passes device positions here)
for (const p of extra || []) add(p[0], p[1]);
// Things that live outside any room still count as content — a gate sensor
// by the fence, a camera on a pole (the card passes device positions here).
// But only within a bounded envelope around the canvas: a stored position is
// any finite number the layout schema took, and one absurd coordinate used
// to stretch the frame until the plan was a dot (HP-1500-03). A point past
// the envelope is still rendered wherever it is — it just does not command
// the opening view.
const lo = -NORM_W * 0.25, hi = NORM_W * 1.25;
for (const p of extra || []) {
if (p[0] >= lo && p[0] <= hi && p[1] >= lo && p[1] <= hi) add(p[0], p[1]);
}
if (minX > maxX || minY > maxY) return null;
// A single marker (or a collinear row of them) has no area, and an SVG
// viewBox with a zero axis draws nothing at all (HP-1500-03). An axis with
// essentially no span — nothing there but icons — opens up to a floor:
// enough canvas around a lone marker to see where it stands. A REAL thin
// shape (a 100-unit corridor) keeps its tight frame; only the degenerate
// case is padded, so the threshold sits at about an icon's size.
const FLOOR = NORM_W * 0.2;
const DEGENERATE = NORM_W * 0.03;
if (maxX - minX < DEGENERATE) { const c = (minX + maxX) / 2; minX = c - FLOOR / 2; maxX = c + FLOOR / 2; }
if (maxY - minY < DEGENERATE) { const c = (minY + maxY) / 2; minY = c - FLOOR / 2; maxY = c + FLOOR / 2; }
const m = Math.max(maxX - minX, maxY - minY) * pad;
const x = minX - m, y = minY - m;
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
+22
View File
@@ -123,3 +123,25 @@ test('contentBounds: devices outside every room stretch the frame', () => {
// and junk coordinates are ignored, not spread across the canvas
assert.deepEqual(contentBounds(one, 0.05, [[NaN, 5]]), contentBounds(one));
});
test('contentBounds: never degenerate, never unbounded (HP-1500-03)', () => {
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
// a single marker has no area — the frame gets a floor instead of a 0x0 viewBox
const one = contentBounds(empty, 0.05, [[500, 500]]);
assert.ok(one.w >= 200 && one.h >= 200, 'a lone marker still frames some canvas');
assert.ok(Math.abs(one.x + one.w / 2 - 500) < 1, 'centred on the marker');
// a collinear row: the flat axis gets the floor, the long one keeps its span
const row = contentBounds(empty, 0.05, [[100, 500], [900, 500]]);
assert.ok(row.h >= 200, 'the flat axis is opened up');
assert.ok(row.w > 800, 'the long axis is untouched');
// an absurd stored coordinate does not command the frame...
const room = spaceModels({ spaces: [{
id: 's', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
}], markers: [] })[0];
assert.deepEqual(contentBounds(room, 0.05, [[1e100, 500]]), contentBounds(room));
assert.deepEqual(contentBounds(room, 0.05, [[-1e100, -1e100]]), contentBounds(room));
// ...but a device a bit past the canvas edge still counts (the gate sensor)
const near = contentBounds(room, 0.05, [[1100, 500]]);
assert.ok(near.x + near.w > 1050, 'slightly outside the canvas still stretches the frame');
});
+91
View File
@@ -1283,3 +1283,94 @@ async def test_parallel_uploads_cannot_slip_past_the_quota_together(
dir_usage, Path(hass.config.path(PLANS_DIR))
)
assert after == stored + 1, "the store holds what the quota promised, not more"
async def test_layout_coordinates_are_bounded(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1500-03: any finite float used to pass, and one stored 1e100
stretched every client's frame until the plan was invisible. Positions are
normalised to the canvas; +-4 is generous slack for an icon dragged past an
edge, not an envelope for absurdity."""
await _setup(hass)
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "houseplan/layout/update", "device_id": "d1",
"pos": {"s": "f1", "x": 1e100, "y": 0.5},
})
resp = await client.receive_json()
assert not resp["success"], "an absurd coordinate is refused at the door"
await client.send_json_auto_id({
"type": "houseplan/layout/update", "device_id": "d1",
"pos": {"s": "f1", "x": -1.2, "y": 0.5},
})
resp = await client.receive_json()
assert resp["success"], "a bit past the edge is a dragged icon, not an attack"
async def test_geometry_repair_is_explicit_previewable_and_undoable(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1500-01: an install that crashed in the v1.48 migration window has a
square config and an old-coordinates layout, and NOTHING left to tell —
both triggers went with the write that succeeded. No automation can fix
that safely (a correct layout looks the same), so the repair is a person
saying "this space, this old aspect": preview first, one-deep backup with
the write, undo restores it."""
await _setup(hass)
client = await hass_ws_client(hass)
# the stranded state: nothing in the layout says it is old
await client.send_json_auto_id({
"type": "houseplan/layout/set",
"layout": {"lamp": {"s": "wide", "x": 0.2, "y": 0.1},
"other": {"s": "elsewhere", "x": 0.9, "y": 0.9}},
})
assert (await client.receive_json())["success"]
# preview does not touch the store
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "dry_run": True,
})
dry = (await client.receive_json())["result"]
assert dry["moved"] == 1 and dry["after"]["lamp"]["y"] == 0.3
await client.send_json_auto_id({"type": "houseplan/layout/get"})
assert (await client.receive_json())["result"]["layout"]["lamp"]["y"] == 0.1
# the repair itself
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0,
})
res = (await client.receive_json())["result"]
assert res["moved"] == 1
await client.send_json_auto_id({"type": "houseplan/layout/get"})
lay = (await client.receive_json())["result"]["layout"]
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.3}
assert lay["other"] == {"s": "elsewhere", "x": 0.9, "y": 0.9}, "another space untouched"
# a routine drag must not eat the backup...
await client.send_json_auto_id({
"type": "houseplan/layout/update", "device_id": "other",
"pos": {"s": "elsewhere", "x": 0.5, "y": 0.5},
})
assert (await client.receive_json())["success"]
# ...because undo is the safety net for repairing the WRONG space
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "wide", "aspect": 2.0, "undo": True,
})
res = (await client.receive_json())["result"]
assert res["restored"] == 1
await client.send_json_auto_id({"type": "houseplan/layout/get"})
lay = (await client.receive_json())["result"]["layout"]
assert lay["lamp"] == {"s": "wide", "x": 0.2, "y": 0.1}, "back to before the repair"
assert lay["other"]["x"] == 0.5, "the drag after the repair survives the undo"
# undo without a backup for that space is a clean error
await client.send_json_auto_id({
"type": "houseplan/geometry/repair", "space_id": "nosuch", "aspect": 2.0, "undo": True,
})
bad = await client.receive_json()
assert not bad["success"] and bad["error"]["code"] == "no_backup"