Fix beta.8 validation regressions

Issue: #75
Issue: #95
Issue: #98
User-Visible: yes
This commit is contained in:
Matysh
2026-08-12 19:38:34 +03:00
parent 9e74051652
commit 661eb784fb
20 changed files with 147 additions and 47 deletions
File diff suppressed because one or more lines are too long
+28 -2
View File
@@ -265,13 +265,39 @@ export async function prepareGoldenScenario(page, scenario) {
throw new Error(`invalid golden openingPreview: ${scenario.id}`);
}
card._activateOpeningPlacement(type);
card._cursorPt = [pointer[0] * 1000, pointer[1] * card._spaceH];
card.requestUpdate();
await card.updateComplete;
await frame();
// Exercise the production pointer path after the toolbar update has
// settled. Writing `_cursorPt` before that update is racy: replacing the
// stage under Chromium's real pointer legitimately emits pointerleave
// and clears the preview before capture.
const svgRoot = card.renderRoot.querySelector('.stage svg');
const stage = card.renderRoot.querySelector('.stage');
const screen = new DOMPoint(pointer[0] * 1000, pointer[1] * card._spaceH)
.matrixTransform(svgRoot.getScreenCTM());
stage.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse',
clientX: screen.x, clientY: screen.y,
}));
await card.updateComplete;
await frame();
const preview = card.renderRoot.querySelector(`.opening-preview[data-kind="${type}"]`);
if (!preview || !preview.querySelector('.op-leaf')) {
throw new Error(`golden opening preview did not render: ${scenario.id}`);
const intervals = card._openingPlacementIntervalsCache?.value || [];
const nearest = intervals.map((interval) => {
const [px, py] = card._cursorPt || [0, 0];
const [ax, ay] = interval.a, [bx, by] = interval.b;
const dx = bx - ax, dy = by - ay, length2 = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2));
return {
a: interval.a, b: interval.b, cm: interval.cm, open: interval.open,
kind: interval.kind,
distance: Math.hypot(px - (ax + dx * t), py - (ay + dy * t)),
};
}).sort((a, b) => a.distance - b.distance).slice(0, 3);
throw new Error(`golden opening preview did not render: ${scenario.id}; `
+ `cursor=${JSON.stringify(card._cursorPt)} nearest=${JSON.stringify(nearest)}`);
}
}
if (scenario.editorTray) {
+4 -4
View File
@@ -15,10 +15,10 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
// The lower-right edge of the nested diamond is a real 45-degree thick
// wall. Keep this diagonal: it catches regressions hidden by horizontal
// preview-only captures (rotation, face offset and ruler placement).
mode: 'plan', openingPreview: { type: 'door', pointer: [0.78, 0.32] },
// The shared centre edge is a long 25 cm physical wall. It can contain the
// 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: 40, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
+38 -12
View File
@@ -266,10 +266,10 @@ async function countHelpTextPixels(page, png, clip, spec) {
}, { png64: png.toString('base64'), clip, spec });
}
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. The
* opening symbol is hidden by the scenario, so both centre lines should be a
* locally constant translucent surface. Sampling both axes catches the wall
* centre split as well as boundaries between atomic wall-profile pieces. */
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. Sample a
* narrow strip around local y=0: this crosses the join between both tunnel
* half-faces at any opening angle while excluding legitimate outer-profile
* steps where adjacent wall intervals have different physical thicknesses. */
async function inspectTunnelContinuity(page, png, clip, spec) {
return page.evaluate(async ({ png64, clip, spec }) => {
const card = window.__goldenCard;
@@ -278,6 +278,10 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
);
if (!tunnel) throw new Error(`semantic golden tunnel missing: ${spec.openingId}`);
const rect = tunnel.getBoundingClientRect();
const matrix = tunnel.getScreenCTM();
if (!matrix) throw new Error(`semantic golden tunnel has no screen transform: ${spec.openingId}`);
const inverse = matrix.inverse();
const localBounds = tunnel.getBBox();
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
@@ -306,22 +310,43 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
const offset = (y * image.width + x) * 4;
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
};
let maxJump = 0, samplePairs = 0;
const compare = (a, b) => {
maxJump = Math.max(maxJump,
let maxJump = 0, maxPair = null, samplePairs = 0;
const compare = (a, b, x, y, direction) => {
const jump = Math.max(
Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
if (jump > maxJump) {
maxJump = jump;
maxPair = { x, y, direction, a, b };
}
samplePairs++;
};
// Inspect every adjacent pair in the interior, not just two centre lines:
// a transverse seam near an end cap must fail the semantic guard too.
const localXScale = Math.max(1e-6, Math.hypot(matrix.a, matrix.b));
const localYScale = Math.max(1e-6, Math.hypot(matrix.c, matrix.d));
const endInset = Math.max(1, spec.insetPx) / localXScale;
const axisBand = Math.max(1.5, spec.axisBandPx || 2.5) / localYScale;
const insideAxisBand = (x, y) => {
const cssX = originX + (x + 0.5) / scaleX;
const cssY = originY + (y + 0.5) / scaleY;
const local = new DOMPoint(cssX, cssY).matrixTransform(inverse);
return local.x >= localBounds.x + endInset
&& local.x <= localBounds.x + localBounds.width - endInset
&& Math.abs(local.y) <= axisBand;
};
for (let y = top; y <= bottom; y++) {
for (let x = left + 1; x <= right; x++) compare(rgb(x - 1, y), rgb(x, y));
for (let x = left + 1; x <= right; x++) {
if (insideAxisBand(x - 1, y) && insideAxisBand(x, y))
compare(rgb(x - 1, y), rgb(x, y), x, y, 'horizontal');
}
}
for (let x = left; x <= right; x++) {
for (let y = top + 1; y <= bottom; y++) compare(rgb(x, y - 1), rgb(x, y));
for (let y = top + 1; y <= bottom; y++) {
if (insideAxisBand(x, y - 1) && insideAxisBand(x, y))
compare(rgb(x, y - 1), rgb(x, y), x, y, 'vertical');
}
}
if (!samplePairs) throw new Error(`semantic golden tunnel axis strip is empty: ${spec.openingId}`);
return {
maxJump, samplePairs, bounds: [left, top, right, bottom],
maxJump, maxPair, samplePairs, bounds: [left, top, right, bottom],
scale: [scaleX, scaleY],
};
}, { png64: png.toString('base64'), clip, spec });
@@ -427,6 +452,7 @@ try {
page, actual, clip, scenario.tunnelContinuity,
);
result.tunnelMaxChannelJump = sample.maxJump;
result.tunnelMaxJumpPair = sample.maxPair;
result.tunnelSamplePairs = sample.samplePairs;
result.tunnelPixelBounds = sample.bounds;
result.tunnelImageScale = sample.scale;
+1 -1
View File
@@ -17,7 +17,7 @@ await page.evaluate(async () => {
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
st.textContent = '.device-pulse.continuous.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(400);
+1 -1
View File
@@ -38,7 +38,7 @@ await page.evaluate(async (STATES) => {
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
st.textContent = '.device-pulse.continuous.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
}, STATES);
await page.waitForTimeout(500);
+1 -1
View File
@@ -17,7 +17,7 @@ await page.evaluate(async () => {
await set('on');
// freeze the pulse at a visible frame for a deterministic capture
const st = document.createElement('style');
st.textContent = '.activity-ring.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
st.textContent = '.device-pulse.short.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(300);
+1 -1
View File
@@ -103,7 +103,7 @@ const out = await page.evaluate(async () => {
plateIsNeutral('opening');
o.openingRings = cls().includes('activity-transition');
o.openingRingIsTheBreathingOne = (() => {
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
const ring = devEl()?.querySelector('.device-pulse.continuous.transition i:first-child');
const cs = ring ? getComputedStyle(ring) : null;
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '2.4s';
})();
+1 -1
View File
@@ -161,7 +161,7 @@ const out = await page.evaluate(async () => {
// ---- the indication ----------------------------------------------------
const afterAnim = () => {
const el = devEl()?.querySelector('.activity-ring.transition i:first-child');
const el = devEl()?.querySelector('.device-pulse.continuous.transition i:first-child');
if (!el) return null;
const cs = getComputedStyle(el);
return { name: cs.animationName, dur: cs.animationDuration };
+1 -1
View File
@@ -154,7 +154,7 @@ Object.assign(out, await page.evaluate(async () => {
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.activity-ring'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.device-pulse'));
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c._setMode('view'); c._showHidden = false;
+5 -5
View File
@@ -53,7 +53,7 @@ const res = await page.evaluate(async () => {
const flashEl = sr().querySelector('.dev.activity-event');
out.motionTripFlashes = !!flashEl;
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
const waves = flashEl ? [...flashEl.querySelectorAll('.activity-ring.event i')] : [];
const waves = flashEl ? [...flashEl.querySelectorAll('.device-pulse.short.event i')] : [];
const firstWave = waves[0] ? getComputedStyle(waves[0]) : null;
out.flashAnimated = !!firstWave && firstWave.animationName.startsWith('hp-pulse-short');
out.flashFinite = waves.length === 3
@@ -105,17 +105,17 @@ const res = await page.evaluate(async () => {
const a3 = senseAnim(el3);
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
out.rapidRingVisibleAfterFirstWindow = !!el3
&& [...el3.querySelectorAll('.activity-ring.event i')]
&& [...el3.querySelectorAll('.device-pulse.short.event i')]
.some((w) => parseFloat(getComputedStyle(w).opacity) > 0.03);
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
await sleep(1400); await c.updateComplete;
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.activity-event').length === 0;
// --- occupancy: статичное кольцо presence, без анимации -----------------
// --- occupancy: continuous presence pulse --------------------------------
await setMotion('on', 'occupancy');
const holdEl = sr().querySelector('.dev.activity-presence');
out.occupancyHolds = !!holdEl;
const holdRing = holdEl?.querySelector('.activity-ring.presence i:first-child');
const holdRing = holdEl?.querySelector('.device-pulse.continuous.presence i:first-child');
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
out.holdAnimated = !!hAfter && hAfter.animationName === 'hp-pulse-continuous';
out.holdAnimationCalm = !!hAfter && hAfter.animationDuration === '2.4s';
@@ -154,7 +154,7 @@ const res = await page.evaluate(async () => {
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
const ghost = sr().querySelector('.dev.ghost');
out.ghostNoSense = !!ghost && !ghost.classList.contains('activity-event') && !ghost.classList.contains('activity-presence')
&& !ghost.querySelector('.activity-ring');
&& !ghost.querySelector('.device-pulse');
return out;
});
checkAll(res, {});
+9
View File
@@ -9,6 +9,13 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const settleMode = async () => {
const started = performance.now();
while (c._modeTransitionBusy && performance.now() - started < 1800) {
await new Promise((resolve) => setTimeout(resolve, 20));
await c.updateComplete;
}
};
const setSun = async (azimuth, elevation) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
@@ -150,10 +157,12 @@ const res = await page.evaluate(async () => {
await setSun(90, 5);
c._setMode('plan');
await upd();
await settleMode();
out.editorNoRays = domPolys().length === 0;
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
c._setMode('view');
await upd();
await settleMode();
out.viewRaysBack = domPolys().length > 0;
return out;
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -263,7 +263,16 @@ export function resolvePresentationSources(
}
}
}
if (cover) {
// A cover-only device still gets cover visuals even when its click action is
// 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 coverOwnsFace = !!cover && (
d.tapAction === 'cover'
|| !!d.primary?.startsWith('cover.')
|| lights.length === 0
);
if (coverOwnsFace) {
sourceKind = 'cover';
visualSources = [sourceOf(hass, cover, 'cover')];
} else if (lights.length) {
+5 -1
View File
@@ -2018,7 +2018,11 @@ export function tunnelFacePath(side: -1 | 1, pieces: OpeningWallPiece[]): string
return components.map((component) => {
const first = component[0], last = component[component.length - 1];
const seam = Math.min(Math.min(...component.map((slab) => slab.half)) * 0.02, 0.05);
// Both half-faces are subpaths of one nonzero-filled path. Give them a
// real device-pixel overlap at ordinary wall depths: a 0.1 px overlap was
// still rasterised as a faint centre seam by Chromium. Because winding is
// identical this remains one alpha application, not a double-fill band.
const seam = Math.min(Math.min(...component.map((slab) => slab.half)) * 0.25, 0.75);
const axisY = -side * seam;
const commands: string[] = [];
if (side === 1) {
+23
View File
@@ -635,3 +635,26 @@ test('cover presentation follows the same service target selected by toggle reso
assert.equal(result.visualSources[0].eid, 'cover.curtain');
assert.equal(result.visualSources[0].state, 'opening');
});
test('an incidental cover does not hijack the light face of a mixed device', () => {
const h = hass({
'light.mixed': state('light.mixed', 'on'),
'cover.mixed': state('cover.mixed', 'opening', {
device_class: 'curtain', supported_features: 15,
}),
}, {
'light.mixed': { entity_id: 'light.mixed', device_id: 'd1', platform: 'demo' },
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd1', platform: 'demo' },
});
const plain = resolvePresentationSources(h, device({
entities: ['light.mixed', 'cover.mixed'], primary: 'light.mixed', tapAction: null,
}));
assert.equal(plain.sourceKind, 'light');
assert.deepEqual(plain.visualSources.map((source) => source.eid), ['light.mixed']);
const explicitCover = resolvePresentationSources(h, device({
entities: ['light.mixed', 'cover.mixed'], primary: 'light.mixed', tapAction: 'cover',
}));
assert.equal(explicitCover.sourceKind, 'cover');
assert.equal(explicitCover.visualSources[0].eid, 'cover.mixed');
});
+1 -1
View File
@@ -144,7 +144,7 @@ test('opening placement golden requires browser-painted preview pixels', () => {
assert.ok(scenario);
assert.equal(scenario.mode, 'plan');
assert.equal(scenario.openingPreview?.type, 'door');
assert.deepEqual(scenario.openingPreview?.pointer, [0.78, 0.32]);
assert.deepEqual(scenario.openingPreview?.pointer, [0.48, 0.65]);
assert.equal(scenario.openingPreviewPixels.minPixels >= 40, true);
assert.equal(scenario.openingPreviewPixels.minChannelDelta >= 4, true);
});
+4 -4
View File
@@ -371,8 +371,8 @@ test('openingTunnelGeometry: an outer thick wall gives the one room both tunnel
assert.deepEqual(g.faces.map((f) => [f.side, f.roomId]), [[-1, 'r'], [1, 'r']]);
closeTo(g.minY, -2);
closeTo(g.maxY, 2);
assert.match(g.faces[0].d, / 0\.04\b/, 'the negative face crosses the axis symmetrically');
assert.match(g.faces[1].d, / -0\.04\b/, 'the positive face crosses the axis symmetrically');
assert.match(g.faces[0].d, / 0\.5\b/, 'the negative face overlaps the axis by a raster-safe amount');
assert.match(g.faces[1].d, / -0\.5\b/, 'the positive face overlaps the axis symmetrically');
});
test('openingTunnelGeometry: a 45° wall keeps the opening-local width and physical depth', () => {
@@ -455,9 +455,9 @@ test('openingTunnelGeometry: three stepped atomic strips form one non-overlappin
const positive = g.faces.find((face) => face.side === 1);
assert.equal((negative.d.match(/\bM /g) || []).length, 1);
assert.equal((positive.d.match(/\bM /g) || []).length, 1);
assert.match(negative.d, /^M 3 0\.02 L -3 0\.02 /,
assert.match(negative.d, /^M 3 0\.25 L -3 0\.25 /,
'negative and positive faces use matching nonzero winding around the wall axis');
assert.match(positive.d, /^M -3 -0\.02 L 3 -0\.02 /);
assert.match(positive.d, /^M -3 -0\.25 L 3 -0\.25 /);
assert.match(positive.d, /L -2 1 L -3 1 Z$/,
'the one contour follows every real thickness step back to the first jamb');
});
+5 -2
View File
@@ -708,7 +708,10 @@ async def _candidate(
await rt.store.async_save({
"layout": current_layout, "rev": 1,
"repair_backup": {"must": "survive-failure"},
"geom_pending": {"ground": {"aspect": 1.5}},
# The durable geometry-migration intent is ``space_id -> old aspect``.
# Keep a valid intent here so rollback preservation can also be proven
# safe across the subsequent integration reload.
"geom_pending": {"ground": 1.5},
})
incoming = json.loads(json.dumps(target_config or current_config))
incoming["spaces"][0]["title"] = "Imported"
@@ -1113,7 +1116,7 @@ async def test_pair_rolls_back_before_reporting_a_persistent_target_failure(
assert config_data["config"]["spaces"][0]["title"] == "Ground"
assert layout_data["layout"]["lamp"]["x"] == 0.1
assert layout_data["repair_backup"] == {"must": "survive-failure"}
assert layout_data["geom_pending"] == {"ground": {"aspect": 1.5}}
assert layout_data["geom_pending"] == {"ground": 1.5}
assert "optimize_pending" not in layout_data
monkeypatch.setattr(rt.config_store, "async_save", real_save)