fix: clean orphaned Optimize positions

Issue: #252
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 10:21:56 +03:00
parent a322db4860
commit da719f5f06
24 changed files with 999 additions and 223 deletions
+15 -7
View File
@@ -339,15 +339,23 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.equal(scenario.sunRayPixels.minChannelDelta >= 4, true);
});
test('issue 244 golden matrix covers orphan repair and invalid default_floor themes', () => {
const optimize = GOLDEN_SCENARIOS.find(
(item) => item.id === 'optimize-orphan-references-dark-en',
test('issue 252 golden matrix covers owner-aware cleanup in both themes and languages', () => {
const optimize = GOLDEN_SCENARIOS.filter(
(item) => item.dialog === 'optimize-orphan-references',
);
assert.ok(optimize);
assert.equal(optimize.dialog, 'optimize-orphan-references');
assert.equal(optimize.markerOverrides[0].space, 'removed-floor');
assert.equal(optimize.layoutOverrides['golden-light-one'].s, 'unresolved-floor');
assert.deepEqual(optimize.map((item) => item.theme).sort(), ['dark', 'light']);
assert.deepEqual(optimize.map((item) => item.language).sort(), ['en', 'ru']);
for (const scenario of optimize) {
const live = scenario.markerOverrides.find((marker) => marker.id === 'golden-light-two');
const removed = scenario.markerOverrides.find((marker) => marker.id === 'golden-presence');
assert.equal(live.space, 'removed-floor');
assert.equal(!!live.name, true);
assert.equal(removed.removed, true);
assert.equal(scenario.layoutOverrides['golden-light-one'].s, 'unresolved-floor');
assert.equal(scenario.layoutOverrides['golden-presence'].s, 'removed-floor');
}
// The existing #244 default-floor warning remains paired light/dark as well.
const editors = GOLDEN_SCENARIOS.filter(
(item) => item.cardEditorInvalidDefaultFloor === 'removed-floor',
);
+21
View File
@@ -63,6 +63,27 @@ test('Optimize distinguishes updated spaces from cleaned coordinate noise', () =
assert.match(cardSource, /d\.report\.coordsCanonicalized \+ d\.report\.wallsMerged/);
});
test('issue 252 Optimize keeps internal ids out of the main orphan report', () => {
assert.equal(
en['gs.optimize_orphans_removed'],
'Forgotten records removed: {total} — room labels: {rooms}; devices: {devices}; group markers: {groups}. They belonged to spaces deleted earlier.',
);
assert.equal(
ru['gs.optimize_orphans_removed'],
'Убрано забытых записей: {total} — подписи комнат: {rooms}; устройства: {devices}; групповые метки: {groups}. Все они принадлежали пространствам, удалённым ранее.',
);
assert.match(cardSource, /gs\.optimize_orphans_removed/);
assert.match(cardSource, /<details class="optimize-details">/);
assert.doesNotMatch(cardSource, /this\._t\('gs\.optimize_reference_warning'/);
for (const key of [
'gs.optimize_orphans_removed', 'gs.optimize_live_positions',
'gs.optimize_unverified', 'gs.optimize_vacuum_warning',
]) {
assert.doesNotMatch(en[key], /\b(?:layout|owner|nested mapping|space id|marker id)\b/i);
assert.doesNotMatch(ru[key], /\b(?:layout|owner|id|вложенн)/i);
}
});
test('i18n: every literal help call has body and full aria keys in both languages', () => {
const allCalls = cardSource.match(/this\._help\(/g) || [];
const helpKeys = [...cardSource.matchAll(/this\._help\('([^']+\.help)'\)/g)].map((match) => match[1]);
+7 -3
View File
@@ -37,7 +37,9 @@ const assertNoPersistedChanges = (result) => {
'moved', 'coordsCanonicalized', 'rotated', 'removedDrafts', 'migrated',
'glowSpacesMigrated', 'glowRoomsMigrated', 'canonicalized', 'wallsMerged',
'spansMerged', 'partitionsMerged', 'spaceRefsRemapped', 'roomRefsRemapped',
'positionsRemapped', 'markersDetached',
'positionsRemapped', 'markersDetached', 'orphanRoomLabelsRemoved',
'orphanDevicePositionsRemoved', 'orphanGroupPositionsRemoved',
'liveMissingPositionsRemoved',
]) assert.equal(result.report[field], 0, `${field} must describe the persisted delta`);
assert.equal(result.report.maxShift, 0);
assert.equal(result.report.maxShiftCm, 0);
@@ -374,7 +376,7 @@ test('model version bookkeeping does not claim a data migration', () => {
assert.equal(result.report.migrated, 0);
});
test('issue 244 reference repair is part of exact Optimize candidate and bumps model version', () => {
test('issue 252 Optimize detaches a live marker without silently deleting its old position', () => {
const input = {
model_version: PLAN_MODEL_VERSION - 1,
spaces: [{ id: 'home', title: 'Home', view_box: [0, 0, 1, 1], rooms: [] }],
@@ -386,8 +388,10 @@ test('issue 244 reference repair is part of exact Optimize candidate and bumps m
assert.equal(result.config.model_version, PLAN_MODEL_VERSION);
assert.equal(result.config.markers[0].space, undefined);
assert.equal(result.config.markers[0].icon, 'mdi:sofa');
assert.equal(result.layout.m, undefined);
assert.deepEqual(result.layout.m, { s: 'gone', x: 0.25, y: 0.5 });
assert.equal(result.report.markersDetached, 1);
assert.equal(result.report.positionsUnresolved, 1);
assert.equal(result.report.liveMissingPositions.length, 1);
assert.equal(result.report.migrated, 0, 'reference counters stay separate from migration');
});
+150 -6
View File
@@ -7,8 +7,17 @@ const space = (id, rooms = []) => ({
id, title: id, cell_cm: 5, view_box: [0, 0, 1, 1], rooms,
});
const room = (id, area = null) => ({ id, name: id, area, x: 0, y: 0, w: 1, h: 1 });
const emptyOwnerReport = {
orphanRoomLabelsRemoved: 0,
orphanDevicePositionsRemoved: 0,
orphanGroupPositionsRemoved: 0,
liveMissingPositionsRemoved: 0,
removedPositions: [],
liveMissingPositions: [],
unverifiedPositions: [],
};
test('issue 244 detaches only dead active placement and drops its stale coordinates', () => {
test('issue 252 detaches a live marker but preserves its stale coordinates until explicit cleanup', () => {
const marker = {
id: 'washer', binding: 'entity:sensor.washer', space: 'gone', room_id: 'gone_room',
name: 'Washer', icon: 'mdi:washing-machine', tap_action: 'more-info',
@@ -23,7 +32,11 @@ test('issue 244 detaches only dead active placement and drops its stale coordina
assert.equal(result.report.spaceRefsRemapped, 0);
assert.equal(result.config.markers[0].space, undefined);
assert.equal(result.config.markers[0].room_id, undefined);
assert.equal(result.layout.washer, undefined);
assert.deepEqual(result.layout.washer, { s: 'gone', x: 0.25, y: 0.5, k: 1.2 });
assert.equal(result.report.positionsUnresolved, 1);
assert.deepEqual(result.report.liveMissingPositions, [{
id: 'washer', spaceId: 'gone', kind: 'device', name: 'Washer',
}]);
assert.equal(result.config.markers[0].binding, marker.binding);
assert.equal(result.config.markers[0].icon, marker.icon);
assert.deepEqual(result.config.markers[0].pdfs, marker.pdfs);
@@ -62,6 +75,7 @@ test('issue 244 exact import signatures repair the full reference graph and are
positionsUnresolved: 0,
nestedRefsUnresolved: 0,
deadSpaceIds: [],
...emptyOwnerReport,
});
const again = repairSpaceReferences(result.config, result.layout);
@@ -74,10 +88,11 @@ test('issue 244 exact import signatures repair the full reference graph and are
positionsUnresolved: 0,
nestedRefsUnresolved: 0,
deadSpaceIds: [],
...emptyOwnerReport,
});
});
test('issue 244 Area remap uses a unique production Area and never transplants coordinates', () => {
test('issue 252 Area remap never transplants or silently deletes old coordinates', () => {
const input = {
spaces: [space('ground', [room('utility', 'utility_area')])],
markers: [{
@@ -91,7 +106,9 @@ test('issue 244 Area remap uses a unique production Area and never transplants c
assert.equal(result.config.markers[0].space, 'ground');
assert.equal(result.config.markers[0].room_id, 'utility');
assert.equal(result.layout.washer, undefined);
assert.deepEqual(result.layout.washer, { s: 'gone', x: 0.9, y: 0.9 });
assert.equal(result.report.positionsUnresolved, 1);
assert.equal(result.report.liveMissingPositions.length, 1);
assert.equal(result.report.spaceRefsRemapped, 1);
assert.equal(result.report.roomRefsRemapped, 1);
assert.equal(result.report.markersDetached, 0);
@@ -121,7 +138,7 @@ test('issue 244 ambiguous, truncated and malformed signatures are never guessed'
assert.equal(result.config.markers[2].space, undefined);
assert.equal(result.config.markers[3].space, 'f1', 'removed tombstone is preserved');
assert.equal(result.report.markersDetached, 3);
assert.deepEqual(result.report.deadSpaceIds, ['f1']);
assert.deepEqual(result.report.deadSpaceIds, []);
});
test('issue 244 preserves unresolved layout and nested calibration while reporting it', () => {
@@ -141,6 +158,9 @@ test('issue 244 preserves unresolved layout and nested calibration while reporti
assert.deepEqual(result.layout, layout);
assert.equal(result.report.nestedRefsUnresolved, 1);
assert.equal(result.report.positionsUnresolved, 1);
assert.deepEqual(result.report.unverifiedPositions, [{
id: 'opaque_owner', spaceId: 'gone', kind: 'unknown', name: '', reason: 'unknown_owner',
}]);
assert.deepEqual(result.report.deadSpaceIds, ['gone']);
});
@@ -175,7 +195,8 @@ test('issue 244 removed tombstones accept only an exact signature map', () => {
assert.deepEqual(result.layout.exact, { s: 'space_f1_deadbeef', x: 0.2, y: 0.3 });
assert.equal(result.config.markers[1].space, 'other');
assert.equal(result.config.markers[1].room_id, 'other-room');
assert.deepEqual(result.layout.guess, { s: 'other', x: 0.4, y: 0.5 });
assert.equal(result.layout.guess, undefined);
assert.equal(result.report.orphanDevicePositionsRemoved, 1);
});
test('issue 244 large valid reference graph stays unchanged in one indexed pass', () => {
@@ -200,3 +221,126 @@ test('issue 244 large valid reference graph stays unchanged in one indexed pass'
assert.equal(result.config.markers.length, markers.length);
assert.equal(Object.keys(result.layout).length, markers.length);
});
test('issue 252 removes only proven room, device and group orphans and is idempotent', () => {
const removedMarkers = Array.from({ length: 3 }, (_, index) => ({
id: `removed-device-${index}`, binding: 'virtual', removed: true, space: 'deleted-floor',
}));
const roomLabels = Object.fromEntries(Array.from({ length: 32 }, (_, index) => [
`rl_deleted-room-${index}`, { s: 'deleted-floor', x: 0.1, y: 0.2 },
]));
const devicePositions = Object.fromEntries(removedMarkers.map((marker) => [
marker.id, { s: 'deleted-floor', x: 0.3, y: 0.4 },
]));
const layout = {
...roomLabels,
...devicePositions,
'lg_light.deleted_one': { s: 'deleted-floor', x: 0.5, y: 0.6 },
'lg_light.deleted_two': { s: 'deleted-floor', x: 0.7, y: 0.8 },
};
const config = {
spaces: [space('home', [room('living')])], markers: removedMarkers, settings: {},
};
const result = repairSpaceReferences(config, layout, {
ownerRoster: { authoritative: true, deviceIds: [], entityIds: [] },
});
assert.equal(result.changed, true);
assert.equal(Object.keys(result.layout).length, 0);
assert.equal(result.report.orphanRoomLabelsRemoved, 32);
assert.equal(result.report.orphanDevicePositionsRemoved, 3);
assert.equal(result.report.orphanGroupPositionsRemoved, 2);
assert.equal(result.report.positionsUnresolved, 0);
assert.deepEqual(result.report.deadSpaceIds, []);
assert.deepEqual(layout, {
...roomLabels,
...devicePositions,
'lg_light.deleted_one': { s: 'deleted-floor', x: 0.5, y: 0.6 },
'lg_light.deleted_two': { s: 'deleted-floor', x: 0.7, y: 0.8 },
}, 'preview must not mutate the source layout');
const again = repairSpaceReferences(result.config, result.layout, {
ownerRoster: { authoritative: true, deviceIds: [], entityIds: [] },
});
assert.equal(again.changed, false);
assert.equal(again.report.orphanRoomLabelsRemoved, 0);
assert.equal(again.report.orphanDevicePositionsRemoved, 0);
assert.equal(again.report.orphanGroupPositionsRemoved, 0);
});
test('issue 252 preserves live owners by default and removes only them after explicit opt-in', () => {
const config = {
spaces: [space('home', [room('living')])],
markers: [{ id: 'virtual-live', binding: 'virtual', name: 'Hall note' }],
settings: { known_devices: ['auto-live'] },
};
const layout = {
'virtual-live': { s: 'gone', x: 0.1, y: 0.2 },
'auto-live': { s: 'gone', x: 0.3, y: 0.4 },
'lg_light.live': { s: 'gone', x: 0.5, y: 0.6 },
rl_living: { s: 'gone', x: 0.7, y: 0.8 },
};
const context = {
ownerRoster: {
authoritative: true,
deviceIds: ['auto-live'],
entityIds: ['light.live'],
names: {
'virtual-live': 'Hall note',
'auto-live': 'Washing machine',
'lg_light.live': 'Living lights',
},
},
};
const kept = repairSpaceReferences(config, layout, context);
assert.deepEqual(kept.layout, layout);
assert.equal(kept.report.positionsUnresolved, 4);
assert.equal(kept.report.liveMissingPositions.length, 4);
assert.deepEqual(kept.report.liveMissingPositions.map((item) => item.name).sort(), [
'Hall note', 'Living lights', 'Washing machine', 'living',
].sort());
assert.equal(kept.report.orphanDevicePositionsRemoved, 0);
const removed = repairSpaceReferences(config, layout, {
...context, removeLiveMissingPositions: true,
});
assert.deepEqual(removed.layout, {});
assert.equal(removed.report.liveMissingPositionsRemoved, 4);
assert.equal(removed.report.orphanRoomLabelsRemoved, 1);
assert.equal(removed.report.orphanDevicePositionsRemoved, 2);
assert.equal(removed.report.orphanGroupPositionsRemoved, 1);
assert.equal(removed.report.positionsUnresolved, 0);
});
test('issue 252 fails closed for limited registry and unknown future layout owners', () => {
const config = {
spaces: [space('home')], markers: [], settings: { known_devices: ['known-old-device'] },
};
const layout = {
'known-old-device': { s: 'gone', x: 0.1, y: 0.2 },
'lg_light.maybe': { s: 'gone', x: 0.3, y: 0.4 },
'future_widget:one': { s: 'gone', x: 0.5, y: 0.6 },
};
const limited = repairSpaceReferences(config, layout, {
ownerRoster: { authoritative: false, deviceIds: [], entityIds: [] },
removeLiveMissingPositions: true,
});
assert.deepEqual(limited.layout, layout);
assert.equal(limited.report.unverifiedPositions.length, 3);
assert.equal(limited.report.positionsUnresolved, 3);
assert.deepEqual(limited.report.unverifiedPositions.map((item) => item.reason).sort(), [
'registry_unavailable', 'registry_unavailable', 'unknown_owner',
].sort());
const authoritative = repairSpaceReferences(config, layout, {
ownerRoster: { authoritative: true, deviceIds: [], entityIds: [] },
});
assert.equal(authoritative.layout['known-old-device'], undefined);
assert.equal(authoritative.layout['lg_light.maybe'], undefined);
assert.deepEqual(authoritative.layout['future_widget:one'], layout['future_widget:one']);
assert.equal(authoritative.report.orphanDevicePositionsRemoved, 1);
assert.equal(authoritative.report.orphanGroupPositionsRemoved, 1);
assert.equal(authoritative.report.unverifiedPositions.length, 1);
});