Files
houseplan-card/demo/smoke_ha_controls.mjs
Claude bf6602bbd8 feat(device): «Устройство на плане» по референсу — серия 4 из четырёх (#600)
§7 SPEC.md референса. Пять карточек #598 остались, менялось содержимое; все
ключи _markerDialog и все записи в них прежние (§7.2):

- Basics — баннеры привязки стали callout'ами; Name с подсказкой «Shown on
  the plan»; привязка — сегмент [Virtual | From the HA list] (те же радио
  bmode) и кнопка выбора #marker-binding с панелью в потоке (sourcePicker
  набора), чекбокс «Show entities» — внутри панели рядом с поиском (решение
  владельца Q8); Room селектом с подсказкой «по зоне / переопределяет»;
  секции радара и пылесоса — своими модулями без изменений;
- Tap action — селект с подсказкой Target/Now, «Ask for confirmation» —
  строка-тумблер только при toggle/run (Q8), Entity to toggle и What to run
  теми же условиями (список целей — панель набора), «Controls other light
  sources» — чипы набора и поиск с панелью в потоке;
- Light and glow — «Include the device temperature…» строкой-тумблером;
  роль — сегмент [Auto | Always | Never] с подсказкой результата Auto;
  Leading light entity по условиям; блок свечения (.hpf-block): подзаголовок
  с «?», сегмент [From source | Set colour | Colour and brightness] с
  отключёнными вариантами и aria-describedby на объяснение, плашка цвета
  вокруг прежнего hp-color-opacity (Q5), яркость и радиус строками с
  числом и единицей, при Never блок неактивен целиком (aria-disabled) с
  callout'ом причины;
- Appearance — поле иконки с превью, очисткой и ссылкой Pin в подсказке;
  Display селектом с подсказкой режима; пульс — плашка цвета и строка
  размера; Value source по условиям; подраздел Value badge — компактный
  тумблер, Value селектом с техническим идентификатором, Position сегментом
  [Above | Right | Below | Left]; предупреждения бейджа — заметки и
  callout'ы; Display preview — прежний hp-device-preview целиком в блоке на
  тинте с тегом Now (решение владельца Q3); «Icon size and rotation» —
  две строки range-line в сетке;
- Details — Model и Link в ряд, Description textarea набора, Manuals чипами
  и строкой действий Attach…, «Additional actions» подзаголовком набора.

Футер: Hide и Delete слева, статус, Cancel и Save справа. К10: снимок
черновика при _openMarkerDialog (marker-form-state.ts поверх
dialog-baseline; транзиентные ключи — раскрытие и фильтры списков, busy,
объявление подсказки, autoIcon, uploadId); в edit Save только при
изменениях, закрытие с изменениями спрашивает через _confirmDanger. Прежние
запреты Save (busy, калибровка радара, привязка не выбрана/не подтверждена)
остались; привязка и радиус свечения названы словами под полем
(marker.error_binding, marker.error_glow_radius), «Review N fields» ведёт к
полю. Тумблеры диалога — строки набора (нативный чекбокс, К7), а не
ha-switch; слайдеры — прежний _rangeInput (ha-slider в HA).

Набор: sourcePicker получил toolbar и disabled; сегмент — value у радио,
disabled и describedBy у варианта; callout — role note и id; unitInput —
describedBy; rangeLine — id; новые классы .hpf-block/.hpf-disabled,
.hpf-toolbar/.hpf-check, .hpf-iconfield/.hpf-iconpreview, .hpf-mono,
.hpf-note, .hpf-hint-line, textarea.hpf-input. settingsCopy вынесен в
editors/settings-copy.ts (общий для пространства, комнаты, устройства).

