Files
houseplan-card/demo/smoke_device_inbox_batch.mjs
Claude d17bcc05fb feat(devices): пакетное «Скрыть/Показать выбранные» в каталоге устройств (#618)
На вкладках «На плане» и «Скрытые» — флажки строк, «Выбрать все (N)» по
всему отфильтрованному набору (включая строки за «Показать ещё»), панель
«Выбрано: K · Скрыть/Показать выбранные (K) · Снять выбор» в отдельном
контейнере .device-inbox-batch вне .device-inbox-filters.

- src/device-inbox.ts: чистые inboxVisibilityAllowed (один источник для
  canHide/canShow и выбора), selectableInboxRows, effectiveInboxSelection,
  applyInboxVisibility — свёртка одиночных действий; одиночное = пакет из
  одной строки.
- src/device-inbox-batch.ts (новый, lazy editor): выбор, панель, строка-
  флажок и writeInboxVisibility — одна запись houseplan/config/set с
  expected_rev на пакет, busy='__batch__' (inert), откат маркеров при отказе,
  выбор сохраняется и сужается. Одиночный _setInboxHidden рантайма — тонкая
  обёртка над той же записью; рантайм стал короче базы на 14 строк.
- сброс выбора при смене вкладки (клик и стрелка), поиска и «Только новые».
- i18n en/ru/de/fr: 9 ключей по ТЗ §8.
- смок demo/smoke_device_inbox_batch.mjs (AC1–AC8, AC10), юниты
  test/device-inbox.test.mjs (AC2, AC4), 4 мутанта в реестре.
- docs: USER-GUIDE ru/en, FILTERING.md, оба changelog.
- monolith-baseline: hostRefs 4948→4959, bundleBytes 2500387→2505569 —
  новая фича (состояние выбора и запись идут через порт хоста).
- bundle-budget: LAZY_EDITOR_GZIP_CEILING 245 400 → 246 600 (замер 245 610,
  +1 190 Б gzip от базы 244 420); initial View 289 449 → 289 765 (+316 Б,
  строки en-словаря), в пределах потолка.

Golden device-inbox-* изменятся ожидаемо — пересъёмка предрелизным гейтом
на Linux CI (§11.4).

Issue: #618
User-Visible: yes
2026-09-24 22:50:30 +03:00

271 lines
13 KiB
JavaScript

// #618: batch Hide/Show in the Devices catalog. Selection is read-only, a
// batch of K rows is exactly one `houseplan/config/set`, a failed write
// changes nothing and keeps the selection, and the selection lives only on
// the "On plan" / "Hidden" tabs.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 980, height: 820 });
const out = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.renderRoot || c.shadowRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const settle = async () => { await c.updateComplete; await frame(); await c.updateComplete; };
const result = {};
// Let the seeder and any debounced background write finish first.
const t0 = Date.now();
while (!c._serverCfg?.settings?.filter_seeded && Date.now() - t0 < 3000) {
await new Promise((resolve) => setTimeout(resolve, 60));
}
await new Promise((resolve) => setTimeout(resolve, 650));
c._saveConfigDebounced?.flush?.();
await c._writeChain;
const realWS = c.hass.callWS;
let configWrites = 0;
let otherWrites = 0;
let gate = null;
let reject = null;
let lastSet = null;
// The demo backend acknowledges config/set without storing it; remember the
// last accepted document so a conflict re-read returns the real server state.
let accepted = null;
const countingWS = async (message) => {
if (message.type === 'houseplan/config/set') {
configWrites += 1;
lastSet = message;
if (gate) await gate.promise;
if (reject) { const error = reject; reject = null; throw error; }
const response = await realWS(message);
accepted = JSON.parse(JSON.stringify(message.config));
return response;
}
if (message.type === 'houseplan/config/get' && accepted) {
const response = await realWS(message);
return { ...response, config: JSON.parse(JSON.stringify(accepted)) };
}
if (/^houseplan\/(config|layout)\/(set|update|delete)$/.test(message.type)) {
otherWrites += 1;
}
return realWS(message);
};
c.hass = { ...c.hass, callWS: countingWS };
// A registry change arrives as a fresh hass object; keep the write counter.
const refreshHass = async () => {
c.hass = { ...window.__mkHass(), callWS: countingWS };
await new Promise((resolve) => setTimeout(resolve, 220));
await settle();
};
c._setMode('devices');
await settle();
c._openDeviceInbox();
await settle();
const tabButton = (index) => root().querySelectorAll('.device-inbox-tabs [role="tab"]')[index];
const tabCount = (index) => Number(tabButton(index)?.querySelector('span')?.textContent || NaN);
const batch = () => root().querySelector('.device-inbox-batch');
const selectAll = () => batch()?.querySelector('.device-inbox-select-all input');
const rowBox = (binding) => root()
.querySelector(`.device-inbox-row[data-binding="${binding}"] .device-inbox-select input`);
const click = async (node) => { node?.click(); await settle(); };
const selected = () => [...(c._deviceInbox?.selected || [])];
const snapshot = () => JSON.stringify({
config: c._serverCfg, layout: c._layout, cfgRev: c._cfgRev, layoutRev: c._layoutRev,
});
// ---- AC1: only "On plan" and "Hidden" carry selection ---------------------
const presence = [];
for (let index = 0; index < 4; index++) {
await click(tabButton(index));
presence.push({
tab: c._deviceInbox?.tab,
batch: !!batch(),
boxes: root().querySelectorAll('.device-inbox-select input').length,
rows: root().querySelectorAll('.device-inbox-row').length,
});
}
const byTab = Object.fromEntries(presence.map((item) => [item.tab, item]));
result.batchOnlyOnTwoTabs = byTab.on_plan?.batch && byTab.on_plan.boxes === byTab.on_plan.rows
&& byTab.on_plan.rows > 3
&& !byTab.available?.batch && byTab.available?.boxes === 0
&& !byTab.readd?.batch && byTab.readd?.boxes === 0
&& byTab.hidden?.batch === true;
await click(tabButton(0));
// smoke_hidden_flag takes the LAST checkbox in .device-inbox-filters.
const filterBoxes = [...root().querySelectorAll('.device-inbox-filters input[type="checkbox"]')];
result.batchLivesOutsideFilters = !root().querySelector('.device-inbox-filters .device-inbox-batch')
&& !!filterBoxes.at(-1)?.closest('.device-inbox-filter-help');
// ---- AC8: selecting is read-only -----------------------------------------
const before = snapshot();
const onPlan = c._deviceInboxRows().filter((row) => row.category === 'on_plan' && row.canHide);
const [a, b, d, e] = onPlan.map((row) => row.binding);
await click(rowBox(a));
await click(rowBox(b));
result.rowSelectionWorks = selected().length === 2 && !!root().querySelector('.device-inbox-batch-actions')
&& /Selected: 2/.test(batch()?.textContent || '');
await click(selectAll());
result.selectAllSelectsEveryEligibleRow = selected().length === onPlan.length
&& new RegExp(`Select all \\(${onPlan.length}\\)`).test(batch()?.textContent || '');
await click(root().querySelector('.device-inbox-batch-clear'));
result.clearSelectionEmpties = selected().length === 0 && !root().querySelector('.device-inbox-batch-actions');
result.selectionIsReadOnly = snapshot() === before && configWrites === 0 && otherWrites === 0;
// ---- AC6: selection resets on tab / arrow / search / New only ------------
await click(rowBox(a));
await click(tabButton(2));
result.selectionResetOnTabClick = selected().length === 0;
await click(tabButton(0));
await click(rowBox(a));
root().querySelector('.device-inbox-tabs')
.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
await settle();
result.selectionResetOnArrow = c._deviceInbox?.tab === 'available' && selected().length === 0;
await click(tabButton(0));
await click(rowBox(a));
const search = root().querySelector('.device-inbox-search');
search.value = 'x';
search.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await settle();
result.selectionResetOnSearch = selected().length === 0;
search.value = '';
search.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await settle();
await click(rowBox(a));
const onlyNew = root().querySelector('.device-inbox-filters input[type="checkbox"]');
await click(onlyNew);
result.selectionResetOnOnlyNew = selected().length === 0;
if (c._deviceInbox?.onlyNew) await click(root().querySelector('.device-inbox-filters input[type="checkbox"]'));
await click(rowBox(a));
await click(rowBox(b));
const nestedRow = c._deviceInboxRows().find((row) => row.binding === d);
c._openInboxMarker(nestedRow);
await settle();
const nestedOpen = !!c._markerDialog && !c._deviceInbox;
c._closeMarkerDialog();
await settle();
result.selectionSurvivesNestedDialog = nestedOpen
&& JSON.stringify(selected().sort()) === JSON.stringify([a, b].sort())
&& rowBox(a)?.checked === true;
await click(root().querySelector('.device-inbox-batch-clear'));
// ---- AC3 + AC7: hide 3 in one write, catalog inert while writing ----------
const hiddenBefore = tabCount(2);
const onPlanBefore = tabCount(0);
for (const binding of [a, b, d]) await click(rowBox(binding));
const writesBeforeHide = configWrites;
let release;
gate = { promise: new Promise((resolve) => { release = resolve; }) };
root().querySelector('.device-inbox-batch-apply').click();
await c.updateComplete;
while (configWrites === writesBeforeHide) await new Promise((resolve) => setTimeout(resolve, 5));
await c.updateComplete;
result.catalogInertWhileWriting = root().querySelector('.device-inbox')?.hasAttribute('inert') === true
&& c._deviceInbox?.busy === '__batch__';
gate = null;
release();
while (c._deviceInbox?.busy) await new Promise((resolve) => setTimeout(resolve, 10));
await settle();
const hideToast = c._toast;
const markerHidden = (binding) => (c._serverCfg.markers || [])
.filter((marker) => marker.binding === binding && !marker.removed);
result.hideIsOneWrite = configWrites - writesBeforeHide === 1
&& typeof lastSet?.expected_rev === 'number';
result.hideAppliedToAll = [a, b, d].every((binding) => {
const live = markerHidden(binding);
return live.length === 1 && live[0].hidden === true;
}) && markerHidden(e).every((marker) => marker.hidden !== true);
result.hideToastCountsRows = hideToast === 'Hidden: 3';
result.selectionClearedAfterSuccess = selected().length === 0
&& root().querySelector('.device-inbox')?.hasAttribute('inert') === false;
result.tabCountersUpdated = tabCount(2) === hiddenBefore + 3 && tabCount(0) === onPlanBefore - 3;
// Show 2 of the 3 on "Hidden": total two writes, one of the three stays.
await click(tabButton(2));
await click(rowBox(a));
await click(rowBox(b));
result.showButtonNamesCount = /Show selected \(2\)/.test(batch()?.textContent || '');
root().querySelector('.device-inbox-batch-apply').click();
await c.updateComplete;
while (c._deviceInbox?.busy) await new Promise((resolve) => setTimeout(resolve, 10));
await settle();
result.showIsOneWrite = configWrites - writesBeforeHide === 2;
result.showToastCountsRows = c._toast === 'Shown: 2';
const hiddenNow = c._deviceInboxRows().filter((row) => row.category === 'hidden').map((row) => row.binding);
result.oneOfThreeStaysHidden = hiddenNow.includes(d) && !hiddenNow.includes(a) && !hiddenNow.includes(b)
&& markerHidden(a).length === 1 && markerHidden(a)[0].hidden === false;
// ---- AC2: an HA-disabled row is listed but not selectable nor counted ----
// (both tabs; the rows keep a marker, so HA deactivation keeps them listed)
const idOf = (binding) => binding.slice(binding.indexOf(':') + 1);
const selectAllCount = () => Number((batch()?.querySelector('.device-inbox-select-all')?.textContent || '')
.match(/\((\d+)\)/)?.[1] ?? NaN);
const inactiveChecks = [];
for (const [tabIndex, binding] of [[2, d], [0, a]]) {
await click(tabButton(tabIndex));
const countBefore = selectAllCount();
window.__setRegistryDisabled('device', idOf(binding), 'user');
await refreshHass();
const box = rowBox(binding);
inactiveChecks.push(!!box && box.disabled && !!box.closest('label')?.title
&& selectAllCount() === countBefore - 1
&& c._deviceInboxRows().find((row) => row.binding === binding)?.status.kind === 'ha_disabled');
await click(selectAll());
inactiveChecks.push(!selected().includes(binding) && selected().length === countBefore - 1);
await click(root().querySelector('.device-inbox-batch-clear'));
window.__setRegistryDisabled('device', idOf(binding), null);
await refreshHass();
}
result.inactiveRowNotSelectable = inactiveChecks.length === 4 && inactiveChecks.every(Boolean);
await click(tabButton(2));
// ---- AC5: a rejected write changes nothing and keeps the selection -------
for (const [label, error] of [
['Error', new Error('batch rejected by test')],
['Conflict', Object.assign(new Error('revision conflict'), { code: 'conflict' })],
]) {
await click(rowBox(d));
const markersBefore = JSON.stringify(c._serverCfg.markers);
const writes = configWrites;
reject = error;
root().querySelector('.device-inbox-batch-apply').click();
await c.updateComplete;
while (configWrites === writes || c._deviceInbox?.busy) await new Promise((resolve) => setTimeout(resolve, 10));
await settle();
result[`rejected${label}KeepsMarkers`] = JSON.stringify(c._serverCfg.markers) === markersBefore
&& markerHidden(d)[0]?.hidden === true;
result[`rejected${label}ShowsError`] = /^Error:|Ошибка|error/i.test(c._toast || '')
&& c._toast !== 'Shown: 1';
result[`rejected${label}KeepsSelection`] = JSON.stringify(selected()) === JSON.stringify([d])
&& rowBox(d)?.checked === true;
result[`rejected${label}CatalogActiveAgain`] = !c._deviceInbox?.busy
&& root().querySelector('.device-inbox')?.hasAttribute('inert') === false;
await click(root().querySelector('.device-inbox-batch-clear'));
}
// ---- AC10: no horizontal overflow on desktop -----------------------------
await click(tabButton(0));
await click(rowBox(e));
const dialog = root().querySelector('hp-dialog');
result.noHorizontalOverflow = !!dialog && dialog.scrollWidth <= dialog.clientWidth + 1;
return result;
});
// Narrow viewport: the selection panel wraps instead of overflowing.
await page.setViewportSize({ width: 390, height: 820 });
out.noHorizontalOverflowNarrow = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.renderRoot || c.shadowRoot;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await c.updateComplete;
const dialog = root().querySelector('hp-dialog');
const inbox = root().querySelector('.device-inbox');
return !!dialog && !!root().querySelector('.device-inbox-batch-actions')
&& dialog.scrollWidth <= dialog.clientWidth + 1 && inbox.scrollWidth <= inbox.clientWidth + 1;
});
checkAll(out);
await finish(browser, out);