Make empty space model explicit

Issue: #113
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-19 02:49:39 +03:00
parent 135497b272
commit 1e8503bd46
12 changed files with 707 additions and 276 deletions
File diff suppressed because one or more lines are too long
+90
View File
@@ -0,0 +1,90 @@
// #113: deleting the final space and receiving an empty plan over WS must be
// a supported lifecycle state, not a synthetic SpaceModel or an exception.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
await card.updateComplete;
await sleep(40);
await card.updateComplete;
};
const original = structuredClone(card._serverCfg);
const first = structuredClone(original.spaces[0]);
// Exercise the real delete command with exactly one remaining space.
card._serverCfg = { ...structuredClone(original), spaces: [first] };
card._space = first.id;
card._cfgEpoch++;
card.requestUpdate();
await settle();
card._openSpaceDialog('edit', first.id);
card._mode = 'plan';
card._path = [[100, 100], [200, 100]];
card._resumeDraftBySpace = { [first.id]: 'stale-draft' };
card._pointers.set(113, { x: 10, y: 10 });
card._drag = { id: 'stale-device', sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
card._saveConfigDebounced();
const confirmBefore = window.confirm;
window.confirm = () => true;
try {
await card._deleteSpace();
} finally {
window.confirm = confirmBefore;
}
await settle();
out.deleteLastRendersEmpty = !!root().querySelector('.empty') && !root().querySelector('.stage');
out.deleteLastClearsSelection = card._space === '' && card._spaceModel() === undefined;
out.deleteLastAbortsEditorState = card._mode === 'view'
&& card._path.length === 0 && card._pointers.size === 0 && card._drag === null
&& Object.keys(card._resumeDraftBySpace).length === 0;
out.deleteLastClosesEditDialog = card._spaceDialog === null;
out.deleteLastCancelsPendingWrite = card._saveConfigDebounced.pending() === false;
// The empty state still owns global recovery flows.
const add = root().querySelector('.empty button.btn.on');
add?.click();
await settle();
out.createFlowSurvivesEmpty = card._spaceDialog?.mode === 'create';
// Recreate a plan, then reproduce the same transition as a config-updated WS
// event. This also arms the once-per-empty cleanup for a second cycle.
card._spaceDialog = null;
card._serverCfg = structuredClone(original);
card._space = original.spaces[0].id;
card._cfgEpoch++;
card.requestUpdate();
await settle();
out.recreateRestoresPlan = !!root().querySelector('.stage') && card._spaceModel()?.id === card._space;
card._mode = 'devices';
card._pointers.set(114, { x: 20, y: 20 });
card._serverCfg = { ...structuredClone(original), spaces: [] };
card._cfgEpoch++;
card.requestUpdate();
await settle();
out.wsEmptyAbortsLiveGesture = card._mode === 'view'
&& card._pointers.size === 0 && card._spaceModel() === undefined;
// Empty plans must remain inert under unrelated HA/render ticks.
card._serverCanWrite = false;
card.hass = {
...card.hass,
themes: { ...(card.hass.themes || {}), darkMode: !card.hass.themes?.darkMode },
};
window.dispatchEvent(new Event('resize'));
await settle();
out.emptySurvivesThemeResizeReadonly = !!root().querySelector('.empty')
&& !root().querySelector('.stage') && card._mode === 'view';
return out;
});
checkAll(result);
await finish(browser, result);
File diff suppressed because one or more lines are too long
+53 -53
View File
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -1,6 +1,6 @@
# House Plan architecture
Updated: 2026-08-17 (#157 open-passage model). The repository = a HACS integration (category **Integration**)
Updated: 2026-08-19 (#113 optional space-model lifecycle). The repository = a HACS integration (category **Integration**)
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
## Layout
@@ -15,6 +15,7 @@ houseplan-card/
│ ├─ floating-surface-controller.ts # shared Popover/fallback portal DOM lifecycle
│ ├─ editor-secondary.ts # context tray model, groups, focus/dismiss lifecycle and stable template
│ ├─ editor-secondary.styles.ts # styles owned by the context tray/submenu surface
│ ├─ space-model-selection.ts # active-or-first and exact optional space selectors
│ ├─ render/opening-tunnels.ts # immutable SVG projection of resolved tunnel geometry/fills
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, fallback order
@@ -137,6 +138,23 @@ the same profiler available between stable promotions.
## Card data model (runtime)
`SpaceModel` is absent when the authoritative configuration has no spaces.
`_spaceModel()` therefore returns `SpaceModel | undefined`: it preserves the
legacy active-or-first selection for rendering and current navigation, but it
never invents a dummy space. Commands carrying a persisted or otherwise stable
space id use the exact `_spaceModelById()` selector; a stale id aborts before
config, layout, file or service side effects instead of mutating the first
space.
The first update that observes an authoritative empty `spaces` array runs one
space-bound lifecycle cleanup. It releases tracked pointer capture, cancels
pan/pinch/drag/resize/vacuum and geometry gestures, clears draft/history and
space dialogs, cancels the debounced config write and returns the card to View.
The global empty-state Create/import flows remain available. Recreating a space
re-arms cleanup so a later WS transition back to empty is handled identically.
Pure render/geometry helpers may return an empty result while the model is
absent; mutation entry points must guard explicitly.
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary
(the first resolved state entity for actions requiring one target), temp,
members[] (light group), link/linkPrimary (Z2M group). Marker state consumes
+21
View File
@@ -32,6 +32,23 @@
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
## Empty-space lifecycle (#113)
- [ ] Active selection keeps active-or-first compatibility, while an empty
model returns `undefined`; exact lookup of a stale saved id never falls
back to another space [unit: `space-model-selection.test.mjs`].
- [ ] There are no unguarded `_spaceModel().…` dereferences, explicit-id calls
use `_spaceModelById()`, and marker persistence validates its target
before config/file/WS side effects
[unit: `optional-space-model-contract.test.mjs`].
- [ ] Delete the last space while an editor gesture and debounced write are
active: the empty card renders, View is restored, pointer/draft/dialog
state is cleared, the pending write is cancelled and Add space still
opens Create. Recreate a plan, then receive an empty WS config and repeat
under a theme/resize/read-only tick [auto: `smoke_optional_space_model`].
- [ ] Removing the authoritative empty-state cleanup makes that smoke red
[mutation: `empty-space-cleanup-disabled`].
## Open passage (#157)
@@ -345,6 +362,7 @@ separately promised workflows:
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
[auto: smoke_optional_space_model]
## Spaces ★
@@ -354,6 +372,9 @@ separately promised workflows:
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [manual]
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [manual]
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
- [ ] Delete the last space → empty state without console errors; active editor
gestures and drafts are aborted, and creating the first space remains available
[auto: smoke_optional_space_model]
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [manual]
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [manual]
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [manual]; toggling a light from the plan recolors the room
+11
View File
@@ -40,6 +40,17 @@ import { fileURLToPath } from 'node:url';
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
{
id: 'empty-space-cleanup-disabled',
guard: 'node demo/smoke_optional_space_model.mjs',
because: 'удаление последнего пространства обязано завершать жесты, редакторы и отложенную запись; '
+ 'смок дважды переводит живую карточку в пустой план и проверяет реальный lifecycle cleanup',
patches: [{
file: 'src/houseplan-card.ts',
find: 'if (this._emptySpaceStateActive) return;',
replace: 'if (empty) return;',
}],
},
{
id: 'continuity-long-resume-noop',
guard: 'node demo/smoke_visual_continuity.mjs',
+295 -115
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
/**
* Active-space selection is allowed to preserve the legacy first-space
* fallback while the model is non-empty. Explicit/stable ids use the exact
* selector so a stale object can never silently target another floor.
*/
export function selectActiveSpaceModel<T extends { id: string }>(
models: readonly T[],
activeId: string | null | undefined,
): T | undefined {
return models.find((space) => space.id === activeId) ?? models[0];
}
export function selectSpaceModelById<T extends { id: string }>(
models: readonly T[],
id: string | null | undefined,
): T | undefined {
if (!id) return undefined;
return models.find((space) => space.id === id);
}
@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const methodBody = (name) => {
const start = source.search(new RegExp(`(?:private|protected)\\s+(?:get\\s+|async\\s+)?${name}(?:\\s*=)?(?:\\(|:)`));
assert.notEqual(start, -1, `${name} exists`);
const tail = source.slice(start + 1);
const next = tail.search(/\n (?:private|protected|public)\s/);
return source.slice(start, next < 0 ? source.length : start + 1 + next);
};
test('_spaceModel exposes absence and no call site bypasses it', () => {
assert.match(source, /private _spaceModel\(\): SpaceModel \| undefined/);
assert.match(source, /private _spaceModelById\([^)]*\): SpaceModel \| undefined/);
assert.doesNotMatch(source, /this\._spaceModel\(\s*[^)]/,
'explicit ids belong to the exact selector');
assert.doesNotMatch(source, /this\._spaceModel\(\)\s*!/,
'absence must not be hidden behind a non-null assertion');
assert.doesNotMatch(source, /this\._spaceModel\(\)\s*\./,
'every dereference is guarded or uses an intentional empty fallback');
});
test('authoritative empty-space cleanup aborts every space-bound transaction', () => {
const cleanup = methodBody('_syncEmptySpaceState');
for (const fragment of [
'releasePointerCapture', '_pointers.clear()', '_cancelModeTransition(false)',
"_mode = 'view'", '_clearGeometryGesture()', '_geometryHistory.clear()',
'_resumeDraftBySpace = {}', '_drag = null', '_vacFit = null',
'_markerDialog = null', '_saveConfigDebounced.cancel()', "_space = ''",
]) assert.ok(cleanup.includes(fragment), fragment);
const willUpdate = methodBody('willUpdate');
assert.ok(willUpdate.indexOf('_syncEmptySpaceState()')
< willUpdate.indexOf('_captureRenderDeviceSnapshot()'),
'cleanup happens before a new render snapshot is captured');
});
test('stable space ids use exact lookup and abort before side effects', () => {
for (const name of [
'_livePos', '_vacPlanRoomAnchors', '_vacStartFit', '_labelMove', '_rlResizeMove',
]) assert.match(methodBody(name), /_spaceModelById\(/, name);
const saveMarker = methodBody('_saveMarker');
const exactAt = saveMarker.indexOf('_spaceModelById(explicitSpaceId)');
const guardAt = saveMarker.indexOf('if (!targetSpaceModel) return;');
const busyAt = saveMarker.indexOf('busy: true');
const migrateAt = saveMarker.indexOf("type: 'houseplan/files/migrate'");
const configMutationAt = saveMarker.indexOf('cfg.markers = markers');
assert.ok(exactAt >= 0 && exactAt < guardAt && guardAt < busyAt
&& busyAt < migrateAt && migrateAt < configMutationAt);
});
test('empty render keeps create/import affordances without spatial layers', () => {
const render = methodBody('render');
const emptyAt = render.indexOf('if (!model.length)');
const addAt = render.indexOf("_openSpaceDialog('create')");
const spatialAt = render.indexOf('const space = this._spaceModel()');
assert.ok(emptyAt >= 0 && emptyAt < addAt && addAt < spatialAt);
assert.match(render, /if \(!space\) return nothing;/);
});
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
selectActiveSpaceModel, selectSpaceModelById,
} from '../test-build/space-model-selection.js';
const spaces = [
{ id: 'ground', rooms: ['living'] },
{ id: 'upper', rooms: ['bedroom'] },
];
test('active selection preserves the active-or-first legacy contract', () => {
assert.equal(selectActiveSpaceModel(spaces, 'upper'), spaces[1]);
assert.equal(selectActiveSpaceModel(spaces, 'stale'), spaces[0]);
assert.equal(selectActiveSpaceModel(spaces, null), spaces[0]);
});
test('an empty model has no invented space', () => {
assert.equal(selectActiveSpaceModel([], 'ground'), undefined);
assert.equal(selectActiveSpaceModel([], null), undefined);
});
test('explicit id selection never falls back to another space', () => {
assert.equal(selectSpaceModelById(spaces, 'upper'), spaces[1]);
assert.equal(selectSpaceModelById(spaces, 'stale'), undefined);
assert.equal(selectSpaceModelById(spaces, null), undefined);
assert.equal(selectSpaceModelById([], 'ground'), undefined);
});
+1 -1
View File
@@ -20,7 +20,7 @@
"src/virtual-light-state.ts",
"src/types.ts",
"src/space-geometry.ts",
"src/signing.ts", "src/initial-load.ts",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts",
"src/visual-continuity.ts", "src/mode-transition.ts",
"src/render-device-snapshot.ts",
"src/command-stack.ts",