mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
Позиция на удалённом пространстве судится по владельцу
Инварианты объявляли нарушением ЛЮБУЮ запись layout, чьё пространство удалено. Продукт так не считает: `space-reference-repair` удаляет такую запись только когда может доказать, что владелец тоже исчез, и сознательно хранит её, когда владелец жив или доказательств нет — удаление уносит расстановку пользователя. Конфиг сразу после Optimize законно содержит такие записи, и проверка называла нарушением штатное состояние. Теперь правило то же, что у продукта: нарушение — только когда владельца нет по самой конфигурации (комната, область или снятый маркер). Остальное — наблюдение, как у ветки `unknown_owner` рядом. Issue: #566 User-Visible: no
This commit is contained in:
@@ -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: 'Виртуальные проёмы вне границ комнат',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user