mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
File diff suppressed because one or more lines are too long
@@ -727,10 +727,16 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const report = card._alignDialog?.report;
|
||||
const body = dialog?.querySelector('.body');
|
||||
if (!body || report?.markersDetached !== 1 || report?.positionsUnresolved !== 1
|
||||
if (!body || report?.markersDetached !== 1
|
||||
|| report?.orphanRoomLabelsRemoved !== 0
|
||||
|| report?.orphanDevicePositionsRemoved !== 1
|
||||
|| report?.liveMissingPositions.length !== 2
|
||||
|| !body.textContent.includes(card._t('gs.optimize_references', {
|
||||
spaces: '0', rooms: '0', positions: '0', detached: '1',
|
||||
}))
|
||||
|| !body.textContent.includes(card._t('gs.optimize_orphans_removed', {
|
||||
total: '1', rooms: '0', devices: '1', groups: '0',
|
||||
}))
|
||||
|| !body.textContent.includes('unresolved-floor')) {
|
||||
throw new Error('golden orphan-reference Optimize dialog is incomplete');
|
||||
}
|
||||
|
||||
+24
-1
@@ -583,13 +583,36 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
dialog: 'optimize-orphan-references', language: 'en', theme: 'dark',
|
||||
markerOverrides: [{
|
||||
id: 'golden-light-two', binding: 'virtual', space: 'removed-floor',
|
||||
room_id: 'removed-room', icon: 'mdi:lightbulb',
|
||||
room_id: 'removed-room', icon: 'mdi:lightbulb', name: 'Living-room lamp',
|
||||
}, {
|
||||
id: 'golden-light-one', binding: 'virtual', name: 'Hall lamp',
|
||||
}, {
|
||||
id: 'golden-presence', binding: 'virtual', removed: true,
|
||||
space: 'removed-floor', name: 'Retired marker',
|
||||
}],
|
||||
layoutOverrides: {
|
||||
'golden-light-two': { s: 'removed-floor', x: 0.35, y: 0.72 },
|
||||
'golden-light-one': { s: 'unresolved-floor', x: 0.20, y: 0.34 },
|
||||
'golden-presence': { s: 'removed-floor', x: 0.45, y: 0.64 },
|
||||
},
|
||||
viewport: { width: 900, height: 760 }, ...page },
|
||||
{ id: 'optimize-orphan-references-light-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'optimize-orphan-references', language: 'ru', theme: 'light',
|
||||
markerOverrides: [{
|
||||
id: 'golden-light-two', binding: 'virtual', space: 'removed-floor',
|
||||
room_id: 'removed-room', icon: 'mdi:lightbulb', name: 'Лампа в гостиной',
|
||||
}, {
|
||||
id: 'golden-light-one', binding: 'virtual', name: 'Лампа в холле',
|
||||
}, {
|
||||
id: 'golden-presence', binding: 'virtual', removed: true,
|
||||
space: 'removed-floor', name: 'Старый маркер',
|
||||
}],
|
||||
layoutOverrides: {
|
||||
'golden-light-two': { s: 'removed-floor', x: 0.35, y: 0.72 },
|
||||
'golden-light-one': { s: 'unresolved-floor', x: 0.20, y: 0.34 },
|
||||
'golden-presence': { s: 'removed-floor', x: 0.45, y: 0.64 },
|
||||
},
|
||||
viewport: { width: 900, height: 880 }, ...page },
|
||||
{ id: 'card-editor-invalid-default-floor-light-en', fixture: 'visual',
|
||||
cardEditorInvalidDefaultFloor: 'removed-floor', language: 'en', theme: 'light',
|
||||
viewport: { width: 900, height: 760 }, ...page },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// #244: production-bundle coverage for Optimize reference preview/apply/undo,
|
||||
// remaining-only warning, space-delete blocker and invalid default_floor UI.
|
||||
// #244/#252: production-bundle coverage for safe owner-aware Optimize
|
||||
// preview/opt-in/apply/undo, plus the existing space-delete/default-floor UI.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 920, height: 900 });
|
||||
@@ -27,12 +27,17 @@ const out = await page.evaluate(async () => {
|
||||
// dependency of the later attempt to delete `home`.
|
||||
id: 'home-blocker', binding: 'virtual', space: 'home', name: 'Home blocker',
|
||||
},
|
||||
{ id: 'removed-marker', binding: 'virtual', removed: true, space: 'gone', name: 'Old marker' },
|
||||
],
|
||||
settings: {},
|
||||
settings: { known_devices: ['removed-auto-device'] },
|
||||
};
|
||||
const originalLayout = {
|
||||
orphan: { s: 'gone', x: 0.25, y: 0.5, k: 1.1 },
|
||||
opaque_owner: { s: 'gone', x: 0.6, y: 0.7 },
|
||||
rl_removed_room: { s: 'gone', x: 0.2, y: 0.3 },
|
||||
'removed-marker': { s: 'gone', x: 0.3, y: 0.4 },
|
||||
'removed-auto-device': { s: 'gone', x: 0.4, y: 0.5 },
|
||||
'lg_light.removed_group': { s: 'gone', x: 0.5, y: 0.6 },
|
||||
};
|
||||
let serverConfig = clone(original);
|
||||
let serverLayout = clone(originalLayout);
|
||||
@@ -84,16 +89,34 @@ const out = await page.evaluate(async () => {
|
||||
card._openAlignDialog();
|
||||
await card.updateComplete;
|
||||
const preview = card._alignDialog;
|
||||
const previewText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
|
||||
result.previewSeparatesRepairAndDebt = preview?.report.markersDetached === 1
|
||||
&& preview.report.positionsUnresolved === 1
|
||||
&& preview.report.deadSpaceIds.join(',') === 'gone'
|
||||
&& previewText.includes('devices detached from missing spaces — 1')
|
||||
&& previewText.includes('unresolved positions — 1');
|
||||
const previewBody = card.renderRoot.querySelector('hp-dialog .body');
|
||||
const previewMainText = [...(previewBody?.children || [])]
|
||||
.filter((element) => element.tagName !== 'DETAILS')
|
||||
.map((element) => element.textContent || '').join(' ');
|
||||
result.previewSeparatesRepairCleanupAndDebt = preview?.report.markersDetached === 1
|
||||
&& preview.report.orphanRoomLabelsRemoved === 1
|
||||
&& preview.report.orphanDevicePositionsRemoved === 2
|
||||
&& preview.report.orphanGroupPositionsRemoved === 1
|
||||
&& preview.report.liveMissingPositions.length === 1
|
||||
&& preview.report.unverifiedPositions.length === 1
|
||||
&& preview.report.positionsUnresolved === 2
|
||||
&& previewMainText.includes('devices detached from missing spaces — 1')
|
||||
&& previewMainText.includes('Forgotten records removed: 4')
|
||||
&& previewMainText.includes('Kept marker')
|
||||
&& previewMainText.includes('Could not safely verify positions: 1')
|
||||
&& !previewMainText.includes('opaque_owner')
|
||||
&& !previewMainText.includes('gone');
|
||||
result.previewPreservesMarkerSettings = preview?.config.markers[0].description === 'must survive'
|
||||
&& preview.config.markers[0].space === undefined
|
||||
&& preview.layout.orphan === undefined
|
||||
&& preview.layout.opaque_owner.s === 'gone';
|
||||
&& preview.layout.orphan.s === 'gone'
|
||||
&& preview.layout.opaque_owner.s === 'gone'
|
||||
&& preview.layout.rl_removed_room === undefined
|
||||
&& preview.layout['removed-marker'] === undefined
|
||||
&& preview.layout['removed-auto-device'] === undefined
|
||||
&& preview.layout['lg_light.removed_group'] === undefined;
|
||||
const details = previewBody?.querySelector('details.optimize-details');
|
||||
result.idsExistOnlyInClosedDetails = details && details.open === false
|
||||
&& details.textContent.includes('opaque_owner') && details.textContent.includes('gone');
|
||||
result.previewOffersOneApplyWithoutWriting = !!card.renderRoot.querySelector('hp-dialog .btn.on')
|
||||
&& calls.length === 0;
|
||||
|
||||
@@ -103,6 +126,13 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
card._openAlignDialog();
|
||||
await card.updateComplete;
|
||||
card._toggleOptimizeLivePositions();
|
||||
await card.updateComplete;
|
||||
result.explicitCleanupRebuildsPreviewWithoutWriting = calls.length === 0
|
||||
&& card._alignDialog?.removeLiveMissingPositions === true
|
||||
&& card._alignDialog.report.liveMissingPositionsRemoved === 1
|
||||
&& card._alignDialog.layout.orphan === undefined
|
||||
&& card.renderRoot.querySelector('.optimize-cleanup')?.getAttribute('aria-pressed') === 'true';
|
||||
await card._runAlignToGrid();
|
||||
await card.updateComplete;
|
||||
result.applyUsesExactAtomicEndpoint = calls.filter((type) => type === 'houseplan/plan/optimize').length === 1;
|
||||
@@ -115,8 +145,8 @@ const out = await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
const noOpText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
|
||||
result.remainingOnlyWarningHasNoApply = card._alignDialog?.changed === false
|
||||
&& noOpText.includes('All plans already use the current optimized data model.')
|
||||
&& noOpText.includes('unresolved positions — 1')
|
||||
&& noOpText.includes('There are no automatic changes to apply.')
|
||||
&& noOpText.includes('Could not safely verify positions: 1')
|
||||
&& !card.renderRoot.querySelector('hp-dialog .btn.on');
|
||||
card._alignDialog = null;
|
||||
await card.updateComplete;
|
||||
@@ -125,6 +155,8 @@ const out = await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
result.undoRestoresDeadRefs = card._serverCfg.markers[0].space === 'gone'
|
||||
&& card._layout.orphan.s === 'gone'
|
||||
&& card._layout.rl_removed_room.s === 'gone'
|
||||
&& card._layout['removed-marker'].s === 'gone'
|
||||
&& !card.renderRoot.querySelector('.dev[data-id="orphan"]');
|
||||
|
||||
let nativeConfirmCalls = 0;
|
||||
|
||||
Vendored
+113
-61
File diff suppressed because one or more lines are too long
@@ -690,6 +690,17 @@ check. A red result means zero WS calls. Python deliberately does not duplicate
|
||||
and crash-resumable atomicity boundary, not a consumer-supplied preflight
|
||||
attestation.
|
||||
|
||||
`src/space-reference-repair.ts` keeps orphan-layout classification pure. The
|
||||
card builds a runtime-only owner roster from the complete HA device/entity
|
||||
registries, current states and config names, and marks absence authoritative
|
||||
only after the registry load succeeds. The repair pass may then distinguish a
|
||||
proven-absent room label/device/group position from a live owner in a deleted
|
||||
space and from an unverified future or registry-limited owner. The first enters
|
||||
the default candidate, the second only an explicit secondary opt-in, and the
|
||||
third never a destructive candidate. No registry data or classification status
|
||||
is persisted; Apply still sends only the exact ordinary config/layout pair that
|
||||
was previewed.
|
||||
|
||||
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
|
||||
the old `houseplan/file/set` was removed in v1.10.0.
|
||||
|
||||
|
||||
+9
-4
@@ -491,10 +491,15 @@ touching virtual spans per room pair, compacts consecutive real-wall
|
||||
intervals of equal thickness and stamps `model_version`. Unknown fields
|
||||
are preserved and every pass is idempotent.
|
||||
|
||||
The optimizer deliberately does **not** alter backdrop calibration or
|
||||
saved view boxes, delete unattached layout entries (a device may only be
|
||||
temporarily unavailable), deduplicate markers, or delete files. File
|
||||
collection remains the backend's reference-aware scheduled job.
|
||||
The optimizer deliberately does **not** alter backdrop calibration or saved
|
||||
view boxes, deduplicate markers, or delete files. It may delete an unattached
|
||||
layout entry only after classifying its owner against current rooms, marker
|
||||
tombstones and an authoritative HA device/entity roster. Proven-absent room
|
||||
labels, devices and group markers are cleaned; live owners are preserved unless
|
||||
the administrator explicitly opts into removing their old positions, and an
|
||||
incomplete registry or unknown namespace always fails closed. The cleanup is
|
||||
part of the pure candidate, Undo and idempotence contract. File collection
|
||||
remains the backend's reference-aware scheduled job.
|
||||
|
||||
`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it
|
||||
copies its input, never mutates it, and returns the new spaces, the new
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- “Optimize plans” now removes layout positions only when their room label,
|
||||
device or light-group owner is proven to no longer exist. The preview reports
|
||||
those cleanups in plain-language categories, keeps technical IDs inside
|
||||
collapsed Details, and preserves live or unverifiable owners. A separate
|
||||
opt-in can remove old positions of live objects before the main Apply; Cancel
|
||||
writes nothing and maintenance Undo restores the complete previous layout.
|
||||
This narrows the v1.59 promise to preserve every unattached entry: temporarily
|
||||
unavailable and unverified objects remain protected, while proven orphans no
|
||||
longer accumulate indefinitely
|
||||
([#252](https://github.com/Matysh/houseplan-card/issues/252)).
|
||||
- Wall junctions where three or more physical walls meet now use a bounded
|
||||
straight bevel instead of producing long hatched spikes, including mixed wall
|
||||
thicknesses. The node stays filled and matches across Plan, View, kiosk,
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- «Оптимизировать планы» теперь удаляет сохранённую позицию, только когда
|
||||
доказано, что её подписи комнаты, устройства или групповой метки больше не
|
||||
существует. Предпросмотр описывает очистку понятными категориями, прячет
|
||||
технические id в свёрнутые «Подробности» и сохраняет живых либо непроверенных
|
||||
владельцев. Отдельная явная опция позволяет включить старые позиции живых
|
||||
объектов в основной Apply; Cancel ничего не записывает, а служебная отмена
|
||||
восстанавливает прежнюю раскладку целиком. Это уточняет обещание v1.59
|
||||
сохранять все непривязанные записи: временно недоступные и непроверенные
|
||||
объекты по-прежнему защищены, а доказанно забытые позиции больше не
|
||||
накапливаются
|
||||
([#252](https://github.com/Matysh/houseplan-card/issues/252)).
|
||||
- В узлах, где сходятся три и более физические стены, длинные выступающие
|
||||
клинья штриховки теперь заменяются ограниченной прямой фаской, в том числе
|
||||
при разной толщине стен. Сам узел остаётся заполненным и одинаковым в Plan,
|
||||
|
||||
@@ -193,10 +193,20 @@ Missing `marker.space`, `marker.room_id`, `vacuum.segment_map` and layout
|
||||
ownership remain readable by the permissive persisted schemas. They are never
|
||||
rewritten during load or an unrelated Save. Explicit Optimize may map an
|
||||
untruncated exact `space_<old>_<8 hex>` / `room_<old>_<8 hex>` import signature,
|
||||
or use the production HA Area placement for an active real marker. Without
|
||||
proof it removes only the active marker's missing placement and stale position;
|
||||
unknown layout and nested vacuum mappings remain stored and reported. The pass
|
||||
is data-driven, idempotent and runs even when `model_version` is already 7.
|
||||
or use the production HA Area placement for an active real marker. Without a
|
||||
valid target it removes the marker's missing placement but preserves its old
|
||||
position for the owner-aware cleanup decision.
|
||||
|
||||
The same explicit Optimize candidate automatically removes layout entries only
|
||||
for owners proven absent: missing room labels, removed marker tombstones, and
|
||||
known devices or `lg_` entities absent from an authoritative HA registry/state
|
||||
roster. A live owner in a deleted space is preserved unless the administrator
|
||||
explicitly opts into removing its old position. A limited or unavailable
|
||||
registry and an unknown/future layout namespace always preserve the entry;
|
||||
nested vacuum mappings likewise remain stored and reported. This is a runtime
|
||||
read-model decision, not a persisted migration: schemas, store/model versions
|
||||
and the layout shape are unchanged. The pass is data-driven, undoable,
|
||||
idempotent and runs even when `model_version` is already 7.
|
||||
|
||||
A one-space import uses its known id map (not a heuristic) to repair matching
|
||||
orphan target references when the original space id is absent. Full restore is
|
||||
|
||||
+18
-3
@@ -2248,9 +2248,10 @@ require hands on real hardware — they remain for the human pass.
|
||||
`optimize-preflight-renders-apply-on-failure`].
|
||||
- [ ] **Missing space references recover without losing a marker (#244)**:
|
||||
exact import signatures remap space, room, marker/room-label positions
|
||||
and vacuum segments; Area remap and detach delete stale coordinates but
|
||||
preserve the marker record; ambiguous/truncated signatures and opaque
|
||||
layout are never guessed. Preview/Apply/Undo use one exact candidate and
|
||||
and vacuum segments; Area remap and detach preserve the marker record and
|
||||
leave an old position for the owner-aware #252 decision rather than
|
||||
guessing. Ambiguous/truncated signatures and opaque layout are never
|
||||
guessed. Preview/Apply/Undo use one exact candidate and
|
||||
show remaining debt even for a no-op. Space import repairs target refs by
|
||||
its known map. With another space present, space delete deduplicates active
|
||||
marker blockers; deleting the sole remaining space instead preserves every
|
||||
@@ -2264,6 +2265,20 @@ require hands on real hardware — they remain for the human pass.
|
||||
card-editor-validation; backend: test_ha_import_export,
|
||||
test_ha_websocket; smoke: orphan-space-references + optional-space-model;
|
||||
pre-release: targeted browser smoke and light/dark golden].
|
||||
- [ ] **Optimize explains and safely cleans forgotten positions (#252)**:
|
||||
the owner matrix covers room labels, marker tombstones, known HA devices,
|
||||
`lg_` entities and unknown namespaces across authoritative and limited
|
||||
registries. Only proven-absent owners enter the default candidate; live
|
||||
owners are named and preserved until the secondary opt-in, and unverified
|
||||
owners never receive a destructive action. The main RU/EN report contains
|
||||
bounded human categories rather than IDs; closed Details contains at most
|
||||
ten technical entries plus the remainder, and vacuum mappings remain a
|
||||
separate warning. Cancel and the secondary toggle write nothing; Apply
|
||||
writes the exact preview once, reload is a no-op, and Undo restores all
|
||||
removed positions [unit: space-reference-repair + plan-optimizer + i18n;
|
||||
auto: smoke_orphan_space_references; golden: dark EN + light RU;
|
||||
mutations: `orphan-cleanup-proven-owners-kept`,
|
||||
`orphan-cleanup-partial-registry-deletes`].
|
||||
- [ ] **Every write prevents new ULP coordinate noise (#224)**: config/layout
|
||||
schema, import, direct storage writers, startup recovery and maintenance
|
||||
Undo produce the same nine-decimal allow-listed geometry as the frontend.
|
||||
|
||||
+17
-7
@@ -621,14 +621,24 @@ Optimization compacts old off-grid geometry and repairs the plan's reference
|
||||
graph while preserving rooms, bindings and supported settings. An exact
|
||||
independent-import signature restores the copied space, room and positions. If
|
||||
there is no exact copy, an active real device follows its unambiguous HA Area;
|
||||
otherwise only its missing placement and stale coordinates are detached, so
|
||||
the marker becomes available on a valid plan without losing its settings.
|
||||
Unknown layout and vacuum mappings are preserved and reported for manual
|
||||
attention. It does not delete plan images or attachments merely because
|
||||
nothing currently references them.
|
||||
otherwise only its missing placement is detached, so the marker becomes
|
||||
available on a valid plan without losing its settings.
|
||||
|
||||
Optimization creates one server-side undo point. Any later edit makes that undo
|
||||
stale, so create a Home Assistant backup before a large maintenance operation.
|
||||
Old positions are classified before Apply. A position whose room label, device
|
||||
or light-group owner is proven absent is removed automatically and counted by a
|
||||
plain-language category. A live owner in a deleted space is named and preserved
|
||||
by default; **Remove old positions** explicitly adds only those entries to the
|
||||
same Apply candidate. An owner that cannot be checked against a complete HA
|
||||
registry is preserved without a destructive action. Raw IDs appear only inside
|
||||
collapsed **Details**, and vacuum room mappings remain a separate warning for
|
||||
manual review. Preview, the secondary option and Cancel do not write anything.
|
||||
Plan images and attachments are never deleted merely because nothing currently
|
||||
references them.
|
||||
|
||||
Optimization creates one server-side undo point which restores automatically
|
||||
and explicitly removed positions with the rest of the previous layout. Any
|
||||
later edit makes that undo stale, so create a Home Assistant backup before a
|
||||
large maintenance operation.
|
||||
|
||||
<!-- docs-section: multiple-cards -->
|
||||
|
||||
|
||||
+11
-6
@@ -1385,7 +1385,8 @@ show_signal: true
|
||||
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
|
||||
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Проёмы остаются на своих местах |
|
||||
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
|
||||
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку и старые координаты; настройки маркера сохраняются |
|
||||
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
|
||||
| Забытые позиции | Позиции доказанно отсутствующих подписей комнат, устройств и групповых меток удаляются и считаются по понятным категориям |
|
||||
|
||||
Перед записью диалог показывает количество затрагиваемых элементов,
|
||||
максимальный сдвиг в сантиметрах и пространство с этим сдвигом. Отдельная
|
||||
@@ -1394,9 +1395,13 @@ show_signal: true
|
||||
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
|
||||
координат. Повторный Optimize над результатом ничего не предлагает — ни сразу,
|
||||
ни после серверного события обновления или перезагрузки страницы.
|
||||
Неразрешённые позиции и вложенные сопоставления робота не удаляются: диалог
|
||||
показывает их отдельным предупреждением даже тогда, когда применять больше
|
||||
нечего.
|
||||
Позиция живого объекта в удалённом пространстве по умолчанию сохраняется и
|
||||
показывается с его именем. Вторичная опция «Убрать старые позиции» добавляет
|
||||
только такие записи в тот же кандидат Apply; сама опция, предпросмотр и Cancel
|
||||
ничего не записывают. Если полного реестра HA недостаточно для безопасной
|
||||
проверки владельца, позиция сохраняется без кнопки удаления. Технические id
|
||||
доступны только в свёрнутых «Подробностях». Сопоставления комнат пылесоса также
|
||||
сохраняются и показываются отдельным предупреждением для ручной проверки.
|
||||
|
||||
Перед подтверждением House Plan дополнительно строит точную геометрию кандидата
|
||||
для **всех** пространств — тем же способом, которым затем рисуются стены,
|
||||
@@ -1412,12 +1417,12 @@ show_signal: true
|
||||
- неизвестные поля новых версий;
|
||||
- положение и масштаб изображения подложки;
|
||||
- сохранённые виды/границы холста;
|
||||
- неиспользуемые записи раскладки;
|
||||
- позиции живых объектов по умолчанию и все непроверенные записи раскладки;
|
||||
- файлы подложек и вложения.
|
||||
|
||||
### Риск и отмена
|
||||
|
||||
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Отмена возвращает прежнюю геометрию и неизвестные поля в чистом числовом представлении, не восстанавливая невидимый floating-point шум. Новый edit делает резервную копию оптимизации устаревшей.
|
||||
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Отмена возвращает прежнюю геометрию, автоматически и явно удалённые позиции и неизвестные поля в чистом числовом представлении, не восстанавливая невидимый floating-point шум. Новый edit делает резервную копию оптимизации устаревшей.
|
||||
|
||||
Обычное открытие и редактирование плана не удаляет даже очень короткие точные
|
||||
границы толщины. Описанное схлопывание выполняется только после явного
|
||||
|
||||
@@ -43,7 +43,7 @@ export const MUTANTS = [
|
||||
{
|
||||
id: 'orphan-space-detach-disabled',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="detaches only dead active placement" '
|
||||
+ '&& node --test --test-name-pattern="detaches a live marker" '
|
||||
+ 'test/space-reference-repair.test.mjs',
|
||||
because: 'an active marker with no exact copy or usable Area must lose only its dead space '
|
||||
+ 'field; retaining that one field is the original invisibility bug and must be caught '
|
||||
@@ -70,17 +70,29 @@ export const MUTANTS = [
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'orphan-space-area-keeps-stale-position',
|
||||
id: 'orphan-cleanup-partial-registry-deletes',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="Area remap uses a unique production Area" '
|
||||
+ '&& node --test --test-name-pattern="fails closed for limited registry" '
|
||||
+ 'test/space-reference-repair.test.mjs',
|
||||
because: 'an Area proves the destination room but not a coordinate transform between plans; '
|
||||
+ 'keeping the deleted-plan x/y silently teleports the restored device (#244)',
|
||||
because: 'limited registry access is not proof that an HA device or group was deleted; '
|
||||
+ 'treating it as authoritative destroys positions on permission/network failures (#252)',
|
||||
patches: [{
|
||||
file: 'src/space-reference-repair.ts',
|
||||
find: ' delete layout[markerId];\n handledLayout.add(markerId);',
|
||||
replace: ' layout[markerId] = { ...position, s: targetSpace || positionSpace };\n'
|
||||
+ ' handledLayout.add(markerId);',
|
||||
find: ' const rosterAuthoritative = roster?.authoritative === true;',
|
||||
replace: ' const rosterAuthoritative = true;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'orphan-cleanup-proven-owners-kept',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="removes only proven room, device and group" '
|
||||
+ 'test/space-reference-repair.test.mjs',
|
||||
because: 'proof without deletion leaves the exact maintenance debt #252 exists to remove; '
|
||||
+ 'all three supported owner categories must change the candidate once and only once',
|
||||
patches: [{
|
||||
file: 'src/space-reference-repair.ts',
|
||||
find: " if (status === 'absent') {\n delete layout[key];\n countRemoval(owner.kind);",
|
||||
replace: " if (status === 'absent') {\n countRemoval(owner.kind);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
+188
-24
@@ -187,6 +187,7 @@ import {
|
||||
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
|
||||
} from './space-geometry';
|
||||
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
|
||||
import type { SpaceReferenceRepairContext } from './space-reference-repair';
|
||||
import { collectSpaceMarkerDependencies } from './space-deletion';
|
||||
import {
|
||||
checkOptimizeGeometry,
|
||||
@@ -1773,6 +1774,8 @@ class HouseplanCard extends LitElement {
|
||||
where: string;
|
||||
changed: boolean;
|
||||
busy: boolean;
|
||||
/** False by default; true only after the secondary preview action. */
|
||||
removeLiveMissingPositions: boolean;
|
||||
} | null = null;
|
||||
|
||||
private _settingsDialog: {
|
||||
@@ -15003,15 +15006,74 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _openAlignDialog = (): void => {
|
||||
private _optimizeReferenceContext(
|
||||
removeLiveMissingPositions: boolean,
|
||||
): SpaceReferenceRepairContext {
|
||||
const registry = this._haRegistry;
|
||||
const full = this._fullRegistryHass;
|
||||
const names: Record<string, string> = {};
|
||||
const humanName = (...values: unknown[]): string => {
|
||||
for (const value of values) {
|
||||
const name = String(value || '').trim();
|
||||
if (name) return name;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
for (const [deviceId, device] of Object.entries<any>(registry.devices || {})) {
|
||||
names[deviceId] = humanName(device?.name_by_user, device?.name, device?.model);
|
||||
}
|
||||
for (const [entityId, entity] of Object.entries<any>(registry.entities || {})) {
|
||||
names[`lg_${entityId}`] = humanName(
|
||||
this.hass?.states?.[entityId]?.attributes?.friendly_name,
|
||||
entity?.name,
|
||||
entity?.original_name,
|
||||
);
|
||||
}
|
||||
for (const device of this._devices) names[device.id] = humanName(device.name, names[device.id]);
|
||||
for (const marker of this._serverCfg?.markers || []) {
|
||||
const separator = String(marker.binding || '').indexOf(':');
|
||||
const kind = separator > 0 ? marker.binding.slice(0, separator) : '';
|
||||
const ref = separator > 0 ? marker.binding.slice(separator + 1) : '';
|
||||
const bound = kind === 'device'
|
||||
? full?.devices?.[ref]
|
||||
: kind === 'entity' ? full?.entities?.[ref] : null;
|
||||
names[marker.id] = humanName(
|
||||
marker.name,
|
||||
kind === 'device' ? bound?.name_by_user : null,
|
||||
bound?.name,
|
||||
kind === 'entity' ? this.hass?.states?.[ref]?.attributes?.friendly_name : null,
|
||||
names[marker.id],
|
||||
);
|
||||
}
|
||||
return {
|
||||
effectiveAreaByMarker: Object.fromEntries(
|
||||
this._devices
|
||||
.filter((device) => !device.virtual && !!device.area)
|
||||
.map((device) => [device.id, device.area]),
|
||||
),
|
||||
ownerRoster: {
|
||||
authoritative: registry.authoritative,
|
||||
deviceIds: Object.keys(registry.devices || {}),
|
||||
// State-only YAML entities are positive existence evidence even though
|
||||
// an authoritative Entity Registry cannot list them.
|
||||
entityIds: [...new Set([
|
||||
...Object.keys(registry.entities || {}),
|
||||
...Object.keys(this.hass?.states || {}),
|
||||
])],
|
||||
names,
|
||||
},
|
||||
removeLiveMissingPositions,
|
||||
};
|
||||
}
|
||||
|
||||
private _previewAlignDialog(removeLiveMissingPositions: boolean): void {
|
||||
if (!this._norm || !this._serverCfg) return;
|
||||
const spaces = this._serverCfg.spaces || [];
|
||||
const effectiveAreaByMarker = Object.fromEntries(
|
||||
this._devices
|
||||
.filter((device) => !device.virtual && !!device.area)
|
||||
.map((device) => [device.id, device.area]),
|
||||
const r = optimizePlans(
|
||||
this._serverCfg,
|
||||
this._layout || {},
|
||||
this._optimizeReferenceContext(removeLiveMissingPositions),
|
||||
);
|
||||
const r = optimizePlans(this._serverCfg, this._layout || {}, { effectiveAreaByMarker });
|
||||
const preflight = r.changed ? this._checkOptimizeGeometry(r.config) : null;
|
||||
// The maximum geometry shift is an UPPER BOUND, not a sample. The run
|
||||
// measured every element in the centimetres of ITS OWN space — converting
|
||||
@@ -15023,8 +15085,16 @@ class HouseplanCard extends LitElement {
|
||||
const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : '';
|
||||
this._alignDialog = {
|
||||
report: r.report, config: r.config, layout: r.layout, cm, where,
|
||||
preflight, changed: r.changed, busy: false,
|
||||
preflight, changed: r.changed, busy: false, removeLiveMissingPositions,
|
||||
};
|
||||
}
|
||||
|
||||
private _openAlignDialog = (): void => this._previewAlignDialog(false);
|
||||
|
||||
private _toggleOptimizeLivePositions = (): void => {
|
||||
const dialog = this._alignDialog;
|
||||
if (!dialog || dialog.busy || !dialog.report.liveMissingPositions.length) return;
|
||||
this._previewAlignDialog(!dialog.removeLiveMissingPositions);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -15076,7 +15146,9 @@ class HouseplanCard extends LitElement {
|
||||
+ d.report.coordsCanonicalized + d.report.wallsMerged + d.report.spansMerged
|
||||
+ d.report.partitionsMerged),
|
||||
r: String(d.report.spaceRefsRemapped + d.report.roomRefsRemapped
|
||||
+ d.report.positionsRemapped + d.report.markersDetached),
|
||||
+ d.report.positionsRemapped + d.report.markersDetached
|
||||
+ d.report.orphanRoomLabelsRemoved + d.report.orphanDevicePositionsRemoved
|
||||
+ d.report.orphanGroupPositionsRemoved),
|
||||
}));
|
||||
} catch (e: any) {
|
||||
if (this._alignDialog) this._alignDialog = { ...this._alignDialog, busy: false };
|
||||
@@ -16077,9 +16149,48 @@ class HouseplanCard extends LitElement {
|
||||
: '';
|
||||
const repaired = r.spaceRefsRemapped + r.roomRefsRemapped
|
||||
+ r.positionsRemapped + r.markersDetached;
|
||||
const referenceWarnings = r.positionsUnresolved + r.nestedRefsUnresolved;
|
||||
const visibleDeadIds = r.deadSpaceIds.slice(0, 10).join(', ');
|
||||
const remainingDeadIds = Math.max(0, r.deadSpaceIds.length - 10);
|
||||
const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized
|
||||
+ r.wallsMerged + r.spansMerged + r.partitionsMerged;
|
||||
const gridWarning = r.moved + r.rotated + r.removedDrafts + r.coordsCanonicalized;
|
||||
const removed = r.orphanRoomLabelsRemoved + r.orphanDevicePositionsRemoved
|
||||
+ r.orphanGroupPositionsRemoved;
|
||||
const liveNames = r.liveMissingPositions.map((item) => item.name).filter(Boolean);
|
||||
const visibleLiveNames = liveNames.slice(0, 3).join(', ');
|
||||
const remainingLiveNames = Math.max(0, liveNames.length - 3);
|
||||
const liveNamesText = visibleLiveNames
|
||||
? this._t('gs.optimize_live_names', {
|
||||
names: visibleLiveNames,
|
||||
more: remainingLiveNames
|
||||
? this._t('gs.optimize_reference_more', { n: String(remainingLiveNames) }) : '',
|
||||
})
|
||||
: '';
|
||||
const registryLimited = r.unverifiedPositions.some(
|
||||
(item) => item.reason === 'registry_unavailable',
|
||||
);
|
||||
const detailStatus = (item: typeof r.removedPositions[number]): string => {
|
||||
if (r.removedPositions.some((removedItem) => removedItem.id === item.id)) {
|
||||
return this._t('gs.optimize_detail_removed');
|
||||
}
|
||||
if (r.liveMissingPositions.some((liveItem) => liveItem.id === item.id)) {
|
||||
return this._t('gs.optimize_detail_live');
|
||||
}
|
||||
return this._t('gs.optimize_detail_unverified');
|
||||
};
|
||||
const detailKind = (kind: typeof r.removedPositions[number]['kind']): string => this._t(
|
||||
kind === 'room_label' ? 'gs.optimize_detail_room_label'
|
||||
: kind === 'group' ? 'gs.optimize_detail_group'
|
||||
: kind === 'device' ? 'gs.optimize_detail_device'
|
||||
: 'gs.optimize_detail_unknown',
|
||||
);
|
||||
const referenceDetails = [
|
||||
...r.removedPositions,
|
||||
...r.liveMissingPositions.filter((item) => (
|
||||
!r.removedPositions.some((removedItem) => removedItem.id === item.id)
|
||||
)),
|
||||
...r.unverifiedPositions,
|
||||
];
|
||||
const visibleDetails = referenceDetails.slice(0, 10);
|
||||
const remainingDetails = Math.max(0, referenceDetails.length - visibleDetails.length);
|
||||
return html`<hp-dialog .hass=${this.hass} .title=${this._t('gs.align_title')} icon="mdi:broom"
|
||||
dismiss-on-scrim @hp-close=${() => (this._alignDialog = null)}>
|
||||
<div class="body">
|
||||
@@ -16088,7 +16199,11 @@ class HouseplanCard extends LitElement {
|
||||
<p class="alignmsg">${this._t('gs.align_preflight_failed', { spaces, more })}</p>
|
||||
<div class="rhint">${this._t('gs.align_preflight_hint')}</div>`
|
||||
: !d.changed
|
||||
? html`<p class="alignmsg">${this._t('gs.align_none')}</p>`
|
||||
? html`<p class="alignmsg">${this._t(
|
||||
r.liveMissingPositions.length || r.unverifiedPositions.length
|
||||
|| r.nestedRefsUnresolved
|
||||
? 'gs.optimize_no_automatic_changes' : 'gs.align_none',
|
||||
)}</p>`
|
||||
: html`
|
||||
${r.moved ? html`<p class="alignmsg">${this._t('gs.align_count', {
|
||||
n: String(r.moved), total: String(r.total), cm: String(d.cm),
|
||||
@@ -16104,32 +16219,81 @@ class HouseplanCard extends LitElement {
|
||||
n: String(r.removedDrafts),
|
||||
})}</p>`
|
||||
: nothing}
|
||||
<p class="alignmsg">${this._t('gs.optimize_changes', {
|
||||
m: String(r.migrated), c: String(r.canonicalized),
|
||||
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
|
||||
s: String(r.spansMerged), i: String(r.partitionsMerged),
|
||||
})}</p>
|
||||
${modelMaintenance ? html`<p class="alignmsg">${this._t('gs.optimize_changes', {
|
||||
m: String(r.migrated), c: String(r.canonicalized),
|
||||
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
|
||||
s: String(r.spansMerged), i: String(r.partitionsMerged),
|
||||
})}</p>` : nothing}
|
||||
${r.glowSpacesMigrated || r.glowRoomsMigrated
|
||||
? html`<p class="alignmsg">${this._t('gs.optimize_glow_migration', {
|
||||
spaces: String(r.glowSpacesMigrated),
|
||||
rooms: String(r.glowRoomsMigrated),
|
||||
})}</p>`
|
||||
: nothing}
|
||||
<div class="rhint">${this._t('gs.align_warn')}</div>`}
|
||||
${gridWarning ? html`<div class="rhint">${this._t('gs.align_warn')}</div>` : nothing}`}
|
||||
${repaired
|
||||
? html`<p class="alignmsg">${this._t('gs.optimize_references', {
|
||||
spaces: String(r.spaceRefsRemapped), rooms: String(r.roomRefsRemapped),
|
||||
positions: String(r.positionsRemapped), detached: String(r.markersDetached),
|
||||
})}</p>`
|
||||
: nothing}
|
||||
${referenceWarnings || r.deadSpaceIds.length
|
||||
? html`<div class="rhint" role="alert">${this._t('gs.optimize_reference_warning', {
|
||||
positions: String(r.positionsUnresolved), nested: String(r.nestedRefsUnresolved),
|
||||
ids: visibleDeadIds || '—',
|
||||
more: remainingDeadIds
|
||||
? this._t('gs.optimize_reference_more', { n: String(remainingDeadIds) }) : '',
|
||||
${removed
|
||||
? html`<p class="alignmsg">${this._t('gs.optimize_orphans_removed', {
|
||||
total: String(removed),
|
||||
rooms: String(r.orphanRoomLabelsRemoved),
|
||||
devices: String(r.orphanDevicePositionsRemoved),
|
||||
groups: String(r.orphanGroupPositionsRemoved),
|
||||
})}</p>`
|
||||
: nothing}
|
||||
${r.liveMissingPositions.length
|
||||
? html`<div class="optimize-live">
|
||||
<p class="alignmsg">${this._t(d.removeLiveMissingPositions
|
||||
? 'gs.optimize_live_positions_remove' : 'gs.optimize_live_positions', {
|
||||
n: String(r.liveMissingPositions.length), names: liveNamesText,
|
||||
})}</p>
|
||||
<button class="btn ghost optimize-cleanup" type="button"
|
||||
aria-pressed=${d.removeLiveMissingPositions ? 'true' : 'false'}
|
||||
@click=${this._toggleOptimizeLivePositions} ?disabled=${d.busy}>
|
||||
<ha-icon icon=${d.removeLiveMissingPositions ? 'mdi:undo' : 'mdi:map-marker-remove-outline'}></ha-icon>
|
||||
${this._t(d.removeLiveMissingPositions
|
||||
? 'gs.optimize_live_keep' : 'gs.optimize_live_remove')}
|
||||
</button>
|
||||
${d.removeLiveMissingPositions
|
||||
? html`<div class="rhint optimize-selected" role="status">
|
||||
${this._t('gs.optimize_live_selected')}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>`
|
||||
: nothing}
|
||||
${r.unverifiedPositions.length
|
||||
? html`<div class="rhint" role="alert">
|
||||
${this._t('gs.optimize_unverified', {
|
||||
n: String(r.unverifiedPositions.length),
|
||||
})}
|
||||
${registryLimited ? ` ${this._t('gs.optimize_registry_limited')}` : ''}
|
||||
</div>`
|
||||
: nothing}
|
||||
${r.nestedRefsUnresolved
|
||||
? html`<div class="rhint" role="alert">${this._t('gs.optimize_vacuum_warning', {
|
||||
n: String(r.nestedRefsUnresolved),
|
||||
})}</div>`
|
||||
: nothing}
|
||||
${referenceDetails.length
|
||||
? html`<details class="optimize-details">
|
||||
<summary>${this._t('gs.optimize_details')}</summary>
|
||||
<ul>
|
||||
${visibleDetails.map((item) => html`<li>${this._t('gs.optimize_detail_item', {
|
||||
status: detailStatus(item), kind: detailKind(item.kind),
|
||||
id: item.id, space: item.spaceId,
|
||||
})}</li>`)}
|
||||
</ul>
|
||||
${remainingDetails
|
||||
? html`<div class="rhint">${this._t('gs.optimize_details_more', {
|
||||
n: String(remainingDetails),
|
||||
})}</div>`
|
||||
: nothing}
|
||||
</details>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="row" slot="footer">
|
||||
<span class="spacer"></span>
|
||||
|
||||
@@ -794,6 +794,7 @@
|
||||
"gs.align_all": "Optimize plans",
|
||||
"gs.align_title": "Optimize plans",
|
||||
"gs.align_none": "All plans already use the current optimized data model.",
|
||||
"gs.optimize_no_automatic_changes": "There are no automatic changes to apply. Review the items below.",
|
||||
"gs.align_count": "{n} of {total} elements will move, by at most {cm} cm.",
|
||||
"gs.align_where": "The largest shift is in “{s}”.",
|
||||
"gs.align_turned": "Openings whose angle is corrected: {n}.",
|
||||
@@ -807,6 +808,26 @@
|
||||
"gs.optimize_references": "References repaired: spaces — {spaces}; rooms — {rooms}; positions — {positions}; devices detached from missing spaces — {detached}.",
|
||||
"gs.optimize_reference_warning": "Some references still need attention: unresolved positions — {positions}; nested mappings — {nested}. Missing space IDs: {ids}{more}.",
|
||||
"gs.optimize_reference_more": ", and {n} more",
|
||||
"gs.optimize_orphans_removed": "Forgotten records removed: {total} — room labels: {rooms}; devices: {devices}; group markers: {groups}. They belonged to spaces deleted earlier.",
|
||||
"gs.optimize_live_positions": "Old positions in deleted spaces belong to existing objects: {n}{names}. They will be kept.",
|
||||
"gs.optimize_live_positions_remove": "Old positions in deleted spaces belong to existing objects: {n}{names}. They are selected for removal.",
|
||||
"gs.optimize_live_names": ": {names}{more}",
|
||||
"gs.optimize_live_remove": "Remove old positions",
|
||||
"gs.optimize_live_keep": "Keep old positions",
|
||||
"gs.optimize_live_selected": "Old positions will be removed after Optimize is applied.",
|
||||
"gs.optimize_unverified": "Could not safely verify positions: {n}. They were left unchanged.",
|
||||
"gs.optimize_registry_limited": "Full administrator access to the Home Assistant registries is required for a safe check.",
|
||||
"gs.optimize_vacuum_warning": "Vacuum room mappings that still need review: {n}.",
|
||||
"gs.optimize_details": "Details",
|
||||
"gs.optimize_details_more": "And {n} more records.",
|
||||
"gs.optimize_detail_removed": "will be removed",
|
||||
"gs.optimize_detail_live": "will be kept",
|
||||
"gs.optimize_detail_unverified": "not verified",
|
||||
"gs.optimize_detail_room_label": "room label",
|
||||
"gs.optimize_detail_device": "device",
|
||||
"gs.optimize_detail_group": "group marker",
|
||||
"gs.optimize_detail_unknown": "unknown owner",
|
||||
"gs.optimize_detail_item": "{status}: {kind} {id}; old space {space}",
|
||||
"gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.",
|
||||
"gs.align_run": "Optimize",
|
||||
"gs.align_done": "Plans optimized: {n} elements moved, {m} records maintained, {r} references repaired",
|
||||
|
||||
@@ -794,6 +794,7 @@
|
||||
"gs.align_all": "Оптимизировать планы",
|
||||
"gs.align_title": "Оптимизировать планы",
|
||||
"gs.align_none": "Все планы уже используют актуальную и оптимальную модель данных.",
|
||||
"gs.optimize_no_automatic_changes": "Автоматических изменений нет. Проверьте пункты ниже.",
|
||||
"gs.align_count": "Сдвинется элементов: {n} из {total}, максимум на {cm} см.",
|
||||
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
|
||||
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
|
||||
@@ -807,6 +808,26 @@
|
||||
"gs.optimize_references": "Исправлено ссылок: пространства — {spaces}; комнаты — {rooms}; позиции — {positions}; устройств отвязано от отсутствующих пространств — {detached}.",
|
||||
"gs.optimize_reference_warning": "Некоторые ссылки требуют внимания: неразрешённых позиций — {positions}; вложенных сопоставлений — {nested}. Отсутствующие id пространств: {ids}{more}.",
|
||||
"gs.optimize_reference_more": ", и ещё {n}",
|
||||
"gs.optimize_orphans_removed": "Убрано забытых записей: {total} — подписи комнат: {rooms}; устройства: {devices}; групповые метки: {groups}. Все они принадлежали пространствам, удалённым ранее.",
|
||||
"gs.optimize_live_positions": "Старые позиции в удалённых пространствах принадлежат существующим объектам: {n}{names}. Они будут сохранены.",
|
||||
"gs.optimize_live_positions_remove": "Старые позиции в удалённых пространствах принадлежат существующим объектам: {n}{names}. Они выбраны для удаления.",
|
||||
"gs.optimize_live_names": ": {names}{more}",
|
||||
"gs.optimize_live_remove": "Убрать старые позиции",
|
||||
"gs.optimize_live_keep": "Сохранить старые позиции",
|
||||
"gs.optimize_live_selected": "Старые позиции будут убраны после применения оптимизации.",
|
||||
"gs.optimize_unverified": "Не удалось безопасно проверить позиций: {n}. Они оставлены без изменений.",
|
||||
"gs.optimize_registry_limited": "Для безопасной проверки нужен полный доступ администратора к реестрам Home Assistant.",
|
||||
"gs.optimize_vacuum_warning": "Сопоставления комнат пылесоса требуют проверки: {n}.",
|
||||
"gs.optimize_details": "Подробности",
|
||||
"gs.optimize_details_more": "И ещё записей: {n}.",
|
||||
"gs.optimize_detail_removed": "будет удалено",
|
||||
"gs.optimize_detail_live": "будет сохранено",
|
||||
"gs.optimize_detail_unverified": "не проверено",
|
||||
"gs.optimize_detail_room_label": "подпись комнаты",
|
||||
"gs.optimize_detail_device": "устройство",
|
||||
"gs.optimize_detail_group": "групповая метка",
|
||||
"gs.optimize_detail_unknown": "неизвестный владелец",
|
||||
"gs.optimize_detail_item": "{status}: {kind} {id}; прежнее пространство {space}",
|
||||
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
|
||||
"gs.align_run": "Оптимизировать",
|
||||
"gs.align_done": "Планы оптимизированы: сдвинуто элементов — {n}, обслужено записей — {m}, исправлено ссылок — {r}",
|
||||
|
||||
@@ -575,6 +575,10 @@ export function optimizePlans(
|
||||
roomRefsRemapped: 0,
|
||||
positionsRemapped: 0,
|
||||
markersDetached: 0,
|
||||
orphanRoomLabelsRemoved: 0,
|
||||
orphanDevicePositionsRemoved: 0,
|
||||
orphanGroupPositionsRemoved: 0,
|
||||
liveMissingPositionsRemoved: 0,
|
||||
};
|
||||
return {
|
||||
config: persistedConfig,
|
||||
|
||||
+121
-14
@@ -10,6 +10,27 @@
|
||||
export interface SpaceReferenceRepairContext {
|
||||
/** Effective Area from the same buildDevices snapshot used by View. */
|
||||
effectiveAreaByMarker?: Readonly<Record<string, string>>;
|
||||
/** Full HA owner evidence. Absence is meaningful only when authoritative. */
|
||||
ownerRoster?: {
|
||||
authoritative: boolean;
|
||||
deviceIds?: readonly string[];
|
||||
entityIds?: readonly string[];
|
||||
/** User-facing names keyed by the persisted layout owner id. */
|
||||
names?: Readonly<Record<string, string>>;
|
||||
};
|
||||
/** Explicit preview opt-in; the default candidate preserves live owners. */
|
||||
removeLiveMissingPositions?: boolean;
|
||||
}
|
||||
|
||||
export type SpaceReferenceOwnerKind = 'room_label' | 'device' | 'group' | 'unknown';
|
||||
|
||||
export interface SpaceReferencePositionDetail {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
kind: SpaceReferenceOwnerKind;
|
||||
/** Empty when no safe human-readable name exists. */
|
||||
name: string;
|
||||
reason?: 'registry_unavailable' | 'unknown_owner';
|
||||
}
|
||||
|
||||
export interface SpaceReferenceReport {
|
||||
@@ -20,6 +41,13 @@ export interface SpaceReferenceReport {
|
||||
positionsUnresolved: number;
|
||||
nestedRefsUnresolved: number;
|
||||
deadSpaceIds: string[];
|
||||
orphanRoomLabelsRemoved: number;
|
||||
orphanDevicePositionsRemoved: number;
|
||||
orphanGroupPositionsRemoved: number;
|
||||
liveMissingPositionsRemoved: number;
|
||||
removedPositions: SpaceReferencePositionDetail[];
|
||||
liveMissingPositions: SpaceReferencePositionDetail[];
|
||||
unverifiedPositions: SpaceReferencePositionDetail[];
|
||||
}
|
||||
|
||||
export interface SpaceReferenceRepairResult {
|
||||
@@ -71,6 +99,7 @@ export function repairSpaceReferences(
|
||||
}
|
||||
const roomSignaturesBySpace = new Map<string, Map<string, string[]>>();
|
||||
const roomOwner = new Map<string, string>();
|
||||
const roomNames = new Map<string, string>();
|
||||
const roomsByArea = new Map<string, { spaceId: string; roomId: string }[]>();
|
||||
for (const space of spaces) {
|
||||
const spaceId = typeof space?.id === 'string' ? space.id : '';
|
||||
@@ -82,6 +111,7 @@ export function repairSpaceReferences(
|
||||
const signature = /^room_(.+)_([0-9a-f]{8})$/.exec(roomId);
|
||||
if (signature) addCandidate(roomSignatures, signature[1], roomId);
|
||||
if (!roomOwner.has(roomId)) roomOwner.set(roomId, spaceId);
|
||||
if (!roomNames.has(roomId)) roomNames.set(roomId, String(room.name || ''));
|
||||
const area = typeof room.area === 'string' ? room.area : '';
|
||||
if (area) {
|
||||
const values = roomsByArea.get(area) || [];
|
||||
@@ -92,11 +122,15 @@ export function repairSpaceReferences(
|
||||
roomSignaturesBySpace.set(spaceId, roomSignatures);
|
||||
}
|
||||
const existingRoomIds = new Set(roomOwner.keys());
|
||||
const activeMarkerIds = new Set<string>();
|
||||
const removedMarkerIds = new Set<string>();
|
||||
const activeMarkers = new Map<string, any>();
|
||||
const removedMarkers = new Map<string, any>();
|
||||
for (const marker of markers) {
|
||||
if (typeof marker?.id !== 'string' || !marker.id) continue;
|
||||
(marker.removed === true ? removedMarkerIds : activeMarkerIds).add(marker.id);
|
||||
if (marker.removed === true) {
|
||||
removedMarkers.set(marker.id, marker);
|
||||
} else {
|
||||
activeMarkers.set(marker.id, marker);
|
||||
}
|
||||
}
|
||||
|
||||
const report: SpaceReferenceReport = {
|
||||
@@ -107,6 +141,13 @@ export function repairSpaceReferences(
|
||||
positionsUnresolved: 0,
|
||||
nestedRefsUnresolved: 0,
|
||||
deadSpaceIds: [],
|
||||
orphanRoomLabelsRemoved: 0,
|
||||
orphanDevicePositionsRemoved: 0,
|
||||
orphanGroupPositionsRemoved: 0,
|
||||
liveMissingPositionsRemoved: 0,
|
||||
removedPositions: [],
|
||||
liveMissingPositions: [],
|
||||
unverifiedPositions: [],
|
||||
};
|
||||
const handledLayout = new Set<string>();
|
||||
|
||||
@@ -204,10 +245,6 @@ export function repairSpaceReferences(
|
||||
layout[markerId] = { ...position, s: targetSpace };
|
||||
report.positionsRemapped++;
|
||||
handledLayout.add(markerId);
|
||||
} else if (!isRemoved) {
|
||||
// Coordinates from an unrelated/deleted plan must not be transplanted.
|
||||
delete layout[markerId];
|
||||
handledLayout.add(markerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,18 +275,88 @@ export function repairSpaceReferences(
|
||||
report.positionsRemapped++;
|
||||
}
|
||||
|
||||
const roster = context.ownerRoster;
|
||||
const rosterAuthoritative = roster?.authoritative === true;
|
||||
const liveDeviceIds = new Set(roster?.deviceIds || []);
|
||||
const liveEntityIds = new Set(roster?.entityIds || []);
|
||||
const knownDeviceIds = new Set(
|
||||
Array.isArray(config.settings?.known_devices)
|
||||
? config.settings.known_devices.filter((value: unknown) => typeof value === 'string')
|
||||
: [],
|
||||
);
|
||||
const ownerName = (id: string, fallback = ''): string => {
|
||||
const value = String(roster?.names?.[id] || fallback || '').trim();
|
||||
return value && value !== id ? value : '';
|
||||
};
|
||||
const detail = (
|
||||
id: string, spaceId: string, kind: SpaceReferenceOwnerKind, fallback = '',
|
||||
reason?: SpaceReferencePositionDetail['reason'],
|
||||
): SpaceReferencePositionDetail => ({
|
||||
id, spaceId, kind, name: ownerName(id, fallback), ...(reason ? { reason } : {}),
|
||||
});
|
||||
const countRemoval = (kind: SpaceReferenceOwnerKind): void => {
|
||||
if (kind === 'room_label') report.orphanRoomLabelsRemoved++;
|
||||
else if (kind === 'group') report.orphanGroupPositionsRemoved++;
|
||||
else report.orphanDevicePositionsRemoved++;
|
||||
};
|
||||
const remainingDead = new Set<string>();
|
||||
for (const marker of markers) {
|
||||
const value = typeof marker?.space === 'string' ? marker.space : '';
|
||||
if (value && !existingSpaceIds.has(value)) remainingDead.add(value);
|
||||
}
|
||||
for (const [key, position] of Object.entries(layout)) {
|
||||
const value = typeof (position as any)?.s === 'string' ? (position as any).s : '';
|
||||
if (!value || existingSpaceIds.has(value)) continue;
|
||||
|
||||
let owner: SpaceReferencePositionDetail;
|
||||
let status: 'absent' | 'live' | 'unverified';
|
||||
const activeMarker = activeMarkers.get(key);
|
||||
const removedMarker = removedMarkers.get(key);
|
||||
if (activeMarker) {
|
||||
owner = detail(key, value, 'device', String(activeMarker.name || ''));
|
||||
status = 'live';
|
||||
} else if (removedMarker) {
|
||||
owner = detail(key, value, 'device', String(removedMarker.name || ''));
|
||||
status = 'absent';
|
||||
} else if (key.startsWith('rl_')) {
|
||||
const roomId = key.slice(3);
|
||||
owner = detail(key, value, 'room_label', roomNames.get(roomId) || '');
|
||||
status = existingRoomIds.has(roomId) ? 'live' : 'absent';
|
||||
} else if (key.startsWith('lg_')) {
|
||||
const entityId = key.slice(3);
|
||||
owner = detail(key, value, 'group');
|
||||
status = liveEntityIds.has(entityId)
|
||||
? 'live'
|
||||
: rosterAuthoritative ? 'absent' : 'unverified';
|
||||
if (status === 'unverified') owner.reason = 'registry_unavailable';
|
||||
} else if (liveDeviceIds.has(key)) {
|
||||
owner = detail(key, value, 'device');
|
||||
status = 'live';
|
||||
} else if (knownDeviceIds.has(key)) {
|
||||
owner = detail(key, value, 'device');
|
||||
status = rosterAuthoritative ? 'absent' : 'unverified';
|
||||
if (status === 'unverified') owner.reason = 'registry_unavailable';
|
||||
} else {
|
||||
owner = detail(key, value, 'unknown', '', 'unknown_owner');
|
||||
status = 'unverified';
|
||||
}
|
||||
|
||||
if (status === 'absent') {
|
||||
delete layout[key];
|
||||
countRemoval(owner.kind);
|
||||
report.removedPositions.push(owner);
|
||||
continue;
|
||||
}
|
||||
if (status === 'live') {
|
||||
report.liveMissingPositions.push(owner);
|
||||
if (context.removeLiveMissingPositions === true) {
|
||||
delete layout[key];
|
||||
report.liveMissingPositionsRemoved++;
|
||||
countRemoval(owner.kind);
|
||||
report.removedPositions.push(owner);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
report.unverifiedPositions.push(owner);
|
||||
}
|
||||
report.positionsUnresolved++;
|
||||
remainingDead.add(value);
|
||||
// Active marker positions should have been deleted above. Count every
|
||||
// preserved opaque/removed owner once in the remaining warning.
|
||||
if (!activeMarkerIds.has(key) || removedMarkerIds.has(key)) report.positionsUnresolved++;
|
||||
}
|
||||
report.deadSpaceIds = [...remainingDead].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
|
||||
@@ -3225,6 +3225,34 @@ export const cardStyles = css`
|
||||
color: var(--hp-muted);
|
||||
}
|
||||
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
|
||||
.optimize-live {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
.optimize-live .alignmsg, .optimize-live .rhint { margin-bottom: 0; }
|
||||
.optimize-cleanup { min-height: 44px; }
|
||||
.optimize-selected { color: var(--hp-txt); }
|
||||
.optimize-details {
|
||||
margin-top: var(--sp-3);
|
||||
color: var(--hp-muted);
|
||||
font-size: var(--fs-s);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.optimize-details > summary {
|
||||
width: fit-content;
|
||||
color: var(--hp-txt);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.optimize-details > summary:focus-visible {
|
||||
outline: 2px solid var(--hp-accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--rad-s);
|
||||
}
|
||||
.optimize-details ul { margin: var(--sp-3) 0; padding-inline-start: 22px; }
|
||||
.optimize-details li + li { margin-top: var(--sp-1); }
|
||||
.btn.alignall { width: 100%; justify-content: center; }
|
||||
.backupactions {
|
||||
display: grid;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user