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 }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
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"
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
hacs:
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 run build # tsc + rollup → dist/houseplan-card.js
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
+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
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
git config core.hooksPath .githooks
npm install
```
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
FILES_DIR = "houseplan/files"
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
# 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",
"requirements": [],
"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';
/** 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 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
// offset and ruler placement on a thick wall.
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 },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
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.
* This protects a reviewed-but-empty baseline from becoming the reference. */
async function countWarmPixels(page, png, region) {
@@ -394,7 +470,15 @@ try {
if (!opening) return null;
const transform = opening.getAttribute('transform') || '';
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 {
type: opening.getAttribute('data-kind'), angle,
width: bounds.width, height: bounds.height,
@@ -446,6 +530,11 @@ try {
result.openingPreviewParts = control.parts;
result.openingPreviewChangedPixels = sample.changed;
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) {
throw new Error(
`semantic golden assertion failed: opening preview is incomplete (${control.parts} parts)`,
@@ -457,6 +546,13 @@ try {
+ `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) {
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 } })) };
c.requestUpdate(); await c.updateComplete;
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;
});
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
+59 -3
View File
@@ -282,6 +282,20 @@ const rasterFixture = await page.evaluate(async () => {
if (!source || !sourceEid || !current) return { ready: false };
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();
for (const room of current.rooms || []) {
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 });
}
}
const walls = [...edges.values()].map(({ a, b }, index) => ({
key: `glow-raster-wall-${index}`, a, b, cm: 15,
const walls = [...edges.values()].map(({ a, b }) => ({
key: wallKey(a, b), a, b, cm: 15,
}));
const openings = [
{ 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 },
];
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,
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) {
@@ -531,6 +553,39 @@ if (rasterFixture.ready) {
res.shadowEdgeIsCrisp = !!metrics.shadow
&& metrics.shadow.high - metrics.shadow.low >= 8
&& 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 {
res.litPartsMatchClip = false;
res.doorwayCarriesLight = false;
@@ -538,6 +593,7 @@ if (rasterFixture.ready) {
res.apertureItselfLit = false;
res.occluderCastsShadow = false;
res.shadowEdgeIsCrisp = false;
res.sourceInsideWallSuppressed = false;
}
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
+14
View File
@@ -66,6 +66,20 @@ const res = await page.evaluate(async () => {
const restoredTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
out.circledQuestionIcon = restoredTrigger?.querySelector('svg[viewBox="0 0 24 24"] path')
?.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();
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)
await page.evaluate(()=>{
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]]});
sp.walls = [...(sp.walls || []), ...outline.map((a, index) => ({
key: wallKey(a, outline[(index + 1) % outline.length]), cm: 15,
}))];
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate();
});
// 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.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.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();
// 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 out = {};
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 и редактор устройств
c._space = 'garden'; c._setMode('devices'); await c.updateComplete;
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);
await settle();
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._tool = 'draw';
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
## 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
- 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
public activity and preview-dot CSS hooks, improved help accessibility and
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
+17 -1
View File
@@ -8,6 +8,21 @@
## 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
- По результатам повторного код-ревью устранены расхождения между состоянием
@@ -19,7 +34,8 @@
геометрии, корректная центральная привязка и более строгая визуальная
проверка предпросмотра. Восстановлены публичные CSS-хуки активности и точки
предпросмотра; улучшены доступность подсказок и внутренние проверки процесса.
- Мелкие исправления и улучшения.
- Мелкие исправления и улучшения
([#108](https://github.com/Matysh/houseplan-card/issues/108)).
## 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,
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
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
+7 -7
View File
@@ -1,16 +1,16 @@
<!-- release: v1.62.0-beta.9 -->
<!-- release: v1.62.0-beta.10 -->
## Основное
- Исправлены выбор управляемой сущности и отображение составных устройств.
- Улучшены стабильность и визуальная проверка предпросмотра проёмов.
- Исправлены отображение и переключение составных устройств.
- Усилены проверки разделения комнат, Glow, подсказок и предпросмотра проёмов.
- Мелкие исправления и улучшения.
## Highlights
- Fixed target selection and presentation for composite devices.
- Improved opening-preview stability and visual verification.
- Fixed presentation and toggling for composite devices.
- Strengthened Split, Glow, contextual-help and opening-preview checks.
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.9/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.9/docs/CHANGELOG.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.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
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
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",
"version": "1.62.0-beta.9",
"version": "1.62.0-beta.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.62.0-beta.9",
"version": "1.62.0-beta.10",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.62.0-beta.9",
"version": "1.62.0-beta.10",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
@@ -19,7 +19,8 @@
"continuity:screencast": "node demo/screencast_visual_continuity.mjs",
"golden:accept": "node demo/golden/accept.mjs",
"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": {
"@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';
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) {
const lines = String(message).replace(/\r/g, '').split('\n');
const lines = cleanedCommitMessage(message).split('\n');
while (lines.length && !lines.at(-1).trim()) lines.pop();
const out = new Map();
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])) {
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) =>
/^demo\/golden\/baselines\/.*\.(png|json)$/.test(file.replaceAll('\\', '/')));
if (changesGolden) {
@@ -48,13 +67,42 @@ function git(args) {
return execFileSync('git', args, { encoding: 'utf8' }).trim();
}
function assertHookMode() {
const row = git(['ls-files', '-s', '.githooks/commit-msg']);
export function assertHookMode(row = git(['ls-files', '-s', '.githooks/commit-msg'])) {
if (!row.startsWith('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) {
const errors = validateCommitMessage(message, files);
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);
}
const rangeAt = argv.indexOf('--range');
if (rangeAt >= 0) {
const range = argv[rangeAt + 1];
const githubRange = argv.includes('--github-range');
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');
const head = range.split('..').at(-1);
assertDescendsFromBoundary(head);
const commits = git(['rev-list', '--reverse', range]).split('\n').filter(Boolean);
for (const commit of commits) {
// PROCESS.md explicitly forbids rewriting published history. The commit
// that introduces this validator is the enforcement boundary; older PR
// ancestry without the script remains auditable but cannot block a
// future release branch forever.
try { git(['cat-file', '-e', `${commit}:scripts/validate-commit-provenance.mjs`]); }
// The immutable introducing commit is the boundary. File presence is
// intentionally irrelevant: deleting the validator inside the range
// must not create a two-commit bypass.
try { git(['merge-base', '--is-ancestor', ENFORCEMENT_BOUNDARY, commit]); }
catch { continue; }
const parentCount = Number(git(['rev-list', '--parents', '-n', '1', commit]).split(/\s+/).length) - 1;
if (parentCount > 1) continue;
validateOne(
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
// not hijack a primary/owned light unless the legacy explicit cover action
// (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 && (
d.tapAction === 'cover'
|| d.primary?.startsWith('cover.')
|| resolvedDeviceRole.some((eid) => eid.startsWith('cover.'))
);
if (coverOwnsFace) {
@@ -301,7 +302,7 @@ export function resolvePresentationSources(
// (and early HA startup snapshots) expose live states before the entity
// registry arrives. Keep the historical whole-device role in that case
// 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
? resolvedIds
: d.entities.filter((eid) => !!hass?.states?.[eid]);
+10 -3
View File
@@ -354,9 +354,16 @@ function resolveEntity(
function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
if (device.bindingKind === 'entity' && device.bindingRef) {
// Entity bindings are exact by contract: a controllable sibling from the
// same HA device must never replace the entity selected by the user.
return [device.bindingRef];
// The binding remains exact unless the user explicitly selected the
// marker's light driver. That persisted override is shared by rendering,
// 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 leading = (device.marker?.light_entity
+26 -19
View File
@@ -196,7 +196,7 @@ import {
} from './opening-placement';
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> = {
badge: 'display.badge',
icon_ripple: 'display.icon_ripple',
@@ -3067,6 +3067,18 @@ class HouseplanCard extends LitElement {
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> {
this._loading = true;
this._loadTries++;
@@ -3103,11 +3115,7 @@ class HouseplanCard extends LitElement {
// absent can_write = older backend / demo stub → keep null (legacy admin fallback)
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
if (this._pendingNavMode && this._canEdit && !this._config?.kiosk) {
const pendingMode = this._pendingNavMode;
this._pendingNavMode = null;
this._setMode(pendingMode, false);
}
this._resumePendingNavMode();
this._adoptStructuralResponses(cfgResp, layResp);
// live sync: the config was changed in another window → re-read it
if (!this._unsubCfg) {
@@ -3233,11 +3241,7 @@ class HouseplanCard extends LitElement {
return;
}
this._adoptStructuralResponses(resp);
if (this._pendingNavMode && this._canEdit && !this._config?.kiosk) {
const pendingMode = this._pendingNavMode;
this._pendingNavMode = null;
this._setMode(pendingMode, false);
}
this._resumePendingNavMode();
this._cacheSnapshot();
this._regSignature = '';
this._maybeRebuildDevices();
@@ -3390,7 +3394,10 @@ class HouseplanCard extends LitElement {
planHass, device, {
liveStates: this._config?.live_states !== 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),
sourceDetails: false,
lightDevices: this._devices,
@@ -3948,7 +3955,7 @@ class HouseplanCard extends LitElement {
return resolveDevicePresentation(this._renderPlanHass, d, {
liveStates: this._config?.live_states !== false,
showTemperature: this._config?.show_temperature !== false,
showSignal: showLqi && this._config?.show_signal !== false,
showSignal: showLqi,
designPreview,
activityRuntime: this._activityRt.get(d.id),
sourceDetails: false,
@@ -9658,16 +9665,16 @@ class HouseplanCard extends LitElement {
roomFingerprint, wallFingerprint, cutFingerprint,
].join('|');
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(
space.rooms, this._spaceWalls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
this._openingWallIndexCache.set(key, value);
if (this._openingWallIndexCache.size > 4) {
const oldest = this._openingWallIndexCache.keys().next().value;
if (oldest) this._openingWallIndexCache.delete(oldest);
}
lruWrite(this._openingWallIndexCache, key, value, 4);
}
return { key, value };
}
+19 -2
View File
@@ -295,10 +295,27 @@ export class HpHelp extends LitElement {
};
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.stopImmediatePropagation();
this._closeHelp(this._floating.ownsActiveElement(), 'escape');
};
private readonly _floating = new FloatingSurfaceController(this, 'help', this._keyDown);
+49 -1
View File
@@ -1,16 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
assertHookMode,
cleanedCommitMessage,
resolveValidationRange,
terminalTrailers,
validateCommitMessage,
} from '../scripts/validate-commit-provenance.mjs';
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`;
assert.deepEqual(validateCommitMessage(message), []);
assert.deepEqual(validateCommitMessage(message, [
'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md',
]), []);
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', () => {
assert.notDeepEqual(validateCommitMessage('Issue: #12\n\nExplanation after it'), []);
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');
});
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', () => {
const h = hass({
'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');
});
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', () => {
const h = hass({
'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');
});
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', () => {
const h = hass({
'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', () => {
assert.equal(helpScrollShouldDismiss(true, true), false);
assert.equal(helpScrollShouldDismiss(true, false), false);
assert.equal(helpScrollShouldDismiss(false, true), true);
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);
});
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', () => {
const horizontal = interval({ key: 'a-horizontal' });
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) => {
if (!value || !value.strings) return '';
return value.strings.join('') + value.values.map(templateText).join('');
if (value == null || value === false) return '';
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', () => {
@@ -31,11 +36,30 @@ test('shared renderer emits the expected visible symbol for every opening type',
const windowText = templateText(renderOpeningVisibleGeometry(spec({
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' })));
assert.match(windowText, /op-glass/);
assert.match(doorText, /op-leaf/);
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.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\)/);
});