From dedcedc8cc22c529573a2095627aa155741104d3 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 01:27:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BF=D1=80=D0=BE=D1=81=D1=82=D1=80=D0=B0=D0=BD=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=20=D1=81=D1=83=D0=B4=D0=B8=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=86=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Инварианты объявляли нарушением ЛЮБУЮ запись layout, чьё пространство удалено. Продукт так не считает: `space-reference-repair` удаляет такую запись только когда может доказать, что владелец тоже исчез, и сознательно хранит её, когда владелец жив или доказательств нет — удаление уносит расстановку пользователя. Конфиг сразу после Optimize законно содержит такие записи, и проверка называла нарушением штатное состояние. Теперь правило то же, что у продукта: нарушение — только когда владельца нет по самой конфигурации (комната, область или снятый маркер). Остальное — наблюдение, как у ветки `unknown_owner` рядом. Issue: #566 User-Visible: no --- scripts/model-invariants.mjs | 38 +++++++++++++++++++++-- scripts/mutation-registry.mjs | 24 +++++++++++++++ test/geometry-corpus.test.mjs | 23 ++++++++++---- test/model-invariants.test.mjs | 56 +++++++++++++++++++++++++++++++--- 4 files changed, 129 insertions(+), 12 deletions(-) diff --git a/scripts/model-invariants.mjs b/scripts/model-invariants.mjs index 8467f233..f08579a3 100644 --- a/scripts/model-invariants.mjs +++ b/scripts/model-invariants.mjs @@ -191,10 +191,43 @@ export function checkReferences({ config, layout = {} } = {}, { notes = [] } = { } } + // Владельцы позиций, разрешимые по одной конфигурации: комнаты и области + // любого пространства. Нужны, чтобы судить запись на удалённом пространстве + // тем же правилом, которым её судит сам продукт (#566). + const allRoomIds = new Set(spaces.flatMap((space) => (space?.rooms || []) + .map((room) => String(room?.id ?? '')).filter(Boolean))); + const allAreas = new Set(spaces.flatMap((space) => (space?.rooms || []) + .map((room) => String(room?.area ?? '')).filter(Boolean))); + const removedMarkerIds = new Set((config?.markers || []) + .filter((marker) => marker?.removed === true) + .map((marker) => String(marker?.id ?? '')).filter(Boolean)); + for (const [key, position] of Object.entries(layout || {})) { const space = position?.s == null ? '' : String(position.s); if (space && !spaceIds.has(space)) { - add('layout_space', key, space, 'пространства не существует'); + // Пространства нет — но это ещё не нарушение (#566). `Optimize` + // (`space-reference-repair.ts`) удаляет такую запись только когда МОЖЕТ + // ДОКАЗАТЬ, что владелец тоже исчез, и сознательно сохраняет её, когда + // владелец жив или доказательств нет: иначе удаление уносит расстановку + // пользователя. Значит конфиг, только что прошедший Optimize, законно + // содержит такие записи, и объявлять их нарушением — врать про модель. + // + // Судим тем же правилом: нарушение только там, где владелец ОТСУТСТВУЕТ + // по самой конфигурации. Остальное — наблюдение, как и у ветки + // `unknown_owner` ниже: проверка с ложными срабатываниями умирает первой. + const roomOwner = key.startsWith('rl_') ? key.slice(3) : ''; + const areaOwner = key.startsWith('grp_') ? key.slice(4) : ''; + const ownerGone = roomOwner ? !allRoomIds.has(roomOwner) + : areaOwner ? !allAreas.has(areaOwner) + : removedMarkerIds.has(key); + if (ownerGone) { + add('layout_space', key, space, + 'пространства не существует, и владельца позиции тоже нет'); + } else { + notes.push({ invariant: 'references', kind: 'stale_layout_space', owner: key, + reference: space, + detail: 'пространства не существует, владелец жив — Optimize хранит позицию намеренно' }); + } continue; } // Владелец позиции: подпись комнаты, групповая метка области либо маркер. @@ -746,6 +779,7 @@ function noteSummary(notes) { for (const note of notes) counts.set(note.kind, (counts.get(note.kind) || 0) + 1); const titles = { unknown_owner: 'позиции без записи маркера', + stale_layout_space: 'позиции живых владельцев на удалённых пространствах', stale_wall_key: 'записей толщины используют exact endpoints вместо своего ключа', }; return [...counts].map(([kind, n]) => `${n} — ${titles[kind] || kind}`).join('; ') + '.'; @@ -802,7 +836,7 @@ function report(violations, notes = []) { room_open_to: 'Связи комнат ссылаются на несуществующие комнаты', opening_host: 'Проёмы ссылаются на несуществующие стены', room_wall_ids: 'Комнаты ссылаются на несуществующие сегменты стен', - layout_space: 'Позиции ссылаются на несуществующие пространства', + layout_space: 'Позиции на несуществующих пространствах, владельцев тоже нет', layout_owner: 'Позиции без владельца', wall_carrier: 'Записи толщины вне рёбер и перегородок', open_span_carrier: 'Виртуальные проёмы вне границ комнат', diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index d0ce6473..98078c45 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -72,6 +72,30 @@ function relocateEditorPatch(patch, cardSource, editorSource) { // `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда // попало», проверяет не то, что объявлен проверять. Это контролирует --check. const MUTANT_DEFINITIONS = [ + { + id: 'invariants-blame-every-stale-position', + guard: 'node --test --test-name-pattern="#566" test/model-invariants.test.mjs', + because: '#566: продукт СОЗНАТЕЛЬНО хранит позицию, чей владелец жив, а пространство ' + + 'удалено; объявляя это нарушением, инварианты краснеют на законном конфиге сразу ' + + 'после Optimize — и следующий разбор начнётся с ложного сигнала', + patches: [{ + file: 'scripts/model-invariants.mjs', + find: ' const ownerGone = roomOwner ? !allRoomIds.has(roomOwner)', + replace: ' const ownerGone = true || roomOwner ? !allRoomIds.has(roomOwner)', + }], + }, + { + id: 'invariants-forgive-a-vanished-position-owner', + guard: 'node --test --test-name-pattern="#566" test/model-invariants.test.mjs', + because: '#566: обратная сторона того же правила — запись, чей владелец отсутствует по ' + + 'самой конфигурации, обязана остаться нарушением, иначе сужение превращается в ' + + 'отключение проверки', + patches: [{ + file: 'scripts/model-invariants.mjs', + find: ' : removedMarkerIds.has(key);', + replace: ' : false;', + }], + }, { id: 'radar-sources-compared-as-text', guard: 'node --test --test-name-pattern="#567" test/radar-editor.test.mjs', diff --git a/test/geometry-corpus.test.mjs b/test/geometry-corpus.test.mjs index b2f41718..67d55abd 100644 --- a/test/geometry-corpus.test.mjs +++ b/test/geometry-corpus.test.mjs @@ -48,10 +48,14 @@ const load = (name) => { return { config: raw.config ?? raw, layout: raw.layout ?? {} }; }; -/** Все нарушения инвариантов одним списком — тот же набор, что у CLI (#254). */ -const violations = (config, layout = {}) => [ - ...checkReferences({ config, layout }), - ...checkWallKeys(config), +/** + * Все нарушения инвариантов одним списком — тот же набор, что у CLI (#254). + * Наблюдения складываются в переданный массив: #566 перевёл часть случаев из + * нарушений в наблюдения, и корпус обязан отличать одно от другого. + */ +const violations = (config, layout = {}, notes = []) => [ + ...checkReferences({ config, layout }, { notes }), + ...checkWallKeys(config, { notes }), ...checkMixedRoleRecords(config), ...checkHiddenObstacles(config), ...checkPhysicalGeometry(config), @@ -242,7 +246,11 @@ const CORPUS = [ what: 'записи layout на пространство, которого в конфиге больше нет', field: 'в поле четыре такие записи на два удалённых пространства', fixedPointOnImport: false, - expectedViolations: ['references/layout_space', 'references/layout_space'], + // #566: владельца этих записей по конфигурации не видно, поэтому продукт + // хранит их намеренно, а инварианты сообщают о них НАБЛЮДЕНИЕМ. Нарушением + // была бы только запись, чей владелец отсутствует по самой конфигурации. + expectedViolations: [], + expectedNotes: ['references/stale_layout_space', 'references/stale_layout_space'], condition(space, layout) { const known = new Set([space.id]); const orphans = Object.values(layout).filter((entry) => !known.has(entry.s)); @@ -263,8 +271,11 @@ for (const item of CORPUS) { test(`корпус #560 ${label}: импорт → Optimize → Optimize`, () => { const { config, layout } = load(item.file); const expected = item.expectedViolations ?? []; - assert.deepEqual(kinds(violations(config, layout)), expected.slice().sort(), + const notes = []; + assert.deepEqual(kinds(violations(config, layout, notes)), expected.slice().sort(), 'импорт: инварианты'); + assert.deepEqual(kinds(notes), (item.expectedNotes ?? []).slice().sort(), + 'импорт: наблюдения названы ровно те, что заявлены'); const guard = clone(config); const first = optimizePlans(config, layout); diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs index cc2117b8..afc5d47b 100644 --- a/test/model-invariants.test.mjs +++ b/test/model-invariants.test.mjs @@ -98,18 +98,66 @@ test('#265: marker-ссылки принимают только активную ]); }); -test('#252: позиция на удалённое пространство и позиция без владельца', () => { +test('#252/#566: позиция на удалённое пространство судится по владельцу', () => { + // #566 сузил правило. Прежде ЛЮБАЯ позиция на удалённом пространстве была + // нарушением, и это расходилось с самим продуктом: `space-reference-repair` + // удаляет такую запись только когда может ДОКАЗАТЬ, что владелец тоже + // исчез, а иначе хранит её намеренно — удаление уносит расстановку + // пользователя. Конфиг сразу после Optimize законно содержит такие записи. const m = model({ layout: { m1: { s: 'sp1', x: 0.2, y: 0.2 }, - ghost: { s: 'space_f1_4c7c573f', x: 0.3, y: 0.3 }, rl_room_gone: { s: 'sp1', x: 0.3, y: 0.3 }, 'grp_no_such_area': { s: 'sp1', x: 0.3, y: 0.3 }, } }); const kinds = checkReferences(m).map((v) => `${v.kind}:${v.owner}`); - assert.ok(kinds.includes('layout_space:ghost')); assert.ok(kinds.includes('layout_owner:rl_room_gone')); assert.ok(kinds.includes('layout_owner:grp_no_such_area')); - assert.equal(kinds.length, 3, 'живая позиция m1 нарушением быть не должна'); + assert.equal(kinds.length, 2, 'живая позиция m1 нарушением быть не должна'); +}); + +test('#566: на удалённом пространстве нарушение — только исчезнувший владелец', () => { + const m = model({ + config: { + spaces: [{ + id: 'sp1', cell_cm: 5, + rooms: [{ id: 'r1', name: 'R', area: 'kitchen', poly: rect(0.1, 0.1, 0.4, 0.4) }], + walls: [{ key: 'k1', cm: 30, a: [0.1, 0.1], b: [0.4, 0.1] }], + partitions: [], open_spans: [], + }], + markers: [ + { id: 'm1', binding: 'virtual', space: 'sp1' }, + { id: 'm_gone', binding: 'virtual', space: 'sp1', removed: true }, + ], + }, + layout: { + // Владелец жив, исчезло только пространство: продукт хранит намеренно. + rl_r1: { s: 'dead_space', x: 0.3, y: 0.3 }, + grp_kitchen: { s: 'dead_space', x: 0.3, y: 0.3 }, + m1: { s: 'dead_space', x: 0.3, y: 0.3 }, + // Владельца по конфигурации не видно: это устройство HA либо мусор, + // отличить нельзя — наблюдение, как и внутри живого пространства. + '980f1446c4ec1a3a9fa9ff5f6d93caed': { s: 'dead_space', x: 0.3, y: 0.3 }, + // Владелец ОТСУТСТВУЕТ по самой конфигурации — это нарушение. + rl_room_never_existed: { s: 'dead_space', x: 0.3, y: 0.3 }, + grp_area_never_existed: { s: 'dead_space', x: 0.3, y: 0.3 }, + m_gone: { s: 'dead_space', x: 0.3, y: 0.3 }, + }, + }); + const notes = []; + const found = checkReferences(m, { notes }); + assert.deepEqual(found.map((v) => `${v.kind}:${v.owner}`).sort(), [ + 'layout_space:grp_area_never_existed', + 'layout_space:m_gone', + 'layout_space:rl_room_never_existed', + ]); + assert.deepEqual(notes.filter((n) => n.kind === 'stale_layout_space') + .map((n) => n.owner).sort(), [ + '980f1446c4ec1a3a9fa9ff5f6d93caed', 'grp_kitchen', 'm1', 'rl_r1', + ]); + // Наблюдение обязано называть причину, иначе читатель решит, что проверка + // просто ослабла. + assert.match(notes.find((n) => n.kind === 'stale_layout_space').detail, + /Optimize хранит позицию намеренно/); }); test('позиция устройства без записи маркера — наблюдение, а не нарушение (#254)', () => {