v1.50.4: one model builder for both cards (HP-1503-01)

The full card's _buildModel() was a hand-copied twin of spaceModels(), and
the twin missed the legacy-store fallbacks v1.50.3 gave the shared builder —
the same broken store rendered recovered in the static card and as
viewBox='0 0 0 0' with negative-width rects in the main one. The divergence
of the duplicates IS the bug, so the duplicate is gone: the full card calls
spaceModels() and only swaps the raw plan url back in (its signing flow must
not bake a signed url into a memoized model — 2026-07-27).

New smoke_legacy_geometry runs the audit's exact vector (zero viewport +
negative rect) through both models and both DOM trees and asserts parity:
full-canvas fallback, normalised rectangle, no negative SVG attributes.
Inventory: 140 / 51 / 43 / 65.
This commit is contained in:
Matysh
2026-07-29 08:33:10 +03:00
parent 9da96abb05
commit 9f36b39379
13 changed files with 121 additions and 40 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.3"
VERSION = "1.50.4"
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.3"
"version": "1.50.4"
}
+65
View File
@@ -0,0 +1,65 @@
// HP-1503-01: один и тот же повреждённый store обязан рендериться одинаково в
// ОБЕИХ карточках. Полная карточка строила модель рукописной копией
// spaceModels и прошла мимо safeViewBox/normRect: статическая показывала
// восстановленный план, полная — viewBox="0 0 0 0" и rect с отрицательной
// шириной. Вектор аудита исполняется через обе модели и оба DOM-дерева.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
// ровно вектор из отчёта: нулевой viewport + отрицательный legacy-rect
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.plan_url = null; f1.plan_aspect = null;
f1.view_box = [0, 0, 0, 0];
f1.rooms = [{ id: 'r1', name: 'R', area: 'living_room', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }];
f1.settings = { ...(f1.settings || {}), show_borders: true };
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
return { ok: true };
} };
main._serverCfg = cfg;
main._cfgEpoch++;
main._view = null;
main.requestUpdate(); await main.updateComplete;
await new Promise((r) => setTimeout(r, 150));
// модель полной карточки: fallback на весь холст + нормализованный rect
const m = main._spaceModel('f1');
out.fullVbFallsBack = JSON.stringify(m.vb) === JSON.stringify([0, 0, 1000, 1000]);
const r = m.rooms[0];
out.fullRectNormalised =
Math.round(r.x) === 400 && Math.round(r.y) === 400
&& Math.round(r.w) === 200 && Math.round(r.h) === 300;
// DOM полной карточки: конечный положительный viewBox, никаких минусов в rect
const svg = (main.shadowRoot || main.renderRoot).querySelector('.stage svg');
const vbAttr = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
out.fullDomViewBoxSane = vbAttr.length === 4 && vbAttr[2] > 0 && vbAttr[3] > 0;
const rect = (main.shadowRoot || main.renderRoot).querySelector('.stage svg rect.room, .stage svg .room rect, .stage svg rect[width]');
out.fullDomRectSane = !rect || (Number(rect.getAttribute('width')) >= 0 && Number(rect.getAttribute('height')) >= 0);
// статическая карточка того же store — паритет
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('svg') && Date.now() - t0 < 6000) {
await new Promise((r2) => setTimeout(r2, 60));
}
await card.updateComplete;
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
out.staticDomViewBoxSane = svb.length === 4 && svb[2] > 0 && svb[3] > 0;
out.parity = JSON.stringify(vbAttr.length === 4 && svb.length === 4
? [vbAttr[2] > 0, vbAttr[3] > 0] : null) === JSON.stringify([svb[2] > 0, svb[3] > 0]);
return out;
});
await finish(browser, checkAll(res));
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## v1.50.4 — 2026-07-29
**From the v1.50.3 review**
- **Both cards build their model with the same code now (HP-1503-01).** The
full card carried a hand-copied twin of the shared model builder, and the
twin missed the legacy-store fallbacks v1.50.3 added — the same broken
store rendered fine in the static card and as a blank `viewBox="0 0 0 0"`
in the main one. The duplicate is gone: the full card calls the shared
builder and only swaps in the raw plan url its signing flow needs. A new
smoke runs the audit's exact legacy vector through both models and both
DOM trees and asserts parity.
## v1.50.3 — 2026-07-29
**From the v1.50.2 review**
+13
View File
@@ -6,6 +6,19 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.50.4 — 2026-07-29
**По ревью v1.50.3**
- **Обе карточки строят модель одним кодом (HP-1503-01).** Полная карточка
носила рукописную копию общего построителя модели, и копия не получила
фолбэки для legacy-store из v1.50.3 — один и тот же битый store в
статической карточке рендерился починенным, а в основной — пустым
`viewBox="0 0 0 0"`. Дубликат удалён: полная карточка вызывает общий
построитель и лишь подменяет сырой url плана, который нужен её подписи.
Новый смок прогоняет точный вектор аудита через обе модели и оба
DOM-дерева и проверяет паритет.
## v1.50.3 — 2026-07-29
**По ревью v1.50.2**
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.50.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.50.4** 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 13 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.3** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.50.4** 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 |
+3
View File
@@ -239,6 +239,9 @@ 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]
- [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero
viewport and a negative rect renders identically sane in BOTH cards —
full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry]
- [ ] Sizes are positive (v1.50.3, HP-1502-01): view_box or room w/h of zero
or below is refused; a store that already holds one opens on the full
canvas, not a blank screen [auto: test_sizes_are_not_coordinates + unit
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.50.3",
"version": "1.50.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.50.3",
"version": "1.50.4",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.50.3",
"version": "1.50.4",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+14 -27
View File
@@ -33,10 +33,10 @@ import type {
import './editor';
import './space-card';
import { cardStyles } from './styles';
import { fitInSquare, contentBounds } from './space-geometry';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.50.3';
const CARD_VERSION = '1.50.4';
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';
@@ -641,31 +641,18 @@ class HouseplanCard extends LitElement {
private _buildModel(): SpaceModel[] {
if (!this._serverCfg) return [];
return this._serverCfg.spaces.map((s: any) => {
const H = NORM_W; // the canvas is always square (v1.48.0)
const scale = (r: any) => ({
id: r.id,
name: r.name,
area: r.area ?? null,
open_to: r.open_to || undefined,
settings: r.settings || undefined,
x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined,
h: r.h != null ? r.h * H : undefined,
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
});
return {
id: s.id,
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],
// raw url on purpose: the model is memoized on the config fingerprint,
// so a signed url baked in here would freeze BEFORE the signature
// arrives and the plan would never load (bug found 2026-07-27).
// _display() is called at render time instead.
bg: s.plan_url ? { href: s.plan_url, ...fitInSquare(s.plan_aspect, NORM_W) } : null,
rooms: s.rooms.map(scale),
};
// ONE model builder for both cards. This used to be a hand-copied twin of
// spaceModels(), and the twin missed the legacy-store fallbacks the shared
// one gained (safeViewBox, normRect) — the same broken store rendered fine
// in the static card and as viewBox="0 0 0 0" here (HP-1503-01). The only
// difference this card needs is the url: raw on purpose, because the model
// is memoized on the config fingerprint, so a signed url baked in here
// would freeze BEFORE the signature arrives and the plan would never load
// (bug found 2026-07-27). _display() is called at render time instead.
const cfg = this._serverCfg;
return spaceModels(cfg).map((m, i) => {
const raw = (cfg.spaces[i] as any)?.plan_url;
return m.bg && raw ? { ...m, bg: { ...m.bg, href: raw } } : m;
});
}