fix: harden v1.62.0-beta.10 candidate

User-Visible: yes
Issue: #108
This commit is contained in:
Matysh
2026-08-13 00:14:51 +03:00
parent cf77d7d2e1
commit 37032203dd
35 changed files with 605 additions and 104 deletions
+2 -8
View File
@@ -28,15 +28,9 @@ jobs:
BEFORE_SHA: ${{ github.event.before }} BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }} BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }} HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: | run: |
if [ "$EVENT_NAME" = "pull_request" ]; then node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
range="$BASE_SHA..$HEAD_SHA"
elif [ -n "$BEFORE_SHA" ] && ! echo "$BEFORE_SHA" | grep -Eq '^0+$'; then
range="$BEFORE_SHA..$HEAD_SHA"
else
range="$HEAD_SHA^..$HEAD_SHA"
fi
node scripts/validate-commit-provenance.mjs --check-hook-mode --range "$range"
hacs: hacs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1 -1
View File
@@ -35,7 +35,7 @@ npm run typecheck # tsc --noEmit (strict)
npm test # node:test — pure logic, i18n parity, tap-action security npm test # node:test — pure logic, i18n parity, tap-action security
npm run build # tsc + rollup → dist/houseplan-card.js npm run build # tsc + rollup → dist/houseplan-card.js
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
git config core.hooksPath .githooks # issue/release provenance trailers npm install # also installs .githooks through the prepare script
``` ```
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
+3 -2
View File
@@ -32,10 +32,11 @@ Baseline-Reviewed: <CI run or artifact reference>
Never invent a review reference merely to pass a gate. Baselines are accepted Never invent a review reference merely to pass a gate. Baselines are accepted
only from the complete Linux CI artifact via `golden:accept -- --reviewed`. only from the complete Linux CI artifact via `golden:accept -- --reviewed`.
Install the repository hook once per clone: Install dependencies once per clone; the `prepare` script activates the
repository hook automatically:
```bash ```bash
git config core.hooksPath .githooks npm install
``` ```
The hook checks message provenance locally; `validate.yml` enforces the same The hook checks message provenance locally; `validate.yml` enforces the same
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600 SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.62.0-beta.9" VERSION = "1.62.0-beta.10"
# Portable backup format. This is deliberately independent from the Home # Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a # Assistant Store version above: storage migrations and files exported by a
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.62.0-beta.9" "version": "1.62.0-beta.10"
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs'; import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */ /** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 15; export const GOLDEN_MATRIX_VERSION = 16;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } }; const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } }; const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -19,7 +19,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
// complete 90 cm door preset while still proving rotation, inner-face // complete 90 cm door preset while still proving rotation, inner-face
// offset and ruler placement on a thick wall. // offset and ruler placement on a thick wall.
mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] }, mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] },
openingPreviewPixels: { minPixels: 150, minChannelDelta: 4 }, openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page }, theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices', { id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page }, theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
+97 -1
View File
@@ -202,6 +202,82 @@ async function countChangedPixels(page, actual, control, spec) {
}); });
} }
/** Count preview pixels which are actually painted over the physical wall
* fill. A global changed-pixel threshold can pass even when the complete
* symbol is accidentally hidden below masonry because its swing arc remains
* outside the body. */
async function countOpeningPreviewPixelsInsideWall(
page, actual, control, clip, minChannelDelta,
) {
return page.evaluate(async ({ actual64, control64, clipRect, minDelta }) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
const canvas = document.createElement('canvas');
const controlCanvas = document.createElement('canvas');
canvas.width = controlCanvas.width = actualImage.width;
canvas.height = controlCanvas.height = actualImage.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
context.drawImage(actualImage, 0, 0);
controlContext.drawImage(controlImage, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
const controls = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
const card = window.__goldenCard;
const preview = card?.renderRoot?.querySelector('.opening-preview');
const walls = [...(card?.renderRoot?.querySelectorAll('.wallbody-fill') || [])]
.map((wall) => {
const matrix = wall.getScreenCTM?.();
return matrix ? { wall, rect: wall.getBoundingClientRect(), inverse: matrix.inverse() } : null;
})
.filter(Boolean);
if (!preview || !walls.length) return { changed: 0, sampled: 0 };
const previewRect = preview.getBoundingClientRect();
const candidates = walls.filter(({ rect }) => rect.right >= previewRect.left
&& rect.left <= previewRect.right && rect.bottom >= previewRect.top
&& rect.top <= previewRect.bottom);
if (!candidates.length) return { changed: 0, sampled: 0 };
const left = Math.ceil(previewRect.left);
const top = Math.ceil(previewRect.top);
const right = Math.floor(previewRect.right);
const bottom = Math.floor(previewRect.bottom);
const originX = clipRect?.x || 0;
const originY = clipRect?.y || 0;
let changed = 0;
let sampled = 0;
for (let y = top; y <= bottom; y++) {
for (let x = left; x <= right; x++) {
const screenPoint = new DOMPoint(x + 0.5, y + 0.5);
const insideWall = candidates.some(({ wall, rect, inverse }) => {
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return false;
return wall.isPointInFill(screenPoint.matrixTransform(inverse));
});
if (!insideWall) continue;
const px = Math.round(x - originX);
const py = Math.round(y - originY);
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) continue;
sampled += 1;
const offset = (py * canvas.width + px) * 4;
const delta = Math.max(
Math.abs(pixels[offset] - controls[offset]),
Math.abs(pixels[offset + 1] - controls[offset + 1]),
Math.abs(pixels[offset + 2] - controls[offset + 2]),
Math.abs(pixels[offset + 3] - controls[offset + 3]),
);
if (delta >= minDelta) changed += 1;
}
}
return { changed, sampled };
}, {
actual64: actual.toString('base64'),
control64: control.toString('base64'),
clipRect: clip || null,
minDelta: minChannelDelta,
});
}
/** Assert scenario semantics against the actual capture, not only its data. /** Assert scenario semantics against the actual capture, not only its data.
* This protects a reviewed-but-empty baseline from becoming the reference. */ * This protects a reviewed-but-empty baseline from becoming the reference. */
async function countWarmPixels(page, png, region) { async function countWarmPixels(page, png, region) {
@@ -394,7 +470,15 @@ try {
if (!opening) return null; if (!opening) return null;
const transform = opening.getAttribute('transform') || ''; const transform = opening.getAttribute('transform') || '';
const angle = Number(transform.match(/rotate\(([-+0-9.eE]+)\)/)?.[1]); const angle = Number(transform.match(/rotate\(([-+0-9.eE]+)\)/)?.[1]);
const bounds = opening.getBoundingClientRect(); const painted = [...opening.querySelectorAll('.op-leaf, .op-arc, .op-glass')]
.map((part) => part.getBoundingClientRect())
.filter((rect) => rect.width > 0 || rect.height > 0);
const bounds = painted.length ? {
width: Math.max(...painted.map((rect) => rect.right))
- Math.min(...painted.map((rect) => rect.left)),
height: Math.max(...painted.map((rect) => rect.bottom))
- Math.min(...painted.map((rect) => rect.top)),
} : { width: 0, height: 0 };
return { return {
type: opening.getAttribute('data-kind'), angle, type: opening.getAttribute('data-kind'), angle,
width: bounds.width, height: bounds.height, width: bounds.width, height: bounds.height,
@@ -446,6 +530,11 @@ try {
result.openingPreviewParts = control.parts; result.openingPreviewParts = control.parts;
result.openingPreviewChangedPixels = sample.changed; result.openingPreviewChangedPixels = sample.changed;
result.openingPreviewMaxChannelDelta = sample.maxDelta; result.openingPreviewMaxChannelDelta = sample.maxDelta;
const insideWall = await countOpeningPreviewPixelsInsideWall(
page, actual, control.png, clip, scenario.openingPreviewPixels.minChannelDelta,
);
result.openingPreviewPixelsInsideWall = insideWall.changed;
result.openingPreviewWallSamples = insideWall.sampled;
if (control.parts < 2) { if (control.parts < 2) {
throw new Error( throw new Error(
`semantic golden assertion failed: opening preview is incomplete (${control.parts} parts)`, `semantic golden assertion failed: opening preview is incomplete (${control.parts} parts)`,
@@ -457,6 +546,13 @@ try {
+ `expected at least ${scenario.openingPreviewPixels.minPixels}`, + `expected at least ${scenario.openingPreviewPixels.minPixels}`,
); );
} }
if (insideWall.changed < scenario.openingPreviewPixels.minInsideWallPixels) {
throw new Error(
`semantic golden assertion failed: opening preview paints ${insideWall.changed} pixels `
+ `inside the wall body, expected at least `
+ `${scenario.openingPreviewPixels.minInsideWallPixels}`,
);
}
} }
if (scenario.warmPixelRegion) { if (scenario.warmPixelRegion) {
const sample = await countWarmPixels(page, actual, scenario.warmPixelRegion); const sample = await countWarmPixels(page, actual, scenario.warmPixelRegion);
+7
View File
@@ -38,6 +38,13 @@ const res = await page.evaluate(async () => {
settings: { ...(s.settings||{}), show_lqi: false } })) }; settings: { ...(s.settings||{}), show_lqi: false } })) };
c.requestUpdate(); await c.updateComplete; c.requestUpdate(); await c.updateComplete;
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length; out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
// The space value is an explicit override, not another condition ANDed with
// the card default. This is the projection shared by preview/static cards.
c._config = { ...c._config, show_signal: false };
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_lqi: true } })) };
c.requestUpdate(); await c.updateComplete;
out.spaceLqiOverridesCardDefault = sr().querySelectorAll('.dev .lqi').length > 0;
return out; return out;
}); });
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер). // CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
+59 -3
View File
@@ -282,6 +282,20 @@ const rasterFixture = await page.evaluate(async () => {
if (!source || !sourceEid || !current) return { ready: false }; if (!source || !sourceEid || !current) return { ready: false };
const edgeKey = (a, b) => `${a.join(',')}/${b.join(',')}`; const edgeKey = (a, b) => `${a.join(',')}/${b.join(',')}`;
const wallKey = (a, b) => {
const pitch = 1 / 240;
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) {
dx = -dx; dy = -dy;
}
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const angleBucket = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angleBucket.toFixed(4)}`;
};
const edges = new Map(); const edges = new Map();
for (const room of current.rooms || []) { for (const room of current.rooms || []) {
for (let index = 0; index < room.poly.length; index++) { for (let index = 0; index < room.poly.length; index++) {
@@ -291,13 +305,21 @@ const rasterFixture = await page.evaluate(async () => {
if (!edges.has(reverse) && !edges.has(edgeKey(a, b))) edges.set(edgeKey(a, b), { a, b }); if (!edges.has(reverse) && !edges.has(edgeKey(a, b))) edges.set(edgeKey(a, b), { a, b });
} }
} }
const walls = [...edges.values()].map(({ a, b }, index) => ({ const walls = [...edges.values()].map(({ a, b }) => ({
key: `glow-raster-wall-${index}`, a, b, cm: 15, key: wallKey(a, b), a, b, cm: 15,
})); }));
const openings = [ const openings = [
{ id: 'glow-raster-east', type: 'door', x: 0.55, y: 0.32, angle: 90, length: 0.09 }, { id: 'glow-raster-east', type: 'door', x: 0.55, y: 0.32, angle: 90, length: 0.09 },
{ id: 'glow-raster-south', type: 'door', x: 0.32, y: 0.58, angle: 0, length: 0.09 }, { id: 'glow-raster-south', type: 'door', x: 0.32, y: 0.58, angle: 0, length: 0.09 },
]; ];
const wallSource = [...edges.values()]
.map(({ a, b }) => ({
point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
clearance: Math.min(...openings.map((opening) => Math.hypot(
(a[0] + b[0]) / 2 - opening.x, (a[1] + b[1]) / 2 - opening.y,
))),
}))
.sort((left, right) => right.clearance - left.clearance)[0]?.point;
c._serverCfg = { c._serverCfg = {
...c._serverCfg, ...c._serverCfg,
settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 }, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 },
@@ -425,7 +447,7 @@ const rasterFixture = await page.evaluate(async () => {
}), }),
}; };
} }
return { ready: true, litParts, sourceEid, doors, shadow }; return { ready: true, litParts, sourceEid, sourceId: source.id, wallSource, doors, shadow };
}); });
if (rasterFixture.ready) { if (rasterFixture.ready) {
@@ -531,6 +553,39 @@ if (rasterFixture.ready) {
res.shadowEdgeIsCrisp = !!metrics.shadow res.shadowEdgeIsCrisp = !!metrics.shadow
&& metrics.shadow.high - metrics.shadow.low >= 8 && metrics.shadow.high - metrics.shadow.low >= 8
&& metrics.shadow.edgeWidth <= 4; && metrics.shadow.edgeWidth <= 4;
const sourceInsideWall = await page.evaluate(async ({ sourceEid, sourceId, wallSource }) => {
if (!wallSource) return false;
const c = window.__card;
const state = c.hass.states[sourceEid];
c._layout = {
...c._layout,
[sourceId]: { s: c._space, x: wallSource[0], y: wallSource[1] },
};
c.hass = {
...c.hass,
states: { ...c.hass.states, [sourceEid]: { ...state, state: 'on' } },
};
c._glowClipCache.clear();
c.requestUpdate();
await c.updateComplete;
// The continuity contract may keep the last complete device frame briefly
// while the moved source and its HA snapshot are staged atomically. Wait
// for that bounded hand-off instead of mistaking the allowed stale frame
// for a geometry leak.
const root = c.shadowRoot || c.renderRoot;
const selector = `[data-glow-source="${CSS.escape(sourceEid)}"] .glow-pool`;
const deadline = performance.now() + 1800;
while (root.querySelector(selector) && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 40));
await c.updateComplete;
}
return !root.querySelector(selector);
}, {
sourceEid: rasterFixture.sourceEid,
sourceId: rasterFixture.sourceId,
wallSource: rasterFixture.wallSource,
});
res.sourceInsideWallSuppressed = sourceInsideWall;
} else { } else {
res.litPartsMatchClip = false; res.litPartsMatchClip = false;
res.doorwayCarriesLight = false; res.doorwayCarriesLight = false;
@@ -538,6 +593,7 @@ if (rasterFixture.ready) {
res.apertureItselfLit = false; res.apertureItselfLit = false;
res.occluderCastsShadow = false; res.occluderCastsShadow = false;
res.shadowEdgeIsCrisp = false; res.shadowEdgeIsCrisp = false;
res.sourceInsideWallSuppressed = false;
} }
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1) // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, { checkAll(res, {
+14
View File
@@ -66,6 +66,20 @@ const res = await page.evaluate(async () => {
const restoredTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger'); const restoredTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
out.circledQuestionIcon = restoredTrigger?.querySelector('svg[viewBox="0 0 24 24"] path') out.circledQuestionIcon = restoredTrigger?.querySelector('svg[viewBox="0 0 24 24"] path')
?.getAttribute('d')?.length > 0; ?.getAttribute('d')?.length > 0;
incompleteHelp.text = Array.from({ length: 320 }, (_, index) => `Line ${index + 1}`).join(' · ');
await incompleteHelp.updateComplete;
const overflowTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
overflowTrigger?.click();
await incompleteHelp.updateComplete;
await frame();
const longSurface = incompleteHelp.shadowRoot?.querySelector('.tooltip');
overflowTrigger?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'PageDown', bubbles: true, composed: true, cancelable: true,
}));
await frame();
out.overflowHelpScrollsFromTrigger = !!longSurface
&& longSurface.scrollHeight > longSurface.clientHeight && longSurface.scrollTop > 0;
overflowTrigger?.click();
incompleteHelp.remove(); incompleteHelp.remove();
card._setMode('devices'); card._setMode('devices');
+24 -1
View File
@@ -43,8 +43,25 @@ await restore();
// grid-aligned test room (real rooms are snapped; demo rooms are not) // grid-aligned test room (real rooms are snapped; demo rooms are not)
await page.evaluate(()=>{ await page.evaluate(()=>{
const c=window.__card; const c=window.__card;
c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push( const sp = c._serverCfg.spaces.find(s=>s.id==='f1');
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (v) => Math.round(v / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
dx /= len; dy /= len;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const outline = [[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]];
sp.rooms.push(
{id:'rg', name:'GridRoom', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]}); {id:'rg', name:'GridRoom', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
sp.walls = [...(sp.walls || []), ...outline.map((a, index) => ({
key: wallKey(a, outline[(index + 1) % outline.length]), cm: 15,
}))];
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate(); c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate();
}); });
// SPLIT rg vertical chord x=0.25 (all on grid nodes) // SPLIT rg vertical chord x=0.25 (all on grid nodes)
@@ -66,6 +83,12 @@ s = await S();
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room'); out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name; out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
out.splitKeepsLegacyWallThickness = await page.evaluate(() => {
const c = window.__card;
const y = 0.0625 * c._spaceH;
return Math.abs(c._intervalCm([50, y, 250, y]) - 15) < 1e-9
&& Math.abs(c._intervalCm([250, y, 500, y]) - 15) < 1e-9;
});
await restore(); await restore();
// AUD-159B7-01: one open span crossing the Split point belongs to two room // AUD-159B7-01: one open span crossing the Split point belongs to two room
+12
View File
@@ -3,6 +3,18 @@ const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
const c = window.__card; const c = window.__card;
// Permission-delayed warm navigation must use the complete transition
// authority instead of assigning the private mode field directly.
c._setMode('view', false); await c.updateComplete;
const realSetMode = c._setMode.bind(c);
let resumedThroughSetMode = 0;
c._setMode = (...args) => { resumedThroughSetMode += 1; return realSetMode(...args); };
c._pendingNavMode = 'devices';
c._serverCanWrite = true;
out.pendingModeUsesTransitionAuthority = c._resumePendingNavMode() === true
&& resumedThroughSetMode === 1 && c._mode === 'devices' && c._pendingNavMode == null;
delete c._setMode;
c._setMode('view', false); await c.updateComplete;
// выбрать f2 и редактор устройств // выбрать f2 и редактор устройств
c._space = 'garden'; c._setMode('devices'); await c.updateComplete; c._space = 'garden'; c._setMode('devices'); await c.updateComplete;
const nav = JSON.parse(localStorage.getItem('houseplan_card_nav_v1')); const nav = JSON.parse(localStorage.getItem('houseplan_card_nav_v1'));
+24
View File
@@ -195,6 +195,30 @@ const res = await page.evaluate(async () => {
card._cursorPt = card._roomCenter(room); card._cursorPt = card._roomCenter(room);
await settle(); await settle();
out.noPreviewAwayFromWalls = !root().querySelector('.opening-preview'); out.noPreviewAwayFromWalls = !root().querySelector('.opening-preview');
// Real independent bodies are occluders, not room-owned wall intervals.
// They must never become accidental opening-placement targets.
const bodyCentre = card._roomCenter(room);
const bodyX = bodyCentre[0] / 1000;
const bodyY = bodyCentre[1] / card._spaceH;
card._curSpaceCfg.partitions = [{
id: 'opening-smoke-partition', a: [bodyX - 0.05, bodyY], b: [bodyX + 0.05, bodyY], cm: 20,
}];
card._cfgEpoch++;
card._openingPlacementIntervalsCache = null;
card._cursorPt = bodyCentre;
await settle();
out.partitionIsNotOpeningTarget = !root().querySelector('.opening-preview')
&& card._resolveOpeningPlacement(bodyCentre) == null;
card._curSpaceCfg.partitions = [];
card._curSpaceCfg.wall_columns = [{
id: 'opening-smoke-column', shape: 'square', cm: 30, center: [bodyX, bodyY], angle: 0,
}];
card._cfgEpoch++;
card._openingPlacementIntervalsCache = null;
await settle();
out.columnIsNotOpeningTarget = !root().querySelector('.opening-preview')
&& card._resolveOpeningPlacement(bodyCentre) == null;
card._cancelPath(); card._cancelPath();
card._tool = 'draw'; card._tool = 'draw';
await settle(); await settle();
File diff suppressed because one or more lines are too long
+7 -7
View File
File diff suppressed because one or more lines are too long
+15 -1
View File
@@ -2,6 +2,19 @@
## Unreleased ## Unreleased
## v1.62.0-beta.10 — 2026-08-13
- Fixed composite device presentation and actions after the beta review:
an explicitly selected light entity now remains the tap target, a primary
cover keeps the cover face, and registry-backed state selection is identical
in the full plan, preview and static card. Strengthened Split, Glow,
contextual-help and opening-preview regression coverage.
- A light source placed inside a physical wall, window tunnel or exterior
door/gate opening intentionally produces no Glow; move its marker onto the
room floor to restore illumination ([#92](https://github.com/Matysh/houseplan-card/issues/92)).
- Small fixes and improvements
([#108](https://github.com/Matysh/houseplan-card/issues/108)).
## v1.62.0-beta.9 — 2026-08-12 ## v1.62.0-beta.9 — 2026-08-12
- Fixed post-review inconsistencies between device state and tap behaviour: - Fixed post-review inconsistencies between device state and tap behaviour:
@@ -12,7 +25,8 @@
correct centre snapping and a stricter visual preview gate. Restored the correct centre snapping and a stricter visual preview gate. Restored the
public activity and preview-dot CSS hooks, improved help accessibility and public activity and preview-dot CSS hooks, improved help accessibility and
strengthened internal process checks. strengthened internal process checks.
- Small fixes and improvements. - Small fixes and improvements
([#108](https://github.com/Matysh/houseplan-card/issues/108)).
## v1.62.0-beta.8 — 2026-08-12 ## v1.62.0-beta.8 — 2026-08-12
+17 -1
View File
@@ -8,6 +8,21 @@
## Unreleased ## Unreleased
## v1.62.0-beta.10 — 2026-08-13
- По результатам ревью беты исправлены отображение и действия составных
устройств: явно выбранный источник света теперь остаётся целью нажатия,
основная сущность шторы сохраняет отображение шторы, а выбор состояния по
реестру одинаков в полном плане, предпросмотре и статичной карточке. Усилены
регрессии для разделения комнат, Glow, контекстных подсказок и предпросмотра
проёмов.
- Источник света внутри тела физической стены, тоннеля окна или внешнего
дверного проёма/ворот намеренно не создаёт Glow; для появления света маркер
нужно переместить на чистую площадь комнаты
([#92](https://github.com/Matysh/houseplan-card/issues/92)).
- Мелкие исправления и улучшения
([#108](https://github.com/Matysh/houseplan-card/issues/108)).
## v1.62.0-beta.9 — 2026-08-12 ## v1.62.0-beta.9 — 2026-08-12
- По результатам повторного код-ревью устранены расхождения между состоянием - По результатам повторного код-ревью устранены расхождения между состоянием
@@ -19,7 +34,8 @@
геометрии, корректная центральная привязка и более строгая визуальная геометрии, корректная центральная привязка и более строгая визуальная
проверка предпросмотра. Восстановлены публичные CSS-хуки активности и точки проверка предпросмотра. Восстановлены публичные CSS-хуки активности и точки
предпросмотра; улучшены доступность подсказок и внутренние проверки процесса. предпросмотра; улучшены доступность подсказок и внутренние проверки процесса.
- Мелкие исправления и улучшения. - Мелкие исправления и улучшения
([#108](https://github.com/Matysh/houseplan-card/issues/108)).
## v1.62.0-beta.8 — 2026-08-12 ## v1.62.0-beta.8 — 2026-08-12
+3 -1
View File
@@ -50,7 +50,9 @@ Source placement follows the same geometry, fail-dark. If the source centre is
inside an opaque wall body, a window tunnel, or an exterior door/gate opening, inside an opaque wall body, a window tunnel, or an exterior door/gate opening,
the source produces no Glow at all. It does not light the indoor half of the the source produces no Glow at all. It does not light the indoor half of the
opening. An interior door/gate passage remains a real hole and is therefore a opening. An interior door/gate passage remains a real hole and is therefore a
valid source position. valid source position. This is an intentional placement rule, not a temporary
availability state: move the source marker onto the clean room floor to make it
emit Glow again ([#92](https://github.com/Matysh/houseplan-card/issues/92)).
## From barriers to a lit region ## From barriers to a lit region
+7 -7
View File
@@ -1,16 +1,16 @@
<!-- release: v1.62.0-beta.9 --> <!-- release: v1.62.0-beta.10 -->
## Основное ## Основное
- Исправлены выбор управляемой сущности и отображение составных устройств. - Исправлены отображение и переключение составных устройств.
- Улучшены стабильность и визуальная проверка предпросмотра проёмов. - Усилены проверки разделения комнат, Glow, подсказок и предпросмотра проёмов.
- Мелкие исправления и улучшения. - Мелкие исправления и улучшения.
## Highlights ## Highlights
- Fixed target selection and presentation for composite devices. - Fixed presentation and toggling for composite devices.
- Improved opening-preview stability and visual verification. - Strengthened Split, Glow, contextual-help and opening-preview checks.
- Small fixes and improvements. - Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.9/docs/CHANGELOG.ru.md) [Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.10/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.9/docs/CHANGELOG.md) · [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.10/docs/CHANGELOG.md)
+3 -1
View File
@@ -150,4 +150,6 @@ it. These bodies are unioned with room-wall bodies only after door/window/gate c
so an opening cannot punch a coincident independent wall. They are subtracted so an opening cannot punch a coincident independent wall. They are subtracted
from the cached clean floor, and the same body set is used by Glow and sun-ray from the cached clean floor, and the same body set is used by Glow and sun-ray
occlusion even when borders are hidden. A source inside/on a physical body is occlusion even when borders are hidden. A source inside/on a physical body is
fully occluded instead of leaking around its own masonry. fully occluded instead of leaking around its own masonry. The same fail-dark
placement rule applies to window tunnels and exterior door/gate openings;
interior passages remain valid source positions (#92).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.62.0-beta.9", "version": "1.62.0-beta.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.62.0-beta.9", "version": "1.62.0-beta.10",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lit": "^3.1.3", "lit": "^3.1.3",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.62.0-beta.9", "version": "1.62.0-beta.10",
"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",
@@ -19,7 +19,8 @@
"continuity:screencast": "node demo/screencast_visual_continuity.mjs", "continuity:screencast": "node demo/screencast_visual_continuity.mjs",
"golden:accept": "node demo/golden/accept.mjs", "golden:accept": "node demo/golden/accept.mjs",
"release:check": "node scripts/release-prerelease.mjs --check", "release:check": "node scripts/release-prerelease.mjs --check",
"release:prerelease": "node scripts/release-prerelease.mjs" "release:prerelease": "node scripts/release-prerelease.mjs",
"prepare": "node scripts/install-hooks.mjs"
}, },
"devDependencies": { "devDependencies": {
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const packageRoot = realpathSync(fileURLToPath(new URL('..', import.meta.url)));
try {
// A git dependency can run `prepare` from node_modules inside the consuming
// project's checkout. Never walk up and rewrite that unrelated repository.
const gitRoot = realpathSync(execFileSync(
'git', ['rev-parse', '--show-toplevel'],
{ cwd: packageRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
).trim());
if (gitRoot.toLowerCase() !== packageRoot.toLowerCase()
|| !existsSync(fileURLToPath(new URL('../.githooks/commit-msg', import.meta.url)))) {
throw new Error('not the House Plan checkout root');
}
execFileSync('git', ['config', 'core.hooksPath', '.githooks'], {
cwd: packageRoot, stdio: 'ignore',
});
console.log('House Plan: installed repository hooks from .githooks');
} catch {
// npm also runs prepare for source archives and dependency installs where
// there is no matching writable checkout. That is not an install failure.
console.log('House Plan: no matching Git checkout; hooks were not installed');
}
+69 -11
View File
@@ -5,9 +5,20 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/; const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/;
export const ENFORCEMENT_BOUNDARY = '8e2973fa7a7cb1a80204ff95ecf3f2d7c36ed2ce';
/** Git invokes commit-msg before it removes the editor template. Ignore the
* standard comment/scissors suffix exactly as Git will when it records the
* commit, while leaving ordinary prose after trailers invalid. */
export function cleanedCommitMessage(message) {
const lines = String(message).replace(/\r/g, '').split('\n');
const scissors = lines.findIndex((line) => /^\s*#\s*-+\s*>8\s*-+\s*$/.test(line));
const visible = scissors >= 0 ? lines.slice(0, scissors) : lines;
return visible.filter((line) => !/^\s*#/.test(line)).join('\n');
}
export function terminalTrailers(message) { export function terminalTrailers(message) {
const lines = String(message).replace(/\r/g, '').split('\n'); const lines = cleanedCommitMessage(message).split('\n');
while (lines.length && !lines.at(-1).trim()) lines.pop(); while (lines.length && !lines.at(-1).trim()) lines.pop();
const out = new Map(); const out = new Map();
for (let index = lines.length - 1; index >= 0; index--) { for (let index = lines.length - 1; index >= 0; index--) {
@@ -31,6 +42,14 @@ export function validateCommitMessage(message, changedFiles = []) {
if (visible.length !== 1 || !/^(yes|no)$/.test(visible[0])) { if (visible.length !== 1 || !/^(yes|no)$/.test(visible[0])) {
errors.push("expected exactly one terminal 'User-Visible: yes|no' trailer"); errors.push("expected exactly one terminal 'User-Visible: yes|no' trailer");
} }
const normalizedFiles = changedFiles.map((file) => file.replaceAll('\\', '/'));
if (visible.length === 1 && visible[0] === 'yes') {
for (const changelog of ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md']) {
if (!normalizedFiles.includes(changelog)) {
errors.push(`user-visible commit must update ${changelog}`);
}
}
}
const changesGolden = changedFiles.some((file) => const changesGolden = changedFiles.some((file) =>
/^demo\/golden\/baselines\/.*\.(png|json)$/.test(file.replaceAll('\\', '/'))); /^demo\/golden\/baselines\/.*\.(png|json)$/.test(file.replaceAll('\\', '/')));
if (changesGolden) { if (changesGolden) {
@@ -48,13 +67,42 @@ function git(args) {
return execFileSync('git', args, { encoding: 'utf8' }).trim(); return execFileSync('git', args, { encoding: 'utf8' }).trim();
} }
function assertHookMode() { export function assertHookMode(row = git(['ls-files', '-s', '.githooks/commit-msg'])) {
const row = git(['ls-files', '-s', '.githooks/commit-msg']);
if (!row.startsWith('100755 ')) { if (!row.startsWith('100755 ')) {
throw new Error('.githooks/commit-msg must be tracked as executable (100755)'); throw new Error('.githooks/commit-msg must be tracked as executable (100755)');
} }
} }
function gitObjectExists(revision, runner = git) {
try { runner(['cat-file', '-e', `${revision}^{commit}`]); return true; }
catch { return false; }
}
export function resolveValidationRange({
eventName, beforeSha, baseSha, headSha, defaultBranch,
}, runner = git) {
if (!headSha) throw new Error('HEAD_SHA is required');
if (eventName === 'pull_request') {
if (!baseSha) throw new Error('BASE_SHA is required for a pull request');
return `${runner(['merge-base', baseSha, headSha])}..${headSha}`;
}
const hasBefore = !!beforeSha && !/^0+$/.test(beforeSha)
&& gitObjectExists(beforeSha, runner);
const comparison = hasBefore
? beforeSha
: `refs/remotes/origin/${defaultBranch || 'main'}`;
return `${runner(['merge-base', comparison, headSha])}..${headSha}`;
}
function assertDescendsFromBoundary(commit) {
try { git(['merge-base', '--is-ancestor', ENFORCEMENT_BOUNDARY, commit]); }
catch {
throw new Error(
`${commit} does not descend from provenance boundary ${ENFORCEMENT_BOUNDARY}`,
);
}
}
function validateOne(label, message, files) { function validateOne(label, message, files) {
const errors = validateCommitMessage(message, files); const errors = validateCommitMessage(message, files);
if (errors.length) throw new Error(`${label}:\n- ${errors.join('\n- ')}`); if (errors.length) throw new Error(`${label}:\n- ${errors.join('\n- ')}`);
@@ -73,23 +121,33 @@ function main(argv) {
validateOne('commit message', readFileSync(messageFile, 'utf8'), files); validateOne('commit message', readFileSync(messageFile, 'utf8'), files);
} }
const rangeAt = argv.indexOf('--range'); const rangeAt = argv.indexOf('--range');
if (rangeAt >= 0) { const githubRange = argv.includes('--github-range');
const range = argv[rangeAt + 1]; if (rangeAt >= 0 || githubRange) {
const range = githubRange
? resolveValidationRange({
eventName: process.env.EVENT_NAME,
beforeSha: process.env.BEFORE_SHA,
baseSha: process.env.BASE_SHA,
headSha: process.env.HEAD_SHA,
defaultBranch: process.env.DEFAULT_BRANCH,
})
: argv[rangeAt + 1];
if (!range) throw new Error('--range requires a git revision range'); if (!range) throw new Error('--range requires a git revision range');
const head = range.split('..').at(-1);
assertDescendsFromBoundary(head);
const commits = git(['rev-list', '--reverse', range]).split('\n').filter(Boolean); const commits = git(['rev-list', '--reverse', range]).split('\n').filter(Boolean);
for (const commit of commits) { for (const commit of commits) {
// PROCESS.md explicitly forbids rewriting published history. The commit // The immutable introducing commit is the boundary. File presence is
// that introduces this validator is the enforcement boundary; older PR // intentionally irrelevant: deleting the validator inside the range
// ancestry without the script remains auditable but cannot block a // must not create a two-commit bypass.
// future release branch forever. try { git(['merge-base', '--is-ancestor', ENFORCEMENT_BOUNDARY, commit]); }
try { git(['cat-file', '-e', `${commit}:scripts/validate-commit-provenance.mjs`]); }
catch { continue; } catch { continue; }
const parentCount = Number(git(['rev-list', '--parents', '-n', '1', commit]).split(/\s+/).length) - 1; const parentCount = Number(git(['rev-list', '--parents', '-n', '1', commit]).split(/\s+/).length) - 1;
if (parentCount > 1) continue; if (parentCount > 1) continue;
validateOne( validateOne(
commit, commit,
git(['show', '-s', '--format=%B', commit]), git(['show', '-s', '--format=%B', commit]),
git(['diff-tree', '--no-commit-id', '--name-only', '-r', commit]).split('\n').filter(Boolean), git(['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', commit]).split('\n').filter(Boolean),
); );
} }
} }
+3 -2
View File
@@ -267,9 +267,10 @@ export function resolvePresentationSources(
// More info. On a mixed device, however, an unrelated cover capability must // More info. On a mixed device, however, an unrelated cover capability must
// not hijack a primary/owned light unless the legacy explicit cover action // not hijack a primary/owned light unless the legacy explicit cover action
// (or the primary entity itself) says that the marker represents the cover. // (or the primary entity itself) says that the marker represents the cover.
const resolvedDeviceRole = resolvedDeviceStateEntities(hass, d.entities); const resolvedDeviceRole = resolvedDeviceStateEntities(registryHass, d.entities);
const coverOwnsFace = !!cover && ( const coverOwnsFace = !!cover && (
d.tapAction === 'cover' d.tapAction === 'cover'
|| d.primary?.startsWith('cover.')
|| resolvedDeviceRole.some((eid) => eid.startsWith('cover.')) || resolvedDeviceRole.some((eid) => eid.startsWith('cover.'))
); );
if (coverOwnsFace) { if (coverOwnsFace) {
@@ -301,7 +302,7 @@ export function resolvePresentationSources(
// (and early HA startup snapshots) expose live states before the entity // (and early HA startup snapshots) expose live states before the entity
// registry arrives. Keep the historical whole-device role in that case // registry arrives. Keep the historical whole-device role in that case
// instead of silently downgrading the same entity to a generic primary. // instead of silently downgrading the same entity to a generic primary.
const resolvedIds = resolvedDeviceStateEntities(hass, d.entities); const resolvedIds = resolvedDeviceStateEntities(registryHass, d.entities);
const ids = resolvedIds.length const ids = resolvedIds.length
? resolvedIds ? resolvedIds
: d.entities.filter((eid) => !!hass?.states?.[eid]); : d.entities.filter((eid) => !!hass?.states?.[eid]);
+10 -3
View File
@@ -354,9 +354,16 @@ function resolveEntity(
function ownRoleCandidates(device: DevItem, registryHass: any): string[] { function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
if (device.bindingKind === 'entity' && device.bindingRef) { if (device.bindingKind === 'entity' && device.bindingRef) {
// Entity bindings are exact by contract: a controllable sibling from the // The binding remains exact unless the user explicitly selected the
// same HA device must never replace the entity selected by the user. // marker's light driver. That persisted override is shared by rendering,
return [device.bindingRef]; // Glow and tap handling, so the face must never represent light.lamp while
// a tap silently toggles the original relay instead.
const explicitLight = device.marker?.light_entity
? forcedLightEntityOf(device)
: null;
return [...new Set([explicitLight, device.bindingRef].filter(
(eid): eid is string => !!eid,
))];
} }
const candidates = device.entities.length ? device.entities : device.allEntities || []; const candidates = device.entities.length ? device.entities : device.allEntities || [];
const leading = (device.marker?.light_entity const leading = (device.marker?.light_entity
+26 -19
View File
@@ -196,7 +196,7 @@ import {
} from './opening-placement'; } from './opening-placement';
import { safeStoredColor } from './color'; import { safeStoredColor } from './color';
const CARD_VERSION = '1.62.0-beta.9'; const CARD_VERSION = '1.62.0-beta.10';
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = { const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'display.badge', badge: 'display.badge',
icon_ripple: 'display.icon_ripple', icon_ripple: 'display.icon_ripple',
@@ -3067,6 +3067,18 @@ class HouseplanCard extends LitElement {
return { configChanged, layoutChanged }; return { configChanged, layoutChanged };
} }
/** Resume only a same-route warm editor intent after permissions arrive.
* Always enter through _setMode: it owns transition state, contextual tray
* cleanup and navigation persistence. Direct assignment leaves those
* surfaces in mutually inconsistent modes. */
private _resumePendingNavMode(): boolean {
if (!this._pendingNavMode || !this._canEdit || this._config?.kiosk) return false;
const pendingMode = this._pendingNavMode;
this._pendingNavMode = null;
this._setMode(pendingMode, false);
return true;
}
private async _loadFromServer(): Promise<void> { private async _loadFromServer(): Promise<void> {
this._loading = true; this._loading = true;
this._loadTries++; this._loadTries++;
@@ -3103,11 +3115,7 @@ class HouseplanCard extends LitElement {
// absent can_write = older backend / demo stub → keep null (legacy admin fallback) // absent can_write = older backend / demo stub → keep null (legacy admin fallback)
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write; if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo); this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
if (this._pendingNavMode && this._canEdit && !this._config?.kiosk) { this._resumePendingNavMode();
const pendingMode = this._pendingNavMode;
this._pendingNavMode = null;
this._setMode(pendingMode, false);
}
this._adoptStructuralResponses(cfgResp, layResp); this._adoptStructuralResponses(cfgResp, layResp);
// live sync: the config was changed in another window → re-read it // live sync: the config was changed in another window → re-read it
if (!this._unsubCfg) { if (!this._unsubCfg) {
@@ -3233,11 +3241,7 @@ class HouseplanCard extends LitElement {
return; return;
} }
this._adoptStructuralResponses(resp); this._adoptStructuralResponses(resp);
if (this._pendingNavMode && this._canEdit && !this._config?.kiosk) { this._resumePendingNavMode();
const pendingMode = this._pendingNavMode;
this._pendingNavMode = null;
this._setMode(pendingMode, false);
}
this._cacheSnapshot(); this._cacheSnapshot();
this._regSignature = ''; this._regSignature = '';
this._maybeRebuildDevices(); this._maybeRebuildDevices();
@@ -3390,7 +3394,10 @@ class HouseplanCard extends LitElement {
planHass, device, { planHass, device, {
liveStates: this._config?.live_states !== false, liveStates: this._config?.live_states !== false,
showTemperature: this._config?.show_temperature !== false, showTemperature: this._config?.show_temperature !== false,
showSignal: showLqi && this._config?.show_signal !== false, // A per-space show_lqi value is an explicit override of the card
// default. Snapshot both projections exactly as requested so the
// full card, preview and static card cannot disagree.
showSignal: showLqi,
activityRuntime: this._activityRt.get(device.id), activityRuntime: this._activityRt.get(device.id),
sourceDetails: false, sourceDetails: false,
lightDevices: this._devices, lightDevices: this._devices,
@@ -3948,7 +3955,7 @@ class HouseplanCard extends LitElement {
return resolveDevicePresentation(this._renderPlanHass, d, { return resolveDevicePresentation(this._renderPlanHass, d, {
liveStates: this._config?.live_states !== false, liveStates: this._config?.live_states !== false,
showTemperature: this._config?.show_temperature !== false, showTemperature: this._config?.show_temperature !== false,
showSignal: showLqi && this._config?.show_signal !== false, showSignal: showLqi,
designPreview, designPreview,
activityRuntime: this._activityRt.get(d.id), activityRuntime: this._activityRt.get(d.id),
sourceDetails: false, sourceDetails: false,
@@ -9658,16 +9665,16 @@ class HouseplanCard extends LitElement {
roomFingerprint, wallFingerprint, cutFingerprint, roomFingerprint, wallFingerprint, cutFingerprint,
].join('|'); ].join('|');
let value = this._openingWallIndexCache.get(key); let value = this._openingWallIndexCache.get(key);
if (!value) { if (value) {
// Refresh recency on hit; the pool is intentionally tiny because each
// entry retains derived wall/tunnel geometry.
lruWrite(this._openingWallIndexCache, key, value, 4);
} else {
value = buildOpeningWallIndex( value = buildOpeningWallIndex(
space.rooms, this._spaceWalls, openCuts, space.rooms, this._spaceWalls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
); );
this._openingWallIndexCache.set(key, value); lruWrite(this._openingWallIndexCache, key, value, 4);
if (this._openingWallIndexCache.size > 4) {
const oldest = this._openingWallIndexCache.keys().next().value;
if (oldest) this._openingWallIndexCache.delete(oldest);
}
} }
return { key, value }; return { key, value };
} }
+19 -2
View File
@@ -295,10 +295,27 @@ export class HpHelp extends LitElement {
}; };
private _keyDown = (event: KeyboardEvent): void => { private _keyDown = (event: KeyboardEvent): void => {
if (!this._open || event.key !== 'Escape') return; if (!this._open || event.defaultPrevented) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
this._closeHelp(this._floating.ownsActiveElement(), 'escape');
return;
}
const trigger = this.renderRoot.querySelector<HTMLButtonElement>('.trigger');
if (!trigger || !event.composedPath().includes(trigger)) return;
const surface = this._surface();
if (!surface || surface.scrollHeight <= surface.clientHeight) return;
const page = Math.max(40, surface.clientHeight * 0.8);
const offsets: Record<string, number> = {
ArrowDown: 40, ArrowUp: -40, PageDown: page, PageUp: -page,
};
if (event.key === 'Home') surface.scrollTop = 0;
else if (event.key === 'End') surface.scrollTop = surface.scrollHeight;
else if (event.key in offsets) surface.scrollBy({ top: offsets[event.key], behavior: 'auto' });
else return;
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
this._closeHelp(this._floating.ownsActiveElement(), 'escape');
}; };
private readonly _floating = new FloatingSurfaceController(this, 'help', this._keyDown); private readonly _floating = new FloatingSurfaceController(this, 'help', this._keyDown);
+49 -1
View File
@@ -1,16 +1,64 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
assertHookMode,
cleanedCommitMessage,
resolveValidationRange,
terminalTrailers, terminalTrailers,
validateCommitMessage, validateCommitMessage,
} from '../scripts/validate-commit-provenance.mjs'; } from '../scripts/validate-commit-provenance.mjs';
test('provenance accepts positive issues and one visibility trailer at the end', () => { test('provenance accepts positive issues and one visibility trailer at the end', () => {
const message = `Fix relay\n\nIssue: #94\nIssue: #98\nUser-Visible: yes\n`; const message = `Fix relay\n\nIssue: #94\nIssue: #98\nUser-Visible: yes\n`;
assert.deepEqual(validateCommitMessage(message), []); assert.deepEqual(validateCommitMessage(message, [
'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md',
]), []);
assert.deepEqual(terminalTrailers(message).get('Issue'), ['#94', '#98']); assert.deepEqual(terminalTrailers(message).get('Issue'), ['#94', '#98']);
}); });
test('editor comments and scissors suffix do not hide terminal trailers', () => {
const message = `Fix relay\n\nIssue: #94\nUser-Visible: no\n# Please enter the commit message\n# On branch dev\n`;
assert.deepEqual(validateCommitMessage(message), []);
assert.equal(cleanedCommitMessage(`${message}# ------------------------ >8 ------------------------\nignored`)
.includes('ignored'), false);
});
test('user-visible provenance requires both localized changelogs', () => {
const message = 'Fix UI\n\nIssue: #94\nUser-Visible: yes';
assert.match(validateCommitMessage(message, [])[0], /CHANGELOG\.md/);
assert.deepEqual(validateCommitMessage(message, [
'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md',
]), []);
});
test('hook mode requires the executable index bit', () => {
assert.doesNotThrow(() => assertHookMode('100755 deadbeef 0\t.githooks/commit-msg'));
assert.throws(
() => assertHookMode('100644 deadbeef 0\t.githooks/commit-msg'),
/must be tracked as executable/,
);
});
test('validation range uses ancestry for PRs, normal pushes and new branches', () => {
const calls = [];
const runner = (args) => {
calls.push(args);
return args[0] === 'merge-base' ? 'common-base' : 'exists';
};
assert.equal(resolveValidationRange({
eventName: 'pull_request', baseSha: 'base', headSha: 'head',
}, runner), 'common-base..head');
assert.deepEqual(calls.at(-1), ['merge-base', 'base', 'head']);
assert.equal(resolveValidationRange({
eventName: 'push', beforeSha: '000000', headSha: 'head', defaultBranch: 'main',
}, runner), 'common-base..head');
assert.deepEqual(calls.at(-1), ['merge-base', 'refs/remotes/origin/main', 'head']);
assert.equal(resolveValidationRange({
eventName: 'push', beforeSha: 'before', headSha: 'head', defaultBranch: 'main',
}, runner), 'common-base..head');
assert.deepEqual(calls.at(-1), ['merge-base', 'before', 'head']);
});
test('provenance ignores trailer-like prose and rejects zero or duplicate visibility', () => { test('provenance ignores trailer-like prose and rejects zero or duplicate visibility', () => {
assert.notDeepEqual(validateCommitMessage('Issue: #12\n\nExplanation after it'), []); assert.notDeepEqual(validateCommitMessage('Issue: #12\n\nExplanation after it'), []);
assert.notDeepEqual(validateCommitMessage('Fix\n\nIssue: #0\nUser-Visible: no'), []); assert.notDeepEqual(validateCommitMessage('Fix\n\nIssue: #0\nUser-Visible: no'), []);
+38
View File
@@ -651,6 +651,27 @@ test('cover presentation follows the same service target selected by toggle reso
assert.equal(result.visualSources[0].state, 'opening'); assert.equal(result.visualSources[0].state, 'opening');
}); });
test('presentation role uses the full registry projection with a frozen state snapshot', () => {
const planHass = hass({
'sensor.position': state('sensor.position', '45'),
'cover.curtain': state('cover.curtain', 'opening', {
device_class: 'curtain', supported_features: 15,
}),
});
const registryHass = hass(planHass.states, {
'sensor.position': {
entity_id: 'sensor.position', device_id: 'd1', platform: 'demo',
entity_category: 'diagnostic',
},
'cover.curtain': { entity_id: 'cover.curtain', device_id: 'd1', platform: 'demo' },
});
const result = resolvePresentationSources(planHass, device({
entities: ['sensor.position', 'cover.curtain'], primary: 'sensor.position',
}), undefined, undefined, registryHass);
assert.equal(result.sourceKind, 'cover');
assert.deepEqual(result.visualSources.map((source) => source.eid), ['cover.curtain']);
});
test('an incidental cover does not hijack the light face of a mixed device', () => { test('an incidental cover does not hijack the light face of a mixed device', () => {
const h = hass({ const h = hass({
'light.mixed': state('light.mixed', 'on'), 'light.mixed': state('light.mixed', 'on'),
@@ -674,6 +695,23 @@ test('an incidental cover does not hijack the light face of a mixed device', ()
assert.equal(explicitCover.visualSources[0].eid, 'cover.mixed'); assert.equal(explicitCover.visualSources[0].eid, 'cover.mixed');
}); });
test('an explicitly primary cover keeps the face when a light sibling exists', () => {
const h = hass({
'cover.mixed': state('cover.mixed', 'opening', {
device_class: 'curtain', supported_features: 15,
}),
'light.mixed': state('light.mixed', 'on'),
}, {
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd1', platform: 'demo' },
'light.mixed': { entity_id: 'light.mixed', device_id: 'd1', platform: 'demo' },
});
const result = resolvePresentationSources(h, device({
entities: ['cover.mixed', 'light.mixed'], primary: 'cover.mixed', tapAction: 'info',
}));
assert.equal(result.sourceKind, 'cover');
assert.deepEqual(result.visualSources.map((source) => source.eid), ['cover.mixed']);
});
test('an incidental cover does not hijack another whole-device role on info tap', () => { test('an incidental cover does not hijack another whole-device role on info tap', () => {
const h = hass({ const h = hass({
'media_player.screen': state('media_player.screen', 'playing'), 'media_player.screen': state('media_player.screen', 'playing'),
+18
View File
@@ -86,6 +86,24 @@ test('exact entity binding never retargets to a controllable sibling', () => {
assert.equal(intent.skippedTargets[0].entityId, 'sensor.room'); assert.equal(intent.skippedTargets[0].entityId, 'sensor.room');
}); });
test('explicit light driver overrides an exact entity binding consistently', () => {
const h = hass({
'switch.relay': state('switch.relay', 'off'),
'light.lamp': state('light.lamp', 'on'),
});
const d = device({
bindingKind: 'entity', bindingRef: 'switch.relay', primary: 'switch.relay',
entities: ['switch.relay', 'light.lamp'],
marker: {
id: 'marker', binding: 'entity:switch.relay', controls: [],
is_light: true, light_entity: 'light.lamp',
},
});
const intent = resolveToggleIntent({ hass: h, devices: [d], device: d });
assert.deepEqual(toggleCommandEntityIds(intent.command), ['light.lamp']);
assert.equal(intent.command?.service, 'turn_off');
});
test('default light ignores external controls until toggle is explicitly selected', () => { test('default light ignores external controls until toggle is explicitly selected', () => {
const h = hass({ const h = hass({
'light.room': state('light.room', 'off'), 'light.room': state('light.room', 'off'),
+1
View File
@@ -14,6 +14,7 @@ test('help is absent unless both visible copy and an accessible name exist', ()
test('only scrolling the owning dialog outside the floating surface dismisses help', () => { test('only scrolling the owning dialog outside the floating surface dismisses help', () => {
assert.equal(helpScrollShouldDismiss(true, true), false); assert.equal(helpScrollShouldDismiss(true, true), false);
assert.equal(helpScrollShouldDismiss(true, false), false);
assert.equal(helpScrollShouldDismiss(false, true), true); assert.equal(helpScrollShouldDismiss(false, true), true);
assert.equal(helpScrollShouldDismiss(false, false), false); assert.equal(helpScrollShouldDismiss(false, false), false);
}); });
-7
View File
@@ -135,13 +135,6 @@ test('centre magnet wins over grid on a wall whose midpoint is off-grid', () =>
assert.equal(awayFromCentre.measure.guide, null); assert.equal(awayFromCentre.measure.guide, null);
}); });
test('non-wall geometry cannot become an opening placement target', () => {
assert.deepEqual(openingPlacementTargets([
interval({ key: 'partition-shape', kind: null, open: false }),
interval({ key: 'column-shape', kind: null, open: false, a: [40, 40], b: [60, 40] }),
]), []);
});
test('Junction tie-break does not depend on interval input order', () => { test('Junction tie-break does not depend on interval input order', () => {
const horizontal = interval({ key: 'a-horizontal' }); const horizontal = interval({ key: 'a-horizontal' });
const vertical = interval({ key: 'b-vertical', a: [50, -50], b: [50, 50] }); const vertical = interval({ key: 'b-vertical', a: [50, -50], b: [50, 50] });
+27 -3
View File
@@ -14,8 +14,13 @@ const spec = (patch = {}) => ({
}); });
const templateText = (value) => { const templateText = (value) => {
if (!value || !value.strings) return ''; if (value == null || value === false) return '';
return value.strings.join('') + value.values.map(templateText).join(''); if (typeof value === 'string' || typeof value === 'number') return String(value);
if (Array.isArray(value)) return value.map(templateText).join('');
if (!value.strings) return '';
return value.strings.reduce((text, part, index) => (
text + part + (index < value.values.length ? templateText(value.values[index]) : '')
), '');
}; };
test('opening metrics expand hit and outline zones with a thick wall face', () => { test('opening metrics expand hit and outline zones with a thick wall face', () => {
@@ -31,11 +36,30 @@ test('shared renderer emits the expected visible symbol for every opening type',
const windowText = templateText(renderOpeningVisibleGeometry(spec({ const windowText = templateText(renderOpeningVisibleGeometry(spec({
type: 'window', face: { ox: 0, oy: 20, cm: 20, side: 1 }, type: 'window', face: { ox: 0, oy: 20, cm: 20, side: 1 },
}))); })));
const doorText = templateText(renderOpeningVisibleGeometry(spec({ type: 'door' }))); const doorText = templateText(renderOpeningVisibleGeometry(spec({ type: 'door', amount: 1 })));
const gateText = templateText(renderOpeningVisibleGeometry(spec({ type: 'gate' }))); const gateText = templateText(renderOpeningVisibleGeometry(spec({ type: 'gate' })));
assert.match(windowText, /op-glass/); assert.match(windowText, /op-glass/);
assert.match(doorText, /op-leaf/); assert.match(doorText, /op-leaf/);
assert.match(doorText, /op-arc/); assert.match(doorText, /op-arc/);
assert.match(doorText, /A 100 100/);
assert.match(doorText, /rotate\(-90deg\)/);
assert.equal((gateText.match(/op-leaf/g) || []).length, 2); assert.equal((gateText.match(/op-leaf/g) || []).length, 2);
assert.ok(openingVisibleMetrics(spec({ type: 'gate' })).gateDepth > 0); assert.ok(openingVisibleMetrics(spec({ type: 'gate' })).gateDepth > 0);
}); });
test('shared renderer preserves flips, wall-face offset and animated geometry', () => {
const text = templateText(renderOpeningVisibleGeometry(spec({
type: 'window', amount: 0.5, flipH: true, flipV: true,
face: { ox: 0, oy: 20, cm: 20, side: 1 },
})));
assert.match(text, /scale\(-1 -1\)/);
assert.match(text, /translate\(0 -20\)/);
assert.match(text, /rotate\(45deg\)/);
assert.match(text, /stroke-dashoffset="39\.269/);
const gate = templateText(renderOpeningVisibleGeometry(spec({
type: 'gate', amount: 1, face: { ox: 0, oy: 20, cm: 20, side: 1 },
})));
assert.match(gate, /rotate\(10deg\)/);
assert.match(gate, /rotate\(-10deg\)/);
});