Compare commits

...
Author SHA1 Message Date
claude[bot] b8c604306f docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 08:06:20 +00:00
claude[bot] 3741bddc62 docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 07:56:41 +00:00
Sergey Matyunin b58136aa2c docs: refresh screenshot fingerprint after rebase
Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32625765217

Issue: #252
User-Visible: no
2026-08-23 10:32:28 +03:00
Sergey Matyunin 6c779b579e chore: remove obsolete Optimize warning copy
Issue: #252
User-Visible: no
2026-08-23 10:26:18 +03:00
Sergey Matyunin 4967d140fa chore: revalidate rebased issue branch
Issue: #252
User-Visible: no
2026-08-23 10:24:55 +03:00
claude[bot]andSergey Matyunin 2e9d097412 docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 10:21:56 +03:00
Sergey Matyunin 24b7e1fee4 test: assert Optimize cleanup selection copy
Issue: #252
User-Visible: no
2026-08-23 10:21:56 +03:00
Sergey Matyunin 96914cd863 test(golden): accept orphan cleanup dialogs
Accepted the complete 97-scenario Linux artifact after visual review. The intentional golden changes are limited to the EN/Dark and RU/Light Optimize orphan-cleanup dialogs; all 95 passing raster candidates were restored to their prior bytes and hashes. The canonical docs artifact used the same Chromium and refreshed its source fingerprint; eight frames were identical, while two differed by only 2 and 17 sub-threshold pixels.

