mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
§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
176 lines
9.2 KiB
JavaScript
176 lines
9.2 KiB
JavaScript
// 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);
|