Корпус геометрий, household-сценарии и аудит доступности View

Полевая проверка основного View по #560: шесть обезличенных планов корпуса и
цепочка import → Optimize → Optimize → Resize → сохранение с численными
оракулами, семь household-путей с независимыми оракулами в двух ширинах
карточки, аудит доступности с измеренными числами. Продуктовых правок нет:
находки заведены отдельно (#564, #565, #566).

Issue: #560
User-Visible: no
This commit is contained in:
Codex
2026-09-13 21:05:28 +03:00
parent a03ccd65a5
commit db7eef5306
11 changed files with 2750 additions and 0 deletions
+182
View File
@@ -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 });
+272
View File
@@ -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);
+201
View File
@@ -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) — активная вкладка этажа не имеет программного состояния
`<button class="tab active">` не несёт ни `aria-current`, ни `aria-pressed`,
ни `aria-selected` (проверено чтением атрибутов в прогоне: все три `null`), а
полоса вкладок — обычный `div`, не `tablist`. Какой этаж открыт, сообщается
только классом, то есть видом. WCAG 4.1.2.
### F4 (Low) — состояние в доступном имени повторяется дважды
`Sink leak sensor, Alarm, Alarm, LQI 117, medium signal` — слово состояния
попадает в имя из двух источников. Скринридер произносит его дважды.
### F5 (Low) — подсказка с названием и метриками не показывается при фокусе
Один и тот же узел подсказки: при `pointerover` — `display: block` и текст
«Ceiling light / Smart Bulb E27», при клавиатурном фокусе — `display: none`.
Данные не теряются для скринридера (они есть в `aria-label`), но зрячий
пользователь клавиатуры их не получает.
### F6 (Low) — записи `layout` на удалённые пространства переживают Optimize
Проверено на `c6-stale-layout`: Optimize видит их (`positionsUnresolved: 2`),
но оставляет, и `npm run invariants` на экспорте пользователя сообщает о них
как о нарушениях. В поле таких записей четыре на два удалённых пространства.
Вреда пользователю нет; цена — ложный сигнал при будущем разборе.
## 6. Гипотезы UX (не находки, проверять с людьми)
- **U1.** Проём, чей `contact` удалён из HA, рисуется ОТКРЫТЫМ:
`openingAmount` при отсутствующем состоянии возвращает 1 для дверей и
калиток (для окон 0). Это отказ в безопасную сторону, но признака «состояние
неизвестно» нет. В поле ровно один такой случай: калитка ссылается на
`binary_sensor`, которого в HA больше не существует (404) — то есть на плане
владельца калитка нарисована открытой постоянно.
- **U2.** До первого маркера на плане 15 остановок Tab: сначала весь хром.
- **U3.** Состояние маркера зрительно передаётся заливкой (`#F0A00C` против
светлой/тёмной) при одинаковой иконке и размере. Светимости различаются
сильно, поэтому нарушения 1.4.1 здесь скорее нет, но без проверки с
симуляцией цветовой слепоты это гипотеза.
## 7. Что заведено отдельно
Продуктовые правки в этой задаче не делались — она research/tests-only.
F1, F2–F5 и F6 заведены отдельными issue со ссылкой на #560; свидетель к
каждой приедет вместе с правкой, а не поселится здесь зелёной проверкой,
узаконивающей дефект.
+44
View File
@@ -72,6 +72,50 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
const MUTANT_DEFINITIONS = [
{
id: 'household-marker-drops-keyboard-reach',
guard: 'node demo/smoke_household_journeys.mjs',
because: '#560 J4: маркер с действием обязан доставаться клавиатурой — без tabindex он '
+ 'остаётся нажимаемым мышью, и обычный прогон этого не замечает',
patches: [{
file: 'src/houseplan-card.ts',
find: " tabindex=${interactive ? '0' : nothing}\n aria-label=${deviceAriaLabel}",
replace: ' aria-label=${deviceAriaLabel}',
}],
},
{
id: 'household-enter-stops-acting',
guard: 'node demo/smoke_household_journeys.mjs',
because: '#560 J4: Enter и Space на маркере обязаны вызывать действие; фокус и роль при '
+ 'этом остаются на месте, поэтому дефект виден только вызовом сервиса',
patches: [{
file: 'src/houseplan-card.ts',
find: " if (ev.key !== 'Enter' && ev.key !== ' ') return;",
replace: ' return;',
}],
},
{
id: 'corpus-loses-its-short-edge',
guard: 'node --test --test-name-pattern="c1-short-edges" test/geometry-corpus.test.mjs',
because: '#560: фикстура, потерявшая своё условие, — это зелёный прогон, который ничего '
+ 'не проверяет; корпус обязан замечать такую потерю, а не считать дальше',
patches: [{
file: 'test/fixtures/560-corpus/c1-short-edges.json',
find: ' 0.24166666666666667,\n 0.15833333333333333\n ],\n [\n 0.24166666666666667,',
replace: ' 0.2,\n 0.15833333333333333\n ],\n [\n 0.2,',
}],
},
{
id: 'optimize-reports-work-it-did-not-do',
guard: 'node --test --test-name-pattern="импорт" test/geometry-corpus.test.mjs',
because: '#477/#560: приведённый план Optimize трогать не должен; вечное changed=true '
+ 'заново пишет конфиг, бьёт rev и множит записи истории на каждом нажатии',
patches: [{
file: 'src/plan-optimizer.ts',
find: ' const changed = JSON.stringify(persistedConfig) !== original\n || JSON.stringify(persistedLayout) !== originalLayout;',
replace: ' const changed = true;',
}],
},
{
id: 'summary-hide-unmounts-before-animation',
guard: 'node demo/smoke_summary_panel_polish.mjs',
+203
View File
@@ -0,0 +1,203 @@
{
"model_version": 10,
"spaces": [
{
"id": "c1",
"title": "c1",
"cell_cm": 5,
"rooms": [
{
"id": "c1-r1",
"name": null,
"area": null,
"poly": [
[
0,
0
],
[
0.25,
0
],
[
0.25,
0.15833333333333333
],
[
0.24166666666666667,
0.15833333333333333
],
[
0.24166666666666667,
0.16666666666666666
],
[
0,
0.16666666666666666
]
],
"wall_ids": [
"c1-w1",
"c1-w2",
"c1-w3",
"c1-w4",
"c1-w5",
"c1-w6"
]
}
],
"walls": [
{
"key": "0.000000,0.083333@1.5706",
"cm": 20,
"a": [
0,
0
],
"b": [
0,
0.16666666666666666
]
},
{
"key": "0.125000,0.000000@0.0000",
"cm": 20,
"a": [
0,
0
],
"b": [
0.25,
0
]
},
{
"key": "0.120833,0.166667@0.0000",
"cm": 20,
"a": [
0,
0.16666666666666666
],
"b": [
0.24166666666666667,
0.16666666666666666
]
},
{
"key": "0.241667,0.162500@1.5706",
"cm": 20,
"a": [
0.24166666666666667,
0.15833333333333333
],
"b": [
0.24166666666666667,
0.16666666666666666
]
},
{
"key": "0.245833,0.158333@0.0000",
"cm": 20,
"a": [
0.24166666666666667,
0.15833333333333333
],
"b": [
0.25,
0.15833333333333333
]
},
{
"key": "0.250000,0.079167@1.5706",
"cm": 20,
"a": [
0.25,
0
],
"b": [
0.25,
0.15833333333333333
]
}
],
"wall_segments": [
{
"id": "c1-w6",
"a": [
0,
0
],
"b": [
0,
0.16666666666666666
],
"cm": 20
},
{
"id": "c1-w1",
"a": [
0,
0
],
"b": [
0.25,
0
],
"cm": 20
},
{
"id": "c1-w5",
"a": [
0,
0.16666666666666666
],
"b": [
0.24166666666666667,
0.16666666666666666
],
"cm": 20
},
{
"id": "c1-w4",
"a": [
0.24166666666666667,
0.15833333333333333
],
"b": [
0.24166666666666667,
0.16666666666666666
],
"cm": 20
},
{
"id": "c1-w3",
"a": [
0.24166666666666667,
0.15833333333333333
],
"b": [
0.25,
0.15833333333333333
],
"cm": 20
},
{
"id": "c1-w2",
"a": [
0.25,
0
],
"b": [
0.25,
0.15833333333333333
],
"cm": 20
}
],
"partitions": [],
"openings": []
}
],
"markers": [],
"settings": {}
}
+352
View File
@@ -0,0 +1,352 @@
{
"model_version": 10,
"spaces": [
{
"id": "c2",
"title": "c2",
"cell_cm": 5,
"rooms": [
{
"id": "c2-nw",
"name": null,
"area": null,
"poly": [
[
0,
0.125
],
[
0.125,
0.125
],
[
0.125,
0.25
],
[
0,
0.25
]
],
"wall_ids": [
"c2-w1",
"c2-w2",
"c2-w3",
"c2-w4"
]
},
{
"id": "c2-sw",
"name": null,
"area": null,
"poly": [
[
0,
0
],
[
0.125,
0
],
[
0.125,
0.125
],
[
0,
0.125
]
],
"wall_ids": [
"c2-w5",
"c2-w6",
"c2-w1",
"c2-w7"
]
},
{
"id": "c2-east",
"name": null,
"area": null,
"poly": [
[
0.125,
0
],
[
0.25,
0
],
[
0.25,
0.25
],
[
0.125,
0.25
],
[
0.125,
0.125
]
],
"wall_ids": [
"c2-w8",
"c2-w9",
"c2-w10",
"c2-w2",
"c2-w6"
]
}
],
"walls": [
{
"key": "0.000000,0.062500@1.5706",
"cm": 20,
"a": [
0,
0
],
"b": [
0,
0.125
]
},
{
"key": "0.062500,0.000000@0.0000",
"cm": 20,
"a": [
0,
0
],
"b": [
0.125,
0
]
},
{
"key": "0.000000,0.187500@1.5706",
"cm": 20,
"a": [
0,
0.125
],
"b": [
0,
0.25
]
},
{
"key": "0.062500,0.125000@0.0000",
"cm": 22,
"a": [
0,
0.125
],
"b": [
0.125,
0.125
]
},
{
"key": "0.062500,0.250000@0.0000",
"cm": 20,
"a": [
0,
0.25
],
"b": [
0.125,
0.25
]
},
{
"key": "0.125000,0.062500@1.5706",
"cm": 33,
"a": [
0.125,
0
],
"b": [
0.125,
0.125
]
},
{
"key": "0.187500,0.000000@0.0000",
"cm": 20,
"a": [
0.125,
0
],
"b": [
0.25,
0
]
},
{
"key": "0.125000,0.187500@1.5706",
"cm": 33,
"a": [
0.125,
0.125
],
"b": [
0.125,
0.25
]
},
{
"key": "0.187500,0.250000@0.0000",
"cm": 20,
"a": [
0.125,
0.25
],
"b": [
0.25,
0.25
]
},
{
"key": "0.250000,0.125000@1.5706",
"cm": 20,
"a": [
0.25,
0
],
"b": [
0.25,
0.25
]
}
],
"wall_segments": [
{
"id": "c2-w7",
"a": [
0,
0
],
"b": [
0,
0.125
],
"cm": 20
},
{
"id": "c2-w5",
"a": [
0,
0
],
"b": [
0.125,
0
],
"cm": 20
},
{
"id": "c2-w4",
"a": [
0,
0.125
],
"b": [
0,
0.25
],
"cm": 20
},
{
"id": "c2-w1",
"a": [
0,
0.125
],
"b": [
0.125,
0.125
],
"cm": 22
},
{
"id": "c2-w3",
"a": [
0,
0.25
],
"b": [
0.125,
0.25
],
"cm": 20
},
{
"id": "c2-w6",
"a": [
0.125,
0
],
"b": [
0.125,
0.125
],
"cm": 33
},
{
"id": "c2-w8",
"a": [
0.125,
0
],
"b": [
0.25,
0
],
"cm": 20
},
{
"id": "c2-w2",
"a": [
0.125,
0.125
],
"b": [
0.125,
0.25
],
"cm": 33
},
{
"id": "c2-w10",
"a": [
0.125,
0.25
],
"b": [
0.25,
0.25
],
"cm": 20
},
{
"id": "c2-w9",
"a": [
0.25,
0
],
"b": [
0.25,
0.25
],
"cm": 20
}
],
"partitions": [],
"openings": []
}
],
"markers": [],
"settings": {}
}
+424
View File
@@ -0,0 +1,424 @@
{
"model_version": 10,
"spaces": [
{
"id": "c3",
"title": "c3",
"cell_cm": 5,
"rooms": [
{
"id": "c3-sw",
"name": null,
"area": null,
"poly": [
[
0,
0
],
[
0.125,
0
],
[
0.125,
0.125
],
[
0,
0.125
]
],
"wall_ids": [
"c3-w1",
"c3-w2",
"c3-w3",
"c3-w4"
]
},
{
"id": "c3-se",
"name": null,
"area": null,
"poly": [
[
0.125,
0
],
[
0.25,
0
],
[
0.25,
0.125
],
[
0.125,
0.125
]
],
"wall_ids": [
"c3-w5",
"c3-w6",
"c3-w7",
"c3-w2"
]
},
{
"id": "c3-nw",
"name": null,
"area": null,
"poly": [
[
0,
0.125
],
[
0.125,
0.125
],
[
0.125,
0.25
],
[
0,
0.25
]
],
"wall_ids": [
"c3-w3",
"c3-w8",
"c3-w9",
"c3-w10"
]
},
{
"id": "c3-ne",
"name": null,
"area": null,
"poly": [
[
0.125,
0.125
],
[
0.25,
0.125
],
[
0.25,
0.25
],
[
0.125,
0.25
]
],
"wall_ids": [
"c3-w7",
"c3-w11",
"c3-w12",
"c3-w8"
]
}
],
"walls": [
{
"key": "0.000000,0.062500@1.5706",
"cm": 20,
"a": [
0,
0
],
"b": [
0,
0.125
]
},
{
"key": "0.062500,0.000000@0.0000",
"cm": 20,
"a": [
0,
0
],
"b": [
0.125,
0
]
},
{
"key": "0.000000,0.187500@1.5706",
"cm": 20,
"a": [
0,
0.125
],
"b": [
0,
0.25
]
},
{
"key": "0.062500,0.125000@0.0000",
"cm": 20,
"a": [
0,
0.125
],
"b": [
0.125,
0.125
]
},
{
"key": "0.062500,0.250000@0.0000",
"cm": 20,
"a": [
0,
0.25
],
"b": [
0.125,
0.25
]
},
{
"key": "0.125000,0.062500@1.5706",
"cm": 20,
"a": [
0.125,
0
],
"b": [
0.125,
0.125
]
},
{
"key": "0.187500,0.000000@0.0000",
"cm": 20,
"a": [
0.125,
0
],
"b": [
0.25,
0
]
},
{
"key": "0.125000,0.187500@1.5706",
"cm": 20,
"a": [
0.125,
0.125
],
"b": [
0.125,
0.25
]
},
{
"key": "0.187500,0.125000@0.0000",
"cm": 20,
"a": [
0.125,
0.125
],
"b": [
0.25,
0.125
]
},
{
"key": "0.187500,0.250000@0.0000",
"cm": 20,
"a": [
0.125,
0.25
],
"b": [
0.25,
0.25
]
},
{
"key": "0.250000,0.062500@1.5706",
"cm": 20,
"a": [
0.25,
0
],
"b": [
0.25,
0.125
]
},
{
"key": "0.250000,0.187500@1.5706",
"cm": 20,
"a": [
0.25,
0.125
],
"b": [
0.25,
0.25
]
}
],
"wall_segments": [
{
"id": "c3-w4",
"a": [
0,
0
],
"b": [
0,
0.125
],
"cm": 20
},
{
"id": "c3-w1",
"a": [
0,
0
],
"b": [
0.125,
0
],
"cm": 20
},
{
"id": "c3-w10",
"a": [
0,
0.125
],
"b": [
0,
0.25
],
"cm": 20
},
{
"id": "c3-w3",
"a": [
0,
0.125
],
"b": [
0.125,
0.125
],
"cm": 20
},
{
"id": "c3-w9",
"a": [
0,
0.25
],
"b": [
0.125,
0.25
],
"cm": 20
},
{
"id": "c3-w2",
"a": [
0.125,
0
],
"b": [
0.125,
0.125
],
"cm": 20
},
{
"id": "c3-w5",
"a": [
0.125,
0
],
"b": [
0.25,
0
],
"cm": 20
},
{
"id": "c3-w8",
"a": [
0.125,
0.125
],
"b": [
0.125,
0.25
],
"cm": 20
},
{
"id": "c3-w7",
"a": [
0.125,
0.125
],
"b": [
0.25,
0.125
],
"cm": 20
},
{
"id": "c3-w12",
"a": [
0.125,
0.25
],
"b": [
0.25,
0.25
],
"cm": 20
},
{
"id": "c3-w6",
"a": [
0.25,
0
],
"b": [
0.25,
0.125
],
"cm": 20
},
{
"id": "c3-w11",
"a": [
0.25,
0.125
],
"b": [
0.25,
0.25
],
"cm": 20
}
],
"partitions": [],
"openings": []
}
],
"markers": [],
"settings": {}
}
+263
View File
@@ -0,0 +1,263 @@
{
"model_version": 10,
"spaces": [
{
"id": "c4",
"title": "c4",
"cell_cm": 5,
"rooms": [
{
"id": "c4-left",
"name": null,
"area": null,
"poly": [
[
0,
0
],
[
0.125,
0
],
[
0.125,
0.16666666666666666
],
[
0,
0.16666666666666666
]
],
"wall_ids": [
"c4-w1",
"c4-w2",
"c4-w3",
"c4-w4"
]
},
{
"id": "c4-right",
"name": null,
"area": null,
"poly": [
[
0.125,
0
],
[
0.25,
0
],
[
0.25,
0.16666666666666666
],
[
0.125,
0.16666666666666666
]
],
"wall_ids": [
"c4-w5",
"c4-w6",
"c4-w7",
"c4-w2"
]
}
],
"walls": [
{
"key": "0.000000,0.083333@1.5706",
"cm": 20,
"a": [
0,
0
],
"b": [
0,
0.16666666666666666
]
},
{
"key": "0.062500,0.000000@0.0000",
"cm": 20,
"a": [
0,
0
],
"b": [
0.125,
0
]
},
{
"key": "0.062500,0.166667@0.0000",
"cm": 20,
"a": [
0,
0.16666666666666666
],
"b": [
0.125,
0.16666666666666666
]
},
{
"key": "0.125000,0.083333@1.5706",
"cm": 20,
"a": [
0.125,
0
],
"b": [
0.125,
0.16666666666666666
]
},
{
"key": "0.187500,0.000000@0.0000",
"cm": 20,
"a": [
0.125,
0
],
"b": [
0.25,
0
]
},
{
"key": "0.187500,0.166667@0.0000",
"cm": 20,
"a": [
0.125,
0.16666666666666666
],
"b": [
0.25,
0.16666666666666666
]
},
{
"key": "0.250000,0.083333@1.5706",
"cm": 20,
"a": [
0.25,
0
],
"b": [
0.25,
0.16666666666666666
]
}
],
"wall_segments": [
{
"id": "c4-w4",
"a": [
0,
0
],
"b": [
0,
0.16666666666666666
],
"cm": 20
},
{
"id": "c4-w1",
"a": [
0,
0
],
"b": [
0.125,
0
],
"cm": 20
},
{
"id": "c4-w3",
"a": [
0,
0.16666666666666666
],
"b": [
0.125,
0.16666666666666666
],
"cm": 20
},
{
"id": "c4-w2",
"a": [
0.125,
0
],
"b": [
0.125,
0.16666666666666666
],
"cm": 20
},
{
"id": "c4-w5",
"a": [
0.125,
0
],
"b": [
0.25,
0
],
"cm": 20
},
{
"id": "c4-w7",
"a": [
0.125,
0.16666666666666666
],
"b": [
0.25,
0.16666666666666666
],
"cm": 20
},
{
"id": "c4-w6",
"a": [
0.25,
0
],
"b": [
0.25,
0.16666666666666666
],
"cm": 20
}
],
"partitions": [],
"openings": [
{
"id": "c4-o1",
"type": "door",
"x": 0.125,
"y": 0.025,
"angle": -90,
"length": 0.05,
"contact": null,
"lock": null,
"flip_h": false,
"host": {
"kind": "wall",
"id": "c4-w2",
"t": 0.15
}
}
]
}
],
"markers": [],
"settings": {}
}
+270
View File
@@ -0,0 +1,270 @@
{
"model_version": 10,
"spaces": [
{
"id": "c5",
"title": "c5",
"cell_cm": 5,
"rooms": [
{
"id": "c5-r1",
"name": null,
"area": null,
"poly": [
[
0,
0
],
[
0.25,
2.0833333333333333e-7
],
[
0.25,
0.16666666666666666
],
[
0,
0.16666666666666666
]
],
"wall_ids": [
"c5-w1",
"c5-w2",
"c5-w3",
"c5-w4"
]
},
{
"id": "c5-r2",
"name": null,
"area": null,
"poly": [
[
0,
0.20833333333333334
],
[
0.25,
0.2125
],
[
0.25,
0.3333333333333333
],
[
0,
0.3333333333333333
]
],
"wall_ids": [
"c5-w5",
"c5-w6",
"c5-w7",
"c5-w8"
]
}
],
"walls": [
{
"key": "0.125000,0.000000@0.0000",
"cm": 20,
"a": [
0,
0
],
"b": [
0.25,
2.0833333333333333e-7
]
},
{
"key": "0.250000,0.083333@1.5706",
"cm": 20,
"a": [
0.25,
2.0833333333333333e-7
],
"b": [
0.25,
0.16666666666666666
]
},
{
"key": "0.125000,0.166667@0.0000",
"cm": 20,
"a": [
0.25,
0.16666666666666666
],
"b": [
0,
0.16666666666666666
]
},
{
"key": "0.000000,0.083333@1.5706",
"cm": 20,
"a": [
0,
0.16666666666666666
],
"b": [
0,
0
]
},
{
"key": "0.125000,0.212500@0.0167",
"cm": 20,
"a": [
0,
0.20833333333333334
],
"b": [
0.25,
0.2125
]
},
{
"key": "0.250000,0.275000@1.5706",
"cm": 20,
"a": [
0.25,
0.2125
],
"b": [
0.25,
0.3333333333333333
]
},
{
"key": "0.125000,0.333333@0.0000",
"cm": 20,
"a": [
0.25,
0.3333333333333333
],
"b": [
0,
0.3333333333333333
]
},
{
"key": "0.000000,0.270833@1.5706",
"cm": 20,
"a": [
0,
0.3333333333333333
],
"b": [
0,
0.20833333333333334
]
}
],
"wall_segments": [
{
"id": "c5-w1",
"a": [
0,
0
],
"b": [
0.25,
2.0833333333333333e-7
],
"cm": 20
},
{
"id": "c5-w2",
"a": [
0.25,
2.0833333333333333e-7
],
"b": [
0.25,
0.16666666666666666
],
"cm": 20
},
{
"id": "c5-w3",
"a": [
0.25,
0.16666666666666666
],
"b": [
0,
0.16666666666666666
],
"cm": 20
},
{
"id": "c5-w4",
"a": [
0,
0.16666666666666666
],
"b": [
0,
0
],
"cm": 20
},
{
"id": "c5-w5",
"a": [
0,
0.20833333333333334
],
"b": [
0.25,
0.2125
],
"cm": 20
},
{
"id": "c5-w6",
"a": [
0.25,
0.2125
],
"b": [
0.25,
0.3333333333333333
],
"cm": 20
},
{
"id": "c5-w7",
"a": [
0.25,
0.3333333333333333
],
"b": [
0,
0.3333333333333333
],
"cm": 20
},
{
"id": "c5-w8",
"a": [
0,
0.3333333333333333
],
"b": [
0,
0.20833333333333334
],
"cm": 20
}
],
"partitions": [],
"openings": []
}
],
"markers": [],
"settings": {}
}
+172
View File
@@ -0,0 +1,172 @@
{
"config": {
"model_version": 10,
"spaces": [
{
"id": "c6",
"title": "c6",
"cell_cm": 5,
"rooms": [
{
"id": "c6-r1",
"name": null,
"area": null,
"poly": [
[
0,
0
],
[
0.25,
0
],
[
0.25,
0.16666666666666666
],
[
0,
0.16666666666666666
]
],
"wall_ids": [
"c6-w1",
"c6-w2",
"c6-w3",
"c6-w4"
]
}
],
"walls": [
{
"key": "0.125000,0.000000@0.0000",
"cm": 20,
"a": [
0,
0
],
"b": [
0.25,
0
]
},
{
"key": "0.250000,0.083333@1.5706",
"cm": 20,
"a": [
0.25,
0
],
"b": [
0.25,
0.16666666666666666
]
},
{
"key": "0.125000,0.166667@0.0000",
"cm": 20,
"a": [
0.25,
0.16666666666666666
],
"b": [
0,
0.16666666666666666
]
},
{
"key": "0.000000,0.083333@1.5706",
"cm": 20,
"a": [
0,
0.16666666666666666
],
"b": [
0,
0
]
}
],
"wall_segments": [
{
"id": "c6-w1",
"a": [
0,
0
],
"b": [
0.25,
0
],
"cm": 20
},
{
"id": "c6-w2",
"a": [
0.25,
0
],
"b": [
0.25,
0.16666666666666666
],
"cm": 20
},
{
"id": "c6-w3",
"a": [
0.25,
0.16666666666666666
],
"b": [
0,
0.16666666666666666
],
"cm": 20
},
{
"id": "c6-w4",
"a": [
0,
0.16666666666666666
],
"b": [
0,
0
],
"cm": 20
}
],
"partitions": [],
"openings": []
}
],
"markers": [
{
"id": "c6-m1",
"binding": "virtual",
"name": null,
"space": "c6",
"room_id": "c6-r1"
}
],
"settings": {}
},
"layout": {
"c6-m1": {
"s": "c6",
"x": 0.3,
"y": 0.4
},
"c6-orphan-1": {
"s": "c6-deleted",
"x": 0.5,
"y": 0.5
},
"c6-orphan-2": {
"s": "c6-deleted",
"x": 0.6,
"y": 0.5
}
}
}
+367
View File
@@ -0,0 +1,367 @@
// Геометрический regression-корпус пользовательских планов (#560).
//
// Зачем он отдельно от 246 смоков и сотни модельных тестов. Каждый прежний
// тест ставит одно условие и проверяет одно следствие. Полевой план так не
// живёт: в нём короткие рёбра, T-узлы, пять разных толщин в одном
// пространстве, проём, упирающийся в вершину, и шум у узлов — одновременно, и
// проходит он не одну операцию, а цепочку import → Optimize → Optimize →
// Resize → save/reload → export. Ломается согласованность как раз на стыках
// (#253, #244, #252, #258), и ловил их до сих пор человек глазами.
//
// Оракулы здесь ЧИСЛЕННЫЕ и не зависят от рендера: инварианты модели,
// сходимость Optimize к фикс-пойнту, сохранность записей толщины, равенство
// после сериализации. Это прямое требование приёмки #560: корпус не имеет
// права принимать текущий рендер за эталон, иначе он узаконит любой регресс,
// который рендер отрисовал «похоже».
//
// Профиль поля, по которому подобраны условия (прочитан 13.09.2026 на
// установке владельца, только чтение, модель v10, rev 2740): 5 пространств,
// 37 комнат, 121 запись толщины, 35 проёмов, 7 перегородок, 156 маркеров;
// толщины по пространствам 20/22/28/29/33, 20/30/40, 4/15/17/33, 10/15;
// минимальное ребро комнаты 5 см при шаге сетки 1 см и 10 см при шаге 5 см;
// T-узлов 7/6/3/2 по пространствам, X-узлов НИ ОДНОГО; координат 1010, из них
// 23 с шумом ближе 1e-4 шага; почти осевых стен 0; один проём, чей край лежит
// ровно в вершине комнаты; в layout четыре записи на два УДАЛЁННЫХ
// пространства. Сама геометрия владельца в репозиторий не попадает: фикстуры
// синтетические и воспроизводят названные условия числами, а не планом дома.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import { applySafeResize, clampSafeResize, resolveSafeResize } from '../test-build/resize.js';
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
import { thicknessCmAt, wallCmToUnits } from '../test-build/wall-thickness.js';
import { classifyNearAxisSegment } from '../test-build/near-axis.js';
import {
checkReferences, checkWallKeys, checkMixedRoleRecords, checkHiddenObstacles,
checkPhysicalGeometry, checkWallRecordsPreserved, latticeProfile, wallKey,
} from '../scripts/model-invariants.mjs';
const STEP = 1 / 240; // шаг решётки в нормированных единицах
const clone = (value) => structuredClone(value);
const load = (name) => {
const raw = JSON.parse(readFileSync(
new URL(`./fixtures/560-corpus/${name}.json`, import.meta.url), 'utf8',
));
return { config: raw.config ?? raw, layout: raw.layout ?? {} };
};
/** Все нарушения инвариантов одним списком — тот же набор, что у CLI (#254). */
const violations = (config, layout = {}) => [
...checkReferences({ config, layout }),
...checkWallKeys(config),
...checkMixedRoleRecords(config),
...checkHiddenObstacles(config),
...checkPhysicalGeometry(config),
];
const kinds = (list) => list.map((item) => `${item.invariant}/${item.kind}`).sort();
/** Степени узлов: сколько рёбер комнат приходит в каждую точку. */
function nodeDegrees(space) {
const degree = new Map();
for (const room of space.rooms || []) {
for (const point of room.poly || []) {
const key = `${point[0].toFixed(9)},${point[1].toFixed(9)}`;
degree.set(key, (degree.get(key) || 0) + 1);
}
}
return degree;
}
/** Длины рёбер комнат в шагах решётки. */
function edgeSteps(space) {
const out = [];
for (const room of space.rooms || []) {
const poly = room.poly || [];
for (let index = 0; index < poly.length; index++) {
const a = poly[index];
const b = poly[(index + 1) % poly.length];
out.push(Math.hypot(b[0] - a[0], b[1] - a[1]) / STEP);
}
}
return out;
}
/** Точки проёма: центр ± половина длины по его углу (angle в градусах). */
function openingEnds(opening) {
const radians = (opening.angle || 0) * Math.PI / 180;
const half = (opening.length || 0) / 2;
const dx = Math.cos(radians) * half;
const dy = Math.sin(radians) * half;
return [[opening.x - dx, opening.y - dy], [opening.x + dx, opening.y + dy]];
}
const roomVertices = (space) => (space.rooms || []).flatMap((room) => room.poly || []);
const near = (a, b, eps = 1e-9) => Math.hypot(a[0] - b[0], a[1] - b[1]) < eps;
/** Вход Resize в единицах NORM_W — та же подготовка, что у #281. */
const resizeInputs = (space) => ({
rooms: (space.rooms || []).map((room) => ({
id: room.id, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
})),
openings: (space.openings || []).map((opening) => ({
id: opening.id,
x: opening.x * NORM_W,
y: opening.y * NORM_W,
length: opening.length * NORM_W,
angle: opening.angle,
hosted: !!opening.host,
})),
obstacles: (space.partitions || []).map((partition) => ({
kind: 'segment',
a: partition.a.map((value) => value * NORM_W),
b: partition.b.map((value) => value * NORM_W),
half: wallCmToUnits(partition.cm, space.cell_cm, GRID_PITCH) / 2,
})),
});
const resizeOptions = (space, inputs, cm) => ({
minDim: wallCmToUnits(30, space.cell_cm, GRID_PITCH),
eps: GRID_PITCH * 0.05,
step: GRID_PITCH,
movingHalf: wallCmToUnits(cm, space.cell_cm, GRID_PITCH) / 2,
obstacles: inputs.obstacles,
});
/**
* Первая подвижная ручка ресайза на плане: комната, ребро, разрешённый шаг.
* Нужна именно подвижная — «включена, но ход нулевой» уже было дефектом #281.
*/
function firstMovableHandle(space) {
const inputs = resizeInputs(space);
for (const room of inputs.rooms) {
for (let edge = 0; edge < room.poly.length; edge++) {
const a = room.poly[edge];
const b = room.poly[(edge + 1) % room.poly.length];
const cm = thicknessCmAt(space.walls, a, b, STEP, NORM_W) || 15;
const options = resizeOptions(space, inputs, cm);
const resolution = resolveSafeResize(inputs.rooms, inputs.openings, room.id, edge, options);
if (!resolution.enabled) continue;
const outward = clampSafeResize(
inputs.rooms, inputs.openings, resolution.plan, GRID_PITCH, GRID_PITCH, options,
);
if (!outward) continue;
return { inputs, options, plan: resolution.plan, room: room.id, edge, d: outward };
}
}
return null;
}
/** Записать результат ресайза обратно в конфиг (единицы NORM_W → нормированные). */
function writeResize(space, moved) {
const next = clone(space);
for (const room of next.rooms || []) {
const poly = moved.polys[room.id];
if (poly) room.poly = poly.map(([x, y]) => [x / NORM_W, y / NORM_W]);
}
for (const opening of next.openings || []) {
const point = moved.openings[opening.id];
if (point) { opening.x = point[0] / NORM_W; opening.y = point[1] / NORM_W; }
}
return next;
}
/**
* Корпус. `condition` проверяется отдельным тестом: фикстура, потерявшая своё
* условие, — это зелёный прогон, который ничего не проверяет, и такой тихий
* успех в этом проекте уже дважды стоил дня (#171, #207).
*/
const CORPUS = [
{
file: 'c1-short-edges',
what: 'короткие рёбра: уступ в два шага (10 см при шаге 5 см)',
field: 'минимальное ребро комнаты в поле — 2 шага на первом этаже, 5 см на втором',
fixedPointOnImport: true,
condition(space) {
const short = edgeSteps(space).filter((steps) => steps <= 2.001);
assert.ok(short.length >= 2, `коротких рёбер ${short.length}, нужно ≥2`);
},
},
{
file: 'c2-t-junction-thicknesses',
what: 'T-узел, в котором сходятся записи трёх разных толщин',
field: 'в поле 7 T-узлов и пять толщин (20/22/28/29/33) в одном пространстве',
fixedPointOnImport: true,
condition(space) {
const tee = [...nodeDegrees(space).entries()].filter(([, degree]) => degree >= 3);
assert.ok(tee.length >= 1, 'T-узла нет');
const thicknesses = new Set((space.walls || []).map((wall) => wall.cm));
assert.ok(thicknesses.size >= 3, `толщин ${thicknesses.size}, нужно ≥3`);
},
},
{
file: 'c3-x-junction',
what: 'X-узел: четыре комнаты в одной точке',
field: 'в поле X-узлов НЕТ НИ ОДНОГО — это рискованный сосед, не наблюдение',
synthetic: true,
fixedPointOnImport: true,
condition(space) {
const cross = [...nodeDegrees(space).values()].filter((degree) => degree >= 4);
assert.ok(cross.length >= 1, 'узла степени 4 нет');
},
},
{
file: 'c4-opening-at-vertex',
what: 'проём, чей край лежит ровно в вершине комнаты',
field: 'в поле один такой проём',
fixedPointOnImport: true,
condition(space) {
const vertices = roomVertices(space);
const touching = (space.openings || []).filter((opening) => openingEnds(opening)
.some((end) => vertices.some((vertex) => near(vertex, end, STEP * 1e-3))));
assert.equal(touching.length, 1, 'проём должен упираться краем в вершину');
assert.ok(touching[0].host?.kind === 'wall' && touching[0].host.id,
'проём должен быть подвешен на сегмент стены');
},
},
{
file: 'c5-node-noise-near-axis',
what: 'шум у узла плюс честно наклонная стена',
field: 'в поле 23 координаты из 1010 лежат ближе 1e-4 шага от узла',
fixedPointOnImport: false, // именно шум Optimize и снимает
condition(space) {
assert.ok(latticeProfile({ config: { spaces: [space] } }).noise >= 1, 'шума у узла нет');
const slanted = (space.rooms || []).some((room) => {
const poly = room.poly || [];
return poly.some((a, index) => {
const b = poly[(index + 1) % poly.length];
const dx = Math.abs(b[0] - a[0]);
const dy = Math.abs(b[1] - a[1]);
return dx > STEP && dy > STEP * 0.5 && dy < dx; // наклон, но не осевой
});
});
assert.ok(slanted, 'наклонной стены нет');
},
},
{
file: 'c6-stale-layout',
what: 'записи layout на пространство, которого в конфиге больше нет',
field: 'в поле четыре такие записи на два удалённых пространства',
fixedPointOnImport: false,
expectedViolations: ['references/layout_space', 'references/layout_space'],
condition(space, layout) {
const known = new Set([space.id]);
const orphans = Object.values(layout).filter((entry) => !known.has(entry.s));
assert.equal(orphans.length, 2, 'осиротевших записей layout должно быть две');
},
},
];
for (const item of CORPUS) {
const label = `${item.file}${item.synthetic ? ' (синтетический)' : ''}`;
test(`корпус #560 ${label}: фикстура несёт своё условие — ${item.what}`, () => {
const { config, layout } = load(item.file);
assert.equal(config.model_version, 10, 'корпус ведётся на актуальной модели');
item.condition(config.spaces[0], layout);
});
test(`корпус #560 ${label}: импорт → Optimize → Optimize`, () => {
const { config, layout } = load(item.file);
const expected = item.expectedViolations ?? [];
assert.deepEqual(kinds(violations(config, layout)), expected.slice().sort(),
'импорт: инварианты');
const guard = clone(config);
const first = optimizePlans(config, layout);
assert.deepEqual(config, guard, 'Optimize не имеет права менять вход (#281)');
assert.deepEqual(kinds(violations(first.config, first.layout)), expected.slice().sort(),
'после Optimize новых нарушений нет');
assert.equal(first.changed, item.fixedPointOnImport ? false : true,
item.fixedPointOnImport
? 'приведённый план Optimize трогать не должен'
: 'фикстура заявлена как требующая приведения');
const second = optimizePlans(clone(first.config), clone(first.layout));
assert.equal(second.changed, false, 'второй Optimize — фикс-пойнт (#477)');
assert.deepEqual(second.config, first.config, 'второй Optimize ничего не двигает');
// Записи толщины — отдельный оракул: именно их теряли #253 и #258.
assert.deepEqual(
checkWallRecordsPreserved(config.spaces[0].walls, first.config.spaces[0].walls),
[], 'Optimize сохранил записи толщины',
);
});
/**
* Граница этой проверки названа прямо: перенос ЗАПИСЕЙ ТОЛЩИНЫ вслед за
* сдвинутым ребром делает не `resize.ts`, а хост карточки — `project()` в
* `ResizeController.move` возвращает `afterWalls`, и чистой функции для этого
* в модуле нет. Поэтому здесь проверяется то, что проверяемо без карточки:
* ручка действительно подвижна, геометрия после сдвига согласована, записи
* толщины целы, и Optimize сходится за ограниченное число проходов. Полный
* путь со штатным writer'ом проверяет браузерный смок корпуса.
*/
test(`корпус #560 ${label}: Resize ребра сохраняет согласованность`, (t) => {
const { config, layout } = load(item.file);
const optimized = optimizePlans(clone(config), clone(layout));
const space = optimized.config.spaces[0];
const handle = firstMovableHandle(space);
if (!handle) return t.skip('подвижной ручки ресайза на этом плане нет');
const moved = applySafeResize(handle.inputs.rooms, handle.inputs.openings, handle.plan, handle.d);
const resized = clone(optimized.config);
resized.spaces[0] = writeResize(space, moved);
const after = optimizePlans(clone(resized), clone(optimized.layout));
const expected = item.expectedViolations ?? [];
assert.deepEqual(kinds(violations(after.config, after.layout)), expected.slice().sort(),
'после Resize и Optimize новых нарушений нет');
assert.deepEqual(
checkWallRecordsPreserved(space.walls, after.config.spaces[0].walls),
[], 'ресайз не потерял ни одной толщины (#253)',
);
// Сходимость, а не «один проход»: вход здесь — рёбра сдвинуты, а записи
// толщины остались на прежних координатах, потому что их переносит хост.
// Из такого состояния Optimize имеет право убирать остатки в два приёма;
// важно, что он ОСТАНАВЛИВАЕТСЯ, а не переписывает план бесконечно.
let passes = 0;
let current = after;
while (current.changed && passes < 5) {
current = optimizePlans(clone(current.config), clone(current.layout));
passes += 1;
}
assert.equal(current.changed, false, `Optimize не сошёлся за ${passes} проходов`);
assert.ok(passes <= 2, `после Resize Optimize сходится за ${passes} проходов, ожидалось ≤2`);
assert.deepEqual(kinds(violations(current.config, current.layout)), expected.slice().sort(),
'сошедшийся план согласован');
});
test(`корпус #560 ${label}: сохранение и перечитывание плана`, () => {
const { config, layout } = load(item.file);
const optimized = optimizePlans(clone(config), clone(layout));
const reloaded = JSON.parse(JSON.stringify(optimized.config));
assert.deepEqual(reloaded, optimized.config, 'план переживает сериализацию без потерь');
const again = optimizePlans(reloaded, JSON.parse(JSON.stringify(optimized.layout)));
assert.equal(again.changed, false, 'перечитанный план не требует приведения');
});
}
test('корпус #560: ключи записей толщины канонические', () => {
for (const item of CORPUS) {
const { config } = load(item.file);
if (!item.fixedPointOnImport) continue; // c5/c6 приводит сам Optimize
for (const wall of config.spaces[0].walls || []) {
assert.equal(wall.key, wallKey(wall.a, wall.b),
`${item.file}: ключ записи разошёлся с координатами`);
}
}
});
test('корпус #560: наклонная стена не считается почти осевой', () => {
// Прямая проверка приёмки «корректная планировка не исправляется ради
// удобства рендера»: честный наклон 1 шаг на 60 не должен попадать в класс,
// который Optimize канонизирует. Шум у узла — попадает, и это правильно.
const { config } = load('c5-node-noise-near-axis');
const slanted = config.spaces[0].rooms.find((room) => room.id === 'c5-r2');
const [a, b] = [slanted.poly[0], slanted.poly[1]];
assert.equal(classifyNearAxisSegment(a, b), null, 'наклон не классифицируется как почти осевой');
const optimized = optimizePlans(clone(config), {});
const after = optimized.config.spaces[0].rooms.find((room) => room.id === 'c5-r2');
assert.deepEqual(after.poly[0], a, 'Optimize не выпрямил наклонную стену');
assert.deepEqual(after.poly[1], b, 'Optimize не выпрямил наклонную стену');
});