Files
houseplan-card/demo/smoke_area_relocation_safety.mjs
T
2026-09-01 15:08:22 +00:00

240 lines
10 KiB
JavaScript

// #403: a rejected Area-provenance write restores deleted manual placement,
// while relocation invalidates only history commands owned by that device.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const paint = async () => {
c.requestUpdate();
await c.updateComplete;
};
const settleRelocation = async () => {
await c._areaRelocationWrite.catch(() => undefined);
await wait(80);
await paint();
};
const samePlacement = (left, right) => !!left && !!right
&& left.s === right.s && left.x === right.x && left.y === right.y;
let serverConfig = structuredClone(c._serverCfg);
let serverLayout = structuredClone(c._layout);
let configRev = Math.max(30, Number(c._cfgRev) || 0);
let layoutRev = Math.max(40, Number(c._layoutRev) || 0);
const calls = [];
let rejectConfig = null;
let rejectRestoreId = null;
let delayConfig = null;
let releaseDelayedConfig = null;
const baseCallWS = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
calls.push({ type: 'config/get' });
return { config: structuredClone(serverConfig), rev: configRev, can_write: true };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(serverLayout), rev: layoutRev };
}
if (message.type === 'houseplan/layout/delete') {
calls.push({ type: 'layout/delete', id: message.device_id });
delete serverLayout[message.device_id];
return { ok: true, rev: ++layoutRev };
}
if (message.type === 'houseplan/layout/update') {
const row = { type: 'layout/update', id: message.device_id };
calls.push(row);
if (rejectRestoreId === message.device_id) {
rejectRestoreId = null;
row.result = 'rejected';
throw new Error('synthetic placement restore failure');
}
serverLayout[message.device_id] = structuredClone(message.pos);
return { ok: true, rev: ++layoutRev };
}
if (message.type === 'houseplan/config/set') {
const row = {
type: 'config/set',
snapshot: structuredClone(message.config?.settings?.marker_area_snapshot || {}),
attention: [...(message.config?.settings?.new_device_ids || [])],
};
calls.push(row);
if (rejectConfig
&& row.snapshot[rejectConfig.id]?.area === rejectConfig.area) {
const rejection = rejectConfig;
rejectConfig = null;
row.result = 'rejected';
const error = new Error('synthetic config failure');
if (rejection.code) error.code = rejection.code;
throw error;
}
if (delayConfig && row.snapshot[delayConfig.id]?.area === delayConfig.area) {
const delayed = delayConfig;
delayConfig = null;
row.result = 'delayed';
await new Promise((resolve) => { releaseDelayedConfig = resolve; });
row.result = 'accepted';
if (delayed.id === '') throw new Error('unreachable delayed fixture');
}
serverConfig = structuredClone(message.config);
return { ok: true, rev: ++configRev };
}
return baseCallWS(message);
} };
c._serverStorage = true;
c._serverCanWrite = true;
c._layoutRev = layoutRev;
c._cfgRev = configRev;
const installScenario = async ({ id, oldArea, point }) => {
await settleRelocation();
window.__setRegistryArea('device', id, oldArea);
await wait(120);
c._serverCfg = {
...c._serverCfg,
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...(c._serverCfg.settings.marker_area_snapshot || {}),
[id]: { binding: `device:${id}`, area: oldArea },
},
new_device_ids: (c._serverCfg.settings.new_device_ids || [])
.filter((candidate) => candidate !== id),
},
};
c._layout = { ...c._layout, [id]: structuredClone(point) };
serverConfig = structuredClone(c._serverCfg);
serverLayout = structuredClone(c._layout);
c._cfgContentFingerprint = '';
c._layoutContentFingerprint = '';
c._areaRelocationSyncKey = '';
c._regSignature = '';
c._maybeRebuildDevices();
await settleRelocation();
};
const pushMove = (id, name, fromX, toX) => {
const device = c._devices.find((candidate) => candidate.id === id);
if (!device) return false;
c._devicePositionHistory.push({
name,
before: { deviceId: id, spaceId: device.space,
placement: { s: device.space, x: fromX, y: 0.31 } },
after: { deviceId: id, spaceId: device.space,
placement: { s: device.space, x: toX, y: 0.31 } },
});
return true;
};
const stableId = 'd_lamp';
const relocatedId = 'd_light1';
const original = { s: 'f1', x: 0.22, y: 0.22 };
await installScenario({ id: relocatedId, oldArea: 'living_room', point: original });
c._devicePositionHistory.clear();
const historyFixtureReady = pushMove(stableId, 'Move lamp', 0.28, 0.34)
&& pushMove(relocatedId, 'Move relocating light', 0.22, 0.27);
rejectConfig = { id: relocatedId, area: 'kitchen', code: null };
window.__setRegistryArea('device', relocatedId, 'kitchen');
await settleRelocation();
const rejectedConfigIndex = calls.findIndex((row) => row.type === 'config/set'
&& row.result === 'rejected' && row.snapshot[relocatedId]?.area === 'kitchen');
const restoreIndex = calls.findIndex((row, index) => index > rejectedConfigIndex
&& row.type === 'layout/update' && row.id === relocatedId);
const restoredLocally = samePlacement(c._layout[relocatedId], original);
const restoredOnServer = samePlacement(serverLayout[relocatedId], original);
const unrelatedHistorySurvives = historyFixtureReady
&& c._devicePositionHistory.size === 1
&& c._devicePositionHistory.undoName === 'Move lamp';
const relocatedHistoryRemoved = c._devicePositionHistory.undoName !== 'Move relocating light'
&& !c._devicePositionHistory.canRedo;
const beforeUndoRelocated = structuredClone(c._layout[relocatedId]);
await c._runDevicePositionHistory('undo');
const unrelatedUndoStillWorks = c._devicePositionHistory.canRedo
&& samePlacement(c._layout[relocatedId], beforeUndoRelocated);
// Retry with a delayed config response. Between delete and acknowledgement
// the old point must not reappear; after release the ordinary #126 result
// (advanced snapshot + attention) must complete.
delayConfig = { id: relocatedId, area: 'kitchen' };
c._regSignature = '';
c._maybeRebuildDevices();
for (let attempt = 0; attempt < 80 && !releaseDelayedConfig; attempt += 1) await wait(10);
await paint();
const movedDuringDelayedWrite = c._devices.find((device) => device.id === relocatedId);
const stalePointSuppressedWhilePending = !!releaseDelayedConfig
&& !c._layout[relocatedId]
&& movedDuringDelayedWrite?.area === 'kitchen';
releaseDelayedConfig?.();
releaseDelayedConfig = null;
await settleRelocation();
const successfulRetry = !serverLayout[relocatedId]
&& c._serverCfg.settings.marker_area_snapshot?.[relocatedId]?.area === 'kitchen'
&& c._serverCfg.settings.new_device_ids?.includes(relocatedId);
// The conflict branch must restore before it reloads and retries. Call
// ordering is the durable witness even though the retry then deletes the
// restored point again and completes normally.
const conflictId = 'd_kettle';
const conflictPoint = { s: 'f1', x: 0.72, y: 0.15 };
await installScenario({ id: conflictId, oldArea: 'kitchen', point: conflictPoint });
c._devicePositionHistory.clear();
pushMove(stableId, 'Move lamp before conflict', 0.30, 0.36);
const conflictStart = calls.length;
rejectConfig = { id: conflictId, area: 'living_room', code: 'conflict' };
window.__setRegistryArea('device', conflictId, 'living_room');
await settleRelocation();
const conflictRows = calls.slice(conflictStart);
const conflictReject = conflictRows.findIndex((row) => row.type === 'config/set'
&& row.result === 'rejected' && row.snapshot[conflictId]?.area === 'living_room');
const conflictRestore = conflictRows.findIndex((row, index) => index > conflictReject
&& row.type === 'layout/update' && row.id === conflictId);
const conflictReload = conflictRows.findIndex((row, index) => index > conflictRestore
&& row.type === 'config/get');
const conflictRetryDelete = conflictRows.findIndex((row, index) => index > conflictReload
&& row.type === 'layout/delete' && row.id === conflictId);
const conflictRestoresBeforeRetry = conflictReject >= 0
&& conflictRestore > conflictReject
&& conflictReload > conflictRestore
&& conflictRetryDelete > conflictReload;
const conflictEventuallyCompletes = !serverLayout[conflictId]
&& c._serverCfg.settings.marker_area_snapshot?.[conflictId]?.area === 'living_room'
&& c._serverCfg.settings.new_device_ids?.includes(conflictId);
const conflictKeepsUnrelatedHistory = c._devicePositionHistory.undoName
=== 'Move lamp before conflict';
// If even layout/update fails, the existing attention channel is persisted
// instead of losing the manual point silently.
const fallbackId = 'd_motion';
const fallbackPoint = { s: 'f1', x: 0.40, y: 0.75 };
await installScenario({ id: fallbackId, oldArea: 'living_room', point: fallbackPoint });
rejectConfig = { id: fallbackId, area: 'kitchen', code: null };
rejectRestoreId = fallbackId;
window.__setRegistryArea('device', fallbackId, 'kitchen');
await settleRelocation();
const fallbackEl = c.renderRoot.querySelector(`.dev[data-id="${fallbackId}"]`);
const failedRestoreLeavesAttention = !serverLayout[fallbackId]
&& c._serverCfg.settings.new_device_ids?.includes(fallbackId)
&& serverConfig.settings.new_device_ids?.includes(fallbackId)
&& c._newIds.has(fallbackId)
&& !!fallbackEl?.querySelector('.newdot');
return {
historyFixtureReady,
rejectedWriteRestoresAfterDelete: rejectedConfigIndex >= 0
&& restoreIndex > rejectedConfigIndex && restoredLocally && restoredOnServer,
unrelatedHistorySurvives,
relocatedHistoryRemoved,
unrelatedUndoStillWorks,
stalePointSuppressedWhilePending,
successfulRetry,
conflictRestoresBeforeRetry,
conflictEventuallyCompletes,
conflictKeepsUnrelatedHistory,
failedRestoreLeavesAttention,
};
});
checkAll(res);
await finish(browser, res);