Files
houseplan-card/test/color-picker.test.mjs
T
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

128 lines
8.3 KiB
JavaScript
Raw 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.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import test from 'node:test';
import {
hexToRgb, hsvToHex, hsvToRgb, normalizeHexColor, normalizeHue, rgbToHex, rgbToHsv,
} from '../test-build/color-picker.js';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
test('hex drafts accept only three or six digits and normalize on commit', () => {
assert.equal(normalizeHexColor('#AbC'), '#aabbcc');
assert.equal(normalizeHexColor('12ef90'), '#12ef90');
assert.deepEqual(hexToRgb('#0a10ff'), { r: 10, g: 16, b: 255 });
for (const value of ['', '#12', '#1234', '#12345', '#1234567', 'red', '#12xx90', null]) {
assert.equal(normalizeHexColor(value), null, String(value));
}
});
test('RGB and HSV round-trip within one channel across the colour cube', () => {
const channels = [0, 1, 17, 63, 127, 191, 254, 255];
for (const r of channels) for (const g of channels) for (const b of channels) {
const source = { r, g, b };
const roundTrip = hsvToRgb(rgbToHsv(source));
assert.ok(Math.abs(roundTrip.r - r) <= 1, `${r},${g},${b}: red`);
assert.ok(Math.abs(roundTrip.g - g) <= 1, `${r},${g},${b}: green`);
assert.ok(Math.abs(roundTrip.b - b) <= 1, `${r},${g},${b}: blue`);
assert.equal(hsvToHex(rgbToHsv(source)), rgbToHex(source));
}
});
test('HSV helpers wrap hue, clamp finite dimensions and keep grayscale achromatic', () => {
assert.equal(normalizeHue(-30), 330);
assert.equal(normalizeHue(390), 30);
assert.equal(normalizeHue(Number.NaN), 0);
assert.deepEqual(hsvToRgb({ h: 120, s: 100, v: 100 }), { r: 0, g: 255, b: 0 });
assert.equal(hsvToHex({ h: 999, s: -5, v: 150 }), '#ffffff');
const gray = rgbToHsv({ r: 128, g: 128, b: 128 });
assert.equal(gray.s, 0);
assert.ok(Math.abs(gray.v - (128 / 255) * 100) < 1e-9);
});
test('the shared component keeps its API and contains no nested native color picker', () => {
const component = readFileSync(new URL('../src/hp-color-opacity.ts', import.meta.url), 'utf8');
const card = readHouseplanProductionSource();
const sourceFiles = (directory) => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = new URL(entry.name + (entry.isDirectory() ? '/' : ''), directory);
return entry.isDirectory() ? sourceFiles(target) : entry.name.endsWith('.ts') ? [target] : [];
});
for (const source of sourceFiles(new URL('../src/', import.meta.url))) {
assert.doesNotMatch(readFileSync(source, 'utf8'), /type\s*=\s*["']color["']/,
`native color input remains in ${source.pathname}`);
}
for (const token of [
'public color', 'public opacity', 'public disabled', 'public showOpacity',
'hp-color-opacity-change', 'detail: { color: normalized, opacity: clamped }',
'confirm: string', "confirm: 'OK'",
]) assert.match(component, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(component, /<button class="confirm" type="button"[^>]*@click=/);
assert.match(component, /\.confirm\s*\{[\s\S]*?width:\s*100%;[\s\S]*?min-height:\s*40px;/);
assert.match(component, /@media \(forced-colors:\s*active\)[\s\S]*?\.confirm\s*\{[\s\S]*?background:\s*Highlight;/);
assert.ok(component.indexOf('<button class="confirm"') > component.indexOf('this.showOpacity ? html`<div class="row">'),
'the confirmation remains the final picker control after the optional opacity row');
assert.match(component, /private _hexNeedsValidInput = false/);
assert.match(component, /this\._hexInvalid = this\._hexNeedsValidInput/);
assert.match(component, /if \(this\._hexInvalid \|\| this\._hexNeedsValidInput\)/);
const pickerCount = (card.match(/<hp-color-opacity/g) || []).length;
assert.ok(pickerCount > 0);
assert.equal((card.match(/\.pickerLabels=\$\{this\._colorPickerLabels\}/g) || []).length, pickerCount,
'every production picker uses the shared localized labels without a brittle call-site count');
const defaultPickerStart = card.indexOf('<hp-color-opacity class="decor-default-color"');
const defaultPickerEnd = card.indexOf('</hp-color-opacity>', defaultPickerStart);
assert.ok(defaultPickerStart >= 0 && defaultPickerEnd > defaultPickerStart);
const defaultPicker = card.slice(defaultPickerStart, defaultPickerEnd);
assert.match(defaultPicker, /\.color=\$\{this\._decorStyle\.color\}/);
assert.match(defaultPicker, /\.opacity=\$\{this\._decorStyle\.opacity\}/);
// #377: the default-style picker routes through the persisting updater
assert.match(defaultPicker, /this\._updateDecorStyle\(\{ \.\.\.this\._decorStyle, \.\.\.e\.detail \}\)/);
assert.equal((card.match(/\.showOpacity=\$\{false\}/g) || []).length, 4,
'Glow, ripple and both background pickers stay color-only');
// #600: плитки палитры (одна общая функция), плашка заливки стен и цвет
// границ комнаты — три явных `showOpacity=true`; раньше стена шла общей
// строкой с палитрой.
assert.equal((card.match(/\.showOpacity=\$\{true\}/g) || []).length, 3,
'general fill tiles, the wall-fill plate and room color explicitly keep their existing opacity');
assert.match(card, /confirm:\s*this\._t\('color_picker\.confirm'\)/);
for (const locale of ['en', 'ru', 'de', 'fr']) {
const messages = JSON.parse(readFileSync(new URL(`../src/i18n/${locale}.json`, import.meta.url), 'utf8'));
assert.equal(messages['color_picker.confirm'], locale === 'ru' ? 'ОК' : 'OK');
}
});
test('the hue range exposes one cyclic spectrum without restyling other ranges', () => {
const component = readFileSync(new URL('../src/hp-color-opacity.ts', import.meta.url), 'utf8');
const spectrum = component.match(/\.hue-range\s*\{[\s\S]*?\n\s*\}/)?.[0] || '';
assert.match(spectrum, /--hp-picker-hue-track:\s*linear-gradient\(to right/);
for (const stop of ['#f00 0%', '#ff0 16.667%', '#0f0 33.333%', '#0ff 50%',
'#00f 66.667%', '#f0f 83.333%', '#f00 100%']) assert.ok(spectrum.includes(stop), stop);
assert.match(component, /\.hue-range::\-webkit-slider-runnable-track\s*\{[\s\S]*?height:\s*10px;[\s\S]*?background:\s*var\(--hp-picker-hue-track\);/);
assert.match(component, /\.hue-range::\-moz-range-track\s*\{[\s\S]*?height:\s*10px;[\s\S]*?background:\s*var\(--hp-picker-hue-track\);/);
assert.match(component, /\.hue-range::\-moz-range-progress\s*\{[\s\S]*?background:\s*transparent;/);
assert.match(component, /\.hue-range::\-webkit-slider-thumb\s*\{[\s\S]*?border-radius:\s*50%;[\s\S]*?box-shadow:\s*var\(--hp-picker-hue-thumb-shadow\);/);
assert.match(component, /\.hue-range::\-moz-range-thumb\s*\{[\s\S]*?border-radius:\s*50%;[\s\S]*?box-shadow:\s*var\(--hp-picker-hue-thumb-shadow\);/);
assert.match(spectrum, /--hp-picker-hue-thumb-shadow:[\s\S]*?card-background-color[\s\S]*?primary-text-color/);
assert.match(component, /@media \(forced-colors:\s*active\)[\s\S]*?background:\s*Canvas;/);
assert.match(component, /class="hue-range" type="range" min="0" max="359" step="1"/);
const commonRange = component.match(/input\[type='range'\]\s*\{[\s\S]*?\n\s*\}/)?.[0] || '';
assert.doesNotMatch(commonRange, /linear-gradient|hp-picker-hue-track/);
});
/**
* #600: цвет и размер пульса — два поля набора друг под другом (плашка цвета
* `colorField` и строка `rangeLine`), а не две flex-строки с общим CSS. Порядок
* прежний: сначала цвет, потом размер; ширину плашки держит лист набора.
*/
test('activity color and ripple size keep independent readable rows', () => {
const card = readHouseplanProductionSource();
const kit = readFileSync(new URL('../src/styles/form-kit.styles.ts', import.meta.url), 'utf8');
const start = card.indexOf("d.display === 'icon_ripple'");
const end = card.indexOf("marker.activity_alarm_note", start);
const ripple = card.slice(start, end);
assert.ok(start >= 0 && end > start);
assert.match(ripple, /class="ripple-colorrow"/);
assert.match(ripple, /class="hpf-field ripple-sizerow"/);
assert.ok(ripple.indexOf('marker.activity_color') < ripple.indexOf('ripple-sizerow'));
assert.ok(ripple.indexOf('ripple-sizerow') < ripple.indexOf('marker.ripple_size'));
assert.match(kit, /-colorfield \{[^}]*display:\s*flex/);
assert.match(kit, /-range \{[^}]*display:\s*flex/);
});