Смоки переписаны осознанно, с пометкой #600: binding_ui (#marker-binding,
.hpf-panel, #marker-show-entities), tap_run (панель набора, #marker-tap-
confirm), value_face_source (.hpf-cand.sel), static_icon (callout'ы),
climate_temp (#marker-use-climate-temp), help_affordance (#marker-name,
.hpf-block.hpf-disabled вместо fieldset[disabled]; второй Escape в грязном
диалоге спрашивает — К10), size_angle_parity (сетка Appearance),
color_picker_consumers (подписи полей), device_preview_parity (сегмент
стороны), ha_controls (тумблеры набора — нативные чекбоксы по К7, ha-slider
прежний). Golden-harness device — .hpf-field/.hpf-block, скроллер .content.
Новый фокусный свидетель demo/smoke_device_settings_form.mjs: пять карточек
без старой разметки, оболочка, привязка/панель/Show entities, Q8, сегменты и
блок свечения, плашки, иконка, бейдж, превью на тинте, размеры, Details,
цели 44, Save/dirty/вопрос при закрытии, create-режим, семь полос экранов.

Мутанты: marker-save-enabled-without-changes, marker-discard-without-asking,
marker-confirm-row-shown-for-do-nothing, marker-never-keeps-glow-block-live,
marker-badge-position-forgets-touch; якоря same-binding-click-resets-source,
state-callout-hidden-under-help, form-kit-segment-drops-radio-semantics
переставлены на новую разметку.

Контрактные тесты: color-picker (два поля набора вместо flex-строк),
native-select-contract (сторона бейджа — сегмент), i18n dead-keys (34 → 38
подсказок), German=English (marker.name_label). Словарь i18n/settings: 21
ключ marker.* на четыре локали; в основном каталоге marker.name_label
«Name», marker.size_label «Icon size and rotation», удалены мёртвые
marker.room_override, marker.light_role_always/never. Фикстуры листа
диалогов набора пересняты намеренно (панель — байт в байт, AC11).

Бюджет: стартовый граф 290 489 (потолок 291 600 без изменений); ленивый
редакторский 243 173 — потолок 240 100 → 243 900 с разбором в
bundle-budget.mjs; старые стили диалога устройства снимаются в финале.

Гейты: typecheck, build, bundle:sync (три копии байт в байт), npm test
2790+ pass по четырём частям, mutation-gate --check, 39 смоков диалога
устройства + новый + три формы серий 1–3.

Issue: #600
User-Visible: no
2026-09-20 12:18:12 +03:00

176 lines
9.2 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.
// Native HA controls in dialogs (ha-slider) + the fallback, and the form-kit
// toggle that stays a native checkbox on purpose.
// The demo env has no HA frontend, so every OTHER smoke already runs the
// fallback branch (plain input[type=range]). Here we check both:
// 1) fallback: without ha-* the dialogs render the classic range input;
// 2) ha branch: after registering lightweight ha-switch/ha-slider stubs
// (checked/value + `change`/`input`, the real contract of the HA ones)
// a re-opened dialog renders ha-slider for ranges, values flow BOTH ways.
// #600: the device dialog's toggles are form-kit rows — a native checkbox
// drawn by CSS (К7: one look in HA, on the demo stand and in golden), so an
// ha-switch is NOT expected there any more; the checkbox still flows both ways.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// --- 1) fallback branch: no ha-* registered -> classic inputs ----------
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
out.fallbackCheckbox = !!sr().querySelector('hp-dialog .hpf-toggle > input[type=checkbox]');
out.fallbackRange = !!sr().querySelector('hp-dialog input[type=range]');
out.fallbackNoHaSwitch = !sr().querySelector('hp-dialog ha-switch');
c._markerDialog = null; await c.updateComplete;
// --- 2) register stubs mimicking the HA contract -----------------------
customElements.define('ha-switch', class extends HTMLElement {
#on = false;
get checked() { return this.#on; }
set checked(v) { this.#on = !!v; }
get disabled() { return this.hasAttribute('disabled'); }
set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
});
customElements.define('ha-slider', class extends HTMLElement {
#v = 0;
get value() { return this.#v; }
set value(v) { this.#v = Number(v); }
set min(_) {} set max(_) {} set step(_) {}
});
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, tapAction: 'toggle', tapActionTouched: true }; await c.updateComplete;
const toggles = () => [...sr().querySelectorAll('hp-dialog .hpf-toggle > input[type=checkbox]')];
out.kitTogglesRendered = toggles().length >= 2; // confirmation + value badge at minimum
out.kitToggleIsNotHaSwitch = !sr().querySelector('hp-dialog ha-switch');
out.lightRoleRadios = sr().querySelectorAll('hp-dialog input[name="marker-light-role"]').length === 3;
out.glowModeRadios = sr().querySelectorAll('hp-dialog input[name="marker-glow-mode"]').length === 3;
const sliders = [...sr().querySelectorAll('hp-dialog ha-slider')];
out.haSliderRendered = sliders.length >= 2; // size + angle
// --- 3) card -> control: state is pushed into the element --------------
c._markerDialog = { ...c._markerDialog, tapConfirm: true };
await c.updateComplete;
out.downstreamChecked = sr().querySelector('hp-dialog #marker-tap-confirm')?.checked === true;
// --- 4) control -> card: change with .checked lands in the dialog ------
const confirmBox = sr().querySelector('hp-dialog #marker-tap-confirm');
confirmBox.checked = false;
confirmBox.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.upstreamChecked = c._markerDialog.tapConfirm === false;
// --- 5) slider both ways ----------------------------------------------
const sizeBefore = c._markerDialog.size;
const sl = [...sr().querySelectorAll('hp-dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
out.downstreamValue = !!sl;
sl.value = 2;
sl.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
out.upstreamValue = c._markerDialog.size === 2;
// `change` (fired on release, HA-version dependent) reaches the same handler
sl.value = 2.5;
sl.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.upstreamValueChangeEvt = c._markerDialog.size === 2.5;
c._markerDialog = null; await c.updateComplete;
// --- 6) #88: the leading-entity selector is contextual ---------------
// Give one demo device a second controllable channel in the same registry
// shape that Home Assistant provides for real multi-channel relays.
const extra = 'switch.ceiling_aux';
c.hass = { ...c.hass, states: { ...c.hass.states,
[extra]: { entity_id: extra, state: 'on', attributes: { friendly_name: 'Ceiling auxiliary' } } } };
const multi = c._devices.find((device) => device.id === 'd_light1');
// The production registry adapter is covered by unit/backend tests. Stub
// only the transactional projection here so the smoke owns no shared
// registry singleton state and remains deterministic.
const runtime = c._editorRuntime;
const realPreview = runtime._markerPreviewDevice.bind(runtime);
runtime._markerPreviewDevice = (draft) => draft.devId === multi.id ? {
...multi,
entities: [multi.primary, extra],
marker: {
...(multi.marker || {}), id: multi.id, binding: `device:${multi.id}`,
is_light: draft.lightRole === 'always' ? true : null,
light_entity: draft.lightEntity || null,
},
} : realPreview(draft);
c._openMarkerDialog({
...multi,
marker: { ...(multi.marker || {}), is_light: true, light_entity: extra },
});
await c.updateComplete;
out.leadingDraftIsAlways = c._markerDialog.lightRole === 'always';
out.leadingPreviewHasExtra = runtime._markerPreviewDevice(c._markerDialog)?.entities?.includes(extra) === true;
const leading = sr().querySelector('hp-dialog #marker-light-entity');
out.leadingSelectorForMultiple = !!leading && leading.options.length === 3;
out.savedLeadingEntitySelected = leading?.value === extra
&& leading?.selectedOptions?.[0]?.value === extra;
if (leading) {
leading.value = multi.primary;
leading.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.leadingUpdatesPreview = c._markerDialog.lightEntity === multi.primary
&& c._markerSpatialSource(c._markerDialog)?.eid === multi.primary;
} else {
out.leadingUpdatesPreview = false;
}
c._markerDialog = null; await c.updateComplete;
runtime._markerPreviewDevice = realPreview;
const single = c._devices.find((device) => device.id === 'd_lamp');
c._openMarkerDialog(single); await c.updateComplete;
c._setMarkerLightRole('always'); await c.updateComplete;
out.noLeadingSelectorForSingle = !sr().querySelector('hp-dialog #marker-light-entity');
c._markerDialog = null; await c.updateComplete;
// --- 7) Rebinding keeps lossless marker:* links -----------------------
const rebindDevice = c._devices.find((item) => item.bindingKind !== 'virtual');
c._openMarkerDialog(rebindDevice); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, controls: ['marker:future-passive-lamp'] };
await c.updateComplete;
sr().querySelector('hp-dialog input[name="bmode"]')?.click();
await c.updateComplete;
out.rebindKeepsMarkerLink = c._markerDialog.controls.includes('marker:future-passive-lamp');
c._markerDialog = null; await c.updateComplete;
// --- 8) #84: passive Always keeps and persists manual Glow -----------
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'Passive lamp', bindingMode: 'virtual',
binding: 'virtual', lightRole: 'always', lightRoleTouched: true };
await c.updateComplete;
c._setMarkerGlowMode('fixed'); await c.updateComplete;
const glowRadios = [...sr().querySelectorAll('hp-dialog input[name="marker-glow-mode"]')];
out.passiveLiveModeDisabled = glowRadios.find((radio) => radio.value === 'auto')?.disabled === true;
out.passiveManualModesEnabled = glowRadios.filter((radio) => radio.value !== 'auto')
.every((radio) => radio.disabled === false);
out.passiveRadiusEnabled = sr().querySelector('hp-dialog #marker-glow-radius')?.disabled === false;
const brightness = sr().querySelector('hp-dialog .markerglowvalue ha-slider');
brightness.value = 42;
brightness.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
const passiveFields = c._markerLightFields(c._markerDialog);
out.passiveManualGlowPersists = c._markerDialog.glowMode === 'fixed'
&& passiveFields.glow_color?.bri === 0.42;
c._markerDialog = null; await c.updateComplete;
// --- 9) #180: general colour opacity now belongs to the shared picker ---
c._openSettingsDialog(); await c.updateComplete;
const generalPickers = [...sr().querySelectorAll('hp-dialog hp-color-opacity')];
out.gsUnifiedPickers = generalPickers.length === 12
&& generalPickers.filter((picker) => picker.showOpacity).length === 11;
out.gsNoLegacyHaOpacitySliders = sr().querySelectorAll('hp-dialog ha-slider').length === 0;
generalPickers[0].dispatchEvent(new CustomEvent('hp-color-opacity-change', {
detail: { color: '#123456', opacity: 0.55 }, bubbles: true, composed: true,
}));
await c.updateComplete;
out.gsUpstream = Object.values(c._settingsDialog.colors)
.some((value) => value.c === '#123456' && Math.round(value.a * 100) === 55);
c._settingsDialog = null; await c.updateComplete;
return out;
});
checkAll(res, {});
await finish(browser, res);