test: prove AC10 — Optimize adds no junction violation (#329 M4)

AC10 was asserted, never shown. Optimize runs alignAllToGrid and
repairNearAxisRoomWalls, which move nodes by fractions of a centimetre, and
none of П1-П5 carries a margin wider than the grid step in general — so
"obviously true by construction" was not available.

Two units, both counting violations the way the write barrier does (each side
through commitWallSegmentModel first):
- the owner's fixture in legacy storage — the inherited apex is there before
  Optimize, and no rule's count grows after;
- the П4 boundary — two rooms exactly 5 cm apart, where snapping could have
  pulled a node under the limit, stay clean.

The first test asserts the baseline actually carries a violation, so it cannot
pass by measuring an empty plan; violationsByRule fails loudly if the space or
its catalogue goes missing, for the same reason. Spec revision 7 records the
proof and the other three review answers.

Issue: #329
User-Visible: no
This commit is contained in:
Codex
2026-08-28 00:24:34 +03:00
parent 574fea98ab
commit 0824e51014
2 changed files with 109 additions and 2 deletions
+15 -2
View File
@@ -1,6 +1,6 @@
# Issue #329 — ограничения стыков стен и честная острая вершина
Статус: ревизия 6 (r1: M1/M2/L1; r2: M1 — AC7; поправки владельца 2026-08-27: §4 — острая вершина без фаски и без зазубрин на гранях; П3 меряет СТЕНУ, а не атом — кейс компенсации перепада толщин). Решения владельца (чат, 2026-08-27, зафиксированы в issue):
Статус: ревизия 7 (r1 код-ревью: H1 — обе стороны на бэкенде судятся после одной миграции, M1 — мёртвый код фаски удалён, M2/M3 — гайды, M4 — AC10 доказан тестами; r1: M1/M2/L1; r2: M1 — AC7; поправки владельца 2026-08-27: §4 — острая вершина без фаски и без зазубрин на гранях; П3 меряет СТЕНУ, а не атом — кейс компенсации перепада толщин). Решения владельца (чат, 2026-08-27, зафиксированы в issue):
пять ограничений приняты; проверки действуют на записи, легаси читается как
есть; для остаточных острых вершин легаси-планов рендер закрывает остриё
честной фаской вместо «трезубца».
@@ -104,6 +104,13 @@ margin), модуль `custom_components/houseplan/junction_limits.py`. Счит
`junction_limit_<rule>`, отдаётся тем же `connection.send_error`, что и
остальные семантические ошибки записи конфига.
Обе стороны сравнения (`previous` и `candidate`) проходят через
`commit_wall_segment_model` до подсчёта. Без этого легаси-документ без
каталога отвечал «нарушений нет» независимо от геометрии, и первая же
структурная правка после обновления карточки читала унаследованное
нарушение как новое — отказ несвязанной правке, прямое нарушение §3
(находка H1 код-ревью r1).
Зеркалятся П1–П4. П5 (просвет комнаты) сознательно оставлен только на
клиенте: это утверждение о ПОСТРОЕННЫХ телах стен, и повторение конвейера
mitre/inset на Python было бы второй реализацией геометрии, расхождение
@@ -211,7 +218,13 @@ mitre/inset на Python было бы второй реализацией гео
фикстурах (тест паритета).
- **AC10.** Идемпотентность: повторный commit валидного конфига байт-иден-
тичен; Optimize на легаси-фикстуре не создаёт новых нарушений (AC-пост-
условие §3).
условие §3). Доказывается двумя юнитами: (1) фикстура владельца
`329-sharp-apex.json` в легаси-хранении — унаследованное нарушение угла
есть до Оптимизации и ни по одному правилу счёт не растёт после;
(2) граничный кейс П4 — две комнаты ровно в 5 см, где привязка к решётке
могла бы утащить узел под порог, после Оптимизации по-прежнему чисты.
Оба считают нарушения ТЕМ ЖЕ способом, что барьер записи: обе стороны
сперва через `commitWallSegmentModel`.
## 9. Принято предположительно (поменять свободно)
+94
View File
@@ -216,3 +216,97 @@ test('П3 меряет стену, а не атом: компенсация пе
assert.ok(checkSegmentLengths(mixed, CELL, PITCH)
.some((item) => item.subject === 'thin'));
});
// --- #329 AC10: Optimize — ремонтный путь, а не источник новых нарушений ---
/**
* Нарушения одного пространства так, как их считает барьер записи: обе
* стороны сперва проходят одну и ту же миграцию каталога.
*/
async function violationsByRule(config, spaceId) {
const { commitWallSegmentModel } = await import('../test-build/wall-segment-model.js');
const { checkNodes, checkSegmentLengths, checkNodeDistances } =
await import('../test-build/junction-limits.js');
const { config: migrated } = commitWallSegmentModel(
JSON.parse(JSON.stringify(config)),
);
const space = (migrated.spaces || []).find(
(item) => String(item.id) === String(spaceId),
);
// Молчаливое «пространства нет» превратило бы тест в проверку пустоты.
assert.ok(space, `пространство ${spaceId} есть после миграции`);
assert.ok((space.wall_segments || []).length > 0, 'каталог стен непуст');
const segments = [
...(space.wall_segments || []).map((item) => ({ id: item.id, a: item.a, b: item.b, cm: Number(item.cm) })),
...(space.partitions || []).map((item) => ({ id: item.id, a: item.a, b: item.b, cm: Number(item.cm) })),
];
const cellCm = Number(space.cell_cm) || 1;
const all = [
...checkNodes(segments),
...checkSegmentLengths(segments, cellCm, GRID_STEP_N),
...checkNodeDistances(segments, cellCm, GRID_STEP_N),
];
const counts = {};
for (const item of all) counts[item.rule] = (counts[item.rule] || 0) + 1;
return counts;
}
test('AC10: Optimize на легаси-плане с нарушением не добавляет новых', async () => {
const { optimizePlans } = await import('../test-build/plan-optimizer.js');
const { readFileSync } = await import('node:fs');
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/329-sharp-apex.json', import.meta.url), 'utf8',
));
// Легаси-хранение: только контуры и `walls`, без каталога — то состояние,
// в котором план приходит на Оптимизацию в первый раз.
const space = {
id: 'legacy', title: 'legacy', cell_cm: fixture.cell_cm,
view_box: [0, 0, 1, 1],
rooms: fixture.rooms.map(({ id, name, area, poly }) => ({ id, name, area, poly })),
walls: fixture.walls.map(({ id, a, b, cm }) => ({ key: id, a, b, cm })),
openings: [], room_drafts: [], partitions: [], wall_columns: [],
};
const config = { spaces: [space], markers: [], settings: {} };
const before = await violationsByRule(config, 'legacy');
// Шпиль владельца обязан читаться как унаследованное нарушение — иначе
// тест доказывал бы «ничего не выросло» на пустом месте.
assert.equal((before.angle || 0) > 0, true, 'фикстура несёт нарушение угла');
const result = optimizePlans(config, {});
const after = await violationsByRule(result.config, 'legacy');
for (const rule of new Set([...Object.keys(before), ...Object.keys(after)])) {
assert.equal((after[rule] || 0) <= (before[rule] || 0), true,
`Оптимизация добавила нарушений по правилу ${rule}: `
+ `${before[rule] || 0} → ${after[rule] || 0}`);
}
});
test('AC10: привязка к решётке не утаскивает узел на пороге П4 под лимит', async () => {
const { optimizePlans } = await import('../test-build/plan-optimizer.js');
// Две комнаты, между гранями ровно 5 см — П4 выполняется впритык, и
// сдвиг на доли сантиметра при выравнивании увёл бы его под порог.
const CELL_LOCAL = 2;
const u = (value) => (value / CELL_LOCAL) * GRID_STEP_N;
const box = (x, y, w, h) => [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
const left = box(u(100), u(100), u(200), u(200));
const right = box(u(100) + u(205), u(100), u(200), u(200));
const wallsOf = (poly, prefix) => poly.map((point, index) => ({
key: `${prefix}${index}`, a: point, b: poly[(index + 1) % poly.length], cm: 15,
}));
const config = { spaces: [{
id: 'edge', title: 'edge', cell_cm: CELL_LOCAL, view_box: [0, 0, 1, 1],
rooms: [
{ id: 'a', name: 'a', area: null, poly: left },
{ id: 'b', name: 'b', area: null, poly: right },
],
walls: [...wallsOf(left, 'l'), ...wallsOf(right, 'r')],
openings: [], room_drafts: [], partitions: [], wall_columns: [],
}], markers: [], settings: {} };
const before = await violationsByRule(config, 'edge');
assert.equal(before.distance || 0, 0, 'исходный план по П4 чист');
const result = optimizePlans(config, {});
const after = await violationsByRule(result.config, 'edge');
assert.equal(after.distance || 0, 0,
'после Оптимизации узлы не сблизились под 5 см');
});