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