From fa319e37b3b12c67992ffceb3d90d880035df994 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:03:32 +0300 Subject: [PATCH] =?UTF-8?q?test(harness):=20=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D1=84=D0=B0=D1=81=D0=B0=D0=B4=20window.?= =?UTF-8?q?=5F=5FhpTest=20=D0=B8=20=D0=B3=D0=B5=D0=B9=D1=82=20no-new-priva?= =?UTF-8?q?te-writes=20(#629)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/no-new-private-writes.mjs: смоки и demo/helpers/** не добавляют записей в приватное состояние карточки (присваивание, ++/--, delete по цепочке с сегментом _x; от this — нет) и вызовов _setMode/_openRoomEdit/ _openMarkerDialog/_openSpaceDialog. Зачёт правки по полю, перенос блока — movedLinesByFile из no-new-any; исключение // private-ok: <причина>. --count — остаток на HEAD. Подключён в gate:small и в шаг frontend рядом с no-new-any, с той же базой. - demo/helpers/hp-test.mjs: 10 операций через контрактные хуки и события фикстуры (setMode, setTool, switchSpace, openRoomEdit, openMarkerDialog, openSpaceDialog, setServerConfig, setLayout, input, close); ставится launch*() из demo/serve.mjs. В бандле фасада нет. - demo/srv/demo.html: доставка houseplan_config_updated/_layout_updated, __pushServerConfig/__pushServerLayout; после доставки запись со старым expected_rev — conflict, как у настоящего сервера. - HP_SMOKE_CHECKS=1 печатает имена проверок в finish(). - smoke_area_relocation, smoke_glow, smoke_grid_snap переведены на фасад без потери утверждений; новый smoke_test_facade доказывает каждую операцию. - 7 мутантов, docs/TESTING.md (раздел + правило №6), PROCESS.md §2.7, AGENTS.md. Issue: #629 User-Visible: no --- .github/workflows/validate.yml | 11 +- AGENTS.md | 9 +- PROCESS.md | 7 + demo/helpers/hp-test.mjs | 289 +++++++++++++++++++++++ demo/serve.mjs | 18 +- demo/smoke_area_relocation.mjs | 179 ++++++-------- demo/smoke_glow.mjs | 110 ++++----- demo/smoke_grid_snap.mjs | 66 +++--- demo/smoke_test_facade.mjs | 123 ++++++++++ demo/srv/demo.html | 38 ++- docs/TESTING.md | 80 +++++++ scripts/check-inputs.mjs | 13 +- scripts/gate-small.mjs | 4 +- scripts/mutation-registry.mjs | 79 +++++++ scripts/no-new-any.mjs | 20 +- scripts/no-new-private-writes.mjs | 350 ++++++++++++++++++++++++++++ test/gate-small.test.mjs | 2 + test/hp-test-facade.test.mjs | 78 +++++++ test/model-invariants.test.mjs | 4 +- test/no-new-private-writes.test.mjs | 218 +++++++++++++++++ test/validate-workflow.test.mjs | 4 + 21 files changed, 1469 insertions(+), 233 deletions(-) create mode 100644 demo/helpers/hp-test.mjs create mode 100644 demo/smoke_test_facade.mjs create mode 100644 scripts/no-new-private-writes.mjs create mode 100644 test/hp-test-facade.test.mjs create mode 100644 test/no-new-private-writes.test.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3a0122c2..75d6b51f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -742,8 +742,10 @@ jobs: run: npm run typecheck # Долг типизации снимается при извлечении подсистем (#34), а не разовой # заменой: в src/** его 1034 вхождения в 49 файлах. Гейт держит приращение - # на нуле — судит только добавленные строки (#342). - - name: "Новый код не добавляет any" + # на нуле — судит только добавленные строки (#342). Тем же диапазоном + # судится и приращение записей смоков в приватное состояние карточки + # (#629): больше двух тысяч старых не трогаем, новых не добавляем. + - name: "Новый код не добавляет any и приватных записей в смоках" env: EVENT_NAME: ${{ github.event_name }} BEFORE_SHA: ${{ github.event.before }} @@ -774,7 +776,10 @@ jobs: echo "не нашёл базу диапазона — гейт пропущен, это увидит следующий пуш" exit 0 fi - node scripts/no-new-any.mjs --base "$base" --head "$HEAD_SHA" + status=0 + node scripts/no-new-any.mjs --base "$base" --head "$HEAD_SHA" || status=1 + node scripts/no-new-private-writes.mjs --base "$base" --head "$HEAD_SHA" || status=1 + exit "$status" - name: Unit tests run: npm test - name: Build diff --git a/AGENTS.md b/AGENTS.md index 2bad9101..2ecfcab1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -408,7 +408,7 @@ npm run bundle:budget # initial View graph <= 256000 B gzip (#337) ``` `npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479, -#576): build with typecheck, `no-new-any` and `smoke-select` start in parallel; +#576): build with typecheck, `no-new-any`, `no-new-private-writes` and `smoke-select` start in parallel; unit tests follow the completed build because their bundle-contract witnesses read the freshly produced `dist`, then the bundle-tree comparison and the bundle budget run. It prints the smokes the @@ -430,7 +430,7 @@ push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated on the `heavy` output: true for a head commit carrying a `Release:` trailer, for `workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night) and for pull requests. A plain push to `dev` runs preflight, frontend (types, -units, build, bundle sync, no-new-any), the narrow TS/Python geometry parity +units, build, bundle sync, no-new-any, no-new-private-writes), the narrow TS/Python geometry parity guard when its inputs changed, backend, hacs and hassfest. Screenshot freshness in `check-docs` is likewise a warning on a plain push and an error on the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate @@ -445,6 +445,11 @@ demo/smoke_.mjs`. A red smoke that reaches the review costs a cycle; run locally it costs a minute. Precedent: on #89 a fixture error lived through a whole review round that a local run would have caught immediately. +**A smoke enters through the public surface (#629)**: contract `data-hp` hooks, +HA/fixture events and the harness facade `window.__hpTest` +(`docs/TESTING.md`); private card fields are read-only in assertions, and a new +write needs `// private-ok: ` or the `no-new-private-writes` gate fails. + **One handoff, one push (#510).** Run `node scripts/process-gate.mjs --issues` locally with `gh` available before pushing (without `gh` the hook cannot check the issue status and stays silent). After `S7-code-review` do not push to the branch diff --git a/PROCESS.md b/PROCESS.md index 06d4bfde..9cf4074d 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -287,6 +287,13 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → харнесса. Снижение фиксируется тем же коммитом (`node scripts/unused-locals-gate.mjs --update`); рост — только с записью в issue задачи и правкой базы в том же коммите. +- **Смок входит в сценарий через публичную поверхность (#629).** DOM с + контрактными хуками, события HA и фикстуры, тестовый фасад `window.__hpTest` + (`docs/TESTING.md`, «Тестовый фасад и приватное состояние»). Приватное поле + карточки — только для чтения в ассертах. Новая запись в него без + `// private-ok: <конкретная причина>` — находка ревью, даже если гейт + `no-new-private-writes` её не увидел (мутация через вызов, запись через + псевдоним). - **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более 4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10). diff --git a/demo/helpers/hp-test.mjs b/demo/helpers/hp-test.mjs new file mode 100644 index 00000000..2d1632ef --- /dev/null +++ b/demo/helpers/hp-test.mjs @@ -0,0 +1,289 @@ +// Тестовый фасад харнесса смоков: `window.__hpTest` (#629). +// +// Смок, который открывает диалог присваиванием приватного поля и меняет план +// присваиванием `_serverCfg`, зелёный и тогда, когда кнопка, поле ввода или путь +// закрытия сломаны. Фасад делает то же самое так, как это делает человек или +// другой клиент HA: нажимает настоящую кнопку, печатает по символу, закрывает +// Escape'ом или крестиком, доставляет конфиг событием сервера. +// +// Живёт в харнессе, а не в бандле: в публичной карточке нет ни кода фасада, ни +// флага, который бы его включал (#629 §15). Ставится `launch()`, +// `launchColdView()` и `launchPanelCold()` из demo/serve.mjs после загрузки +// страницы; смок, который сам перезагружает страницу, зовёт +// `installHpTestOnPage(page)` ещё раз. +// +// Правила (docs/TESTING.md, «Тестовый фасад и приватное состояние»): +// - элементы ищутся ТОЛЬКО по контрактным селекторам SELECTORS — каждый из них +// объявлен в docs/data-hp-contract.json с аудиторией `test`; это держит +// test/hp-test-facade.test.mjs; +// - отсутствующий элемент — именованная ошибка, молчаливого отката на +// приватный метод карточки нет; +// - фасад не пишет ни в одно поле карточки и читает только члены, которые +// читает и сам продукт: READS ниже. + +/** + * Контрактные селекторы фасада. `{name}` — подстановка аргумента операции. + * Единственный источник: страничная часть получает таблицу аргументом. + */ +export const SELECTORS = Object.freeze({ + modeRoot: 'ha-card[data-hp-mode]', + modeTab: '[data-hp="mode-tab"][data-mode="{mode}"]', + editorClose: '[data-hp="editor-close"]', + tool: '[data-hp="toolbar"] [data-hp="tool"][data-tool="{tool}"]', + spaceTab: '[data-hp="space-tab"][data-id="{id}"]', + roomSettings: '[data-hp="room-settings"][data-room="{room}"]', + addDevice: '[data-hp="toolbar"] [data-hp="tool"][data-tool="add-device"]', + device: '[data-hp="device"][data-id="{id}"]', + spaceAdd: '[data-hp="space-add"]', + createSpace: '[data-hp="create-space"]', + spaceSettings: '[data-hp="space-settings"][data-id="{id}"]', + dialog: '[data-hp="dialog"]', + dialogOfKind: '[data-hp="dialog"][data-kind="{kind}"]', + confirmDialog: '[data-hp="dialog"][data-kind="confirm"]', + dialogCancel: '[data-hp="dialog-cancel"]', +}); + +/** Члены карточки, которые фасад читает (F3). Записей нет ни одной. */ +export const READS = Object.freeze(['_cfgRev', '_layoutRev', '_serverCfg', '_layout']); + +/** + * Страничная часть. Сериализуется Playwright'ом, поэтому самодостаточна: + * ничего вне своего тела не видит, таблицу селекторов получает аргументом. + */ +export function installHpTest(selectors) { + const preinstalled = typeof window.__hpTest !== 'undefined'; + const WAIT_MS = 5000; + const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const card = () => window.__card + || document.querySelector('houseplan-card') + || document.querySelector('houseplan-panel')?.shadowRoot?.querySelector('houseplan-card') + || null; + const need = (op) => { + const c = card(); + if (!c) throw new Error(`__hpTest.${op}: карточка не смонтирована`); + return c; + }; + const rootOf = (c) => c.shadowRoot || c.renderRoot; + const sel = (name, params = {}) => selectors[name].replace(/\{(\w+)\}/g, (_, key) => { + const value = String(params[key]); + return value.replace(/["\\]/g, '\\$&'); + }); + /** Поиск по селектору в дереве карточки, включая вложенные открытые shadow root. */ + const deepAll = (root, selector) => { + const out = []; + const walk = (node) => { + if (!node) return; + out.push(...node.querySelectorAll(selector)); + for (const el of node.querySelectorAll('*')) if (el.shadowRoot) walk(el.shadowRoot); + }; + walk(root); + return [...new Set(out)]; + }; + const find = (c, name, params) => deepAll(rootOf(c), sel(name, params))[0] || null; + const mode = (c) => rootOf(c)?.querySelector(selectors.modeRoot)?.getAttribute('data-hp-mode') || '?'; + const missing = (op, c, name, params) => new Error( + `__hpTest.${op}: ${sel(name, params)} не отрисован (режим ${mode(c)})`); + const settled = async () => { + const c = card(); + if (c?.updateComplete) await c.updateComplete; + await frame(); + await frame(); + if (c?.updateComplete) await c.updateComplete; + }; + const waitFor = async (op, predicate, describe, ms = WAIT_MS) => { + const deadline = performance.now() + ms; + for (;;) { + const value = predicate(); + if (value) return value; + if (performance.now() > deadline) throw new Error(`__hpTest.${op}: ${describe()}`); + await sleep(16); + } + }; + const openDialog = async (op, c, kind) => { + const dialogs = () => deepAll(rootOf(c), sel('dialogOfKind', { kind })); + await settled(); + return waitFor(op, () => dialogs().at(-1), () => `диалог ${sel('dialogOfKind', { kind })} не открылся (режим ${mode(c)})`); + }; + const deepActive = (root) => { + let active = root.shadowRoot?.activeElement || document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active && root.contains(active) ? active : null; + }; + const valueSetter = (el) => { + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + return Object.getOwnPropertyDescriptor(proto, 'value').set; + }; + + const facade = { + /** Был ли `__hpTest` на странице ДО установки харнессом — бандл его определять не должен (P2). */ + preinstalled, + settled, + + async setMode(next) { + const c = need('setMode'); + if (mode(c) === next) { await settled(); return; } + const button = next === 'view' + ? find(c, 'editorClose') + : find(c, 'modeTab', { mode: next }); + if (!button) throw missing('setMode', c, next === 'view' ? 'editorClose' : 'modeTab', { mode: next }); + button.click(); + await settled(); + await waitFor('setMode', () => mode(c) === next, + () => `${sel('modeRoot')} не перешёл в ${next} (сейчас ${mode(c)})`); + await settled(); + }, + + async setTool(tool) { + const c = need('setTool'); + const button = find(c, 'tool', { tool }); + if (!button) throw missing('setTool', c, 'tool', { tool }); + button.click(); + await settled(); + return button; + }, + + async switchSpace(id) { + const c = need('switchSpace'); + const tab = find(c, 'spaceTab', { id }); + if (!tab) throw missing('switchSpace', c, 'spaceTab', { id }); + tab.click(); + await settled(); + await waitFor('switchSpace', () => find(c, 'spaceTab', { id })?.getAttribute('aria-current') === 'page', + () => `вкладка ${sel('spaceTab', { id })} не стала текущей`); + await settled(); + }, + + async openRoomEdit(room) { + const c = need('openRoomEdit'); + const gear = find(c, 'roomSettings', { room }); + if (!gear) throw missing('openRoomEdit', c, 'roomSettings', { room }); + gear.click(); + return openDialog('openRoomEdit', c, 'room'); + }, + + async openMarkerDialog(deviceId) { + const c = need('openMarkerDialog'); + const target = deviceId === undefined + ? find(c, 'addDevice') + : find(c, 'device', { id: deviceId }); + if (!target) { + throw deviceId === undefined + ? missing('openMarkerDialog', c, 'addDevice') + : missing('openMarkerDialog', c, 'device', { id: deviceId }); + } + target.click(); + return openDialog('openMarkerDialog', c, 'marker'); + }, + + async openSpaceDialog(kind = 'create', id) { + const c = need('openSpaceDialog'); + let target; + if (kind === 'edit') { + target = find(c, 'spaceSettings', { id }); + if (!target) throw missing('openSpaceDialog', c, 'spaceSettings', { id }); + } else { + target = find(c, 'spaceAdd') || find(c, 'createSpace'); + if (!target) throw missing('openSpaceDialog', c, 'spaceAdd'); + } + target.click(); + return openDialog('openSpaceDialog', c, 'space'); + }, + + /** «Конфиг изменили на сервере» (F2): событие houseplan_config_updated, карточка перечитывает сама. */ + async setServerConfig(next) { + const c = need('setServerConfig'); + if (typeof window.__pushServerConfig !== 'function') throw new Error('__hpTest.setServerConfig: фикстура без __pushServerConfig'); + const current = structuredClone(c._serverCfg); + const cfg = typeof next === 'function' ? (next(current) ?? current) : next; + const rev = window.__pushServerConfig(cfg); + await waitFor('setServerConfig', + () => window.__servedRevision.config >= rev && c._cfgRev >= rev, + () => `карточка не приняла ревизию конфига: ожидалась ${rev}, у карточки ${c._cfgRev}, отдано ${window.__servedRevision.config}`); + await settled(); + return rev; + }, + + /** То же для раскладки: событие houseplan_layout_updated (у карточки дебаунс 200 мс). */ + async setLayout(next) { + const c = need('setLayout'); + if (typeof window.__pushServerLayout !== 'function') throw new Error('__hpTest.setLayout: фикстура без __pushServerLayout'); + const current = structuredClone(c._layout); + const layout = typeof next === 'function' ? (next(current) ?? current) : next; + const rev = window.__pushServerLayout(layout); + await waitFor('setLayout', + () => window.__servedRevision.layout >= rev && c._layoutRev >= rev, + () => `карточка не приняла ревизию раскладки: ожидалась ${rev}, у карточки ${c._layoutRev}, отдано ${window.__servedRevision.layout}`); + await settled(); + return rev; + }, + + /** Посимвольный ввод в поле: keydown → значение → input(insertText) → keyup; в конце change. */ + async input(el, text, { clear = true } = {}) { + if (!el) throw new Error('__hpTest.input: элемент не передан'); + const target = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement + ? el + : el.shadowRoot?.querySelector('input, textarea') || el.querySelector?.('input, textarea'); + if (!target) throw new Error(`__hpTest.input: в <${el.localName}> нет input/textarea`); + const set = valueSetter(target); + target.focus(); + if (clear && target.value !== '') { + set.call(target, ''); + target.dispatchEvent(new InputEvent('input', { + inputType: 'deleteContentBackward', bubbles: true, composed: true, + })); + } + for (const ch of String(text)) { + const key = { key: ch, bubbles: true, composed: true, cancelable: true }; + target.dispatchEvent(new KeyboardEvent('keydown', key)); + set.call(target, target.value + ch); + target.dispatchEvent(new InputEvent('input', { + inputType: 'insertText', data: ch, bubbles: true, composed: true, + })); + target.dispatchEvent(new KeyboardEvent('keyup', key)); + } + target.dispatchEvent(new Event('change', { bubbles: true })); + await settled(); + return target; + }, + + /** Закрыть диалог настоящим путём: Escape, крестик окна или кнопка «Отмена». */ + async close(dialog, { via = 'escape' } = {}) { + const c = need('close'); + const target = dialog || deepAll(rootOf(c), sel('dialog')).filter((d) => d.isConnected).at(-1); + if (!target) throw missing('close', c, 'dialog'); + if (via === 'escape') { + const origin = deepActive(target) || target; + origin.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', code: 'Escape', bubbles: true, composed: true, cancelable: true, + })); + } else if (via === 'x') { + if (target.shadowRoot?.querySelector('ha-dialog')) { + throw new Error('__hpTest.close: крестик HA — в приватном shadow root HA; используйте via: escape или cancel'); + } + const x = target.shadowRoot?.querySelector(sel('dialogCancel')); + if (!x) throw new Error(`__hpTest.close: у диалога нет крестика ${sel('dialogCancel')}`); + x.click(); + } else if (via === 'cancel') { + const cancel = target.querySelector(sel('dialogCancel')); + if (!cancel) throw new Error(`__hpTest.close: в содержимом диалога нет ${sel('dialogCancel')}`); + cancel.click(); + } else { + throw new Error(`__hpTest.close: неизвестный путь ${via}`); + } + await settled(); + const confirmOf = () => deepAll(rootOf(c), sel('confirmDialog')).find((d) => d !== target) || null; + await waitFor('close', () => !target.isConnected || confirmOf(), + () => `диалог ${target.getAttribute('data-kind')} не закрылся через ${via}`); + await settled(); + return { closed: !target.isConnected, confirm: confirmOf() }; + }, + }; + window.__hpTest = Object.freeze(facade); + return preinstalled; +} + +/** Node-сторона: поставить фасад на открытую страницу. */ +export async function installHpTestOnPage(page) { + return page.evaluate(installHpTest, SELECTORS); +} diff --git a/demo/serve.mjs b/demo/serve.mjs index f8d62a9e..67f7c625 100644 --- a/demo/serve.mjs +++ b/demo/serve.mjs @@ -4,6 +4,7 @@ import { chromium } from 'playwright'; import { assertFreshDemoBundleUnlessAllowed } from './bundle-freshness.mjs'; import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs'; import { installHarnessIsoRuntimeHelper } from './iso-runtime-compat.mjs'; +import { installHpTestOnPage } from './helpers/hp-test.mjs'; import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; @@ -17,6 +18,8 @@ const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+ // build reported success. `check()` accumulates named failures, `finish()` // prints them and sets the exit code. const _failures = []; +/** Имена всех проверок прогона — для `HP_SMOKE_CHECKS=1` (#629). */ +const _checkNames = new Set(); let _pageErrors = 0; /** * Открытые страницы — для round-trip'а перед чтением счётчика (#404). @@ -66,6 +69,7 @@ export function watchPage(page) { /** Assert one named fact. `expected` defaults to true. */ export function check(name, actual, expected = true) { + _checkNames.add(String(name)); const ok = JSON.stringify(actual) === JSON.stringify(expected); if (!ok) _failures.push(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); return ok; @@ -112,6 +116,11 @@ export async function finish(browser, out) { await roundTripLivePages(); if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`); await browser?.close?.(); + // #629: доказательство «перевод без потери утверждений» — отсортированный + // список имён проверок; без переменной вывод прежний. + if (process.env.HP_SMOKE_CHECKS === '1') { + console.log('HP_SMOKE_CHECKS ' + JSON.stringify([..._checkNames].sort())); + } if (_failures.length) { console.error('\nFAILED (' + _failures.length + '):'); for (const f of _failures) console.error(' - ' + f); @@ -151,7 +160,10 @@ async function launchInternal( }); await page.goto(`http://demo.local${entry}`, { waitUntil: 'domcontentloaded' }); await installHarnessIsoRuntimeHelper(page); - if (!waitForCard) return { page, browser }; + if (!waitForCard) { + await installHpTestOnPage(page); + return { page, browser }; + } await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 }); // Свежесть бандла проверяется здесь, а не в каждом смоке (#236). Смок читает // demo/srv/assets/houseplan-card.js; если туда не скопирован свежий dist, @@ -178,6 +190,10 @@ async function launchInternal( const ready = await page.evaluate(ensureHarnessEditorRuntime); if (!ready) throw new Error('editor runtime did not preload for browser smoke'); } + // #629: тестовый фасад window.__hpTest — публичный вход смока в сценарий. + // Ставится последним: бандл к этому моменту исполнен, и `preinstalled` + // честно отвечает, не определил ли его кто-то раньше харнесса. + await installHpTestOnPage(page); return { page, browser }; } diff --git a/demo/smoke_area_relocation.mjs b/demo/smoke_area_relocation.mjs index c61b9714..ff00faff 100644 --- a/demo/smoke_area_relocation.mjs +++ b/demo/smoke_area_relocation.mjs @@ -5,7 +5,21 @@ import { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch({ width: 1000, height: 850 }, 1); const res = await page.evaluate(async () => { const c = window.__card; + // #629: исходное состояние сервера доставляется так, как его доставил бы + // другой клиент, — событием через фасад; карточка сама перечитывает конфиг и + // перестраивает устройства. Диалог маркера открывается настоящим кликом. + const hp = window.__hpTest; const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + /** Слить `patch` в settings.marker_area_snapshot серверного конфига. */ + const withSnapshot = (cfg, patch, extra = {}) => ({ + ...cfg, + ...extra, + settings: { + ...cfg.settings, + ...(extra.settings || {}), + marker_area_snapshot: { ...(cfg.settings.marker_area_snapshot || {}), ...patch }, + }, + }); const paint = async (card = c) => { card.requestUpdate(); await card.updateComplete; @@ -26,20 +40,10 @@ const res = await page.evaluate(async () => { c.hass = hass; await paint(); - c._serverCfg = { - ...c._serverCfg, - settings: { - ...c._serverCfg.settings, - marker_area_snapshot: { - ...(c._serverCfg.settings.marker_area_snapshot || {}), - d_light1: { binding: 'device:d_light1', area: 'living_room' }, - }, - new_device_ids: [], - }, - }; - c._layout = { ...c._layout, d_light1: { s: 'f1', x: 0.22, y: 0.22 } }; - c._regSignature = ''; - c._maybeRebuildDevices(); + await hp.setServerConfig((cfg) => withSnapshot(cfg, { + d_light1: { binding: 'device:d_light1', area: 'living_room' }, + }, { settings: { new_device_ids: [] } })); + await hp.setLayout((layout) => ({ ...layout, d_light1: { s: 'f1', x: 0.22, y: 0.22 } })); await paint(); const oldLeft = Number.parseFloat( c.renderRoot.querySelector('.dev[data-id="d_light1"]')?.style.left || '0', @@ -67,48 +71,32 @@ const res = await page.evaluate(async () => { // A dialog opened before another Area transition may show the effective // room, but saving an unrelated field must not persist that stale display as // an explicit House Plan override. - c._serverCfg = { - ...c._serverCfg, - settings: { - ...c._serverCfg.settings, - marker_area_snapshot: { - ...c._serverCfg.settings.marker_area_snapshot, - d_lamp: { binding: 'device:d_lamp', area: 'living_room' }, - }, - }, - }; - c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.42, y: 0.5 } }; - c._regSignature = ''; - c._maybeRebuildDevices(); - c._setMode('devices'); - c._openMarkerDialog(c._devices.find((device) => device.id === 'd_lamp')); + await hp.setServerConfig((cfg) => withSnapshot(cfg, { + d_lamp: { binding: 'device:d_lamp', area: 'living_room' }, + })); + await hp.setLayout((layout) => ({ ...layout, d_lamp: { s: 'f1', x: 0.42, y: 0.5 } })); + await hp.setMode('devices'); + const lampDialog = await hp.openMarkerDialog('d_lamp'); await paint(); window.__setRegistryArea('device', 'd_lamp', 'kitchen'); await wait(450); const roomDraftRefreshed = c._markerDialog?.room === 'f1#kitchen' && c._markerDialog?.roomTouched === false; - c._markerDialog = { ...c._markerDialog, name: 'Floor lamp renamed' }; - await c._saveMarker(); + await hp.input(lampDialog.querySelector('#marker-name'), 'Floor lamp renamed'); + lampDialog.querySelector('[data-hp="dialog-confirm"]').click(); + for (let attempt = 0; attempt < 40 && lampDialog.isConnected; attempt += 1) await wait(50); + await paint(); const savedLamp = c._serverCfg.markers.find((marker) => marker.id === 'd_lamp'); - c._setMode('view'); + await hp.setMode('view'); await paint(); // Delete is committed before provenance. A rejected config write restores // the old local snapshot and the next authoritative pass retries safely. - c._serverCfg = { - ...c._serverCfg, - settings: { - ...c._serverCfg.settings, - marker_area_snapshot: { - ...c._serverCfg.settings.marker_area_snapshot, - d_kettle: { binding: 'device:d_kettle', area: 'kitchen' }, - }, - }, - }; + await hp.setServerConfig((cfg) => withSnapshot(cfg, { + d_kettle: { binding: 'device:d_kettle', area: 'kitchen' }, + })); const kettlePoint = { s: 'f1', x: 0.72, y: 0.15 }; - c._layout = { ...c._layout, d_kettle: kettlePoint }; - c._regSignature = ''; - c._maybeRebuildDevices(); + await hp.setLayout((layout) => ({ ...layout, d_kettle: kettlePoint })); rejectKettleRelocation = true; window.__setRegistryArea('device', 'd_kettle', 'living_room'); await wait(500); @@ -127,19 +115,10 @@ const res = await page.evaluate(async () => { // Cross-space registry movement uses the same production coordinator: the // old Garden point is deleted and the device joins the f1 autogrid. - c._serverCfg = { - ...c._serverCfg, - settings: { - ...c._serverCfg.settings, - marker_area_snapshot: { - ...c._serverCfg.settings.marker_area_snapshot, - d_gate: { binding: 'device:d_gate', area: 'garden' }, - }, - }, - }; - c._layout = { ...c._layout, d_gate: { s: 'garden', x: 0.73, y: 0.22 } }; - c._regSignature = ''; - c._maybeRebuildDevices(); + await hp.setServerConfig((cfg) => withSnapshot(cfg, { + d_gate: { binding: 'device:d_gate', area: 'garden' }, + })); + await hp.setLayout((layout) => ({ ...layout, d_gate: { s: 'garden', x: 0.73, y: 0.22 } })); window.__setRegistryArea('device', 'd_gate', 'living_room'); await wait(450); await paint(); @@ -151,47 +130,31 @@ const res = await page.evaluate(async () => { // An explicitly persisted entity binding follows its own registry Area, // independently of a device marker. window.__setRegistryArea('entity', 'sun.sun', 'living_room'); - c._serverCfg = { - ...c._serverCfg, + await hp.setServerConfig((cfg) => withSnapshot(cfg, { + entity_sun: { binding: 'entity:sun.sun', area: 'living_room' }, + }, { markers: [ - ...c._serverCfg.markers.filter((marker) => marker.id !== 'entity_sun'), + ...cfg.markers.filter((marker) => marker.id !== 'entity_sun'), { id: 'entity_sun', binding: 'entity:sun.sun' }, ], - settings: { - ...c._serverCfg.settings, - marker_area_snapshot: { - ...c._serverCfg.settings.marker_area_snapshot, - entity_sun: { binding: 'entity:sun.sun', area: 'living_room' }, - }, - }, - }; - c._layout = { ...c._layout, entity_sun: { s: 'f1', x: 0.20, y: 0.30 } }; - c._regSignature = ''; - c._maybeRebuildDevices(); + })); + await hp.setLayout((layout) => ({ ...layout, entity_sun: { s: 'f1', x: 0.20, y: 0.30 } })); window.__setRegistryArea('entity', 'sun.sun', 'kitchen'); await wait(450); await paint(); const entityDevice = c._devices.find((device) => device.id === 'entity_sun'); // A user-owned room override remains authoritative when HA changes Area. - c._serverCfg = { - ...c._serverCfg, + await hp.setServerConfig((cfg) => withSnapshot(cfg, { + d_tv: { binding: 'device:d_tv', area: 'living_room' }, + }, { markers: [ - ...c._serverCfg.markers.filter((marker) => marker.id !== 'd_tv'), + ...cfg.markers.filter((marker) => marker.id !== 'd_tv'), { id: 'd_tv', binding: 'device:d_tv', space: 'f1', area: 'living_room' }, ], - settings: { - ...c._serverCfg.settings, - marker_area_snapshot: { - ...c._serverCfg.settings.marker_area_snapshot, - d_tv: { binding: 'device:d_tv', area: 'living_room' }, - }, - }, - }; + })); const explicitPoint = { s: 'f1', x: 0.13, y: 0.50 }; - c._layout = { ...c._layout, d_tv: explicitPoint }; - c._regSignature = ''; - c._maybeRebuildDevices(); + await hp.setLayout((layout) => ({ ...layout, d_tv: explicitPoint })); const explicitDeletesBefore = calls.filter( (message) => message.type === 'houseplan/layout/delete' && message.device_id === 'd_tv', ).length; @@ -207,22 +170,13 @@ const res = await page.evaluate(async () => { // Area may change, but #126 must not discard its saved point or flag it. window.__addRegistryEntity('light.area_group', 'living_room', 'on', 'group'); await wait(150); - c._regSignature = ''; - c._maybeRebuildDevices(); await paint(); const compositeId = 'lg_light.area_group'; const compositePoint = { s: 'f1', x: 0.30, y: 0.40 }; - c._layout = { ...c._layout, [compositeId]: compositePoint }; - c._serverCfg = { - ...c._serverCfg, - settings: { - ...c._serverCfg.settings, - marker_area_snapshot: { - ...c._serverCfg.settings.marker_area_snapshot, - [compositeId]: { binding: 'entity:light.area_group', area: 'living_room' }, - }, - }, - }; + await hp.setLayout((layout) => ({ ...layout, [compositeId]: compositePoint })); + await hp.setServerConfig((cfg) => withSnapshot(cfg, { + [compositeId]: { binding: 'entity:light.area_group', area: 'living_room' }, + })); const compositeDeletesBefore = calls.filter( (message) => message.type === 'houseplan/layout/delete' && message.device_id === compositeId, ).length; @@ -333,18 +287,9 @@ const res = await page.evaluate(async () => { // explicit editor cleanup alone. A disappeared device with no saved marker // must lose its snapshot on the next full pass. const orphanId = 'device-that-disappeared'; - c._serverCfg = { - ...c._serverCfg, - settings: { - ...c._serverCfg.settings, - marker_area_snapshot: { - ...c._serverCfg.settings.marker_area_snapshot, - [orphanId]: { binding: `device:${orphanId}`, area: 'living_room' }, - }, - }, - }; - c._regSignature = ''; - c._maybeRebuildDevices(); + await hp.setServerConfig((cfg) => withSnapshot(cfg, { + [orphanId]: { binding: `device:${orphanId}`, area: 'living_room' }, + })); await wait(450); const authoritativeOrphanRemoved = !c._serverCfg.settings.marker_area_snapshot?.[orphanId]; @@ -404,10 +349,17 @@ const res = await page.evaluate(async () => { let rejected = false; let rev = 100; const base = window.__mkHass(); + // The retrying probe gets a registry that can announce a change: that is + // the product's own reason to run the pass again after a failed write. + const registryListeners = new Set(); const probeHass = { ...base, connection: { - subscribeEvents: async () => () => {}, + subscribeEvents: async (callback, event) => { + if (!rejectFirstCleanup || event !== 'device_registry_updated') return () => {}; + registryListeners.add(callback); + return () => registryListeners.delete(callback); + }, subscribeMessage: async () => () => {}, }, devices: liveDevices, @@ -453,8 +405,7 @@ const res = await page.evaluate(async () => { for (let attempt = 0; attempt < 20 && !card._loadedOnce; attempt += 1) await wait(100); await wait(550); if (rejectFirstCleanup) { - card._regSignature = ''; - card._maybeRebuildDevices(); + for (const callback of registryListeners) callback({ action: 'update', id: 'still-live' }); await wait(450); } const result = { diff --git a/demo/smoke_glow.mjs b/demo/smoke_glow.mjs index 2e27adfb..07db5f34 100644 --- a/demo/smoke_glow.mjs +++ b/demo/smoke_glow.mjs @@ -3,12 +3,15 @@ const { page, browser } = await launch(); const res = await page.evaluate(async () => { const out = {}; const c = window.__card; + // #629: план меняется так, как его меняет другой клиент HA, — событием + // сервера через фасад, а не присваиванием приватного состояния карточки. + const hp = window.__hpTest; const sr = () => c.shadowRoot || c.renderRoot; const spId = c._space; + const inSpace = (cfg, patch) => ({ ...cfg, spaces: cfg.spaces.map((s) => s.id !== spId ? s : patch(s)) }); // включить glow-режим - c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({ - ...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) }; - c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => inSpace(cfg, (s) => ({ + ...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } }))); await new Promise((r) => setTimeout(r, 250)); // 1) legacy glow переносится в отдельный base-overlay, не в data-fill комнат const modelRooms = c._spaceModel().rooms.length; @@ -60,13 +63,11 @@ const res = await page.evaluate(async () => { } return best; })(); - c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({ - ...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) }; - c._cfgEpoch++; c._glowClipCache.clear(); - c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => inSpace(cfg, (s) => ({ + ...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] }))); // источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу) const c1 = c._roomCenter(r1); - c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } }; + await hp.setLayout((layout) => ({ ...layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } })); // Give the shadow assertion a real physical body. The original fixture has // no walls/partitions/columns, so no shadow mask can legitimately exist. const shadowCenter = [ @@ -74,10 +75,8 @@ const res = await page.evaluate(async () => { c1[1] + (doorPt[1] - c1[1]) * 0.35, ]; // радиус 6 м, чтобы дверь заведомо была в зоне досягаемости - c._serverCfg = { - ...c._serverCfg, - settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 }, - spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({ + await hp.setServerConfig((cfg) => ({ + ...inSpace(cfg, (s) => ({ ...s, wall_columns: [ ...(s.wall_columns || []).filter((column) => column.id !== 'glow-shadow-column'), @@ -87,9 +86,8 @@ const res = await page.evaluate(async () => { }, ], })), - }; - c._cfgEpoch++; c._glowClipCache.clear(); - c.requestUpdate(); await c.updateComplete; + settings: { ...(cfg.settings || {}), glow_radius_cm: 600 }, + })); // One source, one shape: the floor this lamp can see. A doorway, the room // behind it and the shadow of a column are the same region, so they can never // disagree — which is what every earlier bug in this area was made of. @@ -120,10 +118,8 @@ const res = await page.evaluate(async () => { // Дверь наружу: свет через неё не выходит — есть проём, но за ним нет пола. const minX = Math.min(...poly1.map((p) => p[0])); const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2; - c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({ - ...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) }; - c._cfgEpoch++; c._glowClipCache.clear(); - c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => inSpace(cfg, (s) => ({ + ...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] }))); const roomsBox = (() => { const xs = [], ys = []; for (const room of c._spaceModel().rooms) { @@ -147,10 +143,7 @@ const res = await page.evaluate(async () => { const litPath = () => [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path.glow-lit')] .map((p) => p.getAttribute('d')).join('|'); const withEntrance = litPath(); - c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({ - ...s, openings: [] })) }; - c._cfgEpoch++; c._glowClipCache.clear(); - c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => inSpace(cfg, (s) => ({ ...s, openings: [] }))); out.entranceChangesNothing = withEntrance.length > 0 && litPath() === withEntrance; // 6б) профиль градиента: монотонное затухание по всему радиусу. Плато до // 70% превращало любую обрезанную форму в плашку сплошного цвета с каймой — @@ -184,54 +177,42 @@ const res = await page.evaluate(async () => { ? 'virtual' : litLight.bindingKind + ':' + litLight.bindingRef; const autoSpotCount = sr().querySelectorAll('.glowlayer circle').length; - c._serverCfg = { ...c._serverCfg, markers: [ - ...(c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId), - { id: litMarkerId, binding: litBinding, is_light: false }, - ] }; - c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete; + const withoutMarker = (cfg, id) => ({ ...cfg, markers: (cfg.markers || []).filter((m) => m.id !== id) }); + const withMarker = (cfg, marker) => ({ ...cfg, markers: [...withoutMarker(cfg, marker.id).markers, marker] }); + await hp.setServerConfig((cfg) => withMarker(cfg, { id: litMarkerId, binding: litBinding, is_light: false })); out.roleNeverHidesOwnPool = sr().querySelectorAll('.glowlayer circle').length === autoSpotCount - 1; - c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) }; - c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => withoutMarker(cfg, litMarkerId)); out.roleAutoRestoresOwnPool = sr().querySelectorAll('.glowlayer circle').length === autoSpotCount; // 7а) персональный радиус источника перекрывает глобальный - c._serverCfg = { ...c._serverCfg, markers: [ - ...(c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId), - { - id: litMarkerId, - binding: litBinding, - glow_radius_cm: 150, - glow_color: { c: '#123456', bri: 0.25 }, - }, - ] }; - c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => withMarker(cfg, { + id: litMarkerId, + binding: litBinding, + glow_radius_cm: 150, + glow_color: { c: '#123456', bri: 0.25 }, + })); const rOwn = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r')); out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5; const ownStop = sr().querySelector('defs radialGradient stop'); out.perSourceAppearance = ownStop?.getAttribute('stop-color') === '#123456' && Math.abs(Number(ownStop.getAttribute('stop-opacity')) - 0.428) < 0.002 && !sr().querySelector('.glow-pools-frame')?.hasAttribute('opacity'); - c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) }; - c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => withoutMarker(cfg, litMarkerId)); // 6в) флаг «источник света»: умный выключатель с обычными светильниками const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.'))); if (swDev) { const swEid = swDev.entities.find((e) => e.startsWith('switch.')); c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } }; const spotsBefore = sr().querySelectorAll('.glowlayer circle').length; - c._serverCfg = { ...c._serverCfg, markers: [ - ...(c._serverCfg.markers || []).filter((m) => m.id !== swDev.id), - { id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true }, - ] }; - c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => withMarker(cfg, { + id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true, + })); out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1; - c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== swDev.id) }; - c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => withoutMarker(cfg, swDev.id)); out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore; } else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; } // 7) радиус из настроек: 600 см против 300 см — вдвое больше const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r')); - c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } }; - c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig((cfg) => ({ ...cfg, settings: { ...(cfg.settings || {}), glow_radius_cm: 300 } })); const r300 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r')); out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01; // Hover must not promote a filtered SVG sibling and flash the screen-blended @@ -326,11 +307,11 @@ const rasterFixture = await page.evaluate(async () => { ))), })) .sort((left, right) => right.clearance - left.clearance)[0]?.point; - c._serverCfg = { - ...c._serverCfg, - settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 }, - markers: (c._serverCfg.markers || []).filter((marker) => marker.id !== source.id), - spaces: c._serverCfg.spaces.map((space) => space.id !== spaceId ? space : ({ + await window.__hpTest.setServerConfig((cfg) => ({ + ...cfg, + settings: { ...(cfg.settings || {}), glow_radius_cm: 600 }, + markers: (cfg.markers || []).filter((marker) => marker.id !== source.id), + spaces: cfg.spaces.map((space) => space.id !== spaceId ? space : ({ ...space, settings: { ...(space.settings || {}), fill_mode: 'custom', @@ -344,8 +325,8 @@ const rasterFixture = await page.evaluate(async () => { }], partitions: [], })), - }; - c._layout = { ...c._layout, [source.id]: { s: spaceId, x: 0.295, y: 0.36 } }; + })); + await window.__hpTest.setLayout((layout) => ({ ...layout, [source.id]: { s: spaceId, x: 0.295, y: 0.36 } })); const states = { ...c.hass.states }; for (const [eid, state] of Object.entries(states)) { if (eid.startsWith('light.')) states[eid] = { ...state, state: 'off' }; @@ -363,11 +344,6 @@ const rasterFixture = await page.evaluate(async () => { context: { id: 'glow-dynamic-door', parent_id: null, user_id: null }, }; c.hass = { ...c.hass, states }; - c._cfgEpoch++; - c._glowClipCache.clear(); - c._regSignature = ''; - c._maybeRebuildDevices(); - c.requestUpdate(); await c.updateComplete; await new Promise((resolve) => setTimeout(resolve, 600)); @@ -680,16 +656,14 @@ if (rasterFixture.ready) { if (!wallSource) return false; const c = window.__card; const state = c.hass.states[sourceEid]; - c._layout = { - ...c._layout, + await window.__hpTest.setLayout((layout) => ({ + ...layout, [sourceId]: { s: c._space, x: wallSource[0], y: wallSource[1] }, - }; + })); c.hass = { ...c.hass, states: { ...c.hass.states, [sourceEid]: { ...state, state: 'on' } }, }; - c._glowClipCache.clear(); - c.requestUpdate(); await c.updateComplete; // The continuity contract may keep the last complete device frame briefly // while the moved source and its HA snapshot are staged atomically. Wait diff --git a/demo/smoke_grid_snap.mjs b/demo/smoke_grid_snap.mjs index 3efbdef1..76665323 100644 --- a/demo/smoke_grid_snap.mjs +++ b/demo/smoke_grid_snap.mjs @@ -14,6 +14,10 @@ const { page, browser } = await launch({ width: 900, height: 820 }, 1); const out = await page.evaluate(async () => { const o = {}; const c = window.__card; + // #629: режимы и инструменты — настоящими кнопками, конфиг и раскладка — + // событием сервера через фасад. Состояние жестов (_drag, _path, черновики + // декора) по-прежнему выставляется напрямую: операций для него нет. + const hp = window.__hpTest; const sr = () => c.shadowRoot || c.renderRoot; const NORM_W = 1000, GRID_N = 240; const PITCH = NORM_W / GRID_N; @@ -31,9 +35,11 @@ const out = await page.evaluate(async () => { })); }); }; - c._serverCfg.model_version = 7; - makePhysical(c._serverCfg.spaces.find((space) => space.id === c._space)); - c._modelCache = null; c._frame = null; c._cfgEpoch++; + const firstSpace = c._space; + await hp.setServerConfig((cfg) => { + cfg.model_version = 7; + makePhysical(cfg.spaces.find((space) => space.id === firstSpace)); + }); const onGridN = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9; const onGridR = (v) => Math.abs(v / PITCH - Math.round(v / PITCH)) < 1e-7; @@ -60,7 +66,7 @@ const out = await page.evaluate(async () => { o.pitchIsTheCanvasFreeConstant = Math.abs(c._gridPitch - PITCH) < 1e-12; // ---- 1b) a DEVICE dropped with the mouse lands on a node -------------- - c._setMode('devices'); await c.updateComplete; + await hp.setMode('devices'); const dev = c._devices.find((d) => !d.virtual); const p0 = c._pos(dev); const mkDelta = (dxu, dyu, extra = {}) => { @@ -90,7 +96,7 @@ const out = await page.evaluate(async () => { o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y); // ---- 1c) a ROOM LABEL too --------------------------------------------- - c._setMode('plan'); await c.updateComplete; + await hp.setMode('plan'); const room = c._spaceModel(c._space).rooms.find((r) => r.name); const lp = c._labelPos(room, c._space); c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false }; @@ -104,8 +110,8 @@ const out = await page.evaluate(async () => { o.untouchedLabelIsOnANode = onGridR(op.x) && onGridR(op.y); // ---- 1d) DECOR: draft, text anchor, and a move ------------------------ - c._setMode('decor'); await c.updateComplete; - c._decorTool = 'rect'; + await hp.setMode('decor'); + await hp.setTool('rect'); // dispatched for real, so the handler sees a target (it looks for .dshape) sr().querySelector('.stage').dispatchEvent(at(300 + OFF, 300 + OFF, 'pointerdown', { pointerId: 21 })); o.decorDraftStartsOnANode = onGridR(c._decorDraft.a[0]) && onGridR(c._decorDraft.a[1]); @@ -114,16 +120,18 @@ const out = await page.evaluate(async () => { const dsh = c._decorList[c._decorList.length - 1]; o.decorShapeIsOnNodes = onGridN(dsh.x) && onGridN(dsh.y) && onGridN(dsh.x + dsh.w) && onGridN(dsh.y + dsh.h); - c._decorTool = 'text'; + await hp.setTool('text'); sr().querySelector('.stage').dispatchEvent(at(500 + OFF, 500 + OFF, 'pointerdown', { pointerId: 22 })); o.decorTextAnchorIsOnANode = onGridN(c._decorTextDialog.x) && onGridN(c._decorTextDialog.y); c._decorTextDialog = null; // a shape that is ALREADY off the grid is put on it by one drag: the mover // snaps the resulting anchor, not the delta (which used to preserve the drift) - const sp = c._curSpaceCfg; - sp.decor = [...c._decorList, { id: 'dcOff', kind: 'rect', - x: 0.3013, y: 0.4017, w: 0.1, h: 0.05, color: '#889', width: 2 }]; - c.requestUpdate(); await c.updateComplete; + const decorSpace = c._space; + await hp.setServerConfig((cfg) => { + const space = cfg.spaces.find((item) => item.id === decorSpace); + space.decor = [...(space.decor || []), { id: 'dcOff', kind: 'rect', + x: 0.3013, y: 0.4017, w: 0.1, h: 0.05, color: '#889', width: 2 }]; + }); const off = c._decorList.find((x) => x.id === 'dcOff'); c._decorMove = { id: 'dcOff', start: c._svgPoint(at(301.3, 401.7)), orig: JSON.parse(JSON.stringify(off)), pid: 9, moved: false }; @@ -132,10 +140,13 @@ const out = await page.evaluate(async () => { const off2 = c._decorList.find((x) => x.id === 'dcOff'); o.oneDragPutsADriftedShapeOnTheGrid = onGridN(off2.x) && onGridN(off2.y); c._decorMove = null; - sp.decor = sp.decor.filter((x) => x.id !== 'dcOff' && x.id !== dsh.id); + await hp.setServerConfig((cfg) => { + const space = cfg.spaces.find((item) => item.id === decorSpace); + space.decor = (space.decor || []).filter((x) => x.id !== 'dcOff' && x.id !== dsh.id); + }); // ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it --- - c._setMode('plan'); await c.updateComplete; + await hp.setMode('plan'); c._activateOpeningPlacement('door'); const spm = c._spaceModel(c._space); const wall = (() => { @@ -174,7 +185,7 @@ const out = await page.evaluate(async () => { } // ---- 1f) a room VERTEX drawn by hand ---------------------------------- - c._tool = 'draw'; c._path = []; + await hp.setTool('draw'); c._path = []; c._markupClick(at(220 + OFF, 220 + OFF, 'click')); o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]); c._path = [[200, 200]]; @@ -184,10 +195,10 @@ const out = await page.evaluate(async () => { o.shiftLocksRoomWallTo45 = c._path.length === 2 && (ldx < 1e-7 || ldy < 1e-7 || Math.abs(ldx - ldy) < 1e-7) && onGridR(locked[0]) && onGridR(locked[1]); - c._path = []; c._tool = 'draw'; + c._path = []; await hp.setTool('draw'); // ---- 2) «Оптимизировать планы» ----------------------------------------- - c._setMode('view'); await c.updateComplete; + await hp.setMode('view'); // The preceding real editor gestures schedule a physical config write. // Finish it before replacing the whole server snapshot with the next // synthetic fixture: production adoption does this at a revision boundary, @@ -211,10 +222,9 @@ const out = await page.evaluate(async () => { decor: [{ id: 'd1', kind: 'line', x1: 0.1 + D, y1: 0.7, x2: 0.9, y2: 0.7 - D }], }], markers: [], settings: {} }; const FIXLAY = { d_light1: { s: 'g1', x: 0.3 + D, y: 0.3 }, rl_r1: { s: 'g1', x: 0.25 + D, y: 0.25 } }; - c._serverCfg = JSON.parse(JSON.stringify(FIX)); - c._layout = JSON.parse(JSON.stringify(FIXLAY)); - c._modelCache = null; c._frame = null; c._space = 'g1'; - c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig(structuredClone(FIX)); + await hp.setLayout(structuredClone(FIXLAY)); + await hp.switchSpace('g1'); c._editorRuntime.optimizePlans.open(); await c.updateComplete; @@ -268,10 +278,9 @@ const out = await page.evaluate(async () => { rooms: [{ id: 'mb', name: 'B', area: 'kitchen', poly: [[0.2 + 1 / GRID_N / 2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]] }] }, ], markers: [], settings: {} }; - c._serverCfg = JSON.parse(JSON.stringify(MULTI)); - c._layout = {}; - c._modelCache = null; c._frame = null; c._space = 'm1'; - c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig(structuredClone(MULTI)); + await hp.setLayout({}); + await hp.switchSpace('m1'); c._editorRuntime.optimizePlans.open(); await c.updateComplete; const md = c._alignDialog; o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51; @@ -288,10 +297,9 @@ const out = await page.evaluate(async () => { walls: [{ key: 'r1-top', a: [0.2, 0.2], b: [0.5, 0.2], cm: 15 }], openings: [{ id: 'oa', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }], }], markers: [], settings: {} }; - c._serverCfg = JSON.parse(JSON.stringify(TURN)); - c._layout = {}; - c._modelCache = null; c._frame = null; c._space = 'a1'; - c.requestUpdate(); await c.updateComplete; + await hp.setServerConfig(structuredClone(TURN)); + await hp.setLayout({}); + await hp.switchSpace('a1'); c._editorRuntime.optimizePlans.open(); await c.updateComplete; const ad = c._alignDialog; o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1; diff --git a/demo/smoke_test_facade.mjs b/demo/smoke_test_facade.mjs new file mode 100644 index 00000000..a9de89d8 --- /dev/null +++ b/demo/smoke_test_facade.mjs @@ -0,0 +1,123 @@ +// #629: тестовый фасад харнесса window.__hpTest (demo/helpers/hp-test.mjs). +// +// Каждая операция доказывается наблюдаемым результатом настоящего пути: +// кнопка нажата — режим сменился в ha-card[data-hp-mode]; конфиг доставлен +// событием сервера — подпись комнаты на плане другая; ввод — по событию input +// на символ; закрытие — диалог отсоединился. Приватные поля карточки здесь +// только ЧИТАЮТСЯ (правило №6 docs/TESTING.md). +import { launch, check, finish } from './serve.mjs'; + +const { page, browser } = await launch({ width: 1100, height: 820 }, 1); + +const out = await page.evaluate(async () => { + const o = {}; + const c = window.__card; + const hp = window.__hpTest; + const sr = () => c.shadowRoot || c.renderRoot; + const mode = () => sr().querySelector('ha-card')?.getAttribute('data-hp-mode'); + const errorOf = async (fn) => { try { await fn(); return null; } catch (error) { return String(error?.message || error); } }; + + // P2/AC9: бандл сам фасад не определяет — его ставит харнесс. + o.bundleDoesNotDefineFacade = hp?.preinstalled === false; + o.facadeIsFrozen = Object.isFrozen(hp); + + // F/AC6: отсутствующий элемент — именованная ошибка, без отката на приватный метод. + const viewError = await errorOf(() => hp.openRoomEdit('r2')); + o.missingElementIsANamedError = !!viewError + && viewError.includes('__hpTest.openRoomEdit') + && viewError.includes('[data-hp="room-settings"][data-room="r2"]') + && viewError.includes('режим view'); + o.noFallbackOpenedADialog = !sr().querySelector('[data-hp="dialog"]'); + + // 1) setMode: настоящая вкладка режима, затем крестик редактора. + await hp.setMode('plan'); + o.setModePlan = mode() === 'plan' + && sr().querySelector('[data-hp="mode-tab"][data-mode="plan"]')?.classList.contains('active') === true; + + // 2) setTool: кнопка в панели активного редактора. + const drawButton = await hp.setTool('draw'); + o.setToolDraw = c._tool === 'draw' && drawButton?.getAttribute('aria-pressed') === 'true'; + await hp.setTool('select'); + o.setToolSelect = c._tool === 'select'; + + // 4) openRoomEdit + 9) input + 10) close через Escape. + const roomDialog = await hp.openRoomEdit('r2'); + o.openRoomEdit = roomDialog?.getAttribute('data-kind') === 'room' && roomDialog.isConnected; + const nameField = roomDialog.querySelector('#room-name'); + const events = []; + nameField.addEventListener('input', (event) => events.push(event.inputType)); + const typed = 'Кухня 2'; + await hp.input(nameField, typed); + o.inputTypesCharByChar = events.filter((type) => type === 'insertText').length === typed.length + && events[0] === 'deleteContentBackward'; + o.inputReachesTheDraft = c._nameSel === typed && nameField.value === typed; + const escaped = await hp.close(roomDialog, { via: 'escape' }); + // черновик изменён: закрытие либо спрашивает подтверждение, либо закрывает + o.closeEscape = escaped.closed || !!escaped.confirm; + if (escaped.confirm) { + const discard = escaped.confirm.querySelector('[data-hp="dialog-confirm"]'); + discard?.click(); + await hp.settled(); + } + o.roomDialogGone = !roomDialog.isConnected; + + // 10) close через крестик окна и через «Отмена» содержимого. + const viaX = await hp.close(await hp.openRoomEdit('r2'), { via: 'x' }); + o.closeX = viaX.closed === true && viaX.confirm === null; + const viaCancel = await hp.close(await hp.openRoomEdit('r2'), { via: 'cancel' }); + o.closeCancel = viaCancel.closed === true && viaCancel.confirm === null; + + // 7) setServerConfig: карточка сама перечитывает конфиг по событию сервера; + // подписи комнат видны в редакторе плана. + const revBefore = c._cfgRev; + const labelText = () => [...sr().querySelectorAll('[data-hp="room-label"]')].map((el) => el.textContent.trim()).join('|'); + const rev = await hp.setServerConfig((cfg) => { + cfg.spaces.find((space) => space.id === 'f1').rooms.find((room) => room.id === 'r2').name = 'Kitchen 2'; + }); + o.setServerConfigAdoptsRevision = rev > revBefore && c._cfgRev >= rev; + o.setServerConfigRepaints = labelText().includes('Kitchen 2'); + + // 5) openMarkerDialog: «Добавить устройство» и клик по маркеру в режиме устройств. + await hp.setMode('devices'); + o.setModeDevices = mode() === 'devices'; + const addDialog = await hp.openMarkerDialog(); + o.openMarkerDialogAdd = addDialog?.getAttribute('data-kind') === 'marker'; + await hp.close(addDialog, { via: 'cancel' }); + const lampDialog = await hp.openMarkerDialog('d_lamp'); + o.openMarkerDialogDevice = lampDialog?.getAttribute('data-kind') === 'marker' + && lampDialog.querySelector('#marker-name')?.value === 'Floor lamp'; + o.closeMarker = (await hp.close(lampDialog, { via: 'x' })).closed === true; + + // 6) openSpaceDialog: создание и настройки существующего пространства. + const createDialog = await hp.openSpaceDialog('create'); + o.openSpaceDialogCreate = createDialog?.getAttribute('data-kind') === 'space'; + await hp.close(createDialog, { via: 'escape' }); + const editDialog = await hp.openSpaceDialog('edit', 'f1'); + o.openSpaceDialogEdit = editDialog?.getAttribute('data-kind') === 'space' && editDialog !== createDialog; + await hp.close(editDialog, { via: 'escape' }); + o.spaceDialogsClosed = !createDialog.isConnected && !editDialog.isConnected; + + // 1) setMode('view') — крестиком на активной вкладке. + await hp.setMode('view'); + o.setModeView = mode() === 'view' + && !sr().querySelector('[data-hp="mode-tab"].active'); + + // 3) switchSpace — настоящая вкладка пространства. + await hp.switchSpace('garden'); + o.switchSpace = c._space === 'garden' + && sr().querySelector('[data-hp="space-tab"][data-id="garden"]')?.getAttribute('aria-current') === 'page'; + await hp.switchSpace('f1'); + + // 8) setLayout: позиция маркера меняется событием раскладки. + const kettle = () => sr().querySelector('[data-hp="device"][data-id="d_kettle"]'); + const leftBefore = Number.parseFloat(kettle()?.style.left || 'NaN'); + const layoutRev = await hp.setLayout((layout) => { layout.d_kettle = { ...layout.d_kettle, x: 0.3, y: 0.3 }; }); + const leftAfter = Number.parseFloat(kettle()?.style.left || 'NaN'); + o.setLayoutAdoptsRevision = c._layoutRev >= layoutRev; + o.setLayoutMovesTheMarker = Number.isFinite(leftBefore) && Number.isFinite(leftAfter) + && Math.abs(leftAfter - leftBefore) > 5; + return o; +}); + +for (const [name, value] of Object.entries(out)) check(name, value); +await finish(browser, out); diff --git a/demo/srv/demo.html b/demo/srv/demo.html index 3037a2ee..1c02e893 100644 --- a/demo/srv/demo.html +++ b/demo/srv/demo.html @@ -49,7 +49,7 @@ class HaCard extends HTMLElement{ customElements.define('ha-card',HaCard);