mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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, {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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
Vendored
+7
-7
File diff suppressed because one or more lines are too long
+15
-1
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -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.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
this._closeHelp(this._floating.ownsActiveElement(), 'escape');
|
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();
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _floating = new FloatingSurfaceController(this, 'help', this._keyDown);
|
private readonly _floating = new FloatingSurfaceController(this, 'help', this._keyDown);
|
||||||
|
|||||||
@@ -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'), []);
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
|||||||
@@ -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\)/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user