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
+102 -2
View File
@@ -5,8 +5,9 @@ import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
checkMixedRoleRecords, checkReferences, checkWallKeys, checkWallRecordsPreserved,
keyMidpoint, latticeProfile, nearAxisProfile, readModel, wallKey,
checkHiddenObstacles, checkMixedRoleRecords, checkReferences, checkWallKeys,
checkWallRecordsPreserved, keyMidpoint, latticeProfile, nearAxisProfile,
readModel, wallKey,
} from '../scripts/model-invariants.mjs';
import { wallKey as productWallKey } from '../test-build/wall-thickness.js';
import { GRID_STEP_N } from '../test-build/space-geometry.js';
@@ -550,3 +551,102 @@ test('#290 near-axis audit deduplicates shared room-owner copies', () => {
const profile = nearAxisProfile({ spaces: [{ id: 'near', ...fixture }] });
assert.deepEqual(profile, { total: 1, spaces: [{ spaceId: 'near', count: 1 }] });
});
// #296/#297. Геометрия, которая ничего не рисует, но выключает ручки ресайза.
// Проверка нужна ровно потому, что все прежние гейтики её не видят: перегородка
// под стеной комнаты не портит ни ключи, ни роли, ни решётку, ни кладку.
const hidden = (space) => checkHiddenObstacles({ spaces: [{ id: 'sp1', cell_cm: 1, ...space }] });
const roomAt = (x0, y0, x1, y1) => ({ id: 'r1', poly: rect(x0, y0, x1, y1) });
test('перегородка под стеной комнаты — нарушение (#296)', () => {
const step = GRID_STEP_N;
const found = hidden({
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
// Ровно нижнее ребро комнаты: на плане неотличимо от стены.
partitions: [{ id: 'p1', a: [0, 0], b: [40 * step, 0], cm: 20 }],
});
assert.equal(found.length, 1);
assert.equal(found[0].kind, 'partition_over_room_wall');
});
test('перегородка, лежащая на части стены, — тоже нарушение (#296)', () => {
const step = GRID_STEP_N;
const found = hidden({
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
partitions: [{ id: 'p1', a: [10 * step, 0], b: [25 * step, 0], cm: 20 }],
});
assert.equal(found.length, 1);
assert.equal(found[0].kind, 'partition_over_room_wall');
});
test('перегородка поперёк комнаты законна и не ловится (#296)', () => {
// Единственный смысл существования перегородки: разделить комнату внутри.
// Инвариант, кричащий на это, отключат в первую неделю.
const step = GRID_STEP_N;
const found = hidden({
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
partitions: [{ id: 'p1', a: [20 * step, 0], b: [20 * step, 30 * step], cm: 20 }],
});
assert.deepEqual(found, []);
});
test('касание углом перекрытием не считается (#296)', () => {
const step = GRID_STEP_N;
const found = hidden({
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
// Продолжение нижнего ребра за угол: общего с ребром — ровно точка.
partitions: [{ id: 'p1', a: [40 * step, 0], b: [60 * step, 0], cm: 20 }],
});
assert.deepEqual(found, []);
});
test('черновик из двух точек не может стать комнатой (#296)', () => {
const step = GRID_STEP_N;
const found = hidden({
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
room_drafts: [{
id: 'd1', points: [[50 * step, 0], [70 * step, 0]], segments: [{ cm: 30 }],
}],
});
assert.equal(found.length, 1);
assert.equal(found[0].kind, 'unusable_draft');
});
test('черновик из трёх точек в стороне от стен законен (#296)', () => {
const step = GRID_STEP_N;
const found = hidden({
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
room_drafts: [{
id: 'd1',
points: [[50 * step, 0], [70 * step, 0], [70 * step, 20 * step]],
segments: [{ cm: 30 }, { cm: 30 }],
}],
});
assert.deepEqual(found, []);
});
test('незакрытый контур на стене комнаты — нарушение (#296)', () => {
const step = GRID_STEP_N;
const found = hidden({
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
room_drafts: [{
id: 'd1',
points: [[5 * step, 0], [35 * step, 0], [35 * step, 20 * step]],
segments: [{ cm: 30 }, { cm: 30 }],
}],
});
assert.equal(found.length, 1);
assert.equal(found[0].kind, 'draft_over_room_wall');
});
test('реальный план: перегородка поверх наружной стены закреплена числом (#296)', () => {
// Долг фикстуры, а не «допустимо»: `partition-mt2on9ou-0` лежит на наружной
// стене трёх комнат и выключает ресайз Мастера с/у и Гардеробной. Число здесь
// обязано стать нулём вместе с закрытием #296 — иначе починка пройдёт молча.
const debt = { 'real-plan-second-floor.json': 1, 'real-plan-first-floor.json': 0 };
for (const [file, expected] of Object.entries(debt)) {
const { config } = readModel(
readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8'));
assert.equal(checkHiddenObstacles(config).length, expected, file);
}
});