diff --git a/demo/smoke_edit_walk.mjs b/demo/smoke_edit_walk.mjs new file mode 100644 index 00000000..164408a3 --- /dev/null +++ b/demo/smoke_edit_walk.mjs @@ -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); diff --git a/scripts/model-invariants.mjs b/scripts/model-invariants.mjs index eec4dd24..73e46044 100644 --- a/scripts/model-invariants.mjs +++ b/scripts/model-invariants.mjs @@ -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)); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index d9756060..8c014e46 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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="реальные планы проекта эту проверку" ' diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index cf6148ab..49bf621a 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -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, diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs index 5ab9de2f..0142c051 100644 --- a/test/model-invariants.test.mjs +++ b/test/model-invariants.test.mjs @@ -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); + } +});