Позиция на удалённом пространстве судится по владельцу

Инварианты объявляли нарушением ЛЮБУЮ запись layout, чьё пространство удалено.
Продукт так не считает: `space-reference-repair` удаляет такую запись только
когда может доказать, что владелец тоже исчез, и сознательно хранит её, когда
владелец жив или доказательств нет — удаление уносит расстановку пользователя.
Конфиг сразу после Optimize законно содержит такие записи, и проверка называла
нарушением штатное состояние.

Теперь правило то же, что у продукта: нарушение — только когда владельца нет по
самой конфигурации (комната, область или снятый маркер). Остальное —
наблюдение, как у ветки `unknown_owner` рядом.

Issue: #566
User-Visible: no
This commit is contained in:
Codex
2026-09-14 01:27:58 +03:00
parent dbb1da5d25
commit dedcedc8cc
4 changed files with 129 additions and 12 deletions
+36 -2
View File
@@ -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: 'Виртуальные проёмы вне границ комнат',
+24
View File
@@ -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',
+17 -6
View File
@@ -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);
+52 -4
View File
@@ -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)', () => {