From db7eef530635b699a72c3cf978a7df79db99fcbd Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 21:05:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D1=80=D0=BF=D1=83=D1=81=20=D0=B3?= =?UTF-8?q?=D0=B5=D0=BE=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=B9,=20household-?= =?UTF-8?q?=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80=D0=B8=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20View?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полевая проверка основного View по #560: шесть обезличенных планов корпуса и цепочка import → Optimize → Optimize → Resize → сохранение с численными оракулами, семь household-путей с независимыми оракулами в двух ширинах карточки, аудит доступности с измеренными числами. Продуктовых правок нет: находки заведены отдельно (#564, #565, #566). Issue: #560 User-Visible: no --- demo/smoke_geometry_corpus.mjs | 182 ++++++++ demo/smoke_household_journeys.mjs | 272 +++++++++++ docs/QUALITY-560.md | 201 +++++++++ scripts/mutation-registry.mjs | 44 ++ test/fixtures/560-corpus/c1-short-edges.json | 203 +++++++++ .../560-corpus/c2-t-junction-thicknesses.json | 352 +++++++++++++++ test/fixtures/560-corpus/c3-x-junction.json | 424 ++++++++++++++++++ .../560-corpus/c4-opening-at-vertex.json | 263 +++++++++++ .../560-corpus/c5-node-noise-near-axis.json | 270 +++++++++++ test/fixtures/560-corpus/c6-stale-layout.json | 172 +++++++ test/geometry-corpus.test.mjs | 367 +++++++++++++++ 11 files changed, 2750 insertions(+) create mode 100644 demo/smoke_geometry_corpus.mjs create mode 100644 demo/smoke_household_journeys.mjs create mode 100644 docs/QUALITY-560.md create mode 100644 test/fixtures/560-corpus/c1-short-edges.json create mode 100644 test/fixtures/560-corpus/c2-t-junction-thicknesses.json create mode 100644 test/fixtures/560-corpus/c3-x-junction.json create mode 100644 test/fixtures/560-corpus/c4-opening-at-vertex.json create mode 100644 test/fixtures/560-corpus/c5-node-noise-near-axis.json create mode 100644 test/fixtures/560-corpus/c6-stale-layout.json create mode 100644 test/geometry-corpus.test.mjs diff --git a/demo/smoke_geometry_corpus.mjs b/demo/smoke_geometry_corpus.mjs new file mode 100644 index 00000000..2e842a79 --- /dev/null +++ b/demo/smoke_geometry_corpus.mjs @@ -0,0 +1,182 @@ +// Браузерная половина геометрического корпуса #560. +// +// Числовые оракулы корпуса живут в test/geometry-corpus.test.mjs и карточку не +// поднимают. Здесь проверяется ровно то, что без карточки проверить нельзя: +// +// 1. штатный writer ресайза — перенос ЗАПИСЕЙ ТОЛЩИНЫ вслед за сдвинутым +// ребром делает хост (`project()` в ResizeController), а не resize.ts; +// 2. хвост цепочки «→ View»: план корпуса действительно рисуется, и важные +// участки (комнаты, проём, штриховка стен) присутствуют при DPR 1 и 2. +// +// Рендер здесь НЕ эталон: сравниваются не пиксели, а присутствие и число узлов +// плюс сохранность модели после жеста. Приёмка #560 прямо запрещает принимать +// текущий рендер за единственный источник истины. +import { readFileSync } from 'node:fs'; +import { launch, check, finish } from './serve.mjs'; + +const corpus = (name) => JSON.parse(readFileSync( + new URL(`../test/fixtures/560-corpus/${name}.json`, import.meta.url), 'utf8', +)); + +const { page, browser } = await launch(); + +/** Положить план корпуса в карточку как готовую модель v10. */ +const loadCorpus = async (config) => { + await page.evaluate((incoming) => { + const card = window.__card; + const space = incoming.spaces[0]; + card._serverCfg.model_version = incoming.model_version; + const target = card._serverCfg.spaces.find((candidate) => candidate.id === card._space); + for (const key of ['rooms', 'walls', 'wall_segments', 'openings', 'partitions']) { + if (space[key]) target[key] = structuredClone(space[key]); + else delete target[key]; + } + target.cell_cm = space.cell_cm; + for (const key of ['decor', 'wall_columns', 'room_drafts', 'open_spans']) delete target[key]; + card._geometryHistory.clear(); + card._resize.reset(); + card._cfgEpoch++; + card.requestUpdate(); + return card.updateComplete; + }, config); + await page.waitForTimeout(60); +}; + +const enter = async (tool) => { + await page.evaluate((next) => { + const card = window.__card; + if (!card._markup) card._setMode('plan'); + card._tool = next; + card._resize.reset(); + card.requestUpdate(); + return card.updateComplete; + }, tool); + await page.waitForTimeout(40); +}; + +const settle = () => page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)))); + +const rendered = () => page.evaluate(() => { + const card = window.__card; + const root = card.renderRoot; + const svg = root.querySelector('.stage svg'); + const rooms = [...root.querySelectorAll('[data-hp="room"]')]; + const box = svg?.viewBox?.baseVal; + // Каждая комната обязана попадать в кадр: уехавшая за viewBox геометрия + // «нарисована» и невидима одновременно — ровно тот случай, когда скриншот + // зелёный, а пользователь видит пустой план. + const inside = rooms.every((node) => { + const bbox = node.getBBox(); + return box ? bbox.x + bbox.width > box.x - 1 && bbox.x < box.x + box.width + 1 + && bbox.y + bbox.height > box.y - 1 && bbox.y < box.y + box.height + 1 : false; + }); + return { + rooms: rooms.length, + roomsInsideViewBox: inside, + openings: root.querySelectorAll('[data-hp="opening"],[data-hp="opening-tunnel"]').length, + wallFills: root.querySelectorAll('.wallfill,.wallhatch,[data-hp="wall"]').length, + strokeWidths: [...new Set(rooms.map((node) => getComputedStyle(node).strokeWidth))].length, + }; +}); + +const thicknessMultiset = () => page.evaluate(() => { + const card = window.__card; + const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space); + return (space.walls || []).map((wall) => wall.cm).sort((a, b) => a - b).join(','); +}); + +const roomPolys = () => page.evaluate(() => { + const card = window.__card; + const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space); + return JSON.stringify(space.rooms.map((room) => [room.id, room.poly])); +}); + +const pointer = (type, planX, planY, { cx, cy, pointerId = 91 } = {}) => + page.evaluate((args) => { + const card = window.__card; + const stage = card.renderRoot.querySelector('.stage'); + const svg = stage.querySelector('svg'); + const point = new DOMPoint(args.planX, args.planY).matrixTransform(svg.getScreenCTM()); + const handles = [...card.renderRoot.querySelectorAll('.rszhandle')]; + const target = args.cx == null + ? handles.find((handle) => handle.getAttribute('aria-disabled') === 'false') + : handles.find((handle) => Math.abs(Number(handle.getAttribute('cx')) - args.cx) < 1 + && Math.abs(Number(handle.getAttribute('cy')) - args.cy) < 1); + target?.dispatchEvent(new PointerEvent(args.type, { + bubbles: true, cancelable: true, pointerId: args.pointerId, + clientX: point.x, clientY: point.y, pointerType: 'mouse', + buttons: args.type === 'pointerup' ? 0 : 1, + })); + return { sent: !!target, handle: target ? [Number(target.getAttribute('cx')), Number(target.getAttribute('cy'))] : null }; + }, { type, planX, planY, cx, cy, pointerId }); + +// --- 1. T-узел с тремя толщинами: рисуется и переживает штатный ресайз ------- +await loadCorpus(corpus('c2-t-junction-thicknesses')); +await enter('plan'); +await settle(); +const c2view = await rendered(); +check('corpus.c2.rooms_rendered', c2view.rooms, 3); +check('corpus.c2.rooms_inside_viewbox', c2view.roomsInsideViewBox, true); +check('corpus.c2.wall_fills_present', c2view.wallFills > 0, true); +const c2thickness = await thicknessMultiset(); +// Ожидание берётся из самой фикстуры, а не вписывается числом: вписанное число +// разошлось бы с корпусом молча, и проверка стала бы декорацией. +const c2fixture = corpus('c2-t-junction-thicknesses').spaces[0].walls + .map((wall) => wall.cm).sort((a, b) => a - b).join(','); +check('corpus.c2.thickness_multiset', c2thickness, c2fixture); +check('corpus.c2.thickness_variety', new Set(c2fixture.split(',')).size >= 3, true); + +await enter('resize'); +await settle(); +const handles = await page.evaluate(() => [...window.__card.renderRoot.querySelectorAll('.rszhandle')] + .filter((handle) => handle.getAttribute('aria-disabled') === 'false').length); +check('corpus.c2.enabled_handles', handles > 0, true); +const target = await page.evaluate(() => { + const handle = [...window.__card.renderRoot.querySelectorAll('.rszhandle')] + .find((candidate) => candidate.getAttribute('aria-disabled') === 'false'); + return handle ? [Number(handle.getAttribute('cx')), Number(handle.getAttribute('cy'))] : null; +}); +const down = await pointer('pointerdown', target[0], target[1], { cx: target[0], cy: target[1] }); +check('corpus.c2.resize_down_sent', down.sent, true); +check('corpus.c2.resize_dragging', await page.evaluate(() => window.__card._resize.dragging), true); +const step = 1000 / 240; // один шаг решётки в единицах плана +await pointer('pointermove', target[0] + step, target[1], { cx: target[0], cy: target[1] }); +await settle(); +await pointer('pointerup', target[0] + step, target[1], { cx: target[0], cy: target[1] }); +await settle(); +await page.waitForTimeout(80); +const c2after = await thicknessMultiset(); +// Главный оракул браузерной половины: набор толщин после штатного ресайза тот +// же. Именно эту сохранность контроллер ресайза проверяет сам +// (checkWallRecordsPreserved с exactMultiplicity) и откатывает жест иначе. +check('corpus.c2.thickness_survives_resize', c2after, c2thickness); +const c2rendered = await rendered(); +check('corpus.c2.rooms_after_resize', c2rendered.rooms, 3); +check('corpus.c2.inside_viewbox_after_resize', c2rendered.roomsInsideViewBox, true); + +// --- 2. Проём у вершины: рисуется и переживает перечитывание ----------------- +await loadCorpus(corpus('c4-opening-at-vertex')); +await enter('plan'); +await settle(); +const c4view = await rendered(); +check('corpus.c4.rooms_rendered', c4view.rooms, 2); +check('corpus.c4.opening_rendered', c4view.openings > 0, true); +check('corpus.c4.rooms_inside_viewbox', c4view.roomsInsideViewBox, true); +const c4before = await roomPolys(); + +// save → reload: тот же конфиг приходит заново, как после перезагрузки вкладки. +await page.evaluate(() => { + const card = window.__card; + card._serverCfg = structuredClone(card._serverCfg); + card._cfgEpoch++; + card.requestUpdate(); + return card.updateComplete; +}); +await settle(); +check('corpus.c4.geometry_survives_reload', await roomPolys(), c4before); +const c4reloaded = await rendered(); +check('corpus.c4.rooms_after_reload', c4reloaded.rooms, 2); +check('corpus.c4.opening_after_reload', c4reloaded.openings > 0, true); + +await finish(browser, { c2view, c2thickness, c2after, c4view, c4reloaded }); diff --git a/demo/smoke_household_journeys.mjs b/demo/smoke_household_journeys.mjs new file mode 100644 index 00000000..a6c8c87b --- /dev/null +++ b/demo/smoke_household_journeys.mjs @@ -0,0 +1,272 @@ +// Household-сценарии основного View (#560). +// +// Шесть путей домашнего пользователя, а не шесть проверок функций: открыть +// карточку на проснувшемся экране, найти тревогу, прочитать значение, выполнить +// разрешённое действие, переключить этаж, вернуться после обновления. У каждого +// названы персона, вход, действие и НЕЗАВИСИМЫЙ наблюдаемый оракул: где можно — +// не «класс появился», а вызов сервиса, доступное имя или содержимое модели. +// +// Две ширины, потому что домашний пользователь живёт в двух разных местах: +// настенный планшет (штатная ширина демо-стенда, 780 px) и узкая колонка +// телефона (390 px) — та самая, на которой ломается первый контакт. +// +// Граница честности: это синтетический стенд. Реальная установка владельца в +// этом заходе читалась только на чтение (профиль в docs/QUALITY-560.md), +// добровольцев и физического Companion в проверке не было. +import { launch, check, finish, watchPage } from './serve.mjs'; + +const { page, browser } = await launch(); +const out = {}; + +const settle = () => page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)))); + +/** Геометрия карточки успокоилась: два одинаковых замера подряд (#533). */ +const settledBox = async (id, attempts = 25) => { + let previous = null; + for (let i = 0; i < attempts; i++) { + await page.waitForTimeout(120); + const now = await page.evaluate((markerId) => { + const node = window.__card.renderRoot.querySelector(`[data-hp="device"][data-id="${markerId}"]`); + if (!node) return null; + const rect = node.getBoundingClientRect(); + return [Number((rect.x + rect.width / 2).toFixed(2)), Number((rect.y + rect.height / 2).toFixed(2)), + Number(rect.width.toFixed(2))]; + }, id); + if (previous && now && previous[0] === now[0] && previous[1] === now[1]) return now; + previous = now; + } + return previous; +}; + +const setWidth = async (px) => { + await page.evaluate(async (width) => { + const card = window.__card; + card.style.width = `${width}px`; + card._lastValidStageSize = null; + card._refitView?.(); + await card.updateComplete; + }, px); + await settle(); +}; + +// --- J1. «Экран проснулся» --------------------------------------------------- +// Персона: домашний пользователь у настенного планшета. Вход: вкладка, которая +// никогда не загружала редакторский чанк. Оракул: план показан (вуаль снята), +// комнаты и устройства нарисованы, редакторский чанк не запрашивался. +const requested = []; +page.on('request', (request) => requested.push(new URL(request.url()).pathname)); +const j1 = await page.evaluate(() => { + const card = window.__card; + const root = card.renderRoot; + return { + booting: card._booting, + rooms: root.querySelectorAll('[data-hp="room"]').length, + devices: root.querySelectorAll('[data-hp="device"]').length, + stageVisible: getComputedStyle(root.querySelector('.stage')).visibility, + }; +}); +out.j1 = j1; +check('j1.plan_revealed', j1.booting === false && j1.stageVisible === 'visible', true); +check('j1.rooms_drawn', j1.rooms > 0, true); +check('j1.devices_drawn', j1.devices > 0, true); + +// --- J2. «Где протечка» ------------------------------------------------------ +// Вход: датчик протечки уходит в тревогу. Оракул независимый: доступное имя +// маркера называет состояние, а не только цвет пятна. +const j2 = await page.evaluate(async () => { + const card = window.__card; + const leak = card._devices.find((device) => device.id === 'd_leak') || card._devices[0]; + const entity = leak.primary || Object.keys(card.hass.states)[0]; + card.hass = { ...card.hass, states: { ...card.hass.states, [entity]: { ...card.hass.states[entity], state: 'on' } } }; + await card.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 150)); + const node = card.renderRoot.querySelector(`[data-hp="device"][data-id="${leak.id}"]`); + return { + id: leak.id, + label: node?.getAttribute('aria-label') || '', + state: node?.dataset.state || null, + alarmClass: node?.classList.contains('alarm') || false, + role: node?.getAttribute('role') || null, + }; +}); +out.j2 = j2; +check('j2.alarm_has_accessible_state', /\S/.test(j2.label) && j2.state !== null, true); +check('j2.alarm_not_colour_only', j2.label.split(',').length >= 2, true); + +// --- J3. «Сколько в спальне» ------------------------------------------------- +// Оракул: числовое значение попадает в ДОСТУПНОЕ ИМЯ, а не только в рисунок. +const j3 = await page.evaluate(() => { + const card = window.__card; + const nodes = [...card.renderRoot.querySelectorAll('[data-hp="device"]')]; + const withValue = nodes + .map((node) => ({ id: node.dataset.id, label: node.getAttribute('aria-label') || '' })) + .filter((item) => /\d/.test(item.label)); + return { total: nodes.length, withValue: withValue.length, sample: withValue.slice(0, 2) }; +}); +out.j3 = j3; +check('j3.values_are_readable_by_name', j3.withValue > 0, true); + +// --- J4. «Выключить свет с клавиатуры» --------------------------------------- +// Вход: Tab до маркера на плане, затем Enter. Оракул: вызов сервиса, а не +// внутренний флаг карточки. +const tabStops = await (async () => { + let stops = 0; + for (let i = 0; i < 40; i++) { + await page.keyboard.press('Tab'); + stops += 1; + const reached = await page.evaluate(() => { + const leaf = (() => { + let node = document.activeElement; + while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement; + return node; + })(); + return leaf?.dataset?.hp === 'device' ? leaf.dataset.id : null; + }); + if (reached) return { stops, id: reached }; + } + return { stops, id: null }; +})(); +const j4 = await page.evaluate(async () => { + const card = window.__card; + const calls = []; + const hass = card.hass; + const original = hass.callService?.bind(hass); + card.hass = { ...hass, callService: (...args) => { calls.push(`${args[0]}.${args[1]}`); return original?.(...args); } }; + await card.updateComplete; + const leaf = (() => { + let node = document.activeElement; + while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement; + return node; + })(); + const focusVisible = leaf?.matches?.(':focus-visible') || false; + const ring = leaf ? getComputedStyle(leaf).getPropertyValue('--device-ring-color').trim() : null; + const idle = card.renderRoot.querySelector('[data-hp="device"]:not(:focus-visible)'); + const idleRing = idle ? getComputedStyle(idle).getPropertyValue('--device-ring-color').trim() : null; + leaf?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + await new Promise((resolve) => setTimeout(resolve, 150)); + return { calls, focusVisible, ring, idleRing }; +}); +out.j4 = { ...j4, ...tabStops }; +check('j4.marker_reachable_by_tab', tabStops.id !== null, true); +check('j4.focus_is_visible', j4.focusVisible && j4.ring !== j4.idleRing, true); +check('j4.enter_calls_service', j4.calls.length > 0, true); + +// --- J5. «Переключить этаж» -------------------------------------------------- +const j5 = await page.evaluate(async () => { + const card = window.__card; + const root = card.renderRoot; + const tabs = [...root.querySelectorAll('[data-hp="space-tab"]')]; + const before = card._space; + const other = tabs.find((tab) => tab.dataset.id !== before); + const beforeRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(','); + other.click(); + await card.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 250)); + const afterRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(','); + const activeTab = root.querySelector('[data-hp="space-tab"].active'); + return { + tabs: tabs.length, before, after: card._space, + roomsChanged: beforeRooms !== afterRooms, + activeTabIsCurrent: activeTab?.dataset.id === card._space, + // Состояние вкладки для программы: есть ли он вообще, кроме класса. + ariaCurrent: activeTab?.getAttribute('aria-current'), + ariaPressed: activeTab?.getAttribute('aria-pressed'), + ariaSelected: activeTab?.getAttribute('aria-selected'), + }; +}); +out.j5 = j5; +check('j5.floor_switched', j5.after !== j5.before, true); +check('j5.plan_rerendered', j5.roomsChanged, true); +check('j5.active_tab_matches_space', j5.activeTabIsCurrent, true); + +// --- J6. «Вернулся после обновления» ---------------------------------------- +const j6 = await page.evaluate(async () => { + const card = window.__card; + const chosen = card._space; + card._serverCfg = structuredClone(card._serverCfg); + card._cfgEpoch++; + card.requestUpdate(); + await card.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 200)); + return { + chosen, after: card._space, + rooms: card.renderRoot.querySelectorAll('[data-hp="room"]').length, + devices: card.renderRoot.querySelectorAll('[data-hp="device"]').length, + }; +}); +out.j6 = j6; +check('j6.space_kept', j6.after === j6.chosen, true); +check('j6.plan_redrawn', j6.rooms > 0 && j6.devices > 0, true); + +// --- J7. Цель нажатия при плотной расстановке -------------------------------- +// Вход: пять маркеров в колонке с шагом 0,0333 нормированной единицы — самая +// плотная расстановка, найденная на реальной установке владельца. +// Оракул: в центре ВИДИМОГО маркера нажатие достаётся ему же. +const dense = await page.evaluate(async () => { + const card = window.__card; + const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space); + const room = space.rooms.find((candidate) => candidate.id && candidate.area) || space.rooms[0]; + const ids = ['dense1', 'dense2', 'dense3', 'dense4', 'dense5']; + card._serverCfg.markers = [...(card._serverCfg.markers || []), ...ids.map((id, index) => ({ + id, name: `Dense ${index + 1}`, binding: 'virtual', is_light: true, tap_action: 'toggle', + space: space.id, area: room.area, room_id: room.id, + }))]; + const layout = { ...card._layout }; + ids.forEach((id, index) => { layout[id] = { s: space.id, x: 0.5, y: 0.3 + index * 0.0333 }; }); + card._layout = layout; + card._cfgEpoch++; + card._regSignature = ''; + card._maybeRebuildDevices(); + card.requestUpdate(); + await card.updateComplete; + window.__journeyHits = []; + const original = card._clickDevice?.bind(card); + if (original) { + card._clickDevice = (event, device) => { window.__journeyHits.push(device?.id || null); return original(event, device); }; + } + return { ids }; +}); + +const ownerOfCentre = async (id) => { + const centre = await settledBox(id); + if (!centre) return null; + return page.evaluate(([cx, cy]) => { + let node = document.elementFromPoint(cx, cy); + while (node?.shadowRoot?.elementFromPoint) { + const deeper = node.shadowRoot.elementFromPoint(cx, cy); + if (!deeper || deeper === node) break; + node = deeper; + } + return node?.closest?.('[data-hp="device"]')?.dataset?.id || null; + }, [centre[0], centre[1]]); +}; + +const tablet = { size: (await settledBox('dense1'))?.[2] ?? null, owners: [] }; +for (const id of dense.ids) tablet.owners.push(await ownerOfCentre(id)); +const tabletCentre = await settledBox('dense1'); +await page.mouse.click(tabletCentre[0], tabletCentre[1]); +await page.waitForTimeout(200); +tablet.clickWentTo = await page.evaluate(() => window.__journeyHits.at(-1) || null); +out.j7_tablet = tablet; +// Настенный планшет: центр видимого маркера принадлежит ему. Это то свойство, +// которое правка плотных целей обязана сохранить. +check('j7.tablet_centre_belongs_to_its_marker', + tablet.owners.every((owner, index) => owner === dense.ids[index]), true); +check('j7.tablet_click_reaches_its_marker', tablet.clickWentTo, 'dense1'); + +// Узкая колонка телефона: тот же замер печатается в отчёт БЕЗ проверки — +// расхождение здесь заведено отдельным issue, и свидетель приедет с правкой. +// Вписывать текущее поведение проверкой значило бы узаконить дефект. +await setWidth(390); +const phone = { size: (await settledBox('dense1'))?.[2] ?? null, owners: [] }; +for (const id of dense.ids) phone.owners.push(await ownerOfCentre(id)); +const phoneCentre = await settledBox('dense1'); +const before = await page.evaluate(() => window.__journeyHits.length); +await page.mouse.click(phoneCentre[0], phoneCentre[1]); +await page.waitForTimeout(200); +phone.clickWentTo = await page.evaluate((from) => window.__journeyHits.slice(from).at(-1) || null, before); +out.j7_phone = phone; +console.log('j7.phone (без проверки, см. docs/QUALITY-560.md):', JSON.stringify(phone)); + +await finish(browser, out); diff --git a/docs/QUALITY-560.md b/docs/QUALITY-560.md new file mode 100644 index 00000000..f8993ff5 --- /dev/null +++ b/docs/QUALITY-560.md @@ -0,0 +1,201 @@ +# Полевая проверка View, аудит доступности и геометрический корпус (#560) + +Дерево проверки: `dev` на 13.09.2026 (после #556). Всё, помеченное +«проверено исполнением», снято прогоном на этом дереве; всё остальное +помечено как чтение или арифметика. + +## 0. Что было и чего не было + +Было: чтение реальной установки владельца (только чтение, по его прямому +разрешению), синтетический стенд `demo/` в двух ширинах, узловые тесты модели. + +**Не было, и это не оговорка задним числом:** добровольцев кроме владельца, +физического Companion на телефоне/планшете, записи в реальную установку, +проверки со скринридером и с симуляцией цветовой слепоты. Пункты, которые без +этого не проверяются, названы ниже гипотезами, а не находками. + +## 1. Профиль реального плана (чтение, 13.09.2026) + +Модель v10, `rev` 2740, интеграция 1.76.0-beta.1. + +| что | сколько | +|---|---| +| пространств / комнат | 5 / 37 | +| записей толщины / сегментов стен | 121 / — | +| проёмов / перегородок / колонн | 35 / 7 / 2 | +| маркеров | 156 | +| шаг сетки по пространствам | 5, 1, 30, 1, 1 см | +| толщины стен в одном пространстве | 20/22/28/29/33 · 20/30/40 · 4/15/17/33 · 10/15 | +| минимальное ребро комнаты | 5 см (шаг 1 см) и 10 см (шаг 5 см) | +| T-узлов по пространствам | 7 / 6 / 3 / 2 / 0 | +| X-узлов (степень ≥ 4) | **ни одного** | +| координат всего / с шумом ближе 1e-4 шага | 1010 / 23 | +| почти осевых стен (`classifyNearAxisSegment`) | 0 | +| проёмов, чей край лежит ровно в вершине комнаты | 1 | +| записей `layout` на УДАЛЁННЫЕ пространства | 4 (на 2 пространства) | +| ссылок на сущности HA в маркерах и проёмах | 42 | +| из проверенных 25 ссылок не существует в HA | 1 | + +Инварианты модели на этом конфиге чистые: `node scripts/model-invariants.mjs` +сообщает «ссылки разрешимы, записи толщины находятся». + +**Геометрия владельца в репозиторий не попала.** Фикстуры корпуса +синтетические и воспроизводят перечисленные условия числами. Это и есть +обезличивание: в репозитории нет ни плана дома, ни имён комнат, ни +идентификаторов сущностей. + +## 2. Household-сценарии + +Стенд: `demo/smoke_household_journeys.mjs`, штатная ширина карточки 780 px +(настенный планшет) и 390 px (узкая колонка телефона). У каждого пути назван +независимый оракул — не «класс появился», а вызов сервиса, доступное имя или +содержимое модели. + +| путь | вход | оракул | результат | +|---|---|---|---| +| J1 «экран проснулся» | вкладка, не грузившая редакторский чанк | `_booting === false`, `.stage` видима, комнат 4, устройств 10 | пройден | +| J2 «где протечка» | датчик протечки уходит в `on` | доступное имя называет состояние (`Sink leak sensor, Alarm, …`), `data-state="alarm"` | пройден, см. F4 | +| J3 «сколько в спальне» | температурный маркер со значением | значение попадает в `aria-label` (`Temperature: 22.4°`), 7 из 10 маркеров несут число в имени | пройден | +| J4 «выключить свет с клавиатуры» | Tab до маркера, затем Enter | вызван `light.turn_off`; кольцо фокуса `#0C82F0` против `transparent` у остальных | пройден; до первого маркера 15 остановок Tab | +| J5 «переключить этаж» | клик по вкладке пространства | `_space` сменился, набор комнат перерисован, активная вкладка соответствует пространству | пройден, см. F3 | +| J6 «вернулся после обновления» | конфиг приходит заново | выбранное пространство сохранено, план и устройства нарисованы | пройден | +| J7 «нажать нужный светильник» | пять маркеров с шагом 0,0333 — самая плотная расстановка из поля | в центре ВИДИМОГО маркера нажатие достаётся ему же | 780 px — пройден; **390 px — не пройден, F1** | + +## 3. Аудит доступности текущего View (проверено исполнением) + +Что уже сделано правильно и должно остаться: + +- маркер устройства с действием — `role="button"`, `tabindex="0"`, осмысленное + `aria-label` с названием, состоянием и значением; +- Enter и Space на маркере действительно вызывают действие (`_keyDevice` → + `_clickDevice` → `light.turn_off` в прогоне); +- фокус видим: `:focus-visible` красит кольцо `#0C82F0` (у остальных + `transparent`), скриншот вокруг маркера меняется; +- зона нажатия увеличена сознательно: `.dev::before` — круг + `max(44px, --device-shell-size)`, то есть 44 px при любом размере маркера. + Это выше минимума WCAG 2.5.8 (24 px) — и именно это порождает F1 при + плотной расстановке. + +Измеренные проблемы — F1…F5 ниже. + +## 4. Геометрический корпус + +`test/fixtures/560-corpus/` — шесть планов; `test/geometry-corpus.test.mjs` — +26 проверок; `demo/smoke_geometry_corpus.mjs` — браузерная половина. + +| фикстура | условие | откуда взято | +|---|---|---| +| `c1-short-edges` | уступ в два шага (10 см при шаге 5 см) | минимальное ребро поля | +| `c2-t-junction-thicknesses` | T-узел, где сходятся три разные толщины | 7 T-узлов и 5 толщин в поле | +| `c3-x-junction` | четыре комнаты в одной точке | **синтетика: в поле X-узлов нет** | +| `c4-opening-at-vertex` | край проёма ровно в вершине комнаты | один такой проём в поле | +| `c5-node-noise-near-axis` | шум у узла плюс честно наклонная стена | 23 шумных координаты в поле | +| `c6-stale-layout` | `layout` на удалённое пространство | 4 таких записи в поле | + +Цепочка на каждом плане: импорт → инварианты → Optimize → инварианты → +Optimize → Resize → Optimize → сохранение и перечитывание. Оракулы численные: + +- нарушений инвариантов ровно столько, сколько заявлено (для c1–c5 — ноль); +- Optimize не меняет вход (immutability, как в #281); +- приведённый план Optimize не трогает (`changed === false`), а второй проход + не двигает ничего никогда — фикс-пойнт #477; +- записи толщины целы (`checkWallRecordsPreserved`); +- ключи записей канонические (`wallKey` пересчитывается независимо); +- **наклонная стена не выпрямляется**: `classifyNearAxisSegment` не относит + наклон 1 шаг на 60 к почти осевым, и Optimize оставляет её как есть. Это + прямая проверка приёмки «корректная планировка не исправляется ради удобства + рендера»; шум у узла, наоборот, снимается — и это правильно. + +Рендер эталоном не является: браузерная половина проверяет присутствие и число +узлов (комнаты, проём, заливка стен), попадание геометрии в `viewBox` и +сохранность набора толщин после ШТАТНОГО ресайза — то, что чистыми функциями +не проверяется, потому что перенос записей толщины делает хост карточки +(`project()` в `ResizeController.move`), а не `resize.ts`. + +**Граница узловой половины названа прямо:** вход её шага Resize — «рёбра +сдвинуты, записи толщины на прежних координатах», состояние, которого штатный +writer не создаёт. Поэтому там проверяется сходимость Optimize за ≤ 2 прохода, +а не «за один». + +## 5. Находки + +### F1 (High) — в узкой колонке нажатие достаётся соседнему маркеру + +Проверено исполнением на осевшей геометрии (замер повторяется до двух +одинаковых подряд — правило #533). + +При ширине карточки 390 px маркеры имеют размер 10,84 px, а расстояние между +центрами при самой плотной расстановке поля (шаг 0,0333) — 11,8 px. Зона +нажатия каждого маркера — круг 44 px (`.dev::before`, `pointer-events: auto`, +`z-index: 3`), то есть ±22 px от центра. Круг соседа накрывает центр маркера +целиком, и выигрывает тот, кто выше по порядку. + +Что измерено: центр `dense1` (203; 461,08) принадлежит `dense2` +(`elementFromPoint` и реальный `page.mouse.click` — обоими). Для пяти маркеров +владельцы центров: `dense2, dense3, dense4, dense5, dense5`. То есть **ни один +из первых четырёх маркеров нельзя нажать в его собственном центре**. + +Порог: расхождение начинается, когда расстояние между центрами меньше 22 px +(половина фиксированного круга). На 780 px тот же план даёт 23,6 px между +центрами — проверка проходит, но запас 1,6 px. + +Почему это важно: J3 «безопасно действовать» — на телефоне пользователь +включает не тот свет, и никакого признака ошибки нет. + +### F2 (Medium) — подписи комнат: контраст 2,01:1 при норме 4,5:1 + +Тёмная тема по умолчанию: цвет подписи `rgb(85,96,108)` на фоне стейджа +`rgb(28,37,48)`, `opacity: 0.8`, размер 12,05 px. Контраст по чистому цвету +2,41:1, с учётом смешивания полупрозрачности — **2,01:1**. WCAG 1.4.3 (AA) для +такого размера требует 4,5:1. + +Механизм: цвет подписи — это цвет комнаты (`disp.color`), а прозрачность — +`min(1, room_opacity + 0.25)`. Отдельного токена для текста плана нет, поэтому +подпись нельзя сделать читаемой, не перекрасив сами комнаты. + +### F3 (Medium) — активная вкладка этажа не имеет программного состояния + +`