mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
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:
@@ -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));
|
||||
|
||||
@@ -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="реальные планы проекта эту проверку" '
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user