test(stairs): take the AC5 reference after the previous write is adopted (#708)

untouchedDialogSaveKeepsSizes compared the stair before and after an
untouched dialog Save byte for byte, but took the reference while the
previous frame gesture's debounced save (500 ms) was still pending. That
write adopts the canonical record it sends, so under load it landed
between the two reads: the reference had x: 0.21699999999999997, the
read after Save had x: 0.217, and the check went red although the dialog
wrote nothing.

The smoke now waits for the card's own "config writes idle" condition
(no debounced save pending, no write in flight; read-only) before taking
the reference, so both sides are the same canonical stair. The exact JSON
comparison, the >1 m field and the no-history check are unchanged; a
reference that never goes idle within 5 s fails the check instead of
racing.

Issue: #708
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-09-30 18:17:50 +00:00
committed by claude[bot]
parent a4af5a2008
commit bcff8c7157
+18 -2
View File
@@ -17,6 +17,18 @@ const out = await page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
await settled();
};
// A gesture's save is debounced, and the write adopts the canonical record
// it sends (`x: 0.21699999999999997` becomes `0.217`). The card's own
// «writes idle» — no debounced save pending, nothing in flight — marks the
// moment the stored stair is final (#708).
const writesIdle = async () => {
const busy = () => card._saveConfigDebounced.pending() || card._writesPending > 0;
const deadline = performance.now() + 5000;
while (busy() && performance.now() < deadline)
await new Promise((resolve) => setTimeout(resolve, 16));
await settled();
return !busy();
};
const spaceCfg = (id) => card._serverCfg.spaces.find((space) => space.id === id);
const stairs = (id = 'f1') => spaceCfg(id)?.stairs || [];
const stairNode = (id) => root().querySelector(`[data-hp="stair"][data-id="${id}"]`);
@@ -346,13 +358,17 @@ const out = await page.evaluate(async () => {
await hp.setTool('stairs');
// Saving the properties dialog untouched changes nothing (#676 AC5):
// sizes above one metre survive and no history entry is written.
// sizes above one metre survive and no history entry is written. The
// reference is the record after the previous gesture's write is adopted:
// both sides of the comparison are then the same canonical stair (#708).
const idleBeforeUntouched = await writesIdle();
const untouched = JSON.stringify(stairs().find((stair) => stair.id === straight.id));
const historyBefore = card._geometryHistory.size;
dialog = await openStairDialog(straight.id);
const fieldsShown = [...dialog.querySelectorAll('input[type="number"]')].map((input) => input.value);
await saveDialog(dialog);
result.untouchedDialogSaveKeepsSizes = JSON.stringify(stairs().find((stair) => stair.id === straight.id)) === untouched
result.untouchedDialogSaveKeepsSizes = idleBeforeUntouched
&& JSON.stringify(stairs().find((stair) => stair.id === straight.id)) === untouched
&& Number(fieldsShown[0]) > 100;
result.untouchedDialogSaveWritesNoHistory = card._geometryHistory.size === historyBefore;