mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
fix: keep large-plan interactions off the full render path
Issue: #451 User-Visible: yes
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 }}'));
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user