Files
houseplan-card/demo/smoke_device_position_history.mjs
T
Codex f4c66bed3f test: prove AC3 on the reconnect path, with a probe that can fail (#397)
CODE-REVIEW-397-r1 Medium: AC3 named the second reader of the same
value — _loadFromServer via _adoptStructuralResponses — and nothing
exercised it. Adding the scenario turned out to be less mechanical than
it looked, and both obstacles are worth recording:

The reconnect path reads BOTH answers, and a differing config clears the
history for its own reason (configChanged). The fake server now echoes
the config the card already holds, so the check answers the layout
question it claims to answer.

The first version of the probe used a round 0.42, which canonicalization
leaves untouched — the check passed with and without the fix, i.e. for
the wrong reason. The probe now starts from a non-canonical position
(0.024999999999999942, which snaps to 0.025), and the scenario runs
immediately after the write, before any reload can align the two sides.

Verified by removing the fix: five checks red, now including
reconnectKeepsHistory and deleteEchoKeepsHistory. Both were green in the
weaker version — which is exactly what the reviewer's Medium was about.

User-Visible: no
Issue: #397
2026-08-31 03:03:59 +03:00

398 lines
19 KiB
JavaScript

import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const settle = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const idle = async () => {
const started = performance.now();
while (c._devicePositionBusy && performance.now() - started < 1500) await wait(10);
await c.updateComplete;
};
[...sr().querySelectorAll('.modetab')][1].click();
await settle();
const deviceNode = () => sr().querySelector('.dev:not(.ha-disabled)');
let node = deviceNode();
const deviceId = node?.getAttribute('data-id');
const device = c._devices.find((candidate) => candidate.id === deviceId);
if (!node || !device) return { fixtureReady: false };
out.fixtureReady = true;
const originalCallWs = c.hass.callWS;
let revision = 20;
let serverLayout = structuredClone(c._layout);
const writes = [];
let failNextUpdate = false;
let failNextDelete = false;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
// #397 AC3: the reconnect path reads BOTH answers, and a config that
// differs clears the history for its own reason (`configChanged` in
// `_adoptStructuralResponses`). To isolate the layout question the
// fake server echoes the config the card already holds.
return { config: structuredClone(c._serverCfg), rev: c._cfgRev };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(serverLayout), rev: revision };
}
if (message.type === 'houseplan/layout/update') {
if (failNextUpdate) { failNextUpdate = false; throw new Error('update refused'); }
writes.push({ type: 'update', id: message.device_id, pos: structuredClone(message.pos) });
serverLayout = { ...serverLayout, [message.device_id]: structuredClone(message.pos) };
return { rev: ++revision };
}
if (message.type === 'houseplan/layout/delete') {
if (failNextDelete) { failNextDelete = false; throw new Error('delete refused'); }
writes.push({ type: 'delete', id: message.device_id });
delete serverLayout[message.device_id];
return { rev: ++revision };
}
return originalCallWs(message);
} };
c._serverStorage = true;
c._layoutRev = revision;
const fire = (target, type, x, y, pointerId = 74) => target.dispatchEvent(new PointerEvent(type, {
pointerId, pointerType: 'mouse', button: 0, buttons: type === 'pointerup' ? 0 : 1,
clientX: x, clientY: y, bubbles: true, composed: true, cancelable: true,
}));
const drag = async ({ dx = 80, cancel = false, cancelType = 'pointercancel', moves = 10, pointerId = 74 } = {}) => {
node = deviceNode();
const rect = node.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
fire(node, 'pointerdown', x, y, pointerId);
for (let index = 1; index <= moves; index++) {
fire(node, 'pointermove', x + dx * index / moves, y, pointerId);
}
const writesBeforeEnd = writes.length;
fire(node, cancel ? cancelType : 'pointerup', x + dx, y, pointerId);
await idle();
return writesBeforeEnd;
};
const beginPreview = (dx, pointerId) => {
node = deviceNode();
const rect = node.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
fire(node, 'pointerdown', x, y, pointerId);
fire(node, 'pointermove', x + dx, y, pointerId);
return { node, x, y };
};
const explicit = c._devicePlacementForCanvas(device, c._pos(device).x, c._pos(device).y);
c._layout = { ...c._layout, [deviceId]: { ...explicit, k: 0, future: 'kept' } };
serverLayout = structuredClone(c._layout);
c._layoutContentFingerprint = '';
c._devicePositionHistory.clear();
c.requestUpdate();
await c.updateComplete;
const writesBeforeRelease = await drag();
out.previewDoesNotPersist = writesBeforeRelease === 0;
out.oneDragOneWrite = writes.length === 1 && writes[0].type === 'update';
out.oneDragOneCommand = c._devicePositionHistory.size === 1;
out.futureFieldsSurviveDrag = c._layout[deviceId].k === 0
&& c._layout[deviceId].future === 'kept';
out.undoEnabled = !sr().querySelector('[data-device-position-history="undo"]').disabled;
// #397 AC1: after a write the local copy IS what went over the wire.
out.localCopyEqualsTheWire = writes.length > 0
&& JSON.stringify(c._layout[deviceId]) === JSON.stringify({
...c._layout[deviceId], ...writes[writes.length - 1].pos,
})
&& JSON.stringify(serverLayout[deviceId]) === JSON.stringify(writes[writes.length - 1].pos);
const afterDrag = structuredClone(c._layout[deviceId]);
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
// #397: the card now keeps what it sent — the canonical position — so a
// restored placement may differ from the raw `explicit` by the lattice snap
// (< 1e-9 of the plan, invisible). The equality is therefore stated to that
// precision, and the snap itself is pinned separately below: a real logical
// drift would exceed it by orders of magnitude.
const near = (a, b) => Math.abs(a - b) < 1e-9;
out.undoRestoresExactStart = near(c._layout[deviceId].x, explicit.x)
&& near(c._layout[deviceId].y, explicit.y)
&& c._layout[deviceId].k === 0
&& c._devicePositionHistory.canRedo;
sr().querySelector('[data-device-position-history="redo"]').click();
await idle();
out.redoRestoresExactEnd = JSON.stringify(c._layout[deviceId]) === JSON.stringify(afterDrag);
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true, cancelable: true,
}));
await idle();
out.keyboardUndoWorks = c._devicePositionHistory.canRedo
&& near(c._layout[deviceId].x, explicit.x) && near(c._layout[deviceId].y, explicit.y);
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, shiftKey: true,
bubbles: true, composed: true, cancelable: true,
}));
await idle();
out.keyboardRedoWorks = JSON.stringify(c._layout[deviceId]) === JSON.stringify(afterDrag);
// Escape and an Undo request during preview abort only the uncommitted drag.
const beforeEscape = structuredClone(c._layout[deviceId]);
const writesBeforeEscape = writes.length;
beginPreview(95, 741);
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', code: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
out.escapeAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
&& writes.length === writesBeforeEscape && c._deviceDrag === null;
const historySizeBeforePreviewUndo = c._devicePositionHistory.size;
beginPreview(105, 742);
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.undoDuringDragOnlyAborts = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
&& writes.length === writesBeforeEscape
&& c._devicePositionHistory.size === historySizeBeforePreviewUndo
&& c._deviceDrag === null;
// Leaving the editor is another abort boundary; re-enter for the remaining cases.
beginPreview(115, 743);
[...sr().querySelectorAll('.modetab')][0].click();
await settle();
out.modeSwitchAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeEscape)
&& writes.length === writesBeforeEscape && c._deviceDrag === null;
[...sr().querySelectorAll('.modetab')][1].click();
await settle();
// A no-op after Undo must not destroy the redo branch.
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
const redoName = c._devicePositionHistory.redoName;
const writesBeforeNoop = writes.length;
await drag({ dx: 0, moves: 0, pointerId: 75 });
out.noopKeepsRedo = c._devicePositionHistory.redoName === redoName
&& writes.length === writesBeforeNoop;
sr().querySelector('[data-device-position-history="redo"]').click();
await idle();
// Cancellation restores the raw before-state and performs no final write.
const beforeCancel = structuredClone(c._layout[deviceId]);
const writesBeforeCancel = writes.length;
await drag({ dx: 120, cancel: true, pointerId: 76 });
out.cancelRestoresWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
&& writes.length === writesBeforeCancel;
await drag({ dx: 120, cancel: true, cancelType: 'lostpointercapture', pointerId: 761 });
out.lostCaptureRestoresWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
&& writes.length === writesBeforeCancel;
// A second pointer invalidates the first single-pointer transaction.
node = deviceNode();
let rect = node.getBoundingClientRect();
let x = rect.left + rect.width / 2;
let y = rect.top + rect.height / 2;
fire(node, 'pointerdown', x, y, 762);
fire(node, 'pointermove', x + 100, y, 762);
fire(node, 'pointerdown', x, y, 763);
await c.updateComplete;
out.secondPointerAbortsWithoutWrite = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeCancel)
&& writes.length === writesBeforeCancel && c._deviceDrag === null;
// An auto-positioned marker round-trips through delete/update.
c._devicePositionHistory.clear();
delete c._layout[deviceId];
delete serverLayout[deviceId];
c.requestUpdate();
await c.updateComplete;
await drag({ dx: 90, pointerId: 77 });
const autoAfter = structuredClone(c._layout[deviceId]);
const beforeAutoUndo = writes.length;
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
out.autoUndoDeletesExplicitPlacement = !(deviceId in c._layout)
&& writes[beforeAutoUndo]?.type === 'delete';
sr().querySelector('[data-device-position-history="redo"]').click();
await idle();
out.autoRedoRestoresPlacement = JSON.stringify(c._layout[deviceId]) === JSON.stringify(autoAfter)
&& writes.at(-1)?.type === 'update';
// Failed final update never enters history; failed Undo restores stack direction.
c._devicePositionHistory.clear();
const beforeFailedDrag = structuredClone(c._layout[deviceId]);
failNextUpdate = true;
await drag({ dx: 110, pointerId: 78 });
out.failedDragRollsBack = JSON.stringify(c._layout[deviceId]) === JSON.stringify(beforeFailedDrag)
&& !c._devicePositionHistory.canUndo;
delete c._layout[deviceId];
delete serverLayout[deviceId];
c.requestUpdate();
await c.updateComplete;
await drag({ dx: 100, pointerId: 79 });
const beforeFailedDelete = structuredClone(c._layout[deviceId]);
failNextDelete = true;
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
out.failedUndoRestoresStackDirection = JSON.stringify(c._layout[deviceId])
=== JSON.stringify(beforeFailedDelete)
&& c._devicePositionHistory.canUndo && !c._devicePositionHistory.canRedo;
// Native text history stays native even with an available command.
const input = document.createElement('input');
sr().append(input);
const sizeBeforeInput = c._devicePositionHistory.size;
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true, cancelable: true,
}));
await wait();
out.nativeInputHistoryNotIntercepted = c._devicePositionHistory.size === sizeBeforeInput;
input.remove();
// Same-content reload is an own/reconnect echo; different content is remote
// authority. #397: the server snapshot is NOT copied from the card here — it
// already holds what actually went over the wire (the fake WS above stores
// `message.pos`). The former `serverLayout = structuredClone(c._layout)`
// erased by hand the very divergence this check exists to catch: the wire
// carries the canonical position while `_layout` kept the raw one, so the
// card mistook its own echo for a remote edit and cleared the stack. With
// the assignment gone the check reddens on the unfixed code.
const ownEchoServerPos = structuredClone(serverLayout[deviceId]);
const ownEchoLocalPos = structuredClone(c._layout[deviceId]);
await c._reloadLayoutOnly();
out.sameContentReloadKeepsHistory = c._devicePositionHistory.canUndo;
// The point of the check is only meaningful if the two sides were equal to
// begin with: pin that explicitly instead of assuming it.
out.ownEchoMatchesWhatWentOverTheWire =
JSON.stringify(ownEchoServerPos) === JSON.stringify(ownEchoLocalPos);
serverLayout = {
...serverLayout,
[deviceId]: { ...serverLayout[deviceId], x: serverLayout[deviceId].x + 0.01 },
};
await c._reloadLayoutOnly();
out.remoteContentClearsHistory = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo;
// #397 AC5b: the echo of a DELETE must not clear the stack either — the
// delete branch removes a key instead of replacing a value, so proving the
// update branch says nothing about it.
const echoProbe = c._devices.find((candidate) => candidate.id !== deviceId
&& candidate.bindingStatus?.kind !== 'ha_disabled');
if (echoProbe) {
// The probe position is deliberately NON-canonical (0.024999999999999942
// snaps to 0.025): a round number would travel unchanged and the checks
// below would pass for the wrong reason — there would be no divergence to
// detect in the first place.
const rawProbe = { s: echoProbe.space, x: 0.024999999999999942, y: 0.7 / 7 };
c._layout = { ...c._layout, [echoProbe.id]: structuredClone(rawProbe) };
await c._persistDevicePlacement(echoProbe.id, structuredClone(rawProbe));
c._devicePositionHistory.push({
name: 'echo probe move',
before: { deviceId: echoProbe.id, spaceId: echoProbe.space, placement: null },
after: { deviceId: echoProbe.id, spaceId: echoProbe.space,
placement: structuredClone(rawProbe) },
});
// #397 AC3: the reconnect path (`_loadFromServer` →
// `_adoptStructuralResponses`) is the OTHER reader of the same value: it
// compares the stored `_layoutContentFingerprint` with the server answer
// instead of recomputing it. It must be exercised right after a write,
// before any reload has had a chance to align the two sides — otherwise
// the check passes for the wrong reason.
const reconnectBefore = c._devicePositionHistory.canUndo;
await c._loadFromServer();
out.reconnectKeepsHistory = reconnectBefore && c._devicePositionHistory.canUndo;
await c._persistDevicePlacement(echoProbe.id, null);
await c._reloadLayoutOnly();
out.deleteEchoKeepsHistory = c._devicePositionHistory.canUndo
&& c._layout[echoProbe.id] === undefined;
// #397 AC7: while a write is in flight the card is the authority — a
// server answer holding the OLD position must not win the merge.
const inFlightPos = { s: echoProbe.space, x: 0.63, y: 0.21 };
serverLayout = { ...serverLayout, [echoProbe.id]: { s: echoProbe.space, x: 0.1, y: 0.1 } };
c._sentPos.set(echoProbe.id, structuredClone(inFlightPos));
c._layout = { ...c._layout, [echoProbe.id]: structuredClone(inFlightPos) };
await c._reloadLayoutOnly();
out.inFlightPositionWinsTheMerge =
JSON.stringify(c._layout[echoProbe.id]) === JSON.stringify(inFlightPos);
c._sentPos.delete(echoProbe.id);
} else {
out.deleteEchoKeepsHistory = null;
out.reconnectKeepsHistory = null;
out.inFlightPositionWinsTheMerge = null;
}
// A valid command owns its original space and makes the result visible there.
const otherDevice = c._devices.find((candidate) => candidate.space !== device.space
&& candidate.bindingStatus?.kind !== 'ha_disabled');
if (otherDevice) {
const otherBefore = { s: otherDevice.space, x: 0.55, y: 0.55 };
const otherAfter = { s: otherDevice.space, x: 0.6, y: 0.55 };
c._layout = { ...c._layout, [otherDevice.id]: structuredClone(otherAfter) };
serverLayout = structuredClone(c._layout);
c._layoutContentFingerprint = '';
c._devicePositionHistory.push({
name: 'other-space move',
before: { deviceId: otherDevice.id, spaceId: otherDevice.space, placement: otherBefore },
after: { deviceId: otherDevice.id, spaceId: otherDevice.space, placement: otherAfter },
});
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await idle();
await settle();
const observedOther = c._layout[otherDevice.id];
out.otherSpaceUndoIsVisible = c._space === otherDevice.space
&& observedOther?.s === otherBefore.s
&& observedOther?.x === otherBefore.x
&& observedOther?.y === otherBefore.y;
c._commitSpace(device.space);
await settle();
} else {
out.otherSpaceUndoIsVisible = false;
}
// Stale commands fail closed instead of recreating a deleted/rebound/disabled marker.
const staleCommand = (id, spaceId) => ({
name: 'stale move',
before: { deviceId: id, spaceId, placement: { s: spaceId, x: 0.4, y: 0.4 } },
after: { deviceId: id, spaceId, placement: { s: spaceId, x: 0.45, y: 0.4 } },
});
const writesBeforeStale = writes.length;
c._devicePositionHistory.clear();
c._devicePositionHistory.push(staleCommand('removed-device', device.space));
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.deletedDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
c._devicePositionHistory.push(staleCommand(device.id, 'garden'));
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.reboundDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
const devicesBeforeDisable = c._devices;
c._devices = devicesBeforeDisable.map((candidate) => candidate.id === device.id
? { ...candidate, bindingStatus: { kind: 'ha_disabled' } } : candidate);
c._devicePositionHistory.push(staleCommand(device.id, device.space));
c.requestUpdate();
await c.updateComplete;
sr().querySelector('[data-device-position-history="undo"]').click();
await c.updateComplete;
out.disabledDeviceCommandFailsClosed = !c._devicePositionHistory.canUndo
&& !c._devicePositionHistory.canRedo && writes.length === writesBeforeStale;
c._devices = devicesBeforeDisable;
return out;
});
checkAll(res, {});
await finish(browser, res);