test: обход последовательностей правок с инвариантами после каждого жеста

Все прежние гейты проверяют снимок модели. Дефекты геометрии рождаются в
редактировании: #289, #296 и #298 прошли решётку, кладку, роли, ключи и аудит
ручек, потому что такая геометрия снимок не портит — она портит следующий жест.

demo/smoke_edit_walk.mjs расшатывает реальный план продуктовыми жестами
(_rszEdgeDown/_rszMove/_rszUp, _confirmRoomDelete, optimizePlans) по фиксированному
семени и после каждого шага судит конфиг в node. Второго представления редактора
не появляется — принцип #292.

Подшаговый шум остаётся наблюдением, а не нарушением: координата пишется девятью
знаками, 304/240 = 1.266666667, отклонение 8e-8 шага неустранимо форматом
хранения и уйдёт на этапе 1 ADR #282. Судится только «вне сетки».

Таблица KNOWN работает в обе стороны: обход падает и когда находок больше, и
когда меньше. Молча позеленевший гейт не сообщает о починке — так
partition-mt2on9ou-0 прожил в плане владельца от беты 9 до rc.1.

Новый инвариант checkHiddenObstacles: перегородка на стене комнаты, незакрытый
контур на стене комнаты, черновик, который не может стать комнатой (#296).

Найдено сразу, в пяти прогонах из шести — на первом жесте: #298 (ресайз уводит
конец записи толщины мимо решётки и мимо ребра), #299 («Оптимизировать» и
удаление комнаты сливают записи через границу роли).

