mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
На вкладках «На плане» и «Скрытые» — флажки строк, «Выбрать все (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
271 lines
13 KiB
JavaScript
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);
|