test v1.43.2: smokes that can fail, in CI, and an honest TESTING.md

T1: demo/serve.mjs exports check/checkAll/finish — all 48 smokes now
assert named facts and exit non-zero on a mismatch or an uncaught
in-card exception (verified by breaking the kiosk guard on purpose).
Informational values were frozen from a v1.43.1 run and cross-read
against the source; timings assert budgets, not exact numbers.

T2: new CI job 'smoke' gated on 'frontend', builds a FRESH bundle
before running (the committed demo/srv/assets copy is a snapshot) and
uploads per-file logs on failure.

T3: [auto] now means 'a named failing check exists' and each line names
it (43 lines); 72 aspirational markers honestly downgraded to [manual].
Fixed the 'ZERO edit buttons' contradiction (wrong since v1.30.1) and
the opening-click line (true again since v1.43.1).

Three smokes carried pre-v1.39.0/v1.25 expectations and were testing
old behaviour: tap defaults for lights, card-wide tap action, label drag
requiring plan mode.

DEVELOPMENT.md documents the harness contract.
This commit is contained in:
Matysh
2026-07-27 11:20:31 +03:00
parent 49b0cb4e05
commit 41b20e1901
57 changed files with 490 additions and 254 deletions
+38
View File
@@ -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:
+1 -1
View File
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
CONTENT_URL = "/api/houseplan/content" 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.43.1" VERSION = "1.43.2"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
@@ -2488,4 +2488,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>`} </button>`}
</div> </div>
</div> </div>
</div>`}}ts.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ts._noHover="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ts.styles=Ei,customElements.get("houseplan-card")||customElements.define("houseplan-card",ts),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.43.1 ","background:#3ea6ff;color:#04121f;font-weight:700",""); </div>`}}ts.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ts._noHover="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ts.styles=Ei,customElements.get("houseplan-card")||customElements.define("houseplan-card",ts),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.43.2 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+1 -1
View File
@@ -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.43.1" "version": "1.43.2"
} }
+36 -1
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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,5 @@ const res = await page.evaluate(async () => {
c._settingsDialog = null; c._settingsDialog = null;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+16 -3
View File
@@ -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);
+6 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+6 -3
View File
@@ -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);
+10 -3
View File
@@ -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
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+8 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+7 -3
View File
@@ -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
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+7 -3
View File
@@ -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,9 @@ const res = await page.evaluate(async () => {
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0; out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"pairsPerRender": 0,
"modelBuildsPer10Renders": 0,
});
await finish(browser, res);
+6 -3
View File
@@ -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);
+9 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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 () => {
out.serverHasIt = server.cfg.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; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+15 -4
View File
@@ -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);
+3 -3
View File
@@ -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);
+6 -3
View File
@@ -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);
+12 -3
View File
@@ -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);
+7 -3
View File
@@ -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
View File
@@ -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);
+11 -3
View File
@@ -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);
+3 -3
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -2488,4 +2488,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>`} </button>`}
</div> </div>
</div> </div>
</div>`}}ts.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ts._noHover="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ts.styles=Ei,customElements.get("houseplan-card")||customElements.define("houseplan-card",ts),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.43.1 ","background:#3ea6ff;color:#04121f;font-weight:700",""); </div>`}}ts.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ts._noHover="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ts.styles=Ei,customElements.get("houseplan-card")||customElements.define("houseplan-card",ts),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.43.2 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+1 -1
View File
@@ -2488,4 +2488,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
</button>`} </button>`}
</div> </div>
</div> </div>
</div>`}}ts.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ts._noHover="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ts.styles=Ei,customElements.get("houseplan-card")||customElements.define("houseplan-card",ts),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.43.1 ","background:#3ea6ff;color:#04121f;font-weight:700",""); </div>`}}ts.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ts._noHover="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ts.styles=Ei,customElements.get("houseplan-card")||customElements.define("houseplan-card",ts),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.43.2 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+19
View File
@@ -1,5 +1,24 @@
# 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) ## v1.43.1 — 2026-07-27 (external audit: P1 fixes)
- **Render cost (L1).** Home Assistant replaces `hass` on every state change in - **Render cost (L1).** Home Assistant replaces `hass` on every state change in
+19
View File
@@ -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.
+124 -107
View File
@@ -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,81 +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 - [ ] 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 computed once per config change, not per HA state push — smoke asserts
zero recomputations across 10 state pushes and recomputation after an zero recomputations across 10 state pushes and recomputation after an
edit; the plan still renders dashes/islands correctly [auto] 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 - [ ] 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 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] 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 - [ ] 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 L-shaped parent is accepted and punches the evenodd hole; a traced
duplicate outline is still NOT containment [auto] duplicate outline is still NOT containment [auto: smoke_inert_openings]
- [ ] Backend hardening (v1.43.1, audit B2-B5): the admin check fails closed - [ ] Backend hardening (v1.43.1, audit B2-B5): the admin check fails closed
when the entry is unavailable; layout/set honours expected_rev; a when the entry is unavailable; layout/set honours expected_rev; a
config/set without expected_rev over a non-empty store logs a warning; config/set without expected_rev over a non-empty store logs a warning;
NaN/Infinity coordinates and oversized collections are rejected [auto] 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 - [ ] 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 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] 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 - [ ] 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 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] 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 - [ ] Authenticated content (v1.43.0, audit B1): plan images and marker files
are only reachable through /api/houseplan/content/… with a session; the are only reachable through /api/houseplan/content/… with a session; the
old /houseplan_files/plans|files paths return 404 after a restart; old old /houseplan_files/plans|files paths return 404 after a restart; old
stored URLs keep working (rewritten on read) [auto+manual] stored URLs keep working (rewritten on read) [auto+manual]
- [ ] Dialog zombies (v1.43.0, audit L3): close a dialog (Esc) while its save is - [ ] 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 in flight and let the save fail — the dialog stays closed, the card keeps
rendering, the error toast still fires [auto] 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), 50300%, dialog) plus per-room name and metrics sizes (room settings), 50300%,
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
@@ -195,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
@@ -258,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.53, stored in layout, survives drag); handles on hover (uniform scale 0.53, 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) ★
@@ -345,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
@@ -375,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 ★
@@ -387,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
@@ -411,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.16v1.21.** All `[auto]` items pass (73 frontend **v1.21.1 (2026-07-16), full audit of v1.16v1.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`.
@@ -421,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
@@ -437,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
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.43.1", "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",
+1 -1
View File
@@ -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.43.1'; 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';