test(smoke): cover the binding picker

git grep _bindingCandidates -- test/ demo/ was empty: the function that decides
what the user is offered under Add had no test and no smoke. Tombstones were
covered from every side, the list they filter was never asked. That is how #262
reached us through a user report instead of a gate — smoke_hidden_flag assigns
binding straight into _markerDialog and bypasses the picker entirely.

Twelve checks against the real bundle: a deleted device is offered again, a
deleted plain entity is offered again behind the checkbox, the checkbox itself
is the trap (a device entity is absent with it off, present with it on, and it
starts off for a new marker), a placed binding is not duplicated, and re-adding
replaces the tombstone and leaves the picker.

The twelfth pins the known defect #262 as current behaviour: a device tombstone
still hides its child entities. Fixing it turns the check red and forces it to
be flipped, so the fix cannot pass the coverage by.

smoke-links registers only the pure tombstone helpers. The picker names itself
and is found by direct match; the helpers are not named anywhere in the
scenario. Verified by probe: touching src/devices.ts alone selects this smoke
as a registered link, and without the entry nothing would select it.

Issue: #263
User-Visible: no
This commit is contained in:
Claude
2026-08-23 15:32:57 +03:00
parent 9f63163339
commit 18d504631c
2 changed files with 109 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
// Пикер привязок: что пользователь видит в списке «Добавить» (#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 nativeConfirm = window.confirm;
// Пикер читает `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;
window.confirm = () => true;
await c._deleteMarker();
await c.updateComplete;
window.confirm = nativeConfirm;
};
const tombstones = () => (c._serverCfg.markers || []).filter((m) => m.removed === true);
// --- 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)?.[0];
o.standHasChildEntity = !!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 -------------------------------------------
// Надгробие ключуется по точной строке привязки, поэтому `device:<id>`
// разблокирует только само устройство: `isRemovedPlanEntity` продолжает
// выкидывать из списка все его дочерние сущности. Здесь закреплено ТЕКУЩЕЕ
// поведение — починят #262, и смок покраснеет, потребовав перевернуть
// проверку. Молча пройти фикс мимо покрытия не сможет.
o.knownDefect262ChildEntityBlocked =
!(await offered(true)).includes(`entity:${childEntity}`);
// --- 4. повторное добавление возвращает устройство в список размещённых --
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);
c._markerDialog = null;
c._setMode('view');
await c.updateComplete;
return o;
}));
checkAll(out);
await finish(browser, out);
+13
View File
@@ -27,6 +27,19 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
// #263: пикер называет себя (`_bindingCandidates`) и потому находится
// прямым совпадением. Регистрируются только чистые помощники надгробий:
// смок не произносит ни одного их имени — он видит лишь список, который
// они отфильтровали. Проверено зондом: правка одного `src/devices.ts` без
// этой записи не выбирает ни одного смока.
symbols: ['removedPlanBindings', 'isRemovedPlanEntity', 'deletePlanMarkerRecords'],
smokes: ['smoke_binding_picker.mjs'],
because: 'смок проверяет, что удалённая привязка снова предлагается в списке «Добавить», '
+ 'что отдельные сущности устройств живут за галкой и что надгробие устройства '
+ 'по-прежнему прячет его дочерние сущности (#262) — всё это следствия фильтров '
+ 'надгробий, чьи имена в браузерный сценарий не попадают',
},
{
// #258: the browser sees only the resulting path/caches; it cannot call
// the pure identity helpers by name through the production bundle.