Files
houseplan-card/demo/smoke_device_settings_form.mjs
2026-09-23 15:27:51 +03:00

283 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #600, серия 4: «Устройство на плане» по референсу docs/design/600-settings-dialogs/
// (§7 SPEC.md). Фокусный свидетель формы: состав карточек и отсутствие старой
// разметки (AC1), оболочка и полосы экранов (AC2), привязка сегментом и кнопкой
// выбора с «Show entities» внутри панели (Q8), «Ask for confirmation» только у
// действий (Q8), сегменты роли/свечения/стороны бейджа и плашки цвета (Q5),
// блок свечения отключается целиком с объяснением, Display preview целиком в
// блоке на тинте (Q3), размеры с числом, чипы источников, Save только при
// изменениях и вопрос при закрытии (AC6/К10), цели и семантика (AC9).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1280, height: 900 });
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; await new Promise((r) => setTimeout(r, 30)); };
const dlg = () => sr().querySelector('hp-dialog[data-kind="marker"]');
const q = (sel) => dlg()?.querySelector(sel);
const qa = (sel) => [...(dlg()?.querySelectorAll(sel) || [])];
const saveBtn = () => q('.dialog-action-commit [data-hp="dialog-confirm"]');
const statusText = () => q('.hpf-status')?.textContent.trim() || '';
const input = (el, value) => { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); };
const change = (el) => el.dispatchEvent(new Event('change', { bubbles: true }));
const pickSeg = async (name, value) => {
const r = qa(`input[name="${name}"]`).find((x) => x.value === value);
r.checked = true; r.dispatchEvent(new Event('change', { bubbles: true })); await upd();
};
const select = async (el, value) => { el.value = value; el.dispatchEvent(new Event('change', { bubbles: true })); await upd(); };
c._setMode('devices'); await upd();
const lamp = c._devices.find((d) => d.id === 'd_light1') || c._devices.find((d) => d.bindingKind === 'device');
c._openMarkerDialog(lamp); await upd();
const d0 = c._markerDialog;
// --- AC1: пять карточек по §7.1, старой разметки нет --------------------
o.fiveCardsInOrder = JSON.stringify(qa('.hpf-card').map((card) => card.dataset.card))
=== JSON.stringify(['basics', 'tap', 'light', 'appearance', 'details']);
o.noLegacyMarkup = qa('.srcrow, .namein, .areasel, .dropbtn, .droppanel, .candlist, fieldset, .markerlightgroup, .markerradios, '
+ '.colorrow, .habindingbanner, .bindsel, .bindharow, .entcheck, .ctrlchips, .ctrllist, .ctrlopt, .opl, .opv, .descin, .tempin, '
+ '.markerhelpfield, .markerhelplabel, .helpfieldlabel, .rhint, .markerlightdisabled, .markerlightwarning').length === 0;
o.badgeShowsSpaceTitle = dlg().badge === c._spaceModelById(lamp.space)?.title && !!dlg().shadowRoot.querySelector('.badge');
o.shellIs560 = dlg().hasAttribute('form-shell') && Math.round(dlg().shadowRoot.querySelector('.surface').getBoundingClientRect().width) === 560;
o.bodyHasNoOwnScroller = getComputedStyle(q('.body')).overflowY === 'visible' && getComputedStyle(q('.body')).maxHeight === 'none';
o.footerGroups = !!q('.markeractions .btn.danger') && q('.markeractions').getBoundingClientRect().left < saveBtn().getBoundingClientRect().left;
// --- Basics: имя с подсказкой, сегмент привязки, кнопка выбора, «Show entities» в панели (Q8)
o.nameFieldWithoutRedundantHint = !!q('#marker-name.hpf-input')
&& !q('#marker-name')?.closest('.hpf-field')?.querySelector('.hpf-hint');
o.bindingIsASegment = qa('input[name="bmode"]').length === 2 && q('input[name="bmode"]:checked')?.value === 'ha'
&& !!q('input[name="bmode"]')?.closest('.hpf-seg[role="radiogroup"]');
o.bindingButtonShowsCurrent = !!q('#marker-binding.hpf-pick b') && q('#marker-binding').getAttribute('aria-expanded') === 'false'
&& !q('#marker-show-entities');
q('#marker-binding').click(); await upd();
o.panelHoldsSearchAndShowEntities = !!q('.hpf-card[data-card="basics"] .hpf-panel .hpf-toolbar input.hpf-input')
&& !!q('.hpf-card[data-card="basics"] .hpf-panel #marker-show-entities')
&& qa('.hpf-card[data-card="basics"] .hpf-cand[role="option"]').length > 0
&& getComputedStyle(q('.hpf-card[data-card="basics"] .hpf-panel')).position !== 'absolute';
q('#marker-show-entities').click(); await upd();
o.showEntitiesWritesOwnKey = c._markerDialog.showEntities === !d0.showEntities && c._markerDialog.binding === d0.binding;
q('#marker-binding').click(); await upd();
o.buttonClosesPanel = !q('.hpf-card[data-card="basics"] .hpf-panel') && c._markerDialog.bindingOpen === false;
o.roomIsASelectWithHint = !!q('#marker-room.hpf-select') && !!q('#marker-room')?.closest('.hpf-field')?.querySelector('.hpf-hint');
// --- Tap action: подтверждение только у действий (Q8), чипы источников -----
o.tapActionCardHasNoLargeHeading = !q('.hpf-card[data-card="tap"] > .hpf-head');
await select(q('#marker-tap-action'), 'none');
o.noConfirmForDoNothing = !q('#marker-tap-confirm') && !q('#marker-toggle-hint');
await select(q('#marker-tap-action'), 'toggle');
o.confirmToggleRowForToggle = !!q('#marker-tap-confirm') && !!q('#marker-tap-confirm')?.closest('.hpf-toggle')
&& !!q('#marker-toggle-hint');
q('#marker-tap-confirm').click(); await upd();
o.confirmWritesOwnKey = c._markerDialog.tapConfirm === true && c._markerDialog.tapAction === 'toggle';
input(q('#marker-controls-filter'), 'l'); await upd();
const controlCands = qa('.hpf-card[data-card="tap"] .hpf-panel .hpf-cand');
o.controlsSearchOpensPanelInFlow = controlCands.length > 0 && !q('.ctrllist');
const controlsBefore = c._markerDialog.controls.length;
controlCands[0].click(); await upd();
o.controlPickAddsAChip = c._markerDialog.controls.length === controlsBefore + 1
&& qa('.hpf-card[data-card="tap"] .hpf-chips .hpf-chip').length === c._markerDialog.controls.length;
q('.hpf-card[data-card="tap"] .hpf-chip button').click(); await upd();
o.chipRemoveWritesControls = c._markerDialog.controls.length === controlsBefore;
// --- Light and glow: сегменты, блок свечения отключается целиком -----------
o.roleIsASegmentWithAutoHint = qa('input[name="marker-light-role"]').length === 3
&& !!q('input[name="marker-light-role"]')?.closest('.hpf-seg[role="radiogroup"]')
&& (c._markerDialog.lightRole !== 'auto' || !!q('input[name="marker-light-role"]')?.closest('.hpf-field')?.querySelector('.hpf-hint'));
await pickSeg('marker-light-role', 'never');
o.neverDisablesGlowBlock = c._markerDialog.lightRole === 'never'
&& q('.markerglowblock')?.classList.contains('hpf-disabled') && q('.markerglowblock').getAttribute('aria-disabled') === 'true'
&& qa('input[name="marker-glow-mode"]').every((r) => r.disabled) && q('#marker-glow-radius')?.disabled === true
&& !!q('#marker-glow-disabled-hint.hpf-callout')
&& q('input[name="marker-glow-mode"]').getAttribute('aria-describedby') === 'marker-glow-disabled-hint'
&& !q('.markerglowblock hp-help button[disabled]');
await pickSeg('marker-light-role', 'always');
await pickSeg('marker-glow-mode', 'fixed');
o.fixedShowsColorPlateAndBrightness = c._markerDialog.glowMode === 'fixed'
&& !!q('.markerglowvalue .hpf-colorfield hp-color-opacity[hide-label]') && !!q('.markerglowvalue .hpf-hex')
&& !!q('#marker-glow-brightness') && qa('input[type="color"]').length === 0
&& !q('.markerglowblock.hpf-disabled');
input(q('#marker-glow-brightness'), '42'); change(q('#marker-glow-brightness')); await upd();
o.brightnessNumberWrites = c._markerDialog.glowBrightness === 42 && c._markerDialog.glowTouched === true;
o.radiusHintWhenEmpty = c._markerDialog.glowRadius === '' && !!q('#marker-glow-radius')?.closest('.hpf-field')?.querySelector('.hpf-hint');
// Ревью r1 M2: радиус — не условие Save. Нечисло или ноль, как на dev, при
// сохранении молча становится общим радиусом; поле не помечается ошибкой.
c._markerDialog = { ...c._markerDialog, glowRadius: 'x' }; await upd();
o.badRadiusDoesNotBlockSave = saveBtn().disabled === false && !q('#marker-glow-radius[aria-invalid="true"]')
&& !q('.hpf-card[data-card="light"] .hpf-error') && !q('.hpf-status .hpf-link');
input(q('#marker-glow-radius'), '2.5'); await upd();
o.radiusWrites = c._markerDialog.glowRadius === '2.5' && saveBtn().disabled === false;
// «Review N fields» ведёт к первому ошибочному полю — здесь к привязке (create-режим ниже проверяет ссылку).
input(q('#marker-glow-radius'), '0'); await upd();
o.zeroRadiusKeepsSaveEnabled = saveBtn().disabled === false;
input(q('#marker-glow-radius'), ''); await upd();
// --- Appearance: иконка, display, бейдж, превью (Q3), размеры ----------------
o.iconFieldHasPreview = !!q('#marker-icon') && !!q('.hpf-iconfield .hpf-iconpreview ha-icon');
const pin = q('.iconauto .hpf-link');
o.autoIconHintHasPinLink = !c._markerDialog.icon ? !!pin : true;
if (pin) { pin.click(); await upd(); }
o.pinWritesIcon = !!c._markerDialog.icon && c._markerDialog.icon === c._markerDialog.autoIcon && !!q('.hpf-iconclear');
q('.hpf-iconclear').click(); await upd();
o.clearResetsIcon = c._markerDialog.icon === '';
o.displaySelectWithHint = !!q('#marker-display.hpf-select') && !!q('#marker-display')?.closest('.hpf-field')?.querySelector('.hpf-hint');
o.badgeBlock = !!q('.markerbadgegroup.hpf-block .hpf-sub h4') && !!q('#marker-value-badge')?.closest('.hpf-toggle.hpf-compact');
if (!q('#marker-value-badge').checked && !q('#marker-value-badge').disabled) { q('#marker-value-badge').click(); await upd(); }
o.badgePositionIsASegment = qa('input[name="marker-value-badge-position"]').length === 4;
await pickSeg('marker-value-badge-position', 'top');
o.badgePositionWrites = c._markerDialog.valueBadgePosition === 'top' && c._markerDialog.valueBadgeTouched === true;
// Q3: hp-device-preview целиком, без второго заголовка над ним (у компонента свой)
o.previewIsTheComponentItself = !!q('.hpf-card[data-card="appearance"] hp-device-preview')
&& !q('.hpf-card[data-card="appearance"] .hpf-tint hp-device-preview');
o.sizeRowsInAGrid = !!q('.hpf-card[data-card="appearance"] .hpf-grid #marker-size') && !!q('.hpf-card[data-card="appearance"] .hpf-grid #marker-angle');
o.sizeAndAngleFieldsDoNotCoverSliders = ['#marker-size', '#marker-angle'].every((selector) => {
const number = q(selector);
const range = number.closest('.hpf-range');
const slider = range.querySelector('ha-slider, input[type="range"]');
const sliderBox = slider.getBoundingClientRect();
const unitBox = number.closest('.hpf-unit').getBoundingClientRect();
return sliderBox.right < unitBox.left && number.scrollWidth <= number.clientWidth
&& unitBox.right <= range.getBoundingClientRect().right + 3;
});
input(q('#marker-size'), '2'); change(q('#marker-size')); await upd();
input(q('#marker-angle'), '35'); change(q('#marker-angle')); await upd();
o.sizeAndAngleNumbersWrite = c._markerDialog.size === 2 && c._markerDialog.angle === 35;
// --- Details: Model и Link в ряд, описание, вложения ------------------------
const modelBox = q('#marker-model').getBoundingClientRect();
const linkBox = q('#marker-link').getBoundingClientRect();
o.modelAndLinkInOneRow = Math.abs(modelBox.top - linkBox.top) < 2 && linkBox.left > modelBox.right;
o.descriptionIsAKitTextarea = q('#marker-description')?.tagName === 'TEXTAREA' && q('#marker-description').classList.contains('hpf-input');
o.attachInActions = !!q('.hpf-card[data-card="details"] .hpf-actions .filebtn');
const radarToggle = q('#marker-radar-presence');
o.radarEntryIsAToggleInAdditionalActions = !!radarToggle
&& !!radarToggle.closest('.hpf-card[data-card="details"] .radaradditional')
&& !q('.hpf-card[data-card="basics"] .radargroup');
if (radarToggle && !radarToggle.checked) { radarToggle.click(); await upd(); }
const firstRadarDraft = c._markerDialog.radar;
const firstInstallation = firstRadarDraft?.installationId;
o.radarExpandsImmediatelyBelowToggle = !!firstRadarDraft && !c._markerDialog.radarRemove
&& !!q('#marker-radar-presence')?.closest('.radaradditional')?.querySelector(':scope > .radargroup')
&& !q('.hpf-card[data-card="basics"] .radargroup');
q('#marker-radar-presence')?.click(); await upd();
o.radarOffKeepsSessionDraft = c._markerDialog.radar === firstRadarDraft
&& c._markerDialog.radarRemove === true && !q('.radaradditional > .radargroup');
q('#marker-radar-presence')?.click(); await upd();
o.radarOnRestoresSessionDraft = c._markerDialog.radar === firstRadarDraft
&& c._markerDialog.radar?.installationId === firstInstallation
&& c._markerDialog.radarRemove === false && !!q('.radaradditional > .radargroup');
input(q('#marker-model'), 'Model 600'); await upd();
o.modelWritesOwnKey = c._markerDialog.model === 'Model 600' && c._markerDialog.link === d0.link;
// --- AC9: цели ≥ 44 ---------------------------------------------------------
o.toggleTargetsAre44 = qa('.hpf-toggle > input[type="checkbox"]').every((i) => { const b = i.getBoundingClientRect(); return b.width >= 44 && b.height >= 44; });
o.segmentsAreRadiogroups = qa('.hpf-seg').length >= 4 && qa('.hpf-seg').every((s) => s.getAttribute('role') === 'radiogroup' && s.getAttribute('aria-label'));
// --- К10: Save только при изменениях, вопрос при закрытии --------------------
c._closeMarkerDialog(); await upd();
c._openMarkerDialog(lamp); await upd();
o.saveDisabledWhenClean = saveBtn().disabled === true && statusText() === '';
const editName0 = c._markerDialog.name;
input(q('#marker-name'), `${editName0} x`); await upd();
o.saveEnabledWhenDirtyWithoutDuplicateStatus = saveBtn().disabled === false && statusText() === '';
input(q('#marker-name'), editName0); await upd();
o.cleanAgainWhenReverted = saveBtn().disabled === true && statusText() === '';
input(q('#marker-name'), `${editName0} staged`); await upd();
const runtime = c._editorRuntime;
const saveMarker = runtime._saveMarker;
runtime._saveMarker = async () => undefined;
saveBtn().click(); await upd();
runtime._saveMarker = saveMarker;
input(q('#marker-name'), editName0); await upd();
o.saveAttemptDoesNotForgetBaseline = saveBtn().disabled === true;
q('.markeractions .btn:not(.danger)').click(); await upd();
o.hideMakesDirty = c._markerDialog.hideFromPlan === true && saveBtn().disabled === false;
dlg().dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
await upd(); await new Promise((r) => setTimeout(r, 60));
const confirm = () => sr().querySelector('hp-confirm hp-dialog');
o.discardAsksFirst = !!confirm() && !!dlg();
confirm()?.querySelector('[data-hp="dialog-cancel"]')?.click(); await upd(); await new Promise((r) => setTimeout(r, 60));
o.keepEditingKeepsDialog = !!dlg() && !confirm() && c._markerDialog?.hideFromPlan === true;
dlg().dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
await upd(); await new Promise((r) => setTimeout(r, 60));
confirm()?.querySelector('[data-hp="dialog-confirm"]')?.click(); await upd(); await new Promise((r) => setTimeout(r, 60));
o.discardCloses = !dlg() && c._markerDialog === null && lamp.marker?.hidden !== true;
c._openMarkerDialog(lamp); await upd();
dlg().dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
await upd(); await new Promise((r) => setTimeout(r, 60));
o.cleanClosesImmediately = !dlg() && !confirm();
// --- create-режим: обязательные поля видны inline до вызова save ----------
c._openMarkerDialog(); await upd();
o.createHasNoHideDelete = !q('.markeractions .btn');
o.virtualNameIsAnInlineProblem = saveBtn().disabled === true
&& q('#marker-name').getAttribute('aria-invalid') === 'true'
&& !!q('#marker-name').closest('.hpf-field').querySelector('.hpf-error')
&& /1/.test(q('.hpf-status .hpf-link').textContent);
await pickSeg('bmode', 'ha');
o.createHaWithoutBindingIsAProblem = saveBtn().disabled === true && !!q('.hpf-status .hpf-link')
&& !!q('.hpf-card[data-card="basics"] .hpf-error') && q('#marker-binding').getAttribute('aria-expanded') === 'true';
q('.hpf-status .hpf-link').click(); await upd();
o.reviewLinkFocusesBinding = (sr().activeElement ?? document.activeElement)?.id === 'marker-binding';
await pickSeg('bmode', 'virtual');
o.createVirtualHasHint = c._markerDialog.binding === 'virtual' && saveBtn().disabled === true
&& !!q('input[name="bmode"]')?.closest('.hpf-field')?.querySelector('.hpf-hint');
input(q('#marker-name'), 'Virtual sensor'); await upd();
o.virtualNameUnblocksSave = saveBtn().disabled === false && !q('#marker-name[aria-invalid="true"]');
await select(q('#marker-tap-action'), 'run');
o.runWithoutTargetIsInline = saveBtn().disabled === true
&& q('#marker-run-target').getAttribute('aria-invalid') === 'true'
&& !!q('#marker-run-target').closest('.hpf-field').querySelector('.hpf-error');
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.demo', runFilter: '' }; await upd();
o.runTargetUnblocksSave = saveBtn().disabled === false;
c._markerDialog = {
...c._markerDialog, valueBadgeTouched: true, valueBadgeEnabled: true, valueBadgeSource: null,
}; await upd();
o.badgeWithoutSourceIsInline = saveBtn().disabled === true
&& q('#marker-value-badge-source').getAttribute('aria-invalid') === 'true'
&& !!q('#marker-value-badge-source').closest('.hpf-field').querySelector('.hpf-error');
c._markerDialog = null; await upd();
return o;
});
// --- AC2: полосы экранов ------------------------------------------------------
const bands = {};
for (const width of [320, 390, 480, 560, 768, 1280, 1920]) {
await page.setViewportSize({ width, height: 900 });
const m = await page.evaluate(async (w) => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._markerDialog = null; c.requestUpdate(); await c.updateComplete;
const lamp = c._devices.find((d) => d.id === 'd_light1') || c._devices.find((d) => d.bindingKind === 'device');
c._openMarkerDialog(lamp); c.requestUpdate(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, lightRole: 'always', glowMode: 'fixed', display: 'icon_ripple' };
c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 60));
const dlg = sr().querySelector('hp-dialog[data-kind="marker"]');
const surface = dlg.shadowRoot.querySelector('.surface');
const content = dlg.shadowRoot.querySelector('.content');
const body = dlg.querySelector('.body');
const sw = Math.round(surface.getBoundingClientRect().width);
const expected = w >= 600 ? sw === 560 : w > 480 ? sw <= Math.round(w * 0.94) + 1 : sw === w;
const noHorizontal = content.scrollWidth <= content.clientWidth + 1 && body.scrollWidth <= body.clientWidth + 1;
const gridCols = getComputedStyle(dlg.querySelector('.hpf-card[data-card="details"] .hpf-grid')).gridTemplateColumns.split(' ').length;
const footer = dlg.querySelector('.dialog-action-footer');
const footerVisible = footer.getBoundingClientRect().bottom <= innerHeight + 1;
const segFits = [...dlg.querySelectorAll('.hpf-seg label')].every((l) => {
const b = l.getBoundingClientRect(); return b.right <= content.getBoundingClientRect().right + 1;
});
c._markerDialog = null; c.requestUpdate(); await c.updateComplete;
return { sw, expected, noHorizontal, gridCols, footerVisible, segFits };
}, width);
bands[`w${width}_surfaceWidth`] = m.expected;
bands[`w${width}_noHorizontalScroll`] = m.noHorizontal;
bands[`w${width}_footerVisible`] = m.footerVisible;
bands[`w${width}_detailsColumns`] = m.gridCols === (width <= 480 ? 1 : 2);
bands[`w${width}_segmentsFit`] = m.segFits;
}
checkAll(out);
checkAll(bands);
await finish(browser, { ...out, ...bands });