mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
- scripts/no-new-private-writes.mjs: смоки и demo/helpers/** не добавляют записей в приватное состояние карточки (присваивание, ++/--, delete по цепочке с сегментом _x; от this — нет) и вызовов _setMode/_openRoomEdit/ _openMarkerDialog/_openSpaceDialog. Зачёт правки по полю, перенос блока — movedLinesByFile из no-new-any; исключение // private-ok: <причина>. --count — остаток на HEAD. Подключён в gate:small и в шаг frontend рядом с no-new-any, с той же базой. - demo/helpers/hp-test.mjs: 10 операций через контрактные хуки и события фикстуры (setMode, setTool, switchSpace, openRoomEdit, openMarkerDialog, openSpaceDialog, setServerConfig, setLayout, input, close); ставится launch*() из demo/serve.mjs. В бандле фасада нет. - demo/srv/demo.html: доставка houseplan_config_updated/_layout_updated, __pushServerConfig/__pushServerLayout; после доставки запись со старым expected_rev — conflict, как у настоящего сервера. - HP_SMOKE_CHECKS=1 печатает имена проверок в finish(). - smoke_area_relocation, smoke_glow, smoke_grid_snap переведены на фасад без потери утверждений; новый smoke_test_facade доказывает каждую операцию. - 7 мутантов, docs/TESTING.md (раздел + правило №6), PROCESS.md §2.7, AGENTS.md. Issue: #629 User-Visible: no
516 lines
23 KiB
JavaScript
516 lines
23 KiB
JavaScript
// #126: authoritative HA Area changes supersede stale layout on the full card
|
|
// and on the read-only hosted space card before persistence can help it.
|
|
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;
|
|
// #629: исходное состояние сервера доставляется так, как его доставил бы
|
|
// другой клиент, — событием через фасад; карточка сама перечитывает конфиг и
|
|
// перестраивает устройства. Диалог маркера открывается настоящим кликом.
|
|
const hp = window.__hpTest;
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
/** Слить `patch` в settings.marker_area_snapshot серверного конфига. */
|
|
const withSnapshot = (cfg, patch, extra = {}) => ({
|
|
...cfg,
|
|
...extra,
|
|
settings: {
|
|
...cfg.settings,
|
|
...(extra.settings || {}),
|
|
marker_area_snapshot: { ...(cfg.settings.marker_area_snapshot || {}), ...patch },
|
|
},
|
|
});
|
|
const paint = async (card = c) => {
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
};
|
|
const calls = [];
|
|
let rejectKettleRelocation = false;
|
|
const hass = window.__mkHass();
|
|
const callWS = hass.callWS;
|
|
hass.callWS = async (message) => {
|
|
calls.push(structuredClone(message));
|
|
if (rejectKettleRelocation && message.type === 'houseplan/config/set'
|
|
&& message.config?.settings?.marker_area_snapshot?.d_kettle?.area === 'living_room') {
|
|
rejectKettleRelocation = false;
|
|
throw new Error('synthetic config failure');
|
|
}
|
|
return callWS(message);
|
|
};
|
|
c.hass = hass;
|
|
await paint();
|
|
|
|
await hp.setServerConfig((cfg) => withSnapshot(cfg, {
|
|
d_light1: { binding: 'device:d_light1', area: 'living_room' },
|
|
}, { settings: { new_device_ids: [] } }));
|
|
await hp.setLayout((layout) => ({ ...layout, d_light1: { s: 'f1', x: 0.22, y: 0.22 } }));
|
|
await paint();
|
|
const oldLeft = Number.parseFloat(
|
|
c.renderRoot.querySelector('.dev[data-id="d_light1"]')?.style.left || '0',
|
|
);
|
|
|
|
window.__setRegistryArea('device', 'd_light1', 'kitchen');
|
|
await wait(450);
|
|
await paint();
|
|
const moved = c._devices.find((device) => device.id === 'd_light1');
|
|
const movedEl = c.renderRoot.querySelector('.dev[data-id="d_light1"]');
|
|
const newLeft = Number.parseFloat(movedEl?.style.left || '0');
|
|
const firstDeleteCount = calls.filter(
|
|
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_light1',
|
|
).length;
|
|
const firstAttentionShown = c._serverCfg.settings.new_device_ids?.includes('d_light1')
|
|
&& !!movedEl?.querySelector('.newdot');
|
|
|
|
// An identical authoritative callback must not start another persistence loop.
|
|
window.__setRegistryArea('device', 'd_light1', 'kitchen');
|
|
await wait(300);
|
|
const secondDeleteCount = calls.filter(
|
|
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_light1',
|
|
).length;
|
|
|
|
// A dialog opened before another Area transition may show the effective
|
|
// room, but saving an unrelated field must not persist that stale display as
|
|
// an explicit House Plan override.
|
|
await hp.setServerConfig((cfg) => withSnapshot(cfg, {
|
|
d_lamp: { binding: 'device:d_lamp', area: 'living_room' },
|
|
}));
|
|
await hp.setLayout((layout) => ({ ...layout, d_lamp: { s: 'f1', x: 0.42, y: 0.5 } }));
|
|
await hp.setMode('devices');
|
|
const lampDialog = await hp.openMarkerDialog('d_lamp');
|
|
await paint();
|
|
window.__setRegistryArea('device', 'd_lamp', 'kitchen');
|
|
await wait(450);
|
|
const roomDraftRefreshed = c._markerDialog?.room === 'f1#kitchen'
|
|
&& c._markerDialog?.roomTouched === false;
|
|
await hp.input(lampDialog.querySelector('#marker-name'), 'Floor lamp renamed');
|
|
lampDialog.querySelector('[data-hp="dialog-confirm"]').click();
|
|
for (let attempt = 0; attempt < 40 && lampDialog.isConnected; attempt += 1) await wait(50);
|
|
await paint();
|
|
const savedLamp = c._serverCfg.markers.find((marker) => marker.id === 'd_lamp');
|
|
await hp.setMode('view');
|
|
await paint();
|
|
|
|
// Delete is committed before provenance. A rejected config write restores
|
|
// the old local snapshot and the next authoritative pass retries safely.
|
|
await hp.setServerConfig((cfg) => withSnapshot(cfg, {
|
|
d_kettle: { binding: 'device:d_kettle', area: 'kitchen' },
|
|
}));
|
|
const kettlePoint = { s: 'f1', x: 0.72, y: 0.15 };
|
|
await hp.setLayout((layout) => ({ ...layout, d_kettle: kettlePoint }));
|
|
rejectKettleRelocation = true;
|
|
window.__setRegistryArea('device', 'd_kettle', 'living_room');
|
|
await wait(500);
|
|
const failedConfigRestoredAndRetryable = c._layout.d_kettle?.s === kettlePoint.s
|
|
&& c._layout.d_kettle?.x === kettlePoint.x
|
|
&& c._layout.d_kettle?.y === kettlePoint.y
|
|
&& c._serverCfg.settings.marker_area_snapshot?.d_kettle?.area === 'kitchen'
|
|
&& c._areaRelocationSyncKey === ''
|
|
&& calls.some((message) => message.type === 'houseplan/layout/update'
|
|
&& message.device_id === 'd_kettle');
|
|
window.__setRegistryArea('device', 'd_kettle', 'living_room');
|
|
await wait(500);
|
|
await paint();
|
|
const configRetrySucceeded = c._serverCfg.settings.marker_area_snapshot?.d_kettle?.area
|
|
=== 'living_room' && c._serverCfg.settings.new_device_ids?.includes('d_kettle');
|
|
|
|
// Cross-space registry movement uses the same production coordinator: the
|
|
// old Garden point is deleted and the device joins the f1 autogrid.
|
|
await hp.setServerConfig((cfg) => withSnapshot(cfg, {
|
|
d_gate: { binding: 'device:d_gate', area: 'garden' },
|
|
}));
|
|
await hp.setLayout((layout) => ({ ...layout, d_gate: { s: 'garden', x: 0.73, y: 0.22 } }));
|
|
window.__setRegistryArea('device', 'd_gate', 'living_room');
|
|
await wait(450);
|
|
await paint();
|
|
const crossSpaceDevice = c._devices.find((device) => device.id === 'd_gate');
|
|
const crossSpaceDeleteCount = calls.filter(
|
|
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_gate',
|
|
).length;
|
|
|
|
// An explicitly persisted entity binding follows its own registry Area,
|
|
// independently of a device marker.
|
|
window.__setRegistryArea('entity', 'sun.sun', 'living_room');
|
|
await hp.setServerConfig((cfg) => withSnapshot(cfg, {
|
|
entity_sun: { binding: 'entity:sun.sun', area: 'living_room' },
|
|
}, {
|
|
markers: [
|
|
...cfg.markers.filter((marker) => marker.id !== 'entity_sun'),
|
|
{ id: 'entity_sun', binding: 'entity:sun.sun' },
|
|
],
|
|
}));
|
|
await hp.setLayout((layout) => ({ ...layout, entity_sun: { s: 'f1', x: 0.20, y: 0.30 } }));
|
|
window.__setRegistryArea('entity', 'sun.sun', 'kitchen');
|
|
await wait(450);
|
|
await paint();
|
|
const entityDevice = c._devices.find((device) => device.id === 'entity_sun');
|
|
|
|
// A user-owned room override remains authoritative when HA changes Area.
|
|
await hp.setServerConfig((cfg) => withSnapshot(cfg, {
|
|
d_tv: { binding: 'device:d_tv', area: 'living_room' },
|
|
}, {
|
|
markers: [
|
|
...cfg.markers.filter((marker) => marker.id !== 'd_tv'),
|
|
{ id: 'd_tv', binding: 'device:d_tv', space: 'f1', area: 'living_room' },
|
|
],
|
|
}));
|
|
const explicitPoint = { s: 'f1', x: 0.13, y: 0.50 };
|
|
await hp.setLayout((layout) => ({ ...layout, d_tv: explicitPoint }));
|
|
const explicitDeletesBefore = calls.filter(
|
|
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_tv',
|
|
).length;
|
|
window.__setRegistryArea('device', 'd_tv', 'kitchen');
|
|
await wait(350);
|
|
await paint();
|
|
const explicitDevice = c._devices.find((device) => device.id === 'd_tv');
|
|
const explicitDeletesAfter = calls.filter(
|
|
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_tv',
|
|
).length;
|
|
|
|
// A markerless light group is a composite, not a direct entity marker. Its
|
|
// Area may change, but #126 must not discard its saved point or flag it.
|
|
window.__addRegistryEntity('light.area_group', 'living_room', 'on', 'group');
|
|
await wait(150);
|
|
await paint();
|
|
const compositeId = 'lg_light.area_group';
|
|
const compositePoint = { s: 'f1', x: 0.30, y: 0.40 };
|
|
await hp.setLayout((layout) => ({ ...layout, [compositeId]: compositePoint }));
|
|
await hp.setServerConfig((cfg) => withSnapshot(cfg, {
|
|
[compositeId]: { binding: 'entity:light.area_group', area: 'living_room' },
|
|
}));
|
|
const compositeDeletesBefore = calls.filter(
|
|
(message) => message.type === 'houseplan/layout/delete' && message.device_id === compositeId,
|
|
).length;
|
|
const compositeAttentionBefore = c._newIds.has(compositeId);
|
|
window.__setRegistryArea('entity', 'light.area_group', 'kitchen');
|
|
await wait(350);
|
|
await paint();
|
|
const compositeDevice = c._devices.find((device) => device.id === compositeId);
|
|
const compositeDeletesAfter = calls.filter(
|
|
(message) => message.type === 'houseplan/layout/delete' && message.device_id === compositeId,
|
|
).length;
|
|
|
|
// Cold-start legacy backfill: the first authoritative build sees a saved
|
|
// point strictly inside the old Area room but no provenance. It must run
|
|
// through the real card startup and preserve delete-before-config ordering.
|
|
const coldConfig = structuredClone(c._serverCfg);
|
|
coldConfig.settings.marker_area_snapshot = {
|
|
...coldConfig.settings.marker_area_snapshot,
|
|
};
|
|
delete coldConfig.settings.marker_area_snapshot.d_motion;
|
|
coldConfig.settings.new_device_ids = (coldConfig.settings.new_device_ids || [])
|
|
.filter((id) => id !== 'd_motion');
|
|
const coldLayout = { ...c._layout, d_motion: { s: 'f1', x: 0.40, y: 0.75 } };
|
|
window.__setRegistryArea('device', 'd_motion', 'kitchen');
|
|
await wait(100);
|
|
const coldCalls = [];
|
|
let coldRev = 40;
|
|
let releaseColdConfig;
|
|
const coldConfigGate = new Promise((resolve) => { releaseColdConfig = resolve; });
|
|
const coldHass = window.__mkHass();
|
|
const coldBaseCallWS = coldHass.callWS;
|
|
coldHass.callWS = async (message) => {
|
|
coldCalls.push(structuredClone(message));
|
|
if (message.type === 'houseplan/config/get') {
|
|
await coldConfigGate;
|
|
return { config: coldConfig, rev: coldRev, can_write: true };
|
|
}
|
|
if (message.type === 'houseplan/layout/get') {
|
|
return { layout: structuredClone(coldLayout), rev: coldRev };
|
|
}
|
|
if (message.type === 'houseplan/layout/delete') {
|
|
delete coldLayout[message.device_id];
|
|
return { ok: true, rev: ++coldRev };
|
|
}
|
|
if (message.type === 'houseplan/config/set') {
|
|
Object.assign(coldConfig, structuredClone(message.config));
|
|
return { ok: true, rev: ++coldRev };
|
|
}
|
|
return coldBaseCallWS(message);
|
|
};
|
|
const coldCard = document.createElement('houseplan-card');
|
|
coldCard.setConfig({ type: 'custom:houseplan-card', title: 'Cold Area backfill' });
|
|
document.body.append(coldCard);
|
|
coldCard.hass = coldHass;
|
|
await wait(100);
|
|
const coldCacheStayedReadOnly = !coldCalls.some((message) =>
|
|
message.type === 'houseplan/config/set'
|
|
|| ['houseplan/layout/set', 'houseplan/layout/update', 'houseplan/layout/delete']
|
|
.includes(message.type));
|
|
releaseColdConfig();
|
|
for (let attempt = 0; attempt < 20 && !coldCard._loadedOnce; attempt += 1) await wait(100);
|
|
await wait(450);
|
|
await paint(coldCard);
|
|
const coldDeleteIndex = coldCalls.findIndex(
|
|
(message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_motion',
|
|
);
|
|
const coldConfigIndex = coldCalls.findIndex(
|
|
(message) => message.type === 'houseplan/config/set'
|
|
&& message.config?.settings?.marker_area_snapshot?.d_motion?.area === 'kitchen',
|
|
);
|
|
const coldDevice = coldCard._devices.find((device) => device.id === 'd_motion');
|
|
const coldStartBackfilled = coldDevice?.area === 'kitchen'
|
|
&& !coldCard._layout.d_motion
|
|
&& coldCard._serverCfg.settings.marker_area_snapshot?.d_motion?.area === 'kitchen'
|
|
&& coldCard._serverCfg.settings.new_device_ids?.includes('d_motion')
|
|
&& coldDeleteIndex >= 0 && coldConfigIndex > coldDeleteIndex;
|
|
coldCard.remove();
|
|
|
|
// A separate read-only card receives an old snapshot/layout but the already
|
|
// authoritative new registry Area. It must be truthful without any writes.
|
|
const staticConfig = structuredClone(c._serverCfg);
|
|
staticConfig.settings.marker_area_snapshot.d_leak = {
|
|
binding: 'device:d_leak', area: 'living_room',
|
|
};
|
|
staticConfig.settings.new_device_ids = [];
|
|
const staticLayout = { ...c._layout, d_leak: { s: 'f1', x: 0.22, y: 0.22 } };
|
|
const staticCalls = [];
|
|
const staticHass = window.__mkHass();
|
|
const staticCallWS = staticHass.callWS;
|
|
staticHass.callWS = async (message) => {
|
|
staticCalls.push(structuredClone(message));
|
|
if (message.type === 'houseplan/config/get') {
|
|
return { config: staticConfig, rev: 20, can_write: false };
|
|
}
|
|
if (message.type === 'houseplan/layout/get') return { layout: staticLayout, rev: 20 };
|
|
return staticCallWS(message);
|
|
};
|
|
const staticCard = document.createElement('houseplan-space-card');
|
|
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
|
staticCard.hass = staticHass;
|
|
document.body.append(staticCard);
|
|
await wait(500);
|
|
await paint(staticCard);
|
|
const staticEl = staticCard.renderRoot.querySelector('.dev[data-id="d_leak"]');
|
|
const staticLeft = Number.parseFloat(staticEl?.style.left || '0');
|
|
|
|
// Lifecycle metadata is bounded by the authoritative registry, not by
|
|
// explicit editor cleanup alone. A disappeared device with no saved marker
|
|
// must lose its snapshot on the next full pass.
|
|
const orphanId = 'device-that-disappeared';
|
|
await hp.setServerConfig((cfg) => withSnapshot(cfg, {
|
|
[orphanId]: { binding: `device:${orphanId}`, area: 'living_room' },
|
|
}));
|
|
await wait(450);
|
|
const authoritativeOrphanRemoved = !c._serverCfg.settings.marker_area_snapshot?.[orphanId];
|
|
|
|
// A separate connection whose full registry calls fail models startup or a
|
|
// limited account. The exact same orphan evidence is not authoritative and
|
|
// therefore must not be destructive.
|
|
const limitedConfig = structuredClone(c._serverCfg);
|
|
limitedConfig.settings.marker_area_snapshot[orphanId] = {
|
|
binding: `device:${orphanId}`, area: 'living_room',
|
|
};
|
|
const limitedHass = window.__mkHass();
|
|
const limitedCallWS = limitedHass.callWS;
|
|
limitedHass.connection = {};
|
|
limitedHass.callWS = async (message) => {
|
|
if (message.type === 'config/device_registry/list'
|
|
|| message.type === 'config/entity_registry/list') throw new Error('synthetic limited registry');
|
|
if (message.type === 'houseplan/config/get') {
|
|
return { config: limitedConfig, rev: 30, can_write: true };
|
|
}
|
|
return limitedCallWS(message);
|
|
};
|
|
const limitedCard = document.createElement('houseplan-card');
|
|
limitedCard.setConfig({ type: 'custom:houseplan-card', title: 'Limited registry' });
|
|
limitedCard.hass = limitedHass;
|
|
document.body.append(limitedCard);
|
|
await wait(500);
|
|
await paint(limitedCard);
|
|
const nonAuthoritativeOrphanPreserved = !!limitedCard._serverCfg?.settings
|
|
?.marker_area_snapshot?.[orphanId];
|
|
limitedCard.remove();
|
|
|
|
// #419: destructive snapshot cleanup uses the raw registry roster and two
|
|
// distinct non-empty revisions. Presentation filtering is never absence.
|
|
const registryRow = (id) => ({
|
|
id, name: id, area_id: 'area-without-houseplan-room', disabled_by: null,
|
|
});
|
|
const entityRow = {
|
|
entity_id: 'sensor.registry_witness', device_id: null,
|
|
platform: 'demo', disabled_by: null,
|
|
};
|
|
const runCleanupProbe = async ({
|
|
snapshot, deviceFrames, liveDevices = {}, rejectFirstCleanup = false,
|
|
}) => {
|
|
const config = structuredClone(c._serverCfg);
|
|
config.markers = [];
|
|
config.settings = {
|
|
...config.settings,
|
|
filter_seeded: true,
|
|
known_devices: [],
|
|
new_device_ids: [],
|
|
marker_area_snapshot: structuredClone(snapshot),
|
|
};
|
|
const probeCalls = [];
|
|
let deviceRegistryCalls = 0;
|
|
let entityRegistryCalls = 0;
|
|
let cleanupWrites = 0;
|
|
let rejected = false;
|
|
let rev = 100;
|
|
const base = window.__mkHass();
|
|
// The retrying probe gets a registry that can announce a change: that is
|
|
// the product's own reason to run the pass again after a failed write.
|
|
const registryListeners = new Set();
|
|
const probeHass = {
|
|
...base,
|
|
connection: {
|
|
subscribeEvents: async (callback, event) => {
|
|
if (!rejectFirstCleanup || event !== 'device_registry_updated') return () => {};
|
|
registryListeners.add(callback);
|
|
return () => registryListeners.delete(callback);
|
|
},
|
|
subscribeMessage: async () => () => {},
|
|
},
|
|
devices: liveDevices,
|
|
entities: { [entityRow.entity_id]: entityRow },
|
|
states: {},
|
|
};
|
|
probeHass.callWS = async (message) => {
|
|
probeCalls.push(structuredClone(message));
|
|
if (message.type === 'config/device_registry/list') {
|
|
const frame = deviceFrames[Math.min(deviceRegistryCalls, deviceFrames.length - 1)];
|
|
deviceRegistryCalls += 1;
|
|
return structuredClone(frame);
|
|
}
|
|
if (message.type === 'config/entity_registry/list') {
|
|
entityRegistryCalls += 1;
|
|
return [structuredClone(entityRow)];
|
|
}
|
|
if (message.type === 'houseplan/config/get') {
|
|
return { config, rev, can_write: true };
|
|
}
|
|
if (message.type === 'houseplan/layout/get') return { layout: {}, rev };
|
|
if (message.type === 'houseplan/config/set') {
|
|
const beforeCount = Object.keys(snapshot).length;
|
|
const afterCount = Object.keys(
|
|
message.config?.settings?.marker_area_snapshot || {},
|
|
).length;
|
|
if (afterCount < beforeCount) {
|
|
cleanupWrites += 1;
|
|
if (rejectFirstCleanup && !rejected) {
|
|
rejected = true;
|
|
throw new Error('synthetic cleanup config failure');
|
|
}
|
|
}
|
|
Object.assign(config, structuredClone(message.config));
|
|
return { ok: true, rev: ++rev };
|
|
}
|
|
return { ok: true };
|
|
};
|
|
const card = document.createElement('houseplan-card');
|
|
card.setConfig({ type: 'custom:houseplan-card', title: 'Area cleanup probe' });
|
|
document.body.append(card);
|
|
card.hass = probeHass;
|
|
for (let attempt = 0; attempt < 20 && !card._loadedOnce; attempt += 1) await wait(100);
|
|
await wait(550);
|
|
if (rejectFirstCleanup) {
|
|
for (const callback of registryListeners) callback({ action: 'update', id: 'still-live' });
|
|
await wait(450);
|
|
}
|
|
const result = {
|
|
snapshot: structuredClone(card._serverCfg?.settings?.marker_area_snapshot || {}),
|
|
deviceIds: card._devices.map((item) => item.id),
|
|
deviceRegistryCalls,
|
|
entityRegistryCalls,
|
|
cleanupWrites,
|
|
configWrites: probeCalls.filter((message) => message.type === 'houseplan/config/set').length,
|
|
};
|
|
card.remove();
|
|
return result;
|
|
};
|
|
|
|
const filteredRow = registryRow('filtered-but-live');
|
|
const transientRow = registryRow('transient-device');
|
|
const recoveredProbe = await runCleanupProbe({
|
|
snapshot: {
|
|
filtered: { binding: 'device:filtered-but-live', area: 'living_room' },
|
|
transient: { binding: 'device:transient-device', area: 'living_room' },
|
|
},
|
|
deviceFrames: [[filteredRow], [filteredRow, transientRow]],
|
|
liveDevices: { [filteredRow.id]: filteredRow },
|
|
});
|
|
|
|
const keepRow = registryRow('still-live');
|
|
const confirmedProbe = await runCleanupProbe({
|
|
snapshot: {
|
|
orphan: { binding: 'device:confirmed-orphan', area: 'living_room' },
|
|
keep: { binding: 'device:still-live', area: 'living_room' },
|
|
},
|
|
deviceFrames: [[keepRow], [keepRow]],
|
|
liveDevices: { [keepRow.id]: keepRow },
|
|
rejectFirstCleanup: true,
|
|
});
|
|
|
|
const emptyProbe = await runCleanupProbe({
|
|
snapshot: {
|
|
orphan: { binding: 'device:empty-frame-orphan', area: 'living_room' },
|
|
},
|
|
deviceFrames: [[]],
|
|
liveDevices: {},
|
|
});
|
|
|
|
return {
|
|
movedToRegistryArea: moved?.area === 'kitchen' && moved?.space === 'f1',
|
|
staleLayoutDeleted: !c._layout.d_light1,
|
|
provenanceAdvanced: c._serverCfg.settings.marker_area_snapshot?.d_light1?.area === 'kitchen',
|
|
attentionShown: firstAttentionShown,
|
|
autoGridReplacedOldPoint: oldLeft < 40 && newLeft > 50,
|
|
deleteFirstAndOnce: firstDeleteCount === 1 && secondDeleteCount === 1,
|
|
configWritten: calls.some((message) => message.type === 'houseplan/config/set'),
|
|
roomDraftRefreshed,
|
|
noStaleExplicitOverride: !!savedLamp
|
|
&& savedLamp.area === undefined && savedLamp.room_id === undefined,
|
|
failedConfigRestoredAndRetryable,
|
|
configRetrySucceeded,
|
|
crossSpaceRelocated: crossSpaceDevice?.space === 'f1'
|
|
&& crossSpaceDevice?.area === 'living_room'
|
|
&& !c._layout.d_gate
|
|
&& c._serverCfg.settings.marker_area_snapshot?.d_gate?.area === 'living_room'
|
|
&& crossSpaceDeleteCount === 1,
|
|
standaloneEntityRelocated: entityDevice?.bindingKind === 'entity'
|
|
&& entityDevice?.bindingRef === 'sun.sun'
|
|
&& entityDevice?.area === 'kitchen'
|
|
&& !c._layout.entity_sun
|
|
&& c._serverCfg.settings.marker_area_snapshot?.entity_sun?.area === 'kitchen',
|
|
explicitOverrideExcluded: explicitDevice?.area === 'living_room'
|
|
&& c._layout.d_tv?.s === explicitPoint.s
|
|
&& c._layout.d_tv?.x === explicitPoint.x
|
|
&& c._layout.d_tv?.y === explicitPoint.y
|
|
&& explicitDeletesAfter === explicitDeletesBefore,
|
|
compositeGroupExcluded: compositeDevice?.marker == null
|
|
&& compositeDevice?.area === 'kitchen'
|
|
&& c._layout[compositeId]?.x === compositePoint.x
|
|
&& c._layout[compositeId]?.y === compositePoint.y
|
|
&& compositeDeletesAfter === compositeDeletesBefore
|
|
&& c._newIds.has(compositeId) === compositeAttentionBefore
|
|
&& !c._areaRelocationIds.has(compositeId),
|
|
coldCacheStayedReadOnly,
|
|
coldStartBackfilled,
|
|
staticReadOnlyProjection: staticCard._areaRelocationIds.has('d_leak')
|
|
&& staticLeft > 50 && !!staticEl?.querySelector('.newdot'),
|
|
staticMadeNoWrites: !staticCalls.some((message) =>
|
|
message.type === 'houseplan/config/set'
|
|
|| ['houseplan/layout/set', 'houseplan/layout/update', 'houseplan/layout/delete']
|
|
.includes(message.type)),
|
|
authoritativeOrphanRemoved,
|
|
nonAuthoritativeOrphanPreserved,
|
|
filteredRosterIsNotAbsence: !recoveredProbe.deviceIds.includes('filtered-but-live')
|
|
&& !!recoveredProbe.snapshot.filtered,
|
|
transientRosterPreserved: !!recoveredProbe.snapshot.transient,
|
|
confirmationRefreshRunsOnce: recoveredProbe.deviceRegistryCalls === 2
|
|
&& recoveredProbe.entityRegistryCalls === 2
|
|
&& recoveredProbe.cleanupWrites === 0,
|
|
confirmedOrphanRemovedAlone: !confirmedProbe.snapshot.orphan
|
|
&& !!confirmedProbe.snapshot.keep,
|
|
confirmedCleanupRetriesAfterWriteFailure: confirmedProbe.cleanupWrites >= 2,
|
|
emptyRegistryPreservesSnapshot: !!emptyProbe.snapshot.orphan
|
|
&& emptyProbe.cleanupWrites === 0,
|
|
emptyRegistryDoesNotReloadLoop: emptyProbe.deviceRegistryCalls === 1
|
|
&& emptyProbe.entityRegistryCalls === 1,
|
|
};
|
|
});
|
|
|
|
checkAll(res);
|
|
await finish(browser, res);
|