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
This commit is contained in:
Codex
2026-08-31 03:03:59 +03:00
parent b17f416740
commit f4c66bed3f
+26 -3
View File
@@ -34,6 +34,13 @@ const res = await page.evaluate(async () => {
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 };
}
@@ -273,14 +280,29 @@ const res = await page.evaluate(async () => {
const echoProbe = c._devices.find((candidate) => candidate.id !== deviceId
&& candidate.bindingStatus?.kind !== 'ha_disabled');
if (echoProbe) {
c._layout = { ...c._layout, [echoProbe.id]: { s: echoProbe.space, x: 0.42, y: 0.42 } };
await c._persistDevicePlacement(echoProbe.id, { s: echoProbe.space, x: 0.42, y: 0.42 });
// 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: { s: echoProbe.space, x: 0.42, y: 0.42 } },
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
@@ -298,6 +320,7 @@ const res = await page.evaluate(async () => {
c._sentPos.delete(echoProbe.id);
} else {
out.deleteEchoKeepsHistory = null;
out.reconnectKeepsHistory = null;
out.inFlightPositionWinsTheMerge = null;
}