fix: keep large-plan interactions off the full render path

Issue: #451
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-04 22:03:45 +03:00
parent 137e160f1a
commit c0d61ca3d2
74 changed files with 21454 additions and 623 deletions
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { RenderLifecycle } from '../test-build/houseplan-render-lifecycle.js';
import { LiveRuntime } from '../test-build/live-interaction-runtime.js';
const row = (state = 'off') => ({ entity_id: 'light.plan', state, attributes: {} });
const base = () => {
const connection = {};
const entities = {};
const devices = {};
const areas = {};
const themes = {};
const user = {};
const config = {};
const floors = {};
const services = {};
const panels = {};
const locale = { language: 'en', number_format: 'language', time_format: 'language' };
return {
connection, entities, devices, areas, themes, user, config, floors, services, panels, locale,
language: 'en', states: { 'light.plan': row(), 'sensor.other': { state: '0' } },
};
};
test('HA intake runs once even when an unrelated visual update is skipped', () => {
const lifecycle = new RenderLifecycle();
const before = base();
const after = { ...before, states: { ...before.states, 'sensor.other': { state: '1' } } };
let intakes = 0;
lifecycle.observe(before, after, { entityIds: ['light.plan'] }, () => intakes++);
lifecycle.intake(after, () => intakes++);
assert.equal(intakes, 1);
});
const liveHost = (lifecycle) => ({
_renderLife: lifecycle, _pointers: new Map(), _cameraTransition: { active: false },
_deviceDrag: null, _physicalDrag: null, _physicalRotate: null, _decorMove: null,
_decorDraft: null, _dtDrag: null, _bdDrag: null, _opDrag: null,
_resize: { dragging: false },
});
test('a relevant HA tick is deferred only while a continuous interaction is active', () => {
const lifecycle = new RenderLifecycle();
const host = liveHost(lifecycle);
const live = new LiveRuntime(host);
const before = base();
const after = { ...before, states: { ...before.states, 'light.plan': row('on') } };
host._pointers.set(1, {});
assert.equal(live.hass(before, after, { entityIds: ['light.plan'] }, () => {}), false);
assert.equal(live.take(), true);
assert.equal(live.take(), false);
host._pointers.clear();
const final = { ...after, states: { ...after.states, 'light.plan': row('off') } };
assert.equal(live.hass(after, final, { entityIds: ['light.plan'] }, () => {}), true);
});
test('gesture reconciliation is last-wins and a normal full update subsumes it', () => {
const lifecycle = new RenderLifecycle();
const host = liveHost(lifecycle);
const live = new LiveRuntime(host);
const before = base();
const first = { ...before, states: { ...before.states, 'light.plan': row('on') } };
const second = { ...first, states: { ...first.states, 'light.plan': row('unavailable') } };
host._pointers.set(1, {});
assert.equal(live.hass(before, first, { entityIds: ['light.plan'] }, () => {}), false);
assert.equal(live.hass(first, second, { entityIds: ['light.plan'] }, () => {}), false);
assert.equal(live.take(), true);
assert.equal(live.take(), false);
live.hass(second, first, { entityIds: ['light.plan'] }, () => {});
live.clear();
assert.equal(live.take(), false);
});
test('diagnostics scan is cached and invalidated by tracked state presence', () => {
const lifecycle = new RenderLifecycle();
const before = base();
const markers = [{ binding: 'device:kitchen' }];
let scans = 0;
const resolve = () => {
scans++;
return { kind: 'active', enabledEntityIds: ['light.plan'], allEntityIds: ['light.plan'] };
};
assert.equal(lifecycle.diagnostics(before, markers, resolve).bindings.active, 1);
assert.equal(lifecycle.diagnostics(before, markers, resolve).bindings.active, 1);
assert.equal(scans, 1);
const missing = { ...before, states: { 'sensor.other': before.states['sensor.other'] } };
lifecycle.observe(before, missing, { entityIds: ['light.plan'] }, () => {});
lifecycle.diagnostics(missing, markers, resolve);
assert.equal(scans, 2);
});
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
disposeHouseplanEditor,
routeHouseplanEditorUpdate,
} from '../test-build/live-editor.js';
import {
cancelHouseplanPointerMove,
flushHouseplanPointerMove,
queueHouseplanPointerMove,
} from '../test-build/pointer-move-queue.js';
test('editor live routing keeps pointerdown and terminal changes reactive', () => {
const beforeRaf = globalThis.requestAnimationFrame;
const beforeCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = () => 17;
globalThis.cancelAnimationFrame = () => {};
const host = {
isConnected: true,
_mode: 'plan',
_physicalDrag: { moved: false },
};
try {
assert.equal(routeHouseplanEditorUpdate(host, '_physicalDrag', null), false);
assert.equal(routeHouseplanEditorUpdate(host, '_physicalDrag', { moved: false }), true);
host._physicalDrag = null;
assert.equal(routeHouseplanEditorUpdate(host, '_physicalDrag', { moved: true }), false);
assert.equal(routeHouseplanEditorUpdate(host, '_toast', ''), false);
} finally {
disposeHouseplanEditor(host);
globalThis.requestAnimationFrame = beforeRaf;
globalThis.cancelAnimationFrame = beforeCancel;
}
});
test('pointer-following editor hover routes while View remains reactive', () => {
const beforeRaf = globalThis.requestAnimationFrame;
const beforeCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = () => 21;
globalThis.cancelAnimationFrame = () => {};
const host = { isConnected: true, _mode: 'plan' };
try {
assert.equal(routeHouseplanEditorUpdate(host, '_cursorPt', null), true);
host._mode = 'view';
assert.equal(routeHouseplanEditorUpdate(host, '_cursorPt', [1, 2]), false);
} finally {
disposeHouseplanEditor(host);
globalThis.requestAnimationFrame = beforeRaf;
globalThis.cancelAnimationFrame = beforeCancel;
}
});
test('pointer move queue is RAF-coalesced, last-wins and flushable', () => {
const beforeRaf = globalThis.requestAnimationFrame;
const beforeCancel = globalThis.cancelAnimationFrame;
const callbacks = new Map();
const cancelled = [];
let nextId = 0;
globalThis.requestAnimationFrame = (callback) => {
const id = ++nextId;
callbacks.set(id, callback);
return id;
};
globalThis.cancelAnimationFrame = (id) => {
cancelled.push(id);
callbacks.delete(id);
};
const host = {};
const seen = [];
try {
queueHouseplanPointerMove(host, 'drag', () => seen.push(1));
queueHouseplanPointerMove(host, 'drag', () => seen.push(2));
assert.equal(callbacks.size, 1);
const [[id, callback]] = callbacks;
callbacks.delete(id);
callback();
assert.deepEqual(seen, [2]);
queueHouseplanPointerMove(host, 'drag', () => seen.push(3));
flushHouseplanPointerMove(host, 'drag');
assert.deepEqual(seen, [2, 3]);
assert.equal(cancelled.length, 1);
queueHouseplanPointerMove(host, 'drag', () => seen.push(4));
cancelHouseplanPointerMove(host, 'drag');
assert.deepEqual(seen, [2, 3]);
assert.equal(cancelled.length, 2);
} finally {
disposeHouseplanEditor(host);
globalThis.requestAnimationFrame = beforeRaf;
globalThis.cancelAnimationFrame = beforeCancel;
}
});
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { liveLayerProjection, liveViewBoxText } from '../test-build/live-viewport.js';
test('live viewport projects pan and zoom from the last complete frame', () => {
assert.deepEqual(
liveLayerProjection(
{ x: 0, y: 0, w: 1000, h: 500 },
{ x: 100, y: 50, w: 500, h: 250 },
),
{ translateXPercent: -20, translateYPercent: -20, scaleX: 2, scaleY: 2 },
);
});
test('live viewport serializes one exact SVG camera box', () => {
assert.equal(liveViewBoxText({ x: -12.5, y: 4, w: 800, h: 450 }), '-12.5 4 800 450');
});
+25
View File
@@ -111,3 +111,28 @@ test('absolute performance smoke needs no baseline and enforces hard ceilings',
assert.equal(rejected.pass, false);
assert.ok(rejected.failures.some((check) => check.id === 'timing.firstStableRenderMs.median'));
});
test('named interaction windows enforce their own absolute Long Task limits', () => {
const windowBudgets = {
...budgets,
longTaskWindows: {
editorSeries: { maxSingleMs: 150, maxCountP95: 3, maxTotalP95Ms: 300 },
},
};
const candidate = report();
candidate.rows[0].longTasks.editorSeries = {
supported: true, count: 3, maxMs: 149, totalMs: 299,
};
candidate.rows[1].longTasks.editorSeries = {
supported: true, count: 4, maxMs: 151, totalMs: 301,
};
const result = evaluatePerformanceBudget({ baseline: report(), candidate, budgets: windowBudgets });
assert.deepEqual(
new Set(result.failures.map((check) => check.id)),
new Set([
'longTask.editorSeries.maxSingleMs',
'longTask.editorSeries.countP95',
'longTask.editorSeries.totalP95Ms',
]),
);
});
+7 -1
View File
@@ -70,7 +70,7 @@ test('performance contracts reference real production members', () => {
const source = readHouseplanProductionSource();
for (const contract of [LARGE_HOUSE_CARD_CONTRACT, GLOW_CARD_CONTRACT]) {
for (const name of currentProductionMembers(contract)) {
assert.match(source, new RegExp(`\\b(?:private\\s+(?:declare\\s+|get\\s+)?|get\\s+)${name}\\b`),
assert.match(source, new RegExp(`\\b(?:private\\s+(?:(?:declare|readonly|get)\\s+)*|get\\s+)${name}\\b`),
`${contract.label} declares missing production member ${name}`);
}
}
@@ -90,10 +90,16 @@ test('large-house contract accepts only an explicit current or stable resize own
);
Object.assign(fields, {
_booting: false,
_bootSoft: false,
_cameraTransition: {},
_cleanFloorCache: new Map(),
_continuity: {},
_decorList: [],
_decorTool: 'select',
_devices: [],
_glowClipCache: new Map(),
_gridPitch: 1,
_hassSequence: 0,
_loadOk: true,
_model: [],
_path: [],
+2 -1
View File
@@ -33,6 +33,7 @@ test('full performance is isolated to stable, scheduled and manual entry points'
'- large-house',
'- isometric',
'- plan-snap',
'- interaction',
'- blend',
'- overlay',
'- space-default',
@@ -43,7 +44,7 @@ test('full performance is isolated to stable, scheduled and manual entry points'
]) assert.ok(workflow.includes(contract), `missing full-gate contract: ${contract}`);
assert.ok(workflow.includes('if [ -f baseline/scripts/bundle-sync.mjs ]; then'));
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 14);
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 16);
const release = readWorkflow('release.yml');
assert.ok(release.includes('if: ${{ !github.event.release.prerelease }}'));
+2
View File
@@ -58,9 +58,11 @@ test('RenderDeviceSnapshot keeps immutable facts and excludes live HA capabiliti
assert.deepEqual(snapshot.positions.get('one'), { x: 12, y: 34 });
assert.equal(snapshot.presentations.get(presentationSnapshotKey('one', true)).visual.status, 'working');
assert.equal(snapshot.facts.get('vacuum:one').moving, true);
assert.deepEqual([...snapshot.entityIds], ['light.one']);
assert.equal('set' in snapshot.positions, false);
assert.equal('set' in snapshot.presentations, false);
assert.equal('set' in snapshot.facts, false);
assert.equal('add' in snapshot.entityIds, false);
});
test('RenderDeviceSnapshot exposes one immutable vacuum-only roster subset', () => {
+63
View File
@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { classifyHassRenderChange } from '../test-build/render-invalidation.js';
const entity = (id, state = 'off') => ({ entity_id: id, state, attributes: {} });
const base = () => {
const connection = {};
const entities = {};
const devices = {};
const areas = {};
const themes = {};
const user = {};
const config = {};
const floors = {};
const services = {};
const panels = {};
const locale = { language: 'en', number_format: 'language', time_format: 'language' };
return {
connection, entities, devices, areas, themes, user, config, floors, services, panels, locale,
language: 'en',
states: { 'light.plan': entity('light.plan'), 'sensor.other': entity('sensor.other') },
};
};
const deps = { entityIds: new Set(['light.plan']) };
test('an unrelated HA state row does not invalidate the plan frame', () => {
const before = base();
const after = {
...before,
states: { ...before.states, 'sensor.other': entity('sensor.other', 'on') },
};
assert.equal(classifyHassRenderChange(before, after, deps), 'none');
});
test('a dependency row identity or presence change invalidates state', () => {
const before = base();
const changed = {
...before,
states: { ...before.states, 'light.plan': entity('light.plan', 'on') },
};
assert.equal(classifyHassRenderChange(before, changed, deps), 'state');
const missing = { ...before, states: { 'sensor.other': before.states['sensor.other'] } };
assert.equal(classifyHassRenderChange(before, missing, deps), 'state');
});
test('structural HA changes fail open while same-object assignment is free', () => {
const before = base();
assert.equal(classifyHassRenderChange(before, before, deps), 'none');
assert.equal(classifyHassRenderChange(before, { ...before, themes: {} }, deps), 'structural');
assert.equal(classifyHassRenderChange(before, { ...before, entities: {} }, deps), 'structural');
assert.equal(classifyHassRenderChange(before, { ...before, locale: { ...before.locale, language: 'ru' } }, deps), 'structural');
assert.equal(classifyHassRenderChange(before, { ...before, future_capability: {} }, deps), 'structural');
});
test('missing dependency authority fails open', () => {
const before = base();
assert.equal(classifyHassRenderChange(before, { ...before, states: { ...before.states } }, null), 'structural');
assert.equal(classifyHassRenderChange(null, before, deps), 'structural');
});