mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +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
193 lines
7.9 KiB
JavaScript
193 lines
7.9 KiB
JavaScript
// 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);
|