mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
File diff suppressed because one or more lines are too long
@@ -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
Vendored
+53
-53
File diff suppressed because one or more lines are too long
+19
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;/);
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user