Files
houseplan-card/demo/smoke_live_text.mjs
Claudeandclaude[bot] 5a258f3128 docs(hygiene): свести дубли документов подсистем и снять устаревшее (#679)
Волна 2 эпика #674 — у каждого правила один дом, остальные места ссылаются.

DECOR-EDITOR.md ← BACKDROP.md + LIVE-TEXT.md: один документ с нумерованными
разделами (§3 подложка, §5 текст с живыми значениями), на которые теперь
указывают комментарии кода вместо несуществовавших «BACKDROP §2/§3»;
исправлено утверждение, что space-card не рисует декор (он рисует подложку
и картинки декора, но не фигуры, мебель и текст). LIGHT.md ← матрица
настроек света (перевод, тест назван явно: test/devices.test.mjs «issues
84/88»). DEVICE-PRESENTATION.md ← правила «что показывает маркер» из
FILTERING.md (порядок cover → light sources → device role, шторы,
медиаплееры); «в одном pull request» → «в одном коммите». CANVAS.md: §9.5
«Оптимизировать планы» → CONFIG-COMPATIBILITY.md, overlay и планарные грани
Walls → WALL-THICKNESS.md §10–11, таблицы «было/стало» сняты. TESTING-DEMO.md
→ demo/stand/README.md: карта демо-дома и «чего на стенде нет», ручной
чек-лист снят (ручной фазы в процессе нет). ISOMETRIC.md — только текущее;
история Stage 2/4 — docs/adr/570-isometric-stage4-visual-handoff.md.
SUN.md: удалённый контракт фона снят, правило бумаги — в текущем разделе.
UX-MODES.md: декор над заливками, а не «под комнатами»; «hidden isometric»;
follow-up из #3 — все выпущены. Шапки VACUUM, WARM-REMOUNT («Выровнять всё
по сетке» → «Оптимизировать планы»), WALL-THICKNESS, STYLING-HOOKS,
CONFIG-COMPATIBILITY (#33), PDF-EXPORT — без устаревших статусов и планов.
README EN/RU: абзац про пересъёмку скриншотов → CONTRIBUTING.md, RADAR и
PDF-EXPORT в списке документации, RU догнал EN (2.5D, повторное
использование загруженного изображения, STAIRS). Один список канонических
документов подсистем в AGENTS.md и промпте ревьюера (_process.yml).
WALL-THICKNESS.md ссылается на ADR 282.

Правки src/** и validation.py — только пути документов в комментариях.

Issue: #679
User-Visible: no
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 18:07:37 +00:00

173 lines
9.8 KiB
JavaScript

// Живой текст в декоре (docs/DECOR-EDITOR.md §5): все связи с HA живут прямо в
// тексте как {entity} / {entity:attribute}. Один блок может смешивать обычный
// текст и несколько переменных; выбор значения вставляет токен в позицию
// курсора. Старые entity/attr/unit читаются, но первое сохранение мигрирует их.
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;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** текст фигуры в DOM, склеенный из строк (tspan) */
const dom = (id) => {
const el = sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
if (!el) return null;
return [...el.querySelectorAll('tspan')].map((t) => t.textContent).join('\n');
};
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
/** подменить одно состояние — ровно так, как HA присылает новый hass */
const push = async (id, st) => {
const states = { ...c.hass.states };
if (st === null) delete states[id]; else states[id] = st;
c.hass = { ...c.hass, states };
await c.updateComplete;
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
c._curSpaceCfg.decor = [];
// ---------- 1. несколько inline-переменных и обычная надпись -------------
c._decorTextDialog = { x: 0.3, y: 0.3,
text: 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}',
color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const live = c._decorList.find((x) => x.kind === 'text');
out.onlyTextStored = !('entity' in live) && !('attr' in live) && !('unit' in live);
out.templateStoredRaw = live.text ===
'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
out.multipleValuesRendered = dom(live.id) === 'Бак 22.4 °C; LQI 154';
c._decorTextDialog = { x: 0.6, y: 0.3, text: 'Сауна', color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const plain = c._decorList.find((x) => x.text === 'Сауна');
out.plainUntouched = dom(plain.id) === 'Сауна' && !('entity' in plain);
// ---------- 2. значение живое: новый hass — новый текст -------------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.9',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C; LQI 154';
out.plainStillUntouched = dom(plain.id) === 'Сауна';
// как отдаёт HA: ни округления, ни переформатирования
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.94781',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.noRounding = dom(live.id) === 'Бак 23.94781 °C; LQI 154';
// ---------- 3. мёртвая сущность — прочерк, шаблон остаётся ---------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unavailable',
attributes: { unit_of_measurement: '°C' } });
out.deadIsDash = dom(live.id) === 'Бак —; LQI —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unknown', attributes: {} });
out.unknownIsDash = dom(live.id) === 'Бак —; LQI —';
await push('sensor.living_temp', null);
out.missingIsDash = dom(live.id) === 'Бак —; LQI —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.backAlive = dom(live.id) === 'Бак 22.4 °C; LQI 154';
// ---------- 4. picker вставляет полный токен ровно в позицию курсора ------
c._decorTextDialog = { x: 0.45, y: 0.45, text: 'До после', color: '#112233',
pickerEntity: 'sensor.living_temp' };
c._decorTextSelection = { start: 3, end: 3 };
c._decorInsertLiveVariable('linkquality'); await c.updateComplete;
out.insertedAtCaret = c._decorTextDialog.text ===
'До {sensor.living_temp:linkquality}после';
c._decorSaveText(); await c.updateComplete;
const inserted = c._decorList.find((x) => x.text?.startsWith('До '));
out.insertedRenders = dom(inserted.id) === 'До 154после';
// ---------- 5. ручная dotted-форма и отсутствующий атрибут ---------------
c._decorTextDialog = { x: 0.55, y: 0.55,
text: '{sensor.living_temp.linkquality} / {sensor.living_temp:nope}',
color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const manual = c._decorList.find((x) => x.text?.includes(':nope'));
out.manualTokensWork = dom(manual.id) === '154 / —';
// ---------- 6. старая связка мигрирует при первом редактировании ----------
const legacy = { id: 'legacy_live', kind: 'text', x: 0.7, y: 0.7,
text: 'Старый {}', color: '#112233', entity: 'sensor.living_temp' };
c._curSpaceCfg.decor = [...c._decorList, legacy];
c.requestUpdate(); await c.updateComplete;
out.legacyStillRenders = dom(legacy.id) === 'Старый 22.4 °C';
open(legacy); await c.updateComplete;
out.legacyOpensAsToken = c._decorTextDialog?.text ===
'Старый {sensor.living_temp}';
c._decorSaveText(); await c.updateComplete;
const migrated = c._decorList.find((x) => x.id === legacy.id);
out.legacyMigrated = !('entity' in migrated)
&& migrated.text === 'Старый {sensor.living_temp}'
&& dom(legacy.id) === 'Старый 22.4 °C';
// Explicit legacy units and attribute names outside the inline grammar are
// not silently destroyed. They stay in the compatible representation until
// the user explicitly replaces the binding.
const legacyUnit = { id: 'legacy_unit', kind: 'text', x: 0.75, y: 0.75,
text: 'Бак {}', color: '#112233', entity: 'sensor.living_temp', unit: 'K' };
c._curSpaceCfg.decor = [...c._decorList, legacyUnit]; c.requestUpdate(); await c.updateComplete;
out.legacyUnitRenders = dom(legacyUnit.id) === 'Бак 22.4 K';
open(legacyUnit); await c.updateComplete;
out.legacyUnitIsNotLossyToken = c._decorTextDialog?.preserveLegacy === true
&& c._decorTextDialog?.text === 'Бак {}';
c._decorSaveText(); await c.updateComplete;
const keptUnit = c._decorList.find((x) => x.id === legacyUnit.id);
out.legacyUnitSurvivesSave = keptUnit?.unit === 'K'
&& keptUnit?.entity === 'sensor.living_temp' && dom(legacyUnit.id) === 'Бак 22.4 K';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
attributes: { unit_of_measurement: '°C', linkquality: 154, 'bad attr': 7 } });
const legacyAttr = { id: 'legacy_attr', kind: 'text', x: 0.8, y: 0.8,
text: 'Атрибут {}', color: '#112233', entity: 'sensor.living_temp', attr: 'bad attr' };
c._curSpaceCfg.decor = [...c._decorList, legacyAttr]; c.requestUpdate(); await c.updateComplete;
out.legacyOddAttrRenders = dom(legacyAttr.id) === 'Атрибут 7';
open(legacyAttr); await c.updateComplete;
out.legacyOddAttrPreserved = c._decorTextDialog?.preserveLegacy === true;
c._decorSaveText(); await c.updateComplete;
const keptAttr = c._decorList.find((x) => x.id === legacyAttr.id);
out.legacyOddAttrSurvivesSave = keptAttr?.attr === 'bad attr'
&& keptAttr?.entity === 'sensor.living_temp' && dom(legacyAttr.id) === 'Атрибут 7';
c._decorTextDialog = { x: 0.2, y: 0.2, text: 'x', color: '#112233',
pickerEntity: 'sensor.living_temp' };
c.requestUpdate(); await c.updateComplete;
out.pickerHidesUnrepresentableAttr = ![...sr().querySelectorAll('select.namein option')]
.some((o) => o.value === 'bad attr');
c._decorTextDialog = null;
// ---------- 7. просмотр и киоск рисуют то же самое ------------------------
const editorText = dom(live.id);
c._setMode('view'); await c.updateComplete; await sleep(60);
out.sameInViewMode = dom(live.id) === editorText
&& editorText === 'Бак 22.4 °C; LQI 154';
const kiosk = document.createElement('houseplan-card');
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true });
kiosk.hass = c.hass;
document.body.appendChild(kiosk);
await kiosk.updateComplete; await sleep(400); await kiosk.updateComplete;
const kr = kiosk.shadowRoot || kiosk.renderRoot;
const kEl = kr.querySelector(`.decorlayer text.dtext[data-id="${live.id}"]`);
out.sameInKiosk = !!kEl
&& [...kEl.querySelectorAll('tspan')].map((t) => t.textContent).join('\n') === editorText;
kiosk.remove();
// ---------- 8. round-trip хранит только текстовый шаблон ------------------
const sent = [];
const ws = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return ws(m); } };
c._setMode('decor'); await c.updateComplete;
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorSaveText();
await sleep(900);
const set = sent.filter((m) => m.type === 'houseplan/config/set').pop();
const shape = set && (set.config.spaces.find((s) => s.id === c._space).decor || [])
.find((x) => x.id === live.id);
out.roundTrip = !!shape && !('entity' in shape) && !('attr' in shape) && !('unit' in shape)
&& shape.text === 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
return out;
});
checkAll(res);
await finish(browser, res);