Files
houseplan-card/demo/smoke_value_face_source.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

193 lines
7.9 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.
// Issue #378: an explicit Value + state source is selected in the real dialog,
// previewed, saved, reopened and projected by both renderers without changing
// the device action. Missing live data must keep the selection and render a dash.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const entityId = 'cover.value_face_awning';
const deviceId = 'd_value_face_awning';
const source = { kind: 'entity_attribute', entity_id: entityId, attribute: 'current_position' };
const sourceKey = `attr:${entityId}:current_position`;
const calls = [];
const setState = async (state, position) => {
c.hass = {
...c.hass,
devices: {
...c.hass.devices,
[deviceId]: {
id: deviceId, name: 'Value face awning', model: 'Test cover', area_id: 'bedroom',
identifiers: [['demo', deviceId]], entry_type: null, via_device_id: null,
},
},
entities: {
...c.hass.entities,
[entityId]: { entity_id: entityId, device_id: deviceId, platform: 'demo' },
},
states: {
...c.hass.states,
[entityId]: {
entity_id: entityId, state,
attributes: {
friendly_name: 'Value face awning', device_class: 'awning',
supported_features: 15, current_position: position,
},
},
},
callService: async (domain, service, data) => {
calls.push([domain, service, data]);
return {};
},
};
c._regSignature = '';
c._maybeRebuildDevices();
const item = c._devices.find((candidate) => candidate.bindingRef === deviceId);
if (item) c._layout = { ...c._layout, [item.id]: { s: item.space, x: 0.12, y: 0.88 } };
c.requestUpdate();
await c.updateComplete;
};
const item = () => c._devices.find((candidate) => candidate.bindingRef === deviceId);
const faceValue = (root, selector) => root.querySelector(selector)
?.querySelector('.valtext')?.textContent?.trim() || '';
await setState('open', 42);
c._setMode('devices');
c._openMarkerDialog(item());
await c.updateComplete;
c._markerDialog = {
...c._markerDialog, display: 'value', tapAction: 'toggle', tapActionTouched: true,
};
c.requestUpdate();
await c.updateComplete;
const select = sr().querySelector('#marker-value-source');
const candidateValues = [...(select?.options || [])].map((option) => option.value);
select.value = sourceKey;
select.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
await c.updateComplete;
const preview = sr().querySelector('hp-device-preview');
await preview?.updateComplete;
const preview42 = faceValue(preview?.renderRoot, '.dev') === '42 %';
const draftSourceExact = JSON.stringify(c._markerDialog?.valueSource) === JSON.stringify(source);
await c._saveMarker();
await c.updateComplete;
let saved = (c._serverCfg.markers || []).find((marker) => marker.binding === `device:${deviceId}`);
const savedExact = JSON.stringify(saved?.value_source) === JSON.stringify(source);
const actionSaved = saved?.tap_action === 'toggle';
c._openMarkerDialog(item());
await c.updateComplete;
const reopenedSelect = sr().querySelector('#marker-value-source');
const reopenedExact = reopenedSelect?.value === sourceKey
&& reopenedSelect?.selectedOptions?.[0]?.value === sourceKey;
// Cancel an unsaved switch to auto: the persisted source must survive.
reopenedSelect.value = '';
reopenedSelect.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
await c.updateComplete;
c._closeMarkerDialog();
await c.updateComplete;
saved = (c._serverCfg.markers || []).find((marker) => marker.binding === `device:${deviceId}`);
const cancelKeptSource = JSON.stringify(saved?.value_source) === JSON.stringify(source);
c._setMode('view');
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
const plan42 = faceValue(sr(), `.dev[data-id="${item().id}"]`) === '42 %';
calls.length = 0;
c._clickDevice({ stopPropagation() {} }, item());
await wait(20);
const actionUnchanged = JSON.stringify(calls.at(-1)) === JSON.stringify([
'cover', 'close_cover', { entity_id: entityId },
]);
await customElements.whenDefined('houseplan-space-card');
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: item().space });
staticCard._snap = {
config: structuredClone(c._serverCfg), rev: c._cfgRev,
configFingerprint: `fixture-${c._cfgRev}`,
layout: structuredClone(c._layout), layoutRev: c._layoutRev,
layoutFingerprint: `fixture-${c._layoutRev}`,
};
staticCard._loadedOnce = true;
staticCard.hass = c.hass;
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('.hp-static-stage') && Date.now() - started < 6000) {
await wait(60);
}
await staticCard.updateComplete;
const static42 = faceValue(staticCard.renderRoot, `.dev[data-id="${item().id}"]`) === '42 %';
await setState('unavailable', 42);
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
await wait(120);
await c.updateComplete;
const unavailableDash = faceValue(sr(), `.dev[data-id="${item().id}"]`) === '—';
const unavailableStillValue = sr().querySelector(`.dev[data-id="${item().id}"]`)?.classList
.contains('valonly') === true;
await setState('open', 55);
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const recovered55 = faceValue(sr(), `.dev[data-id="${item().id}"]`) === '55 %';
// #385(а) AC1: a SAME-binding click is a no-op — the configured source and
// badge survive; only an actual change resets them (spec #378 §1.6).
c._setMode('devices');
c._openMarkerDialog(item());
await c.updateComplete;
c._markerDialog = { ...c._markerDialog, valueSource: source, valueSourceTouched: true, bindingOpen: true };
await c.updateComplete;
const badgeBefore = JSON.stringify({
e: c._markerDialog?.valueBadgeEnabled, s: c._markerDialog?.valueBadgeSource,
});
sr().querySelector('.hpf-cand.sel')?.click(); // #600: кандидаты — кнопки панели набора
await c.updateComplete;
const sameBindingKeepsSource =
JSON.stringify(c._markerDialog?.valueSource) === JSON.stringify(source)
&& !c._markerDialog?.bindingOpen
&& JSON.stringify({
e: c._markerDialog?.valueBadgeEnabled, s: c._markerDialog?.valueBadgeSource,
}) === badgeBefore;
c._closeMarkerDialog();
// A real binding choice resets the old source to automatic before Save.
c._setMode('devices');
c._openMarkerDialog(item());
await c.updateComplete;
const virtualBinding = [...sr().querySelectorAll('input[name="bmode"]')]
.find((node) => node.closest('label')?.textContent?.includes(c._t('marker.virtual_option')));
virtualBinding?.click();
await c.updateComplete;
const bindingResetToAuto = !!virtualBinding && c._markerDialog?.binding === 'virtual'
&& c._markerDialog?.valueSource === null
&& c._markerDialog?.valueSourceTouched === true;
// #385(а) r2-M1: no AC for the virtual radio — an already-checked radio
// input fires no change event, so the reset branch is unreachable by a
// real click (verified by review r2); the guard and its vacuous assert
// were removed rather than kept as false evidence.
c._closeMarkerDialog();
return {
candidatePresent: candidateValues.includes(sourceKey),
preview42, draftSourceExact, savedExact, actionSaved, reopenedExact,
cancelKeptSource, plan42, static42, actionUnchanged,
unavailableDash, unavailableStillValue, recovered55, bindingResetToAuto,
sameBindingKeepsSource,
};
});
checkAll(res);
await finish(browser, res);