test(floor): judge both frames of a held resize drag (#744)

smoke_floor_geometry_cache failed in Validate run 36875756451 on one check,
ac2cPreviewWallStandsWhereAFreshCardDrawsIt, with every area check green.
The check read the moving wall only from the live layer (data-kind
"preview"). A host render during a held drag ends that layer: updated()
commits it, and nothing repaints it until the next accepted move. The
settled scene then draws the preview record itself, and its walls equal
those of a fresh card on that record. In the smoke, the "Room updated"
toast from the AC1 rename expires 3.5 s after the save, about when the drag
starts: locally the rename-to-drag gap is 3.47-3.6 s. On a faster runner
the expiry landed after the last move, so the check found no live path. A
light toggled in Home Assistant mid-drag gives the same red.

The drag now starts once the toast is gone, so the live frame normally
judges the live layer. If a host render still lands, the check judges the
settled union the same way; a stale union has none of the moved faces. A
new check takes the settled frame on purpose: a state change during the
held drag, then the union path and the areas must equal the fresh card's.
The live strips never pass through the floor-geometry caches, so the old
check stayed green with a floor key that ignores the preview. The new
check fails on that key, and a failure prints a diagnostic line naming the
judged layer and whether the settled union is the stored one.

Issue: #744
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-02 00:56:04 +03:00
parent f5b20e07f6
commit 6ed9aa1652
+58 -7
View File
@@ -14,12 +14,17 @@
// floors (the stairs editor used to clear the cache of every floor).
// AC2c a live Resize preview on `f1`: the frame follows the preview record,
// as the independent card on that record says; cancelling restores the
// walls and areas of the stored record.
// walls and areas of the stored record. Both frames of a held drag are
// judged: the live layer that paints the moving wall, and the settled
// scene that replaces it whenever the host renders mid-drag (an entity
// state change from Home Assistant, a toast expiring) and draws the
// preview record through the floor-geometry caches.
//
// The independent card is the oracle: a new `houseplan-card` given the same
// config through its own `config/get`, with nothing in its caches to reuse.
// Card internals are read-only here; writes go through the harness facade,
// the room dialog, the stairs tool and real pointer input.
// the room dialog, the stairs tool, real pointer input and the demo's Home
// Assistant stub (a service call delivers the new entity state).
import { launch, checkAll, finish } from './serve.mjs';
import { fixtureWallKey } from './fixtures/wall-key.mjs';
@@ -251,6 +256,17 @@ const res = await page.evaluate(async (fixture) => {
// ======== AC2c setup: the Resize tool and the r1/r2 shared wall handle =====
// Outer walls of this fixture are partial (their records span two rooms) and
// stay disabled; the shared wall moves the faces of both rooms.
// The drag starts on a quiet card. A host render ends the live layer of a
// held drag (`updated()` commits it) and nothing repaints it until the next
// accepted move. The rename's toast expires 3.5 s after the save; on a fast
// runner that landed after the last move (Validate run 36875756451), and the
// live frame below found no live layer. The settled frame is taken on
// purpose, by a state change, after the live one.
const toastDeadline = performance.now() + 5000;
while (root().querySelector('[data-hp="toast"]') && performance.now() < toastDeadline)
await new Promise((done) => setTimeout(done, 30));
out.ac2cStartsWithoutAToast = !root().querySelector('[data-hp="toast"]');
await settled();
await hp.setTool('resize');
diag.ac2cStored = shown();
const handle = [...root().querySelectorAll('.rszhandle:not(.rszcorner)')].find((node) =>
@@ -283,6 +299,20 @@ if (gesture) {
.map((label) => [label.roomId, label.text]);
return { ac2cPreviewIsLive: !!preview && preview.space === 'f1' && card._resize.dragging };
}));
// The settled frame of the held drag: a light switched in the house renders
// the host; the settled scene now draws the walls and areas of the preview.
Object.assign(checks, await page.evaluate(async () => {
const card = window.__card;
const { diag, shown, settled } = window.__hp744;
const record = JSON.stringify(card._resize.preview?.sp ?? null);
await card.hass.callService('light', 'toggle', { entity_id: 'light.ceiling' });
await settled();
diag.ac2cSettled = shown();
return {
ac2cStateChangeKeepsThePreview: card._resize.dragging
&& JSON.stringify(card._resize.preview?.sp ?? null) === record,
};
}));
await page.keyboard.press('Escape');
await page.mouse.up();
Object.assign(checks, await page.evaluate(async () => {
@@ -303,14 +333,30 @@ if (gesture) {
out.ac2cPreviewMovesBothRooms = diag.ac2cPreview.areas.r1 !== diag.ac2cStored.areas.r1
&& diag.ac2cPreview.areas.r2 !== diag.ac2cStored.areas.r2;
// The live layer draws the moving wall; its faces are where the fresh
// card on the preview record draws them, and not where they were.
// card on the preview record draws them, and not where they were. Should
// a host render still land after the last move, the settled scene draws
// the drag instead, and its walls are judged the same way: a stale
// (stored) union has none of the moved faces.
const numbers = (text) => new Set((text.match(/-?\d+(?:\.\d+)?/g) || []).map(Number));
const parts = (walls, kind) => walls.split(' | ').filter((part) => part.startsWith(`${kind}:`));
const stored = numbers(diag.ac2cStored.walls);
const moved = [...numbers(fresh.walls)].filter((value) => !stored.has(value));
const previewPath = numbers(diag.ac2cPreview.walls.split(' | ')
.filter((part) => part.startsWith('preview:')).join(' '));
out.ac2cPreviewWallStandsWhereAFreshCardDrawsIt = moved.length > 0
&& moved.every((value) => previewPath.has(value));
const live = parts(diag.ac2cPreview.walls, 'preview');
const drawn = numbers((live.length ? live : parts(diag.ac2cPreview.walls, 'union')).join(' '));
const missing = moved.filter((value) => !drawn.has(value));
out.ac2cPreviewWallStandsWhereAFreshCardDrawsIt = moved.length > 0 && !missing.length;
// The settled scene mid-drag is the fresh card's frame on the preview
// record: the same union path, the same areas. A key that ignores the
// preview hands it the stored union and contours.
const union = (frame) => parts(frame?.walls || '', 'union').join(' | ');
const settledUnion = union(diag.ac2cSettled);
out.ac2cSettledFrameEqualsAFreshCard = !!settledUnion && settledUnion === union(fresh)
&& JSON.stringify(diag.ac2cSettled.areas) === JSON.stringify(fresh.areas);
diag.ac2cJudged = {
layer: live.length ? 'preview' : 'union', moved: moved.length, missing,
settledUnion: !settledUnion ? 'none' : settledUnion === union(fresh) ? 'fresh'
: settledUnion === union(diag.ac2cStored) ? 'stored' : 'other',
};
// Room areas through the cached contour, and the live labels beside the
// moving wall, are the fresh card's numbers for the preview record.
out.ac2cPreviewAreasEqualAFreshCard = JSON.stringify(diag.ac2cPreview.areas) === JSON.stringify(fresh.areas);
@@ -320,6 +366,11 @@ if (gesture) {
diag.ac2cCancelled = cancelled;
return out;
}));
if (!checks.ac2cPreviewWallStandsWhereAFreshCardDrawsIt || !checks.ac2cSettledFrameEqualsAFreshCard) {
// Validate prints only `diagnostic` lines and the tail of a failed smoke.
const judged = await page.evaluate(() => window.__hp744.diag.ac2cJudged);
console.log(`diagnostic ac2c: ${JSON.stringify(judged)}`);
}
}
checkAll(checks, {