Issue: #297
User-Visible: no
This commit is contained in:
Claude
2026-08-24 21:14:30 +03:00
parent 8c03656412
commit 1816042a1a
5 changed files with 577 additions and 2 deletions
+101
View File
@@ -542,6 +542,103 @@ export function checkMixedRoleRecords(config) {
return violations;
}
/**
* Геометрия, которая ничего не рисует, но выключает ручки ресайза (#296).
*
* Зачем отдельная проверка. Перегородка, лежащая ровно под стеной комнаты, и
* черновик из двух точек не видны на плане и не портят ни один снимок модели —
* ни ключи, ни роли, ни решётку, ни кладку. При этом `resolveSafeResize`
* законно отказывает по ним `duplicate-physical-wall`, и пользователь получает
* выключенную ручку с подсказкой про объект, который нельзя ни увидеть, ни
* выделить, ни удалить. Ни один прежний гейт этого класса не видит: все они
* измеряют снимок, а эта геометрия снимок не портит.
*
* Судится только доказуемое: перекрытие по длине больше шага решётки — то есть
* не касание углом, — и черновик, который не может стать комнатой ни при какой
* последующей правке.
*/
const collinearOverlapN = (a, b, c, d) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (!(length > GRID_STEP_N)) return 0;
const ux = dx / length, uy = dy / length;
const across = (p) => Math.abs((p[0] - a[0]) * uy - (p[1] - a[1]) * ux);
if (across(c) > EDGE_TOLERANCE || across(d) > EDGE_TOLERANCE) return 0;
const along = (p) => (p[0] - a[0]) * ux + (p[1] - a[1]) * uy;
const lo = Math.max(0, Math.min(along(c), along(d)));
const hi = Math.min(length, Math.max(along(c), along(d)));
return Math.max(0, hi - lo);
};
/** Точки черновика как есть: замыкание контура здесь не предполагается. */
const draftSegments = (draft) => {
const points = Array.isArray(draft?.points) ? draft.points.map(point).filter(Boolean) : [];
const segments = [];
for (let i = 0; i + 1 < points.length; i++) segments.push([points[i], points[i + 1]]);
return { points, segments };
};
export function checkHiddenObstacles(config) {
const violations = [];
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
const spaceId = String(space?.id ?? '?');
const edges = [];
for (const room of space?.rooms || []) {
const poly = roomPolygon(room);
if (!poly) continue;
for (const [a, b] of edgesOf(poly)) edges.push({ a, b, room: String(room?.id ?? '?') });
}
const longestOverlap = (a, b) => {
let best = null;
for (const edge of edges) {
const overlap = collinearOverlapN(edge.a, edge.b, a, b);
if (overlap > GRID_STEP_N && (!best || overlap > best.overlap)) {
best = { overlap, room: edge.room };
}
}
return best;
};
for (const partition of space?.partitions || []) {
const a = point(partition?.a), b = point(partition?.b);
if (!a || !b) continue;
const hit = longestOverlap(a, b);
if (!hit) continue;
violations.push({
invariant: 'hidden_obstacles', kind: 'partition_over_room_wall',
owner: `${spaceId}:${partition?.id ?? '?'}`,
reference: `${(hit.overlap / GRID_STEP_N).toFixed(0)} шагов по стене ${hit.room}`,
detail: 'перегородка лежит на стене комнаты: на плане её не видно,'
+ ' а ресайз этой стены она выключает',
});
}
for (const draft of space?.room_drafts || []) {
const { points, segments } = draftSegments(draft);
if (points.length < 3) {
violations.push({
invariant: 'hidden_obstacles', kind: 'unusable_draft',
owner: `${spaceId}:${draft?.id ?? '?'}`,
reference: `${points.length} точки`,
detail: 'контур не может стать комнатой ни при какой правке,'
+ ' но препятствием для ресайза остаётся',
});
continue;
}
for (const [a, b] of segments) {
const hit = longestOverlap(a, b);
if (!hit) continue;
violations.push({
invariant: 'hidden_obstacles', kind: 'draft_over_room_wall',
owner: `${spaceId}:${draft?.id ?? '?'}`,
reference: `${(hit.overlap / GRID_STEP_N).toFixed(0)} шагов по стене ${hit.room}`,
detail: 'незакрытый контур лежит на стене комнаты и выключает её ресайз',
});
break;
}
}
}
return violations;
}
/** Разобрать runtime-ответы, сырой config и tracked single-space fixtures. */
export function readModel(text) {
const parsed = JSON.parse(text);
@@ -633,6 +730,9 @@ function report(violations, notes = []) {
lost: 'Потерянные записи толщины',
wall_key: 'Записи толщины, которые не найдутся по ключу',
mixed_role_record: 'Записи толщины, описывающие сразу общую и наружную стену',
partition_over_room_wall: 'Перегородки, лежащие на стенах комнат',
draft_over_room_wall: 'Незакрытые контуры, лежащие на стенах комнат',
unusable_draft: 'Черновики, которые не могут стать комнатой',
};
for (const [kind, list] of byKind) {
lines.push(`${titles[kind] || kind}: ${list.length}`);
@@ -680,6 +780,7 @@ function main(argv) {
...checkReferences(model, { notes }),
...checkWallKeys(model.config, { notes }),
...checkMixedRoleRecords(model.config),
...checkHiddenObstacles(model.config),
...checkPhysicalGeometry(model.config),
];
if (argv.includes('--json')) console.log(JSON.stringify({ violations, notes }, null, 2));
+38
View File
@@ -582,6 +582,44 @@ export const MUTANTS = [
replace: ' if (false) {',
}],
},
{
id: 'invariant-hidden-counts-corner-touch',
guard: 'node --test --test-name-pattern="касание углом" test/model-invariants.test.mjs',
because: 'перегородка, продолжающая стену за угол, законна: общего с ребром у неё ровно '
+ 'точка. Порог в шаг решётки — единственное, что отделяет находку от ложного '
+ 'срабатывания на каждом углу плана (#296)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (overlap > GRID_STEP_N && (!best || overlap > best.overlap)) {',
replace: ' if (overlap >= 0 && (!best || overlap > best.overlap)) {',
}],
},
{
id: 'invariant-hidden-tolerates-two-point-draft',
guard: 'node --test --test-name-pattern="черновик из двух точек" '
+ 'test/model-invariants.test.mjs',
because: 'ровно это условие пропускает вырожденный черновик в align-grid: `points.length '
+ '>= 2` считает контур из двух точек годным, а комнатой он не станет никогда — '
+ 'и остаётся невидимым препятствием для ресайза (#296)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (points.length < 3) {',
replace: ' if (points.length < 2) {',
}],
},
{
id: 'invariant-hidden-ignores-collinearity',
guard: 'node --test --test-name-pattern="перегородка поперёк комнаты" '
+ 'test/model-invariants.test.mjs',
because: 'перегородка поперёк комнаты — единственный смысл существования перегородки; '
+ 'без проверки поперечного отклонения проверка ловит её и будет отключена в первую '
+ 'неделю (#296)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (across(c) > EDGE_TOLERANCE || across(d) > EDGE_TOLERANCE) return 0;',
replace: ' if (false) return 0;',
}],
},
{
id: 'invariant-roles-sample-endpoints',
guard: 'node --test --test-name-pattern="реальные планы проекта эту проверку" '
+13
View File
@@ -37,6 +37,19 @@ export const SMOKE_LINKS = [
+ 'preview one deduplicated real shared wall, pass production geometry preflight, apply one '
+ 'atomic Optimize write, reload idempotently and restore the original through one Undo (#290)',
},
{
// #297: обход не проверяет снимок — он расшатывает план продуктовыми жестами
// и спрашивает инварианты после каждого. Поиском по тексту связь между этим
// смоком и кодом ресайза, удаления комнаты и «Оптимизировать» не находится.
symbols: [
'resolveSafeResize', 'applySafeResize', 'clampSafeResize', 'validateSafeResize',
'planRoomDeletion', 'materializeWallIntervals', 'optimizePlans',
'reconcileCoincidentPartitions',
],
smokes: ['smoke_edit_walk.mjs'],
because: 'дефекты геометрии рождаются в редактировании, а не в хранении: #289, #296 и '
+ '#298 прошли все снимковые гейты — обход находит их на первом-втором жесте',
},
{
// #285: смок не произносит ни одного имени продуктового кода — он подставляет
// геометрию реального этажа и спрашивает сам продукт через isPointInFill,