mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +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:
@@ -0,0 +1,323 @@
|
||||
// Обход последовательностей правок: инварианты после каждого жеста (#297).
|
||||
//
|
||||
// Зачем это отдельно от всех прежних гейтов. Решётка и шум (#283), непрерывность
|
||||
// кладки (#285/#286), смешанные роли (#287), ключи стен (#259), аудит ручек
|
||||
// (#292) — каждый берёт ГОТОВЫЙ план и что-то в нём измеряет. А дефекты
|
||||
// геометрии рождаются не в хранении, а в РЕДАКТИРОВАНИИ: #289 (ресайз наружного
|
||||
// ребра создал запись со смешанной ролью), #290, #296 (после удаления комнаты
|
||||
// остались перегородка поверх стены и черновик из двух точек, и обе ручки
|
||||
// ресайза выключились). Ни один снимок этого не показывает: такая геометрия
|
||||
// ничего не портит в модели, она портит следующий жест.
|
||||
//
|
||||
// Поэтому здесь план не проверяется, а РАСШАТЫВАЕТСЯ: продуктовыми жестами, в
|
||||
// продуктовом карте, по детерминированному семени, с проверкой инвариантов
|
||||
// после каждого шага. Второе представление редактора при этом не появляется —
|
||||
// жесты идут через `_rszEdgeDown/_rszMove/_rszUp` и `_confirmRoomDelete`, то
|
||||
// есть через тот самый код, который выполняется у пользователя.
|
||||
//
|
||||
// Найденное нарушение печатается вместе с семенем и полной цепочкой жестов,
|
||||
// поэтому воспроизводится одной командой:
|
||||
// node demo/smoke_edit_walk.mjs --seed 7 --plan real-plan-second-floor.json
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import {
|
||||
checkHiddenObstacles, checkMixedRoleRecords, checkWallKeys, checkReferences,
|
||||
checkPhysicalGeometry, latticeProfile, readModel,
|
||||
} from '../scripts/model-invariants.mjs';
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Известный долг фикстур, а не «должно быть ноль».
|
||||
*
|
||||
* `real-plan-second-floor.json` содержит перегородку `partition-mt2on9ou-0`,
|
||||
* лежащую на наружной стене трёх комнат: это живой дефект из плана владельца,
|
||||
* предмет #296. Обход обязан замечать НОВЫЕ нарушения, а не падать на старом —
|
||||
* иначе он не заработает до починки #296 и никого не защитит в промежутке.
|
||||
* Когда #296 закроется, число здесь станет нулём, и тест это потребует.
|
||||
*/
|
||||
const PLANS = [
|
||||
{ file: 'real-plan-second-floor.json', debt: 1 },
|
||||
{ file: 'real-plan-first-floor.json', debt: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Что обход находит СЕГОДНЯ. Не «допустимо», а «заведено и ждёт починки».
|
||||
*
|
||||
* Таблица работает в обе стороны: обход падает и когда находок стало больше, и
|
||||
* когда стало меньше. Второе — не придирчивость. Молча позеленевший гейт не
|
||||
* сообщает о починке, и долг перестаёт быть виден; ровно так исчез из вида
|
||||
* `partition-mt2on9ou-0`, проживший в плане владельца от беты 9 до rc.1.
|
||||
* Починили — обновить строку в этой таблице тем же коммитом.
|
||||
*/
|
||||
const KNOWN = {
|
||||
// Первый же жест пишет координату мимо решётки: запись толщины уезжает с
|
||||
// ребра, по которому её потом ищут. Класс #253, заведено отдельно.
|
||||
'real-plan-second-floor.json:1': { step: 0, kinds: ['off_lattice_coordinate'] },
|
||||
'real-plan-second-floor.json:2': { step: 0, kinds: ['off_lattice_coordinate'] },
|
||||
'real-plan-second-floor.json:3': { step: 0, kinds: ['off_lattice_coordinate'] },
|
||||
// На первом этаже к тому же добавляется запись, потерявшая носителя,
|
||||
// и запись со смешанной ролью — её рождают «Оптимизировать» и удаление
|
||||
// комнаты с сохранением стен. Класс #287/#289.
|
||||
'real-plan-first-floor.json:1': { step: 1, kinds: ['mixed_role_record'] },
|
||||
'real-plan-first-floor.json:2': {
|
||||
step: 0, kinds: ['off_lattice_coordinate', 'wall_carrier'],
|
||||
},
|
||||
'real-plan-first-floor.json:3': { step: 1, kinds: ['mixed_role_record'] },
|
||||
};
|
||||
const STEPS = Number(arg('--steps', 24));
|
||||
const SEEDS = arg('--seed') ? [Number(arg('--seed'))] : [1, 2, 3];
|
||||
|
||||
/** mulberry32: короткий, воспроизводимый, без зависимостей. */
|
||||
const rng = (seed) => () => {
|
||||
seed = (seed + 0x6D2B79F5) | 0;
|
||||
let t = seed;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
|
||||
const configOf = (file) => readModel(readFileSync(
|
||||
new URL(`../test/fixtures/${file}`, import.meta.url), 'utf8',
|
||||
));
|
||||
|
||||
/**
|
||||
* Полный набор инвариантов по одному конфигу. Судит node, не страница.
|
||||
*
|
||||
* Про решётку судится ТОЛЬКО «вне сетки», а подшаговый шум остаётся
|
||||
* наблюдением. Причина измерена, а не выбрана: координата пишется девятью
|
||||
* знаками после запятой, и `304/240 = 1.2666666666…` в этой записи становится
|
||||
* `1.266666667` — отклонение 8e-8 шага. Такой шум неустраним никаким исправлением
|
||||
* жеста, он свойство формата хранения и уйдёт на этапе 1 ADR #282. Гейт,
|
||||
* падающий на неустранимом, отключат в первую неделю.
|
||||
*/
|
||||
const inspect = (config, layout = {}) => {
|
||||
const notes = [];
|
||||
const lattice = latticeProfile({ config, layout });
|
||||
return {
|
||||
violations: [
|
||||
...checkReferences({ config, layout }, { notes }),
|
||||
...checkHiddenObstacles(config),
|
||||
...checkMixedRoleRecords(config),
|
||||
...checkWallKeys(config, { notes }),
|
||||
...checkPhysicalGeometry(config),
|
||||
],
|
||||
noise: lattice.noise,
|
||||
offGrid: lattice.offGrid,
|
||||
};
|
||||
};
|
||||
|
||||
/** Виды нарушений, которых стало больше, чем было в исходной фикстуре. */
|
||||
const countKinds = (violations) => {
|
||||
const counts = new Map();
|
||||
for (const violation of violations) {
|
||||
counts.set(violation.kind, (counts.get(violation.kind) || 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const beyondBaseline = (violations, baseline) => {
|
||||
const now = countKinds(violations);
|
||||
return [...now]
|
||||
.filter(([kind, count]) => count > (baseline.get(kind) || 0))
|
||||
.map(([kind]) => kind)
|
||||
.sort();
|
||||
};
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
const failures = [];
|
||||
|
||||
/** Установить конфиг в карту и включить разметку с инструментом ресайза. */
|
||||
const install = (config) => page.evaluate(async (config) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = JSON.parse(JSON.stringify(config));
|
||||
card._cfgEpoch = (card._cfgEpoch || 0) + 1;
|
||||
card._modelCache = null;
|
||||
card._space = config.spaces[0].id;
|
||||
card._setMode?.('plan');
|
||||
card._markup = true;
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._fitAll?.();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return true;
|
||||
}, config);
|
||||
|
||||
/** Перечислить ручки ресайза так, как их видит рендер слоя. */
|
||||
const handles = () => page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const snap = card._rszSnapshot();
|
||||
const rooms = card._rszRooms();
|
||||
const list = [];
|
||||
for (const room of rooms) {
|
||||
for (let edge = 0; edge < room.poly.length; edge++) {
|
||||
const a = room.poly[edge], b = room.poly[(edge + 1) % room.poly.length];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < card._gridPitch) continue;
|
||||
const resolution = card._rszResolution(room.id, edge, snap);
|
||||
list.push({
|
||||
roomId: room.id, edge, enabled: !!resolution.enabled,
|
||||
reason: resolution.enabled ? null : resolution.reason,
|
||||
mid: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
|
||||
});
|
||||
}
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
/**
|
||||
* Перетащить ручку на `steps` шагов решётки.
|
||||
*
|
||||
* Смещение задаётся в клиентских координатах, поэтому масштаб «клиент → SVG»
|
||||
* измеряется у самой карты: подставлять свои цифры значило бы завести второе
|
||||
* представление её геометрии.
|
||||
*/
|
||||
const dragHandle = (roomId, edge, mid, steps) => page.evaluate(
|
||||
async ({ roomId, edge, mid, steps }) => {
|
||||
const card = window.__card;
|
||||
const at = (x, y) => card._svgPoint({ clientX: x, clientY: y });
|
||||
const origin = at(0, 0), unitX = at(100, 0), unitY = at(0, 100);
|
||||
const perClientX = (unitX[0] - origin[0]) / 100;
|
||||
const perClientY = (unitY[1] - origin[1]) / 100;
|
||||
if (!(Math.abs(perClientX) > 1e-9) || !(Math.abs(perClientY) > 1e-9)) return 'нет масштаба';
|
||||
// Клиентская точка, попадающая в середину ребра.
|
||||
const clientX = (mid[0] - origin[0]) / perClientX;
|
||||
const clientY = (mid[1] - origin[1]) / perClientY;
|
||||
const ev = (x, y) => ({
|
||||
pointerId: 77, clientX: x, clientY: y,
|
||||
target: card._stageEl, preventDefault() {}, stopPropagation() {},
|
||||
});
|
||||
card._rszEdgeDown(ev(clientX, clientY), roomId, edge);
|
||||
if (!card._rszDrag) return 'жест не начался';
|
||||
const plan = card._rszDrag.plan;
|
||||
const shift = card._gridPitch * steps;
|
||||
const dx = plan.n[0] * shift / perClientX;
|
||||
const dy = plan.n[1] * shift / perClientY;
|
||||
card._rszMove(ev(clientX + dx, clientY + dy));
|
||||
const moved = !!card._rszDrag?.moved;
|
||||
card._rszUp(ev(clientX + dx, clientY + dy));
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return moved ? null : 'предпросмотр не сдвинулся';
|
||||
}, { roomId, edge, mid, steps },
|
||||
);
|
||||
|
||||
/** Удалить комнату с сохранением стен — путь, который и родил #296. */
|
||||
const deleteRoom = (roomId) => page.evaluate(async (roomId) => {
|
||||
const card = window.__card;
|
||||
card._roomDeleteDialog = { roomId, name: roomId };
|
||||
card._confirmRoomDelete(true);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return null;
|
||||
}, roomId);
|
||||
|
||||
const currentConfig = () => page.evaluate(() => JSON.parse(JSON.stringify(window.__card._serverCfg)));
|
||||
|
||||
for (const plan of PLANS) {
|
||||
const source = configOf(plan.file);
|
||||
for (const seed of SEEDS) {
|
||||
const random = rng(seed);
|
||||
const trace = [];
|
||||
let config = JSON.parse(JSON.stringify(source.config));
|
||||
let broke = null;
|
||||
const baseline = inspect(config);
|
||||
baseline.counts = countKinds(baseline.violations);
|
||||
if (baseline.violations.length !== plan.debt) {
|
||||
failures.push({
|
||||
plan: plan.file, seed, step: -1,
|
||||
action: `объявленный долг фикстуры ${plan.debt}`
|
||||
+ `, фактический ${baseline.violations.length}`,
|
||||
violations: baseline.violations, trace: [],
|
||||
});
|
||||
out[`debt_${plan.file.replace(/[^a-z0-9]+/gi, '_')}`] = false;
|
||||
continue;
|
||||
}
|
||||
await install(config);
|
||||
for (let step = 0; step < STEPS && !broke; step++) {
|
||||
const list = await handles();
|
||||
const enabled = list.filter((handle) => handle.enabled);
|
||||
const roll = random();
|
||||
let action = null;
|
||||
if (roll < 0.75 && enabled.length) {
|
||||
const handle = enabled[Math.floor(random() * enabled.length)];
|
||||
const steps = (random() < 0.5 ? -1 : 1) * (1 + Math.floor(random() * 4));
|
||||
const note = await dragHandle(handle.roomId, handle.edge, handle.mid, steps);
|
||||
action = `ресайз ${handle.roomId}#${handle.edge} на ${steps}${note ? ` (${note})` : ''}`;
|
||||
} else if (roll < 0.9) {
|
||||
const rooms = [...new Set(list.map((handle) => handle.roomId))];
|
||||
if (!rooms.length) continue;
|
||||
const roomId = rooms[Math.floor(random() * rooms.length)];
|
||||
await deleteRoom(roomId);
|
||||
action = `удаление комнаты ${roomId} с сохранением стен`;
|
||||
} else {
|
||||
const before = await currentConfig();
|
||||
const result = optimizePlans(before, {}, {}, {});
|
||||
await install(result.config);
|
||||
action = `оптимизация (изменений: ${result.report.total})`;
|
||||
}
|
||||
trace.push(action);
|
||||
config = await currentConfig();
|
||||
const state = inspect(config);
|
||||
const found = [...state.violations];
|
||||
if (state.offGrid > baseline.offGrid) {
|
||||
found.push({
|
||||
kind: 'off_lattice_coordinate', owner: 'config',
|
||||
reference: `вне сетки ${baseline.offGrid} → ${state.offGrid}`
|
||||
+ ` (подшаговый шум ${baseline.noise} → ${state.noise})`,
|
||||
detail: 'жест записал координату мимо решётки',
|
||||
});
|
||||
}
|
||||
const fresh = beyondBaseline(found, baseline.counts);
|
||||
if (fresh.length) broke = { step, action, violations: found, kinds: fresh };
|
||||
}
|
||||
const key = `${plan.file}:${seed}`;
|
||||
const known = KNOWN[key] || null;
|
||||
const label = `${plan.file.replace('real-plan-', '').replace('.json', '')}_seed${seed}`;
|
||||
const asExpected = known
|
||||
? !!broke && broke.step === known.step
|
||||
&& JSON.stringify(broke.kinds) === JSON.stringify([...known.kinds].sort())
|
||||
: !broke;
|
||||
out[`walk_${label.replace(/[^a-z0-9]+/gi, '_')}`] = asExpected;
|
||||
if (!asExpected) {
|
||||
failures.push({
|
||||
plan: plan.file, seed, trace,
|
||||
step: broke ? broke.step : -1,
|
||||
action: broke
|
||||
? broke.action
|
||||
: `находок нет, а объявлено: шаг ${known.step}, ${known.kinds.join(', ')}`
|
||||
+ ' — если это починка, обновите KNOWN тем же коммитом',
|
||||
violations: broke ? broke.violations : [],
|
||||
});
|
||||
mkdirSync(new URL('../artifacts/', import.meta.url), { recursive: true });
|
||||
writeFileSync(
|
||||
new URL(`../artifacts/edit-walk-${label}.json`, import.meta.url),
|
||||
JSON.stringify(config, null, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error('\nОбход нашёл нарушения:');
|
||||
for (const failure of failures) {
|
||||
console.error(`\n ${failure.plan}, семя ${failure.seed}, шаг ${failure.step}: ${failure.action}`);
|
||||
for (const violation of failure.violations.slice(0, 8)) {
|
||||
console.error(` ${violation.kind} · ${violation.owner} · ${violation.reference}`);
|
||||
}
|
||||
console.error(' цепочка жестов:');
|
||||
for (const [index, action] of failure.trace.entries()) {
|
||||
console.error(` ${index}. ${action}`);
|
||||
}
|
||||
console.error(` конфиг сохранён: artifacts/edit-walk-${failure.plan}-seed${failure.seed}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user