mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
Fix beta.8 validation regressions
Issue: #75 Issue: #95 Issue: #98 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
+28
-2
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
})();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user