Files
houseplan-card/demo/smoke_binding_picker.mjs
T
2026-08-31 01:18:00 +03:00

149 lines
7.6 KiB
JavaScript

// Пикер привязок: что пользователь видит в списке «Добавить» (#263).
//
// Зачем смок, а не юнит-тест. `_bindingCandidates` — приватный метод карточки,
// читающий `_planHass`, `_devices`, `_markers` и состояние диалога. Переписать
// его логику в тесте значит проверять копию: ровно так дефект #262 и дожил до
// отчёта пользователя — надгробия были покрыты со всех сторон, а список никто
// не спрашивал. Здесь дёргается настоящий метод собранного бандла.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const confirmDanger = c._confirmDanger;
// Пикер читает `showEntities` и `bindingFilter` из открытого диалога, поэтому
// спрашивать его при закрытом бессмысленно: отдельные сущности устройств не
// перечисляются вовсе, и «сущность вернулась» ничего не значило бы.
const openAdd = async (showEntities) => {
c._openMarkerDialog();
await c.updateComplete;
c._markerDialog = { ...c._markerDialog, showEntities: !!showEntities, bindingFilter: '' };
await c.updateComplete;
};
const offered = async (showEntities) => {
await openAdd(showEntities);
return c._bindingCandidates().map((item) => item.value);
};
const remove = async (device) => {
c._openMarkerDialog(device);
await c.updateComplete;
c._confirmDanger = async () => true;
await c._deleteMarker();
await c.updateComplete;
c._confirmDanger = confirmDanger;
};
const markers = () => c._serverCfg.markers || [];
const tombstones = () => markers().filter((m) => m.removed === true);
const liveMarkers = (binding) => markers().filter((m) => m.removed !== true && m.binding === binding);
// --- 1. удалили устройство — оно снова предлагается ----------------------
const device = c._devices.find((d) => d.bindingKind === 'device' && d.bindingRef && !d.hidden);
o.standHasDeviceMarker = !!device;
const deviceBinding = `device:${device.bindingRef}`;
const childEntity = Object.entries(c._planHass.entities)
.find(([, reg]) => reg.device_id === device.bindingRef && !reg.hidden)?.[0];
o.standHasChildEntity = !!childEntity;
const childBinding = `entity:${childEntity}`;
o.placedDeviceNotOffered = !(await offered(false)).includes(deviceBinding);
await remove(device);
o.deleteLeavesTombstone = tombstones().some((m) => m.binding === deviceBinding);
o.deletedDeviceOfferedAgain = (await offered(false)).includes(deviceBinding);
// --- 2. чекбокс «показывать сущности» и есть ловушка ---------------------
// «Нет в списке» и «спрятано за галкой» для пользователя неразличимы: для
// нового маркера галка выключена, и обычная сущность устройства отсутствует.
const withoutCheckbox = await offered(false);
const withCheckbox = await offered(true);
const plainEntity = Object.entries(c._planHass.entities)
.find(([eid, reg]) => reg.device_id && reg.device_id !== device.bindingRef
&& !withoutCheckbox.includes(`entity:${eid}`))?.[0];
o.standHasPlainEntity = !!plainEntity;
o.plainEntityHiddenWithoutCheckbox = !withoutCheckbox.includes(`entity:${plainEntity}`);
o.plainEntityShownWithCheckbox = withCheckbox.includes(`entity:${plainEntity}`);
o.checkboxOffForNewMarker = (await (async () => {
c._openMarkerDialog();
await c.updateComplete;
return c._markerDialog.showEntities;
})()) === false;
// --- 3. #262: из удалённого parent возвращается одна exact entity --------
// Parent tombstone открывает переход в picker, но сохраняется после Save:
// выбранная X оживает, siblings и автоматический device:D — нет.
o.deletedDeviceChildHiddenWithoutCheckbox =
!(await offered(false)).includes(childBinding);
o.deletedDeviceChildOfferedWithCheckbox =
(await offered(true)).includes(childBinding);
const saveChild = async () => {
await openAdd(true);
const reg = c._planHass.entities[childEntity];
const state = c._planHass.states[childEntity];
c._markerDialog = {
...c._markerDialog,
bindingMode: 'ha',
binding: childBinding,
name: reg?.name || state?.attributes?.friendly_name || childEntity,
};
await c._saveMarker();
await c.updateComplete;
return liveMarkers(childBinding)[0];
};
const childMarker = await saveChild();
o.childSaveCreatesOneLiveMarker = liveMarkers(childBinding).length === 1
&& !tombstones().some((m) => m.binding === childBinding);
o.childSaveKeepsParentTombstone = tombstones().some((m) => m.binding === deviceBinding);
const childDevice = () => c._devices.find((d) =>
d.bindingKind === 'entity' && d.bindingRef === childEntity);
o.childBuiltWithoutAutoParent = !!childDevice()
&& !c._devices.some((d) => d.bindingKind === 'device' && d.bindingRef === device.bindingRef);
const childPos = childMarker && c._layout[childMarker.id];
o.childGetsFreshLayoutPosition = !!childPos
&& Number.isFinite(childPos.x) && Number.isFinite(childPos.y) && !!childPos.s;
c._setMode('view');
await c.updateComplete;
o.childDrawnInView = !!c.renderRoot.querySelector(
`.dev[data-id="${CSS.escape(childMarker.id)}"]`,
);
o.parentStillOfferedAfterChildSave = (await offered(false)).includes(deviceBinding);
o.liveChildRemovedFromPicker = !(await offered(true)).includes(childBinding);
// Повторный Delete → Add той же X обязан быть идемпотентным и не затрагивать
// соседний parent tombstone.
await remove(childDevice());
o.childDeleteLeavesBothTombstones = tombstones().some((m) => m.binding === deviceBinding)
&& tombstones().some((m) => m.binding === childBinding);
o.deletedChildOfferedAgain = (await offered(true)).includes(childBinding);
await saveChild();
o.secondChildSaveIsIdempotent = liveMarkers(childBinding).length === 1
&& !tombstones().some((m) => m.binding === childBinding)
&& tombstones().some((m) => m.binding === deviceBinding);
// --- 4. повторное добавление возвращает устройство в список размещённых --
// Явные device:D + entity:X сосуществуют по принятому контракту #226.
await openAdd(false);
c._markerDialog = { ...c._markerDialog, bindingMode: 'ha', binding: deviceBinding,
name: device.name };
await c._saveMarker();
await c.updateComplete;
o.readdReplacesTombstone = !tombstones().some((m) => m.binding === deviceBinding)
&& (c._serverCfg.markers || []).some((m) => m.binding === deviceBinding);
o.readdRemovesFromPicker = !(await offered(false)).includes(deviceBinding);
o.explicitParentAndChildCoexist = c._devices.some((d) =>
d.bindingKind === 'device' && d.bindingRef === device.bindingRef)
&& !!childDevice();
c._markerDialog = null;
c._setMode('view');
await c.updateComplete;
return o;
}));
checkAll(out);
await finish(browser, out);