Issue: #252
User-Visible: no
Release: v1.67.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32623704126
2026-08-23 10:21:56 +03:00
Sergey Matyunin da719f5f06 fix: clean orphaned Optimize positions
Issue: #252
User-Visible: yes
2026-08-23 10:21:56 +03:00
claude[bot]andSergey Matyunin a322db4860 docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 10:21:05 +03:00
Sergey Matyunin 13594ba6c9 docs: clarify orphan cleanup invariant
Issue: #252
User-Visible: no
2026-08-23 10:21:05 +03:00
claude[bot]andSergey Matyunin 2cc089319c docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 10:21:05 +03:00
Sergey Matyunin 561ce9f6c7 docs: specify orphan layout cleanup
Issue: #252
User-Visible: no
2026-08-23 10:21:05 +03:00
35 changed files with 2113 additions and 240 deletions
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -1,8 +1,8 @@
{
"schema": 1,
"matrixVersion": 39,
"acceptedAt": "2026-08-23T05:36:23.917Z",
"sourceFingerprint": "9f02bbdd4af8b4bb62b64b6c02b34185edd3d5072031d8adb6d54393ec7b7c54",
"acceptedAt": "2026-08-23T06:49:21.230Z",
"sourceFingerprint": "f3ed53aabec5afb371295d3a681d1cfc11ff6c704cffd8325874c337983b8572",
"chromium": "151.0.7922.34",
"scenarios": {
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
@@ -98,7 +98,8 @@
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
"optimize-preflight-dialog-dark-en": "d42cab423d2838b7592b661548f7d524d6403db5258419e635733af9ffdda685",
"optimize-preflight-dialog-light-ru": "d8262ed5bf93347c4727365aab71897dcb2f24f0c8196e4fcdcbf43b07919b5a",
"optimize-orphan-references-dark-en": "6071c61b90243fc10ff178a4ba8bb965515b3c955a184d1039ba257158d90625",
"optimize-orphan-references-dark-en": "a39b2946ea69939941b9f22784111707513d4a4a951df77ed6e8893539ab69eb",
"optimize-orphan-references-light-ru": "cb21ccfa4426e5ff92a547ef4f0f4592affbda7f235f419dc84640ef40501892",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+7 -1
View File
@@ -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
View File
@@ -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 },
+48 -13
View File
@@ -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,16 @@ const out = await page.evaluate(async () => {
card._openAlignDialog();
await card.updateComplete;
card._toggleOptimizeLivePositions();
await card.updateComplete;
const selectedText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
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'
&& selectedText.includes('They are selected for removal.')
&& !selectedText.includes('They will be kept.');
await card._runAlignToGrid();
await card.updateComplete;
result.applyUsesExactAtomicEndpoint = calls.filter((type) => type === 'houseplan/plan/optimize').length === 1;
@@ -115,8 +148,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 +158,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;
+113 -61
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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,
+11
View File
@@ -8,6 +8,17 @@
## Не выпущено
- «Оптимизировать планы» теперь удаляет сохранённую позицию, только когда
доказано, что её подписи комнаты, устройства или групповой метки больше не
существует. Предпросмотр описывает очистку понятными категориями, прячет
технические id в свёрнутые «Подробности» и сохраняет живых либо непроверенных
владельцев. Отдельная явная опция позволяет включить старые позиции живых
объектов в основной Apply; Cancel ничего не записывает, а служебная отмена
восстанавливает прежнюю раскладку целиком. Это уточняет обещание v1.59
сохранять все непривязанные записи: временно недоступные и непроверенные
объекты по-прежнему защищены, а доказанно забытые позиции больше не
накапливаются
([#252](https://github.com/Matysh/houseplan-card/issues/252)).
- В узлах, где сходятся три и более физические стены, длинные выступающие
клинья штриховки теперь заменяются ограниченной прямой фаской, в том числе
при разной толщине стен. Сам узел остаётся заполненным и одинаковым в Plan,
+14 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 делает резервную копию оптимизации устаревшей.
Обычное открытие и редактирование плана не удаляет даже очень короткие точные
границы толщины. Описанное схлопывание выполняется только после явного
Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

+12 -12
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceFingerprint": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"imageSha256": "7553cff1b56feb6cc1e3e8243aac64d690338b6432718c9d93a025b6aa645995"
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "abc6ca91ef76fe4892b95414c24b5d4452a403aaa42de89c7f8559cf3cf60ec6"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "367d0ff05c14f945d60ff6ab1369a8747fc72dac5f2d599266ac0a2e848587bb"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "6df7753f00b7d87cd35fd18fb8fbac0a2a7fa2e549902b3afee38f38c93a2ac0"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "5cf937653db3861091f6526b2bffda936d2a707bcd753cb25afa3d711b8b9e8f"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "e03b27d42dd84b203cd025eab4da5748a90d2fa312518de6c865ee02e6cdd01b"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "25b0e13e28ec406b0202440d10fb51b48ad9889269533e5789812ec0011a2da3"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "4dcd28f886af304219d4ad6762c398cfdf3af85c1cb2aec31a5fa9c12d8033be"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "833a3ed64337a848110d826c45b3878bd99668ab4271018469acaefee3569571"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "9fa69f7f3db5abbe411d1444ac97afc9c2f960be93013d14577c991e86ee0d21",
"sourceSha256": "63cc709915a5eb7482e3984798150d693107ec2c4c7d0582b1db88d9c49469ad",
"imageSha256": "094aaea598ea1d8f96bcaa059e66922a94adc27340baa28c3c275cc2efa400c1"
}
}
+205
View File
@@ -0,0 +1,205 @@
# CODE-REVIEW-252-r1
- Issue: [#252](https://github.com/Matysh/houseplan-card/issues/252) — «Отчёт "Оптимизировать" перечисляет внутренние id вместо того, чтобы починить или сказать, что делать»
- Этап: code (PROCESS.md §2.7)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4
- ТЗ: `docs/specs/252-optimize-orphan-layout-report.md`, зелёное ревью SPEC-REVIEW-252-r2
- Диапазон: `origin/dev..HEAD`, коммиты `883a95a…df51542`; поведенческий коммит — `8fd8ccb` (`Issue: #252`, `User-Visible: yes`)
- Вердикт: **зелёный** · High: 0 · Medium: 0 · Low: 1 (снято с записью)
## Скоуп
Задача — J6 (`docs/SCOPE.md`): «Keep the plan true as the home evolves». Optimize
теперь классифицирует layout-позиции с мёртвым `space` по владельцу (комната,
устройство/маркер, групповая метка, неизвестный namespace), автоматически
удаляет только доказанно отсутствующих владельцев, сохраняет живых и
непроверенных, и убирает внутренние id из основного текста отчёта в
свёрнутые «Подробности». Изменение не расширяет скоуп за рамки ТЗ: новых
персистентных полей, миграций или новых экранов нет; `config`/HA registry не
модифицируются, изменяется только layout-запись. Правило SCOPE.md «никогда не
удалять файл пользователя по догадке» не нарушено — здесь удаляются не файлы, а
координатные записи, и только при доказанном отсутствии владельца, а не по
инференции.
## Как проверялось
Дешёвые гейты (прогнаны в этом раунде, код изменился существенно):
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | зелёный, без вывода |
| `npm test` | зелёный: 1124/1124 (в моём прогоне 0 skipped; автор указывал 1123/1 skipped — расхождение не воспроизвелось, не блокирует) |
| `npm run build` + сверка трёх копий бандла | зелёный; SHA-256 `dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js`, `demo/srv/assets/houseplan-card.js` идентичны и равны `341ef5e3…` из отчёта автора |
| `node scripts/check-docs.mjs` | зелёный: «7 files, 10 external links» (diff трогает `src/**`) |
Инварианты модели (`npm run invariants`): в этой версии репозитория такого
npm-скрипта не существует (`package.json` его не содержит, `#254` ещё не
слит в `dev`) — гейт неприменим, не пропущен молча.
`python -m pytest tests_backend`: не запускался — diff не трогает
`custom_components/**/*.py` (только сгенерированный JS-бандл в той же папке).
Smoke-подбор — `node scripts/smoke-select.mjs --base origin/dev --head HEAD`:
```
Прямое совпадение (6):
demo/smoke_orphan_space_references.mjs ← _alignDialog, _openAlignDialog, _toggleOptimizeLivePositions
demo/smoke_grid_snap.mjs ← _alignDialog, _openAlignDialog
demo/smoke_optimize_coordinate_canonicalization.mjs ← _alignDialog, _openAlignDialog
demo/smoke_optimize_geometry_preflight.mjs ← _alignDialog, _openAlignDialog
demo/smoke_optimize_micro_interval.mjs ← _alignDialog, _openAlignDialog
demo/smoke_warm_dialogs.mjs ← _alignDialog, _openAlignDialog
```
Совпадает с выбором автора. Все 6 прогнаны локально (headless Chromium) —
все `OK`, включая `smoke_orphan_space_references.mjs`, который прямо
проверяет отсутствие сырых id (`opaque_owner`, `gone`) в основном тексте,
`aria-pressed` на кнопке opt-in и текст «выбраны для удаления» без
противоречивого «будут сохранены» рядом (регресс, который сам автор поймал и
закрыл в `df51542`). Остальные 165 смоков не запускались — тема (grid/geometry
preflight/micro-interval/warm-dialogs) действительно про общий диалог
Optimize, других сильных или слабых связей инструмент не назвал.
`node scripts/mutation-gate.mjs --check` — полный прогон, 136/136 `ok`,
включая оба новых мутанта:
- `orphan-cleanup-partial-registry-deletes` — ловит мутацию, при которой
`rosterAuthoritative` захардкожен в `true` (неполный registry перестаёт быть
доказательством отсутствия);
- `orphan-cleanup-proven-owners-kept` — ловит мутацию, которая перестаёт
реально удалять `absent`-позицию из layout, оставляя только счётчик.
`npm run golden:verify` — политика репозитория (`demo/golden/policy.mjs`)
запрещает частичный `--scenario=`, разрешён только полный прогон. Прогнал
полный матрикс (97 сценариев, ~2 минуты, Chromium 151.0.7922.34 — совпадает с
зафиксированным в `baselines-index.json`): **97/97 passed**, включая
`optimize-orphan-references-dark-en` и `optimize-orphan-references-light-ru`.
Дополнительно сверил `git diff` по `baselines-index.json` — единственные
изменённые/добавленные хэши это ровно эти два сценария, что подтверждает
запись автора «изменены только две ожидаемые #252-сцены, 95 остальных
возвращены к прежним байтам» независимо от CI.
Performance-профили не запускались — не названы в AC8 явно как обязательные
для цикла ревью (AC8 требует их только на pre-release гейте), путь
maintenance-only и не в render loop.
## Разбор по AC
- **AC1** (доказанно отсутствующие room label/device/group удаляются,
категории без id, сумма верна, второй Optimize — no-op): подтверждено
unit-фикстурой `issue 252 removes only proven room, device and group orphans
and is idempotent` (32/3/2, ровно числа из issue) и мутантом
`orphan-cleanup-proven-owners-kept`; идемпотентность — вторым вызовом
`repairSpaceReferences` в том же тесте (`again.report.*Removed === 0`).
- **AC2** (живой config-marker/HA device/`lg_` entity в мёртвом пространстве не
удаляется по умолчанию, называется именем, удаляется только после opt-in +
Apply): unit `issue 252 preserves live owners by default and removes only
them after explicit opt-in` покрывает все три категории живых владельцев;
smoke проверяет UI-цепочку preview → toggle → Apply → Undo сквозно.
- **AC3** (неавторитетный/ограниченный registry, неизвестный namespace,
объект, пропавший только из `_devices`, — сохраняются, разрушительной кнопки
нет): unit `issue 252 fails closed for limited registry and unknown future
layout owners` + мутант `orphan-cleanup-partial-registry-deletes`. Отдельно
прочитан код `_optimizeReferenceContext`: `liveDeviceIds`/`liveEntityIds`
строятся из полного `registry.devices`/`registry.entities` (+ `hass.states`
для entity), а не из отфильтрованного `_devices`, значит временный уход
объекта из render-снапшота (area-фильтр и т.п.) не превращается в удаление —
проверено чтением, не исполнением, отдельного unit на этот конкретный канал
фильтрации нет, но принцип (`_devices` только для имён, не как доказательство)
структурно закреплён и покрыт мутационным гейтом на противоположный канал
(полный registry).
- **AC4** (id не в основном тексте; нет старых терминов «неразрешённых
позиций»/«вложенных сопоставлений»): `test/i18n.test.mjs` явно проверяет
отсутствие `this._t('gs.optimize_reference_warning'` в исходнике и
отсутствие технических слов в новых RU/EN строках; smoke проверяет реальный
DOM (сырые id есть только в закрытом `<details>`).
- **AC5** (clean config/layout deep-equal; #244 remap/detach не регрессирует;
unknown fields сохраняются; #248 round-trip): существующие #244-тесты
адаптированы, а не удалены (например, «detaches a live marker but preserves
its stale coordinates until explicit cleanup» — намеренная смена поведения
ровно в сторону AC2, не регресс); «large valid reference graph stays
unchanged» не тронут и зелёный.
- **AC6** (Cancel не пишет; Apply — exact preview; Undo восстанавливает всё;
no-op не трогает backup): подтверждено smoke (`previewOffersOneApplyWithoutWriting`,
`cancelWritesNothing`, `applyUsesExactAtomicEndpoint`, `undoRestoresDeadRefs`
— включая восстановление `rl_removed_room` и `removed-marker`, не только
старого `orphan`-кейса).
- **AC7** (keyboard/screen reader, `<details>` свёрнут по умолчанию, фокус не
теряется, touch target): `<details>` без `open` — свёрнут; `.optimize-cleanup`
— настоящая `<button type="button">` с `min-height: 44px`; `aria-pressed`
и `role="status"`/`role="alert"` расставлены по смыслу. Сохранение фокуса на
кнопке после пересчёта preview не покрыто отдельным assert на
`document.activeElement` — проверено чтением: кнопка рендерится в одной и той
же ветке шаблона независимо от состояния `removeLiveMissingPositions`
(меняются только атрибуты/текст внутри неё), поэтому lit-html переиспользует
тот же DOM-узел и не может увести фокус структурно. Не блокирует, но стоит
отметить как не доказанное исполнением.
- **AC8** (гейты implementation-цикла зелёные): `typecheck`/`unit`/`build`
зелёные лично; targeted smoke и полный golden выполнены выше и тоже зелёные.
## Что проверено и корректно
- Классификация владельца (`src/space-reference-repair.ts:302-360`) построена
на индексах, собранных один раз (`existingRoomIds`, `activeMarkers`,
`removedMarkers`, `liveDeviceIds`, `liveEntityIds`, `knownDeviceIds`) —
линейна по layout, соответствует заявленной сложности §12 ТЗ.
`removed:true` маркер — единственное «мгновенное» доказательство отсутствия
устройства (соответствует принятому предположению §14.4 ТЗ); `unknown`
namespace всегда `unverified` независимо от `authoritative` (соответствует
§6.3 «неизвестный ключ: всегда unverified»).
- Классификация не мутирует `config` — удаляется только `layout[key]`; сумма
`orphan*Removed` корректно входит и в toast (`gs.align_done`), и в maintenance
count диалога.
- `plan-optimizer.ts`: новые счётчики зануляются в «no persisted change» ветке
наравне со старыми (`spaceRefsRemapped` и т.п.), а не персонально, что
сохраняет прежний инвариант «нулевые счётчики на чистом конфиге» (AC5) без
дублирования логики.
- Документация: `docs/CANVAS.md:494-497` — старая формулировка **заменена**
(не дополнена) новым доказательным контрактом, как и требовало ТЗ и
зелёное `SPEC-REVIEW-252-r2`; `docs/CHANGELOG.md`/`.ru.md` в том же
коммите `8fd8ccb` явно называют сужение обещания v1.59.0-rc.1;
`docs/CONFIG-COMPATIBILITY.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md`,
`docs/USER-GUIDE(.ru).md` обновлены по списку §13 ТЗ.
- `Baseline-Reviewed` golden-коммит (`668ed49`) соответствует правилу §12
«не принимать golden по частичному артефакту»: явно указано, что просмотрен
полный 97-сценарийный Linux-артефакт, изменения ограничены двумя
#252-сценами, остальные 95 возвращены к прежним байтам — независимо
перепроверено (см. выше).
## Находки
**Low (снято с записью, не блокирует).** `src/i18n/en.json`/`ru.json`
сохраняют ключ `gs.optimize_reference_warning` со старой формулировкой
(«неразрешённых позиций», «вложенных сопоставлений»), хотя код его больше не
вызывает (`test/i18n.test.mjs` прямо проверяет отсутствие вызова). Мёртвый
литерал, не влияющий на поведение и не нарушающий AC4 (AC4 — про то, что
видит пользователь, а не про наличие неиспользуемого ключа в словаре). Не
возвращаю автору: цена держания записи ниже цены повторного цикла ради одной
строки в JSON.
## Чего не проверял и почему
- `npm run invariants` — скрипта нет в этой версии репозитория (#254 не
слит); гейт неприменим, а не пропущен.
- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py`.
- Полная браузерная матрица smoke (171 файл за вычетом выбранных 6) — тема не
пересекается с изменённым символьным следом (`_alignDialog`,
`_openAlignDialog`, `_toggleOptimizeLivePositions`); инструмент подбора не
назвал других прямых или слабых связей.
- Performance-профили (`benchmark:*`) — не названы в AC8 как обязательные для
цикла ревью; путь maintenance-only, не render loop, полный performance-гейт
явно отнесён ТЗ (§11) к пред-релизному циклу.
- Ручное тестирование в браузере (не входит в конвейер ревью) — вместо него
использованы браузерные smoke на headless Chromium и полный `golden:verify`.
- Фокус-контракт AC7 (кнопка opt-in) — разобран чтением кода/шаблона lit-html,
не отдельным исполняемым assert-ом на `document.activeElement` (см. AC7 выше).
## Итог
Реализация точно следует одобренному ТЗ: доказательный, а не эвристический,
контракт удаления; живые и непроверенные владельцы по умолчанию сохраняются;
внутренние id ушли из основного отчёта в «Подробности»; Preview/Cancel/Apply/
Undo/идемпотентность #248 не регрессируют. Все обязательные для этого этапа
гейты (typecheck, unit, build+bundle parity, check-docs, targeted smoke,
mutation-gate, полный golden) прогнаны лично и зелёные. Единственная находка —
Low, снята с записью. Blocking-находок нет.
+176
View File
@@ -0,0 +1,176 @@
# CODE-REVIEW-252-r2
Вердикт: зелёный · заход r2 · блокирующих циклов израсходовано 0 из 4 · High: 0 · Medium: 0
## Скоуп раунда: почему это переиздание r2, а не новый разбор с нуля
Этот запуск — повторное проведение того же раунда r2, а не заход r3. Round r2
уже был проведён и опубликован (комментарий issue от 2026-08-23T07:56:33Z,
документ `docs/reviews/CODE-REVIEW-252-r2.md`, зелёный вердикт, High:0/Medium:0),
но пайплайн не довёл дело до конца: автор сообщил (2026-08-23T07:56:45Z), что
автоматический прогон [упал](https://github.com/Matysh/houseplan-card/actions/runs/32625878719)
и статусная метка не переставилась. С точки зрения оркестратора раунд не
завершён (бюджет §4 не потрачен: зелёный вердикт цикл не расходует), поэтому
задача пришла на повторное r2, а не на r3.
Проверено, что переиздавать нечего заново с нуля:
- SHA, на котором был получен предыдущий вердикт r2: `b58136aa2cc943652af5adb8a94047b668d68dc6`.
- Текущий HEAD: `3741bddc6236ffe3d85965dc630704e578561710`.
- `git diff b58136a..HEAD --stat` → ровно один файл:
`docs/reviews/CODE-REVIEW-252-r2.md | 249 +++++++++++++++++++++++++++++++++++++`
(1 file changed, 249 insertions(+)) — это САМ ранее опубликованный документ
ревью, закоммиченный шагом публикации предыдущего (упавшего после вердикта)
прогона. Ни один файл `src/**`, `demo/**`, `docs/CANVAS.md` и т.п. между
`b58136a` и текущим HEAD не менялся.
Значит, весь код, который уже был полностью разобран в CODE-REVIEW-252-r1
(на пре-ребейзном коммите, полный разбор) и CODE-REVIEW-252-r2 (на
пост-ребейзном `b58136a`, тоже полный разбор — ребейз на ушедший вперёд `dev`
подпадает под §7.2), остался байт-в-байт тем же кодом. Дельта этого раунда —
пустая по существу. Полный разбор AC по коду в третий раз подряд на неизменном
дереве был бы именно той «потерей времени», от которой явно предостерегает
инструкция («полные наборы — это предрелизный гейт, а не гейт ревью»).
Дополнительно проверено расхождение с `origin/dev`, который тем временем ушёл
дальше собственной прошлой проверки в CODE-REVIEW-252-r2 (там уже был учтён
`a952f5f`/#256 и его review-документ `2d1fca1`, оба признаны не пересекающимися
с #252 по файлам). С тех пор `dev` получил ещё два коммита:
- `10999a5` — `ci(process): привести ветку к dev до код-ревью, а не после` (#257),
правит только `.github/workflows/process.yml`;
- `4b6331f` — `docs(process): описать приведение ветки к dev до код-ревью` (#257),
правит только `PROCESS.md`.
Оба — чистый процесс/CI, ноль пересечения по файлам с диффом #252
(`src/space-reference-repair.ts`, `plan-optimizer.ts`, `houseplan-card.ts`,
`styles.ts`, i18n, доки CANVAS/CONFIG-COMPATIBILITY/USER-GUIDE, golden-сцены).
Ретроактивно новое правило «ребейзить до ревью» на уже идущий с r1 код-ревью
#252 не распространяется (правило описывает будущий шаг пайплайна перед
следующим запуском ревью, а не требование к уже проверенному коду). Само
слияние ветки #252 в `dev`, как и раньше, потребует технического ребейза —
это не меняет оценку текущего диффа.
## Закрытие раунда r1 (CODE-REVIEW-252-r1)
| Находка r1 | Чем закрыта | Где видно |
|---|---|---|
| Low, снято с записью на будущее: мёртвый ключ `gs.optimize_reference_warning` остался в `en.json`/`ru.json`, код его больше не вызывает | Ключ полностью удалён из обоих словарей коммитом `6c779b5` | `git diff origin/dev...HEAD -- src/i18n/en.json src/i18n/ru.json` — строка присутствует только как удаление; `test/i18n.test.mjs:77` (`assert.doesNotMatch(..., /this\._t\('gs\.optimize_reference_warning'/)`) прогнан лично в этом раунде в составе `npm test` (1140/1140) — зелёный |
Это закрытие не изменилось со времени предыдущего r2 — код тот же самый.
## Унаследовано из r1
Полный построчный разбор кода (`src/space-reference-repair.ts` целиком,
изменённые фрагменты `plan-optimizer.ts`/`houseplan-card.ts`/`styles.ts`,
все юнит- и smoke-тесты, канонические доки, release-артефакты) взят из
CODE-REVIEW-252-r1 (документ в `docs/reviews/CODE-REVIEW-252-r1.md`, SHA
проверки в этом раунде — `b58136aa2cc943652af5adb8a94047b668d68dc6`, где этот
разбор был повторён ПОЛНОСТЬЮ заново после ребейза, а не как наследование —
см. раздел «Скоуп» того документа). Наследуется без повторного построчного
чтения в этом раунде:
- классификация владельца (`absent`/`live-in-missing-space`/`unverified`) —
доказательная схема, три исхода, fail-closed при неполном/неавторитетном
registry и при неизвестном namespace (AC1–AC3);
- отчёт без внутренних id в основном тексте, id — только в свёрнутых
«Подробностях» (AC4);
- Preview/Cancel/Apply/Undo и идемпотентность #248 не регрессируют (AC5, AC6);
- осознанное и задокументированное расширение поведения detach у #244
(немедленное авто-удаление stale-позиции заменено общей классификацией;
переименованные тесты и `docs/CANVAS.md`/`docs/CONFIG-COMPATIBILITY.md`
прямо называют это заменой, а не регрессом);
- `docs/CANVAS.md:494-497` заменён (не дополнен), release-артефакты (оба
changelog, канонические доки, синхронные bundle-копии) в одном
поведенческом коммите с `User-Visible: yes`;
- golden-семантика двух #252-сцен (`optimize-orphan-references-dark-en`,
`optimize-orphan-references-light-ru`), доказанная полным
`golden:verify` (97/97) дважды — на пре-ребейзном дереве в r1 и на
пост-ребейзном `b58136a` в r2.
Основание доверять этому наследованию без повторного чтения — не слова
автора, а свежая проверка в этом раунде (см. ниже), что дерево с тех пор не
изменилось ни на байт.
## Как проверялось в этом раунде
Лично прогнано на текущем HEAD (`3741bdd`), не со слов автора и не по
памяти о прошлых раундах:
- `npx tsc --noEmit` → чисто, без вывода;
- `npm test` → 1140/1140 pass, 0 fail, 0 skipped (включает
`test/model-invariants.test.mjs` — 12/12, в т.ч. `#253: исчезнувшая запись
толщины` и `readModel понимает экспорт/config/get/сырой config (#254)` —
обязательный гейт, диф трогает layout-ссылки на пространства);
- `npm run build` → зелёный; `sha256sum dist/houseplan-card.js
custom_components/houseplan/frontend/houseplan-card.js` →
`e5389ba8e8250c6030fb5365b81619b0d2b4687b4c32f2f2c227ca527b7dbcec` для
обеих копий — **тот же хеш**, что зафиксирован в CODE-REVIEW-252-r1
независимо от меня в этом раунде; совпадение хеша — самостоятельное
машинное доказательство того, что исходный код не менялся с r1/r2, а не
доверие на слово;
- `npm run bundle:sync` → пересобрал и синхронизировал нетрекаемую
стенд-копию `demo/srv/assets/houseplan-card.js` (не коммитится с #255) —
зелёно;
- `node scripts/check-docs.mjs` → «Documentation checks passed (7 files, 10
external links)» — обязателен, диф трогает `src/**`;
- `node scripts/mutation-gate.mjs --check` (дешёвый режим, без пересборки
бандла на каждого мутанта) → 139/139 `ok`, включая все четыре мутанта
этой темы: `orphan-space-detach-disabled`,
`orphan-space-ambiguous-signature-guessed`,
`orphan-cleanup-partial-registry-deletes`,
`orphan-cleanup-proven-owners-kept`. Дорогой полный прогон (пересборка на
каждого мутанта) не повторялся: он уже дважды пройден целиком (r1 —
136/136, r2 — переподтверждён) на этом же дереве, а `--check` подтверждает,
что реестр патчей и код с тех пор не разошлись;
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → те же 6
смоков, что в r1/r2: `smoke_orphan_space_references`, `smoke_grid_snap`,
`smoke_optimize_coordinate_canonicalization`,
`smoke_optimize_geometry_preflight`, `smoke_optimize_micro_interval`,
`smoke_warm_dialogs`; остальные 165 не пересекаются по символам
(инструмент не назвал других связей);
- все 6 отобранных смоков лично прогнаны headless-Chromium в этом раунде —
все `OK`, все булевы поля результата `true` (в т.ч.
`idsExistOnlyInClosedDetails`, `explicitCleanupRebuildsPreviewWithoutWriting`,
`undoRestoresDeadRefs` из `smoke_orphan_space_references`).
Не повторял в этом раунде (обоснование):
- **`golden:verify` (полный, 97 сценариев).** Уже пройден целиком дважды в
этом же код-ревью: в r1 на пре-ребейзном дереве и в r2 на `b58136a` —
оба раза 97/97, включая обе #252-сцены. Текущее дерево байт-в-байт
идентично `b58136a` (доказано выше диффом и совпадением SHA-256 бандла).
Третий полный прогон на неизменном дереве не добавил бы информации и
прямо противоречил бы правилу соразмерности гейтов ревью.
- **`mutation-gate.mjs` без `--check` (полная пересборка на мутанта).** По
той же причине — уже дважды 136+/136+ на этом дереве, `--check` в этом
раунде подтвердил отсутствие расхождения.
- **`python -m pytest tests_backend`** — диф не трогает
`custom_components/**/*.py` (только сгенерированный JS-бандл в той же
папке).
- **`npm run invariants -- --config <файл>`** — точечного экспорта живой
инсталляции для этой ветки по-прежнему нет; вместо него — `npm test`
прогнал `test/model-invariants.test.mjs` на всех fixture-моделях проекта
(см. выше), это тот же `checkReferences`, что стоит за флагом.
- **Performance-профили** — не названы в AC8, путь maintenance-only, не
render loop (§12 ТЗ).
- **Ручное браузерное тестирование** — не входит в конвейер ревью; заменено
headless smoke (свежепрогнанные) и дважды пройденным полным golden.
## Находки
Нет находок уровня High или Medium. Low-находка r1 (мёртвый i18n-ключ)
закрыта — см. таблицу выше.
## Итог
Код не изменился с зелёного вердикта предыдущего r2 (`b58136a`) ни на
байт — единственная разница в дереве это сам ранее опубликованный документ
ревью. Этот раунд — техническое переиздание того же вердикта после сбоя
пайплайна на шаге после публикации (перестановка метки), а не пересмотр
кода. Все дешёвые гейты, обязательные для каждого раунда, лично прогнаны
заново на текущем HEAD и зелёные, включая независимое совпадение SHA-256
собранного бандла с зафиксированным в r1. AC1–AC8 остаются подтверждёнными
полным разбором r1/r2 (см. «Унаследовано из r1» и исходные документы).
Дрейф `origin/dev` (#256, #257) не пересекается с диффом #252 и не требует
пересмотра. Новых issue не требуется.
+168
View File
@@ -0,0 +1,168 @@
# SPEC-REVIEW-252-r1
- Issue: [#252](https://github.com/Matysh/houseplan-card/issues/252) — «Отчёт
"Оптимизировать" перечисляет внутренние id вместо того, чтобы починить или
сказать, что делать»
- Этап: spec (PROCESS.md §2.4)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4
- ТЗ: `docs/specs/252-optimize-orphan-layout-report.md`, коммит `883a95a`,
ветка `issue/252-optimize-orphan-layout-report`
- Трек: обычный (не `small`/`trivial`, файл ТЗ обязателен — подтверждено)
## Скоуп проверки
Полный разбор — первый заход. Читал в порядке из инструкции: `docs/SCOPE.md`,
`AGENTS.md`/`PROCESS.md` §2.4/§5/§7.1, тело issue #252 и все три комментария
(аналитика, занятие, ТЗ готово), `docs/USER-GUIDE.ru.md` (термин «групповой
маркер»), канонические `docs/CANVAS.md` и `docs/CONFIG-COMPATIBILITY.md`.
## Как проверялось
Автор — не я; устных пояснений не было, работал только с issue и текстом ТЗ.
1. Сверил обязательные разделы ТЗ (PROCESS.md §7.1) — все 12 присутствуют, плюс
раздел «Принятые технические предположения» (§14), как того требует §7.1 для
решений, не наблюдаемых пользователем.
2. Прочитал текущий код, на который ссылается диагноз ТЗ (§3), чтобы отличить
проверенный факт от догадки:
- `src/space-reference-repair.ts` — подтверждает `positionsUnresolved`,
`nestedRefsUnresolved`, `deadSpaceIds`, порядок remap/detach-проходов,
трактовку `removed:true` как «unresolved, не удаляется» **сегодня**.
- `src/houseplan-card.ts:16065–16132` (`_renderAlignDialog`) — подтверждает
текст `gs.optimize_reference_warning` и вывод сырых `deadSpaceIds` ровно
как описано в issue и в ТЗ.
- `src/i18n/ru.json:796,808` — подтверждает дословный текст текущих строк.
- `src/logic.ts:728-738`, `src/devices.ts:1151` — подтверждают, что
`lg_<entity_id>` — реальный префикс marker id для групп света, а не
придуманный термин.
- `src/ha-binding-status.ts` — подтверждает, что `HaRegistrySnapshot` с
полем `authoritative`, кэшем на соединение и подписками на
device/entity registry **уже существует и уже используется**
(`houseplan-card.ts:4542`). План ТЗ «расширить runtime context
авторитетным roster» (§11.1) переиспользует готовый механизм, а не
придумывает новый с нуля — техническая осуществимость подтверждена.
- `src/align-grid.ts:375-390`, `src/houseplan-card.ts:18133-18201` —
подтверждают, что `rl_<roomId>` и marker-id-ключ — единственные два
вида layout-ключей в коде; категория «неизвестный namespace» в §6.1
действительно исчерпывающий catch-all, а не дыра в классификации.
- `src/houseplan-card.ts:15006-15028` (`_openAlignDialog`) — подтверждает,
что `_devices` сегодня используется как отфильтрованный
presentation-снимок (`effectiveAreaByMarker` строится с
`.filter(d => !d.virtual && !!d.area)`), что оправдывает требование ТЗ не
считать отсутствие в `_devices` доказательством отсутствия владельца.
3. Сверил §6/§7 (классификация и её доказательства) с этими файлами построчно
— расхождений не нашёл; диагноз не является догадкой, выданной за факт.
4. Проверил `docs/CANVAS.md` (канонический документ подсистемы) и
`docs/CHANGELOG.md` на противоречия с новым контрактом — здесь нашлась
единственная содержательная находка, см. ниже.
5. Проверил соответствие персоне/скоупу: `docs/SCOPE.md` J6 («keep the plan
true as the home evolves») и стоящее правило «never delete a user's file on
an inference» (SCOPE.md, строки 81-91) — новый контракт **согласован** с
духом этого правила (удаляет только доказанно-мёртвое, при неполном
registry сохраняет), но затрагивает соседний инвариант, см. находку.
6. Проверил, что автор не оставил владельцу технических вопросов под видом
продуктовых — комментарий «Продуктовых вопросов не осталось» (2026-08-23)
подтверждён: все решения в §14 действительно не наблюдаемы пользователем
(внутреннее имя `lg_`, устройство opt-in внутри существующего диалога,
лимиты 3/10 в UI, трактовка tombstone-маркера).
## Находки
### Medium (в скоупе — правится в этом же ТЗ)
**M1. ТЗ меняет задокументированный и опубликованный инвариант Optimize, не
называя его.**
`docs/CANVAS.md:494-497` фиксирует как **намеренное** архитектурное решение:
> 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.
То же самое опубликовано пользователю в `docs/CHANGELOG.md:1378-1385` (релиз
v1.59.0-rc.1, English changelog): «Backdrop calibration, saved views,
**unattached layout entries** and user files are left alone.»
ТЗ #252 (§4, цель 1; §7.1) вводит ровно обратное для категории `absent`:
Optimize **начинает** удалять unattached layout entries — при условии, что
отсутствие владельца доказано по авторитетному registry. Технически это
разумное сужение старого правила (закрывает ту же дыру «temporarily
unavailable», которую защищала старая формулировка, но через доказательство,
а не через полный запрет), и §13 корректно включает `docs/CANVAS.md` в список
файлов на обновление. Но нигде в теле ТЗ (§3 «диагноз», §4 «цели» или отдельным
пунктом §14) не сказано прямо: *«это меняет существующий инвариант CANVAS.md
[494-497] / обещание CHANGELOG v1.59.0-rc.1 — старое правило было
консервативным приближением, новое доказывает отсутствие, а не предполагает
его»*.
Почему это не Low и не «само собой закроется веткой §13»: без явного указания,
**что именно** в CANVAS.md заменяется, а не просто дополняется, есть риск, что
реализация допишет новый абзац рядом со старым «deliberately does not delete
unattached layout entries» — и канонический документ подсистемы станет
внутренне противоречивым (ровно то, чего требует избегать сам жанр
канонического документа). Такой же явный след нужен в записи CHANGELOG для
#252: старое обещание «unattached layout entries... are left alone» не должно
молча стать ложным без указания, что оно сужено, а не отменено.
**Как чинится в ТЗ:** одним абзацем в §3 или отдельным пунктом рядом с §14 —
явная ссылка на `docs/CANVAS.md:494-497` и на changelog-запись v1.59.0-rc.1,
формулировка «заменяет», а не «дополняет», и требование к release-артефакту в
§13 переписать (не приписать к) старое предложение в CANVAS.md.
**Воспроизведение:** `docs/CANVAS.md` строки 494-497 vs
`docs/specs/252-optimize-orphan-layout-report.md` §4 п.1 и §7.1 — прямое
текстовое противоречие без ссылки друг на друга.
## Что проверено и корректно
- Все обязательные разделы ТЗ (§7.1) присутствуют, включая блок принятых
предположений (§14) для всего, что не наблюдает пользователь.
- Диагноз (§3) построчно подтверждён кодом (`space-reference-repair.ts`,
`houseplan-card.ts`, i18n) — не догадка.
- Классификация владельцев (§6) исчерпывающая: `rl_`, marker-id (device/`lg_`),
unknown — других префиксов layout-ключей в коде нет.
- Техническая база для fail-closed authority (`HaRegistrySnapshot.
authoritative`) уже существует и уже используется в проде — план не полагается
на код, которого нет.
- AC1–AC8 однозначны, у каждого указан способ доказательства (unit fixture,
browser smoke, semantic/golden), включая mutation guard — термин уже принят
в этом репозитории (`docs/TESTING.md`, множество прежних ревью), не изобретён.
- Три исхода классификации (`absent` / `live-in-missing-space` / `unverified`)
соответствуют трём случаям из AC4 issue и корректно не пересекаются.
- Safety-контракт согласован с духом стоящего правила SCOPE.md «never delete a
user's file on an inference»: удаление разрешено только по доказательству,
неполный registry — fail-closed в сторону сохранения.
- Не-скоуп (§5) корректно исключает миграцию схемы, автоперенос живых объектов,
очистку vacuum segment map и новый экран диагностики — совпадает с тем, что
реально решает issue.
- Release-артефакты (§13) перечисляют оба changelog, User-Visible: yes,
обновление docs screenshot fingerprint (обязательно, т.к. меняется `src/**`)
и golden-решение — ничего не забыто по чек-листу §13 самого PROCESS.md.
- Владельцу не оставлено технических вопросов под видом продуктовых; все пункты
§14 действительно ненаблюдаемы пользователем.
- small/trivial треки корректно не применены (меняется UX-контракт и i18n).
## Чего не проверял
- Не проверял production-код реализации — задачи ещё нет, стадия `S4-spec-review`.
- Не запускал гейты (`tsc`, `npm test`, `npm run build`, smoke) — на этапе
ревью ТЗ они неприменимы, кода нет.
- Не проверял полноту `docs/ARCHITECTURE.md` на предмет уже существующего
языка для «runtime authority boundary» — доверился формулировке ТЗ §13, это
файл, а не поведенческий контракт, и ревью ТЗ не требует вычитывать все
канонические документы построчно, если задача их не переопределяет.
- Не проверял точные будущие RU/EN i18n-ключи и их plural-формы — ТЗ не обязано
фиксировать их дословно на этапе ТЗ, это область реализации, а не контракта.
- Не оценивал `docs/UX-MODES.md`/`docs/TOUCH-SUPPORT.md` построчно на
противоречия — §9 ТЗ (touch/accessibility) ссылается на уже существующий
паттерн диалога Optimize, новых touch-механизмов не вводит.
## Вердикт
Один Medium-дефект в скоупе — документируемое расхождение с существующим
каноническим инвариантом, устраняется правкой текста ТЗ (без изменения
технического контракта). High-находок нет.
**Жёлтый.** Возврат автору на правку ТЗ (добавить явную ссылку на
`docs/CANVAS.md:494-497` / CHANGELOG v1.59.0-rc.1 и требование заменить, а не
дополнить, старую формулировку в release-артефактах §13).
+164
View File
@@ -0,0 +1,164 @@
# SPEC-REVIEW-252-r2
- Issue: [#252](https://github.com/Matysh/houseplan-card/issues/252) — «Отчёт
"Оптимизировать" перечисляет внутренние id вместо того, чтобы починить или
сказать, что делать»
- Этап: spec (PROCESS.md §2.4)
- Заход: r2 · блокирующих циклов израсходовано 1 из 4 (зелёный вердикт r2
бюджет не тратит, #227)
- ТЗ: `docs/specs/252-optimize-orphan-layout-report.md`, коммит `0e4a562`
(HEAD), ветка `issue/252-optimize-orphan-layout-report`
- Трек: обычный (не `small`/`trivial`)
- Предыдущий раунд: [SPEC-REVIEW-252-r1](https://github.com/Matysh/houseplan-card/blob/issue/252-optimize-orphan-layout-report/docs/reviews/SPEC-REVIEW-252-r1.md)
(закоммичен в `2fbab95`), вердикт **жёлтый**, ТЗ на момент этого вердикта —
коммит `883a95a`
## SHA этого раунда (§2.10 п.1)
r1 не назвал SHA в тексте вердикта (issue-комментарий), но назвал его в самом
документе ревью: «ТЗ: …, коммит `883a95a`». Это не пропуск — шаблон вердикта
из PROCESS.md §7.2 не требует SHA в комментарии, а документ его содержит.
Восстановленная цепочка коммитов веток `issue/252-optimize-orphan-layout-report`:
```
883a95a docs: specify orphan layout cleanup — ТЗ на момент r1
2fbab95 docs: review document for #252 — вердикт r1 (жёлтый, M1)
0e4a562 docs: clarify orphan cleanup invariant — фикс M1 (HEAD, этот раунд)
```
`git diff 883a95a..2fbab95` меняет только `docs/reviews/SPEC-REVIEW-252-r1.md`
(добавление документа ревью, ТЗ не тронуто). `git diff 2fbab95..0e4a562` —
единственный файл спецификации, +17/-2 строки. Дельта этого раунда — ровно
этот второй диапазон.
## Дельта раунда (§2.10 п.2)
```
diff --git a/docs/specs/252-optimize-orphan-layout-report.md
--- (883a95a) +++ (0e4a562)
@@ §3, после абзаца про недостаточность "id отсутствует в config.markers" @@
+ Это осознанно **заменяет**, а не дополняет, прежний абсолютный инвариант из
+ `docs/CANVAS.md:494-497` ... То же консервативное обещание было опубликовано
+ в английском changelog для v1.59.0-rc.1. ... При реализации старое
+ предложение в `CANVAS.md` должно быть переписано новым доказательным
+ контрактом, а не оставлено рядом с ним.
@@ §13, список release-артефактов @@
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #252;
+ `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #252 и явным
+ пояснением, что прежнее полное сохранение unattached layout entries из
+ v1.59.0-rc.1 сужено: ...
- `docs/CANVAS.md` — owner classification и идемпотентность cleanup;
+ `docs/CANVAS.md` — **заменить**, не дополнить, старое предложение строк
+ 494–497 «does not ... delete unattached layout entries» новым owner
+ classification/fail-closed контрактом и идемпотентностью cleanup;
```
Дельта строго локальна: два вставленных абзаца внутри §3 (диагноз) и §13
(release-артефакты). Она не трогает §4 (цели), §5 (scope/не-scope), §6-7
(контракт классификации и поведения), §8-12 (данные, UX, AC1-AC8, план
тестов, риски) и §14 (принятые предположения). Это не ребейз на ушедший
вперёд `dev`, не смена контракта поведения (контракт удаления уже был описан
в §4/§6.2/§7.1 исходной редакции и не менялся — меняется только то, как ТЗ
называет отношение к старому инварианту CANVAS.md) и не новая подсистема.
Условия «разбирать полностью» (§2.10) не выполнены — разбор по дельте.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1** (Medium, в скоупе): ТЗ вводит поведение, обратное задокументированному в `docs/CANVAS.md:494-497` инварианту («deliberately does not ... delete unattached layout entries») и опубликованному в `docs/CHANGELOG.md` (v1.59.0-rc.1, «unattached layout entries ... are left alone»), не называя явно, что это замена, а не дополнение | Автор добавил в §3 абзац с прямой ссылкой на `docs/CANVAS.md:494-497` и на changelog-запись v1.59.0-rc.1, словом «заменяет» (не «дополняет»); в §13 бюллет `docs/CANVAS.md` явно требует «заменить, не дополнить» старое предложение строк 494–497, а бюллет CHANGELOG требует явно описать сужение старого обещания | `docs/specs/252-optimize-orphan-layout-report.md:61-69` (§3) и `:342-350` (§13), коммит `0e4a562` |
Проверка не ограничена заявлением автора: сверил оба цитируемых источника
построчно.
- `docs/CANVAS.md:493-497` (текущее состояние репозитория) содержит именно ту
формулировку, которую ТЗ цитирует: «The optimizer deliberately does **not**
alter … delete unattached layout entries (a device may only be temporarily
unavailable) …» — совпадает буквально.
- `docs/CHANGELOG.md:1373-1385` (`## v1.59.0-rc.1 — 2026-08-06`) содержит
«Backdrop calibration, saved views, unattached layout entries and user
files are left alone.» — совпадает буквально, включая номер релиза.
Обе цитаты в ТЗ точны, ссылки на строки/релиз верны, требование «заменить, не
дополнить» сформулировано без места для двойного толкования при реализации.
M1 закрыт полностью, находка не переоткрывается.
## Унаследовано из r1
Принято без повторной проверки в этом раунде, на основании
[SPEC-REVIEW-252-r1](https://github.com/Matysh/houseplan-card/blob/issue/252-optimize-orphan-layout-report/docs/reviews/SPEC-REVIEW-252-r1.md)
(документ закоммичен в `2fbab95`, ТЗ на момент вывода — `883a95a`), поскольку
дельта этого раунда не касается ни одного из этих участков:
- полнота обязательных разделов ТЗ по PROCESS.md §7.1 (все присутствуют,
включая блок принятых предположений §14);
- построчное соответствие диагноза (§3, часть до вставленного абзаца) коду
(`space-reference-repair.ts`, `_renderAlignDialog`, `ha-binding-status.ts`,
i18n) — диагноз не является догадкой, выданной за факт;
- исчерпывающая классификация владельцев (§6): `rl_`, marker-id/`lg_`,
unknown — других префиксов layout-ключей в коде нет;
- техническая база fail-closed authority (`HaRegistrySnapshot.authoritative`)
уже существует и используется в проде;
- однозначность и способ доказательства AC1–AC8;
- соответствие не-скоупа (§5) реальному объёму issue;
- полнота списка release-артефактов §13 (кроме изменённых двух бюллетов —
они проверены заново выше);
- согласованность safety-контракта с правилом SCOPE.md «never delete a
user's file on an inference»;
- отсутствие технических вопросов, поданных владельцу как продуктовые;
- корректность решения не применять `small`/`trivial`.
## Проверка дельты — новые находки
Дельта не вводит новых противоречий и не расширяет скоуп:
- новый текст §3 и §13 ссылается только на уже существующие в репозитории
документы (`docs/CANVAS.md`, `docs/CHANGELOG.md`) точными номерами строк и
релиза — проверено выше, не догадка;
- формулировка «заменить, не дополнить» однозначна для будущей реализации —
не оставляет решение на усмотрение имплементора и не создаёт риска
дублирующего абзаца в каноническом документе, на который указывал M1;
- изменение не открывает нового продуктового вопроса: замена инварианта
CANVAS.md — прямое следствие уже принятого в §4/§6.2 контракта удаления
доказанно отсутствующих владельцев (сам контракт не менялся этим раундом,
и его принятие исходно инициировано владельцем в теле issue #252);
- AC1–AC8, scope, UX и risk-раздел не затронуты дельтой и не требуют
повторной проверки по правилу «только те AC, чьё доказательство дельта
задевает» (§2.10 п.4) — дельта не касается доказательства ни одного AC.
Находок в этом раунде нет: ни High, ни Medium, ни Low.
## Что проверено и корректно
- Дельта раунда полностью соответствует объёму, который M1 требовал закрыть:
явная ссылка на источник + слово «заменяет» + требование к release-артефакту.
- Обе цитаты (CANVAS.md, CHANGELOG.md) сверены с текущим состоянием
репозитория и совпадают буквально.
- SHA обеих сторон дельты восстановлены из истории ветки и подтверждают
заявление автора комментарием («M1 закрыт в `0e4a562`»).
- Раздел «Унаследовано из r1» покрывает всё, что не перепроверялось в этом
раунде, со ссылкой на документ и SHA r1.
## Чего не проверял
- Не проверял production-код реализации — задача остаётся на этапе
`S4-spec-review`, кода ещё нет.
- Не запускал `typecheck`/`test`/`build`/`check-docs` — раунд правит только
документацию ТЗ (`docs/specs/**`), класс C по AGENTS.md, код не менялся;
дешёвые гейты релевантны код-ревью, а не ревью ТЗ без единой строки в `src/**`.
- Не перечитывал заново разделы §4-§12, не тронутые дельтой — см. раздел
«Унаследовано из r1» с указанием документа и SHA, на которых вывод получен.
- Не проверял `docs/ARCHITECTURE.md`, `docs/UX-MODES.md`, `docs/TOUCH-SUPPORT.md`
построчно — то же основание, что в r1: эти документы дельтой не затронуты и
не переопределяются.
## Вердикт
Единственная находка предыдущего раунда (M1, Medium, в скоупе) закрыта точной,
проверяемой правкой текста ТЗ, без изменения технического контракта. Новых
находок дельта не вносит.
**Зелёный.** ТЗ переходит в «Готово к разработке».
Вердикт: зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0
@@ -0,0 +1,381 @@
# Issue #252 — понятная и безопасная очистка забытых позиций в Optimize
- Дата: 2026-08-23
- Тип: bug + polish · приоритет P2
- Оценка: пользовательская ценность 8/10 · ценность для разработки 7/10 · сложность 5/10 · риск 6/10
- Issue: [#252](https://github.com/Matysh/houseplan-card/issues/252)
- Связанные задачи: [#244](https://github.com/Matysh/houseplan-card/issues/244),
[#248](https://github.com/Matysh/houseplan-card/issues/248)
- Ветка: `issue/252-optimize-orphan-layout-report`
- Статус ТЗ: на ревью
Канонические документы: `docs/SCOPE.md`, `docs/CANVAS.md`,
`docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`,
`docs/TOUCH-SUPPORT.md`, `docs/USER-GUIDE.md`,
`docs/USER-GUIDE.ru.md`, `docs/TESTING.md`.
## 1. Сценарий и персона
Администратор дома несколько раз импортировал или удалял этажи, комнаты и
устройства. В layout остались координаты объектов, которых уже нет. Он открывает
«Общие настройки → Оптимизировать планы», чтобы обслужить план, а получает
список внутренних id и не понимает, исправлена ли проблема и что делать дальше.
Сценарий относится к J6 из `docs/SCOPE.md`: старый план должен оставаться
правдивым и обслуживаемым. Optimize — desktop-first административная
поверхность; View и kiosk только читают уже сохранённый результат.
## 2. Что человек увидит до и после
**До:** основной отчёт сообщает о «неразрешённых позициях», «вложенных
сопоставлениях» и перечисляет технические id удалённых пространств. Из отчёта
неясно, безопасно ли нажимать Apply и останутся ли эти записи после него.
**После:** доказанно забытые позиции удаляются в candidate и описываются
человеческими категориями, например: «Убрано забытых записей: 37 — подписи
комнат: 32, устройства: 3, групповые метки: 2». Если объект существует, но его
прежнее пространство удалено, позиция сохраняется и Optimize предлагает
отдельно убрать её. Внутренние id доступны только в свёрнутых «Подробностях».
После Apply, reload и повторного Optimize уже удалённые записи не возвращаются.
## 3. Подтверждённый диагноз
`repairSpaceReferences()` уже исправляет однозначные import-signature ссылки,
отвязывает активный marker от отсутствующего пространства и удаляет его
непереносимую позицию. Но layout-запись без активного config-marker намеренно
сохраняется как opaque user data. Затем она учитывается в
`positionsUnresolved`, а `deadSpaceIds` собирает внутренние id пространств.
`_renderAlignDialog()` складывает `positionsUnresolved` и
`nestedRefsUnresolved` и выводит `gs.optimize_reference_warning`, включая до
десяти `deadSpaceIds`. Поэтому наблюдаемый отчёт соответствует коду и является
багом продукта, а не только неудачной конфигурацией пользователя.
Простое правило «ключ отсутствует в `config.markers` — удалить» некорректно:
автоматические HA-устройства и `lg_<entity_id>` могут иметь layout, не имея
config-marker. `_devices` тоже не является доказательством отсутствия: фильтры,
отсутствующая HA Area и ограниченный registry могут временно убрать живой объект
из render snapshot. Удаление допустимо только после классификации владельца по
config и полному авторитетному HA registry/state snapshot.
Это осознанно **заменяет**, а не дополняет, прежний абсолютный инвариант из
`docs/CANVAS.md:494-497`, по которому Optimize вообще не удаляет unattached
layout entries, поскольку устройство может быть временно недоступно. То же
консервативное обещание было опубликовано в английском changelog для
v1.59.0-rc.1. Новое правило не считает временную недоступность удалением:
неавторитетный или неполный registry по-прежнему сохраняет запись, а очистка
разрешена лишь при доказанном отсутствии владельца. При реализации старое
предложение в `CANVAS.md` должно быть переписано новым доказательным контрактом,
а не оставлено рядом с ним.
## 4. Цели
1. Автоматически удалять позицию, чей владелец доказанно больше не существует.
2. Не удалять молча позицию живого либо непроверенного объекта.
3. Описывать результат терминами интерфейса, а не storage-модели.
4. Оставлять технические id только для раскрываемой диагностики.
5. Сохранить pure preview, Apply, one-deep Undo и идемпотентность #248.
## 5. Scope
### Входит
- классификация orphan layout owners для подписей комнат, устройств и
групповых меток;
- безопасное автоматическое удаление доказанно отсутствующих владельцев;
- отдельное явное действие для позиции живого объекта в удалённом пространстве;
- fail-closed поведение при неполном/неавторитетном registry;
- структурированный report по пользовательским категориям и диагностике;
- понятные RU/EN строки, доступные keyboard/screen reader;
- сохранение nested vacuum mappings без автоматического удаления и их
человекопонятное описание;
- unit, production-bundle smoke, semantic/golden review, документация и оба
changelog.
### Не входит
- догадки о новом пространстве или автоматический перенос живого объекта;
- удаление самого HA-устройства, entity, config-marker, комнаты или пространства;
- автоматическая очистка calibration/segment map пылесоса;
- очистка неизвестных future layout namespaces;
- изменение правил импорта, удаления пространства, device discovery или
`known_devices`;
- schema/store/model-version migration и backend protocol change;
- новый экран диагностики либо переработка всего диалога Optimize;
- публикация maintenance-действия для read-only пользователя.
## 6. Термины и классификация владельца
### 6.1 Категории отчёта
| Внутренний owner layout | Пользовательская категория |
|---|---|
| `rl_<roomId>` | подпись комнаты / room label |
| config-marker или HA device id | устройство / device |
| `lg_<entity_id>` | групповая метка / group marker |
| неизвестный namespace | непроверенная позиция / unverified position |
В основном отчёте не используются слова `layout`, `owner`, `nested mapping`,
`space id`, `marker id` и сами идентификаторы. Они допустимы в «Подробностях».
### 6.2 Три результата классификации
Для каждой позиции с отсутствующим `position.s` результат ровно один:
1. **absent** — владелец доказанно отсутствует; позиция входит в автоматическое
удаление candidate;
2. **live-in-missing-space** — владелец существует, но сохранённая позиция
относится к удалённому пространству; позиция сохраняется по умолчанию и
получает user-facing имя;
3. **unverified** — полноты данных недостаточно или namespace неизвестен;
позиция сохраняется, разрушительное действие не предлагается.
### 6.3 Доказательство существования и отсутствия
- `rl_<roomId>`: комната жива, если `roomId` есть в текущих spaces. Комната
отсутствует, если полного прохода текущей config достаточно доказать, что её
нет. Удалённая room-label tombstone автоматически очищается.
- Явный config-marker: активный marker жив; `removed:true` является
доказательством завершённого удаления владельца для его старой позиции.
- Автоматическое HA-устройство: живо, если id есть в полном device registry;
отсутствует только если авторитетный snapshot успешно загружен и id нет ни в
device registry, ни среди допустимых entity-backed владельцев.
- `lg_<entity_id>`: живо, если entity есть в полном entity registry или
актуальном state snapshot; отсутствует только при авторитетном registry и
отсутствии в обоих источниках.
- неизвестный ключ: всегда `unverified`; его нельзя автоматически удалить лишь
потому, что текущая версия не умеет его отрисовать.
`HaRegistrySnapshot.authoritative === false`, ошибка доступа или snapshot до
первой успешной загрузки никогда не служат доказательством отсутствия. Индексы
config/rooms/devices/entities строятся один раз до прохода layout.
## 7. Контракт поведения
### 7.1 Preview без записи
Открытие Optimize остаётся pure preview и не пишет config/layout. Сначала
сохраняются действующие безопасные remap/detach правила #244, затем
классифицируются оставшиеся позиции в удалённых пространствах.
Все `absent` позиции удаляются из candidate сразу и попадают в структурированные
счётчики:
- `orphanRoomLabelsRemoved`;
- `orphanDevicePositionsRemoved`;
- `orphanGroupPositionsRemoved`.
Их сумма входит в `changed`, maintenance count и Apply candidate. Удаляется
только соответствующая layout entry; config/HA registry не меняются. Clean
config/layout остаются deep-equal и дают нулевые счётчики.
### 7.2 Живой объект в удалённом пространстве
Для `live-in-missing-space` основной отчёт показывает количество и до трёх
user-facing имён: «Позиции 3 устройств остались от удалённого пространства:
Стиральная машина, Датчик движения, …». Имя берётся из config/HA registry/state
по действующим fallback-правилам; внутренний id не является отображаемым именем.
Рядом находится вторичное действие «Убрать старые позиции». Оно:
- не является выбранным по умолчанию;
- не пишет storage само по себе;
- пересобирает preview с явным opt-in и добавляет только перечисленные позиции
live owners в candidate удаления;
- меняется на понятное состояние «Старые позиции будут убраны» и допускает
отмену opt-in до Apply;
- оставляет владельцев живыми и не скрывает их из inbox/discovery: после Apply
они просто считаются неразмещёнными.
Закрытие/Cancel ничего не меняют. Единственной подтверждающей записью остаётся
основная кнопка Apply. Apply и последующий Undo действуют на всю показанную пару,
включая opt-in cleanup, атомарно по существующему контракту.
### 7.3 Непроверенные позиции и пылесос
`unverified` позиции сохраняются. Основной отчёт говорит: «Не удалось безопасно
проверить позиций: N — они оставлены без изменений». При ограниченном доступе к
registry дополнительно объясняется, что для безопасной очистки нужен полный
доступ администратора. Кнопки удаления для этого блока нет.
Неисправленные vacuum segment mappings сохраняются и описываются как
«Сопоставления комнат пылесоса требуют проверки: N». Они не смешиваются со
счётчиком позиций и не делают вид, что были исправлены.
### 7.4 Основной отчёт и подробности
Если удалено хотя бы одно доказанное orphan, показывается одна сводка:
> Убрано забытых записей: 37 — подписи комнат: 32, устройства: 3, групповые
> метки: 2. Все они принадлежали пространствам, удалённым ранее.
Нулевые категории не обязаны перечисляться. Остальные существующие блоки
Optimize сохраняются. Report не утверждает «убрано», если preview не содержит
удаления.
Под предупреждениями доступен нативный раскрываемый блок «Подробности» /
“Details”. Он по умолчанию закрыт, управляется с клавиатуры и содержит:
- тип причины и bounded list до 10 внутренних owner/space id;
- число остальных записей (`и ещё N`);
- явную пометку, какие записи будут удалены, сохранены либо не проверены.
Технические id не попадают в главный текст, accessible name основной кнопки,
toast или changelog. Они не отправляются наружу и берутся только из локального
candidate report.
### 7.5 Apply, reload, Undo и идемпотентность
Существующие schema validation, geometry preflight, CAS, admin permission,
durable `optimize_pending`, one-deep backup и Undo не меняются. Preview exact:
backend получает именно показанные config/layout.
После успешного Apply и server-event reload повторный Optimize:
- не находит удалённые `absent` entries;
- не повторяет их счётчики;
- при отсутствии других изменений показывает `gs.align_none`;
- не пишет новый backup и не меняет revisions.
Undo восстанавливает удалённые layout entries вместе с прежней парой. Следующий
Optimize после Undo снова честно предлагает ту же очистку.
## 8. Данные, compatibility и миграция
Persisted schema, Store version и `PLAN_MODEL_VERSION` не меняются: задача
использует существующее право Optimize удалить ключ layout. Новых persisted
полей нет. Старые карточки продолжают читать результат как обычный layout.
Миграции при загрузке и lazy write нет. Очистка происходит только после явного
Apply администратора. Unknown/future keys сохраняются. Существующие exact
import-signature remap, marker detach, coordinate canonicalization и #248
storage/reload boundary выполняются в прежнем порядке и остаются идемпотентны.
Контекст классификации — runtime-only read model: полнота registry, device/entity
roster и user-facing names. Pure optimizer не читает глобальное состояние и не
делает сетевые запросы.
## 9. UX, accessibility и touch
- RU и EN имеют отдельные строки для removed/live/unverified/vacuum/details и
явного действия; pluralisation следует существующему i18n contract.
- Сводка и предупреждения доступны screen reader в логичном DOM-порядке;
динамическая смена opt-in объявляется через существующий dialog update без
захвата фокуса.
- «Убрать старые позиции» — настоящий `<button>`, доступный Tab/Enter/Space;
состояние передаётся текстом, а не только цветом или иконкой.
- `<details><summary>` имеет видимый focus indicator. После recompute фокус
остаётся на вызвавшей кнопке; Cancel и Apply сохраняют прежний focus contract.
- Touch: maintenance desktop-first, но secondary action имеет не меньшую
эффективную hit-area, чем остальные dialog buttons; pinch/pan плана не
участвуют. Это best effort, не новый touch editor workflow.
## 10. Acceptance criteria
| AC | Критерий | Доказательство |
|---|---|---|
| AC1 | Позиции 32 отсутствующих комнат, 3 доказанно отсутствующих устройств и 2 отсутствующих групп удалены из candidate; отчёт показывает категории и сумму 37 без id. Второй Optimize после Apply/reload — no-op. | Pure unit fixture + production-bundle smoke; mutation guard на удаление каждой категории и второй проход. |
| AC2 | Живой config-marker, HA device и `lg_` entity с позицией в удалённом пространстве не удаляются по умолчанию, называются человеческими именами и удаляются только после secondary opt-in + основной Apply. | Unit classification matrix + browser smoke preview/Cancel/Apply/Undo. |
| AC3 | Неавторитетный/ошибочный registry, неизвестный namespace и исчезнувший только из `_devices` живой объект сохраняются; разрушительная кнопка для них отсутствует. | Unit authority matrix; smoke restricted-registry state; mutants, трактующие partial absence как dead. |
| AC4 | Unresolved vacuum mappings сохраняются и имеют отдельную понятную строку. Main report не содержит `id`, raw space/marker ids, «неразрешённых позиций» или «вложенных сопоставлений» в RU/EN. | i18n/unit string assertions + semantic smoke; bounded Details assertions. |
| AC5 | Clean config/layout deep-equal; exact import remap и detach #244 не регрессируют; unknown future fields сохраняются; первый реальный cleanup и последующий storage round-trip соблюдают #248. | Расширенные `space-reference-repair`/`plan-optimizer` unit tests и shared reload fixture. |
| AC6 | Cancel не пишет, Apply пишет exact preview через прежний preflight/CAS, Undo восстанавливает все удалённые entries; повторный no-op не заменяет backup. | Production-bundle WS spy smoke + существующие backend optimize/undo tests. |
| AC7 | Диалог keyboard/screen-reader понятен, Details свёрнут, focus не теряется, secondary action имеет достаточную touch target. RU/EN и светлая/тёмная тема читаемы. | Semantic DOM assertions + reviewed screenshot/golden matrix. |
| AC8 | Implementation gates зелёные, linear performance и bundle parity сохранены. | `typecheck`, `unit`, `build`; targeted smoke/golden перед S7; общие performance/golden/smoke перед бетой. |
## 11. План реализации и тестов
1. Расширить runtime context `space-reference-repair` авторитетным roster и
именами, сохранив pure API и fail-closed defaults для старых callers.
2. Вынести pure owner classifier/formatter либо эквивалентные тестируемые helpers;
построить room/device/entity maps один раз и пройти layout один раз.
3. После существующих remap/detach passes удалить только `absent`; сохранить
structured removed/live/unverified/details arrays и отдельно vacuum count.
4. Добавить optimizer option для явного удаления только уже классифицированных
live-in-missing-space entries; default остаётся `false`.
5. Передать из карточки authoritative registry snapshot, полный device/entity
roster, state fallback и user-facing names; `_devices` использовать только
как presentation metadata, не как доказательство отсутствия.
6. Обновить `_renderAlignDialog()`: сводка, предупреждения, secondary opt-in,
recompute без записи, `<details>`, Apply/Cancel/focus; обновить toast count.
7. Добавить RU/EN i18n и unit matrices: три owner outcomes, категории, authority,
unknown keys, vacuum, clean, second pass, mutation guards.
8. Расширить targeted production-bundle smoke orphan references: default
preview, Details, Cancel, opt-in, Apply, server reload, Undo и partial registry.
9. Обновить canonical docs, user guide, testing docs, обе changelog и
синхронные production bundles.
В implementation-цикле выполняются только `typecheck`, `unit`, `build`.
Targeted smoke и визуальная проверка выполняются перед S7. Полные
golden/smoke/performance и Linux HA harness остаются предрелизными гейтами.
## 12. Performance, security, риски и rollback
Сложность классификации `O(spaces + rooms + markers + registry + layout)`,
память `O(owners)`. Запрещены поиск владельца полным проходом registry для каждой
layout entry и сетевой запрос на запись. Это maintenance-only путь, не render
loop; общий pre-release performance gate не должен регрессировать.
Optimize остаётся admin-only. Ограниченный registry приводит к сохранению, а не
удалению данных. Идентификаторы показываются локально только после раскрытия
Details, не логируются и не отправляются внешним сервисам. Schema limits,
preflight, CAS и backend authorization не ослабляются.
| Риск | Мера |
|---|---|
| Живой auto-device принят за удалённый | Полный authoritative registry + fail-closed matrix; `_devices` не authority. |
| Future owner удалён как мусор | Unknown namespace всегда `unverified`. |
| Preview не совпал с Apply | Opt-in пересобирает pure candidate; backend получает exact pair. |
| Пользователь считает warning исправленным | Раздельные формы «убрано», «можно убрать», «не удалось проверить». |
| Большой список ломает диалог | Основная сводка только counters/names до 3; Details ids до 10 + remainder. |
| Cleanup повторяется | Apply/reload second-pass fixture #248. |
Rollback — revert implementation-коммита. Persisted format не меняется и
обратная миграция не нужна. Уже удалённые пользователем entries восстанавливаются
штатным Undo, пока one-deep backup не заменён следующим Optimize.
## 13. Release-артефакты
Изменение пользовательски видимо. Implementation-коммит получает
`User-Visible: yes` и в том же коммите включает:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #252 и явным
пояснением, что прежнее полное сохранение unattached layout entries из
v1.59.0-rc.1 сужено: временно недоступные/непроверенные владельцы по-прежнему
сохраняются, удаляются только доказанно отсутствующие;
- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md` — новый смысл отчёта,
Details, secondary cleanup и Undo;
- `docs/CANVAS.md` — **заменить**, не дополнить, старое предложение строк
494–497 «does not ... delete unattached layout entries» новым owner
classification/fail-closed контрактом и идемпотентностью cleanup;
- `docs/CONFIG-COMPATIBILITY.md` — отсутствие миграции, fail-closed registry и
сохранение unknown layout;
- `docs/ARCHITECTURE.md` — runtime authority boundary, если добавляется новый
classifier/context module;
- `docs/TESTING.md` — owner/authority matrix, smoke и mutation guards;
- RU/EN i18n, unit fixtures, targeted production-bundle smoke;
- синхронные `houseplan-card.js` в корне, `dist/` и `custom_components/`;
- актуальный docs screenshot source fingerprint и canonical screenshot workflow,
потому что меняется `src/**` и видимый maintenance dialog;
- reviewed light/dark RU/EN golden либо semantic golden с явным решением о
baseline; новые пиксельные baseline принимаются только при ожидаемом diff.
`docs/STATUS.md` меняется лишь если текущая unreleased-сводка перечисляет этот
класс исправлений. Полный release gate выполняется перед бетой по runbook.
## 14. Принятые технические предположения
1. Явное предложение удаления реализуется внутри существующего preview как
secondary opt-in, а не отдельный modal: это сохраняет один Apply и exact
preview contract.
2. Main report показывает имена живых объектов максимум для трёх записей;
диагностика ограничена десятью id. Полные данные остаются в памяти candidate,
но не раздувают DOM.
3. «Групповая метка» — пользовательский термин для `lg_<entity_id>`; точная RU/EN
формулировка может быть согласована с уже существующим `device.light_group`
без изменения поведения.
4. Позиция удалённого config-marker (`removed:true`) считается безопасной для
автоматической очистки, поскольку tombstone хранит metadata для discovery, а
старые координаты не дают ей пользовательской функции.
5. Модель не повышается: добавлен новый explicit Optimize pass без нового
persisted представления.
+1
View File
@@ -128,6 +128,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#244](https://github.com/Matysh/houseplan-card/issues/244) Восстановление маркеров с мёртвой ссылкой на пространство | [244-orphan-space-references.md](244-orphan-space-references.md) |
| [#248](https://github.com/Matysh/houseplan-card/issues/248) Идемпотентный Optimize после записи и reload | [248-optimize-idempotence.md](248-optimize-idempotence.md) |
| [#251](https://github.com/Matysh/houseplan-card/issues/251) Доступность контроллера не наследуется от управляемой цели | [251-controller-target-availability.md](251-controller-target-availability.md) |
| [#252](https://github.com/Matysh/houseplan-card/issues/252) Понятная и безопасная очистка забытых позиций в Optimize | [252-optimize-orphan-layout-report.md](252-optimize-orphan-layout-report.md) |
## P3
+20 -8
View File
@@ -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
View File
@@ -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>
+21 -1
View File
@@ -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}.",
@@ -805,8 +806,27 @@
"gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.",
"gs.optimize_glow_migration": "Legacy Glow: {spaces} spaces → no data fill + independent Glow; {rooms} rooms → inherited data fill + independent Glow.",
"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",
+21 -1
View File
@@ -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}.",
@@ -805,8 +806,27 @@
"gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.",
"gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.",
"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}",
+4
View File
@@ -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
View File
@@ -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));
+28
View File
@@ -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;
+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);
});