v1.60.0: harden background editing and device state

This commit is contained in:
Matysh
2026-08-07 13:02:46 +03:00
parent 29fb9deb43
commit 3028122016
47 changed files with 2566 additions and 674 deletions
+72 -1
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxCorners, decorStrokeUnits, decorStyleOf, resizeDecorBox, snapDecorPoint,
boxCorners, decorStrokeUnits, decorStyleOf, decorStylePatch, normalizeAngle,
resizeDecorBox, resizedBoxTopLeft, snapDecorPoint, validDecorDraft,
} from '../test-build/editors/decor/geometry.js';
const close = (actual, expected, epsilon = 1e-9) =>
@@ -17,6 +18,37 @@ test('canonical physical stroke and legacy render stroke remain visually exact',
close(legacy.fillOpacity, 0.25);
});
test('decor style fallbacks and canonical patch stay inside the persisted schema', () => {
const fallback = {
color: '#112233', opacity: 0.8, widthCm: 4,
fill: false, fillColor: '#445566', fillOpacity: 0.3,
};
const style = decorStyleOf({ id: 'r', kind: 'rect', x: 0, y: 0, w: 1, h: 1,
color: 'invalid', opacity: 8, fill: true, fill_color: 'invalid' }, 5, 12, fallback);
assert.equal(style.color, fallback.color);
assert.equal(style.opacity, 1);
assert.equal(style.fillColor, fallback.color);
assert.equal(style.fillOpacity, 0.25);
const high = decorStylePatch({ ...fallback, widthCm: 1e6, opacity: -2,
fill: true, fillOpacity: 4 }, true);
assert.equal(high.width_cm, 100);
assert.equal(high.opacity, 0);
assert.equal(high.fill_opacity, 1);
const lowLine = decorStylePatch({ ...fallback, widthCm: -1 }, false);
assert.equal(lowLine.width_cm, 0.1);
assert.equal('fill' in lowLine, false);
});
test('angle normalization is stable at wrap boundaries', () => {
assert.equal(normalizeAngle(0), 0);
assert.equal(normalizeAngle(180), 180);
assert.equal(normalizeAngle(181), -179);
assert.equal(normalizeAngle(-181), 179);
assert.equal(normalizeAngle(360), 0);
assert.equal(normalizeAngle(Number.POSITIVE_INFINITY), 0);
});
test('oriented resize preserves ratio by default and Shift-style mode separates axes', () => {
const original = { x: 10, y: 20, w: 40, h: 20, angle: 30 };
const proportional = resizeDecorBox(original, 1, 1, 100, 100, true, 5, 5);
@@ -38,9 +70,48 @@ test('decor magnet may choose a point or edge but always returns a grid point',
const edge = snapDecorPoint([63, 47], geometry, 8, grid);
assert.equal(edge.kind, 'edge');
assert.deepEqual(edge.point, [60, 50]);
const empty = snapDecorPoint([14, 26], { points: [], segments: [] }, 8, grid);
assert.deepEqual(empty, { point: [10, 30], target: null, kind: 'grid' });
const tie = snapDecorPoint([20, 20], {
points: [[20, 30]], segments: [{ a: [10, 10], b: [30, 10] }],
}, 10, grid);
assert.equal(tie.kind, 'point', 'equal-distance point wins deterministically');
});
test('rotated box exposes four rotated content corners around the same centre', () => {
const corners = boxCorners({ x: 0, y: 0, w: 20, h: 10, angle: 90 });
assert.deepEqual(corners.map((p) => p.map((v) => Math.round(v))), [[15, -5], [15, 15], [5, 15], [5, -5]]);
});
test('oriented resize caller preserves the derived top-left instead of translating the fixed corner', () => {
const original = { x: 10, y: 20, w: 40, h: 20, angle: 30 };
const resized = resizeDecorBox(original, 1, 1, 97, 83, true, 5, 5);
const fixedBefore = boxCorners(original)[0];
const fixedAfter = boxCorners(resized)[0];
close(fixedAfter[0], fixedBefore[0]);
close(fixedAfter[1], fixedBefore[1]);
const grid = ([x, y]) => [Math.round(x / 5) * 5, Math.round(y / 5) * 5];
assert.deepEqual(resizedBoxTopLeft(resized, original.angle, grid), [resized.x, resized.y]);
assert.deepEqual(resizedBoxTopLeft({ x: 12.2, y: 18.1 }, 0, grid), [10, 20]);
});
test('resize cannot flip across the fixed corner and honours the common grid scale', () => {
const original = { x: 0, y: 0, w: 40, h: 20, angle: 0 };
const common = resizeDecorBox(original, 1, 1, 51, 31, true, 10, 10);
assert.deepEqual([common.w, common.h], [60, 30]);
assert.equal(common.w % 10, 0);
assert.equal(common.h % 10, 0);
const crossed = resizeDecorBox(original, 1, 1, -100, -100, false, 10, 10);
assert.deepEqual([crossed.w, crossed.h], [10, 10]);
assert.deepEqual(boxCorners(crossed)[0], boxCorners(original)[0],
'opposite corner stays fixed at the minimum-size floor');
});
test('flat rectangle and ellipse drafts are rejected while a straight line is valid', () => {
assert.equal(validDecorDraft('line', [0, 0], [20, 0], 5), true);
assert.equal(validDecorDraft('rect', [0, 0], [20, 0], 5), false);
assert.equal(validDecorDraft('ellipse', [0, 0], [0, 20], 5), false);
assert.equal(validDecorDraft('rect', [0, 0], [20, 10], 5), true);
});
+117 -7
View File
@@ -5,6 +5,7 @@ import {
areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate, areaClimateMap,
litLightEntity, resolvedDeviceStateEntities, resolvedLightSources, resolvedLightState,
resolvedLightStats, seedHiddenBindings,
deletePlanMarkerRecords, effectiveMarkerControls,
} from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
@@ -22,6 +23,30 @@ const baseCtx = (hass, over = {}) => ({
const dev = (id, name, model, area, extra = {}) =>
({ id, name, model, area_id: area, identifiers: [['demo', id]], entry_type: null, via_device_id: null, ...extra });
test('deletePlanMarkerRecords removes only the selected virtual marker', () => {
const markers = [
{ id: 'v_a', binding: 'virtual', name: 'A' },
{ id: 'v_b', binding: 'virtual', name: 'B', pdfs: [{ name: 'manual', url: '/files/v_b/manual.pdf' }] },
{ id: 'plug', binding: 'device:plug' },
];
const result = deletePlanMarkerRecords(markers, 'v_a', 'virtual', true);
assert.deepEqual(result.markers, [markers[1], markers[2]]);
assert.deepEqual([...result.cleanupIds], ['v_a']);
});
test('deletePlanMarkerRecords deduplicates HA binding and leaves old-card-safe tombstone', () => {
const result = deletePlanMarkerRecords([
{ id: 'old', binding: 'entity:sensor.x' },
{ id: 'new', binding: 'entity:sensor.x' },
{ id: 'other', binding: 'entity:sensor.y' },
], 'new', 'entity:sensor.x', false);
assert.deepEqual(result.markers, [
{ id: 'other', binding: 'entity:sensor.y' },
{ id: 'new', binding: 'entity:sensor.x', removed: true, hidden: true },
]);
assert.deepEqual(new Set(result.cleanupIds), new Set(['old', 'new']));
});
test('buildDevices: devices outside bound areas are dropped', () => {
const h = mkHass({ devices: {
a: dev('a', 'Lamp', 'Bulb', 'living'),
@@ -106,7 +131,7 @@ test('buildDevices: deleted binding is claimed but never built', () => {
);
});
test('buildDevices: deleted entities cannot remain as controls of another marker', () => {
test('buildDevices: deleted controls are inactive without being destructively persisted', () => {
const h = mkHass({
entities: {
'light.keep': { entity_id: 'light.keep' },
@@ -122,10 +147,31 @@ test('buildDevices: deleted entities cannot remain as controls of another marker
{ id: 'gone', binding: 'entity:light.gone', removed: true },
];
const item = buildDevices(baseCtx(h, { markers })).find((d) => d.id === 'v1');
assert.deepEqual(item.marker.controls, ['light.keep']);
assert.deepEqual(item.marker.controls, ['light.keep', 'light.gone']);
assert.deepEqual(item.controls, ['light.keep']);
assert.deepEqual(resolvedLightSources(h, [item]).map((source) => source.eid), ['light.keep']);
});
test('entity tombstone does not strip that entity from a live parent device', () => {
const h = mkHass({
devices: { sensorbox: dev('sensorbox', 'Climate box', 'Sensor', 'living') },
entities: {
'sensor.box_temp': {
entity_id: 'sensor.box_temp', device_id: 'sensorbox',
original_device_class: 'temperature',
},
},
states: {
'sensor.box_temp': { state: '23.5', attributes: { device_class: 'temperature' } },
},
});
const markers = [{ id: 'gone_entity', binding: 'entity:sensor.box_temp', removed: true }];
const parent = buildDevices(baseCtx(h, { markers })).find((d) => d.id === 'sensorbox');
assert.ok(parent);
assert.deepEqual(parent.entities, ['sensor.box_temp']);
assert.equal(parent.primary, 'sensor.box_temp');
});
test('buildDevices: virtual marker lands in its room; entity marker resolves name from state', () => {
const h = mkHass({
entities: { 'sensor.avg': { entity_id: 'sensor.avg', platform: 'min_max', area_id: 'kitchen' } },
@@ -270,6 +316,36 @@ test('primaryEntity: a real cover beats its option switch, but a mixed lamp stay
);
});
test('media role shields a marker from auxiliary light and switch entities', () => {
const hass = mkHass({
entities: {
'light.soundbar_display': { entity_id: 'light.soundbar_display' },
'switch.soundbar_night_mode': { entity_id: 'switch.soundbar_night_mode' },
'media_player.soundbar': { entity_id: 'media_player.soundbar' },
},
states: {
'light.soundbar_display': { state: 'on', attributes: {} },
'switch.soundbar_night_mode': { state: 'on', attributes: {} },
'media_player.soundbar': { state: 'playing', attributes: {} },
},
});
const entities = [
'light.soundbar_display', 'switch.soundbar_night_mode', 'media_player.soundbar',
];
assert.deepEqual(resolvedDeviceStateEntities(hass, entities), ['media_player.soundbar']);
assert.equal(primaryEntity(hass, entities, 'mdi:soundbar'), 'media_player.soundbar');
const soundbar = {
id: 'soundbar', area: 'living', primary: 'media_player.soundbar', entities,
};
assert.deepEqual(resolvedLightSources(hass, [soundbar]), []);
assert.deepEqual(
resolvedLightSources(hass, [{ ...soundbar, marker: { is_light: true } }])
.map((source) => source.eid),
['light.soundbar_display'],
);
});
test('resolvedDeviceStateEntities: passive readings aggregate availability instead of picking the first', () => {
const hass = mkHass({
entities: {
@@ -509,12 +585,12 @@ test('sourceValue: explicit entity and device sources (tier 3)', () => {
);
});
test('sourceValue: deleted members are excluded from a surviving device source', () => {
test('sourceValue: entity tombstone does not remove data from a surviving device source', () => {
const hass = {
devices: { dev1: { id: 'dev1' } },
entities: {
'sensor.keep': { device_id: 'dev1' },
'sensor.gone': { device_id: 'dev1' },
'sensor.keep': { device_id: 'dev1' },
},
states: {
'sensor.keep': { state: '20', attributes: { device_class: 'temperature' } },
@@ -522,7 +598,9 @@ test('sourceValue: deleted members are excluded from a surviving device source',
},
};
const markers = [{ id: 'gone', binding: 'entity:sensor.gone', removed: true }];
assert.equal(sourceValue(hass, 'device:dev1', 'temp', markers), 20);
// Device sources intentionally use the first valid reading. Keeping the
// tombstoned entity inside its live parent therefore returns 30, not 20.
assert.equal(sourceValue(hass, 'device:dev1', 'temp', markers), 30);
});
test('areaClimate: counts sensors that are NOT on the plan (field report)', () => {
@@ -749,7 +827,7 @@ test('litLightEntity: one truth for "this thing is shining"', () => {
);
});
test('areaClimateMap: deleted entity and whole device contribute no room data', () => {
test('areaClimateMap: whole device tombstone wins; entity tombstone stays binding-scoped', () => {
const hass = {
devices: {
a: { id: 'a', name: 'Room thermometer A', area_id: 'living' },
@@ -768,7 +846,7 @@ test('areaClimateMap: deleted entity and whole device contribute no room data',
{ id: 'a', binding: 'device:a', removed: true },
{ id: 'b-temp', binding: 'entity:sensor.b_temperature', removed: true },
];
assert.equal(areaClimateMap(hass, undefined, markers).get('living'), undefined);
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('living'), { temp: 30, hum: null });
});
test('resolvedLightSources: one source set feeds room fill, card, glow and controls', () => {
@@ -819,6 +897,38 @@ test('resolvedLightSources: marker room_id is more precise than a shared HA area
);
});
test('standalone entity cannot list itself as an external light control', () => {
assert.deepEqual(
effectiveMarkerControls('entity:switch.hood', [
'switch.hood', 'light.mirror', 'switch.hood', 'lock.front_door',
]),
['light.mirror'],
);
assert.deepEqual(
effectiveMarkerControls(
'device:hood', ['switch.hood', 'switch.mirror'], ['switch.hood'],
),
['switch.mirror'],
'a device marker also separates its own entities from external targets',
);
const hass = { states: { 'switch.hood': { state: 'on' } } };
const hood = {
id: 'hood', area: 'bathroom', primary: 'switch.hood', entities: ['switch.hood'],
marker: { binding: 'entity:switch.hood', controls: ['switch.hood'] },
};
assert.deepEqual(resolvedLightSources(hass, [hood]), []);
assert.deepEqual(resolvedLightSources(hass, [{
...hood, marker: { binding: 'device:hood', controls: ['switch.hood'] },
}]), [], 'the same self-control is ignored for a bound HA device');
assert.deepEqual(
resolvedLightSources(hass, [{ ...hood, marker: { ...hood.marker, is_light: true } }])
.map(({ eid, via }) => ({ eid, via })),
[{ eid: 'switch.hood', via: 'forced' }],
'the explicit source flag remains the supported way to make this switch a light',
);
});
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
+85
View File
@@ -63,3 +63,88 @@ test('optimizePlans migrates, aligns and canonicalises idempotently', () => {
assert.deepEqual(second.config, first.config);
assert.deepEqual(second.layout, first.layout);
});
test('legacy physical decor fields are clamped to the persisted schema', () => {
const result = optimizePlans({
spaces: [{
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1], cell_cm: 1000,
rooms: [],
decor: [
{ id: 'wide', kind: 'line', x1: 0, y1: 0, x2: 1, y2: 0, width: 300 },
{ id: 'thin', kind: 'line', x1: 0, y1: 0, x2: 1, y2: 0, width: 0 },
{ id: 'huge', kind: 'text', x: 0, y: 0, text: 'X', scale: 20 },
],
}],
markers: [], settings: {},
}, {});
const [wide, thin, huge] = result.config.spaces[0].decor;
assert.equal(wide.width_cm, 100);
assert.equal(thin.width_cm, 0.1);
assert.equal(huge.size_cm, 2000);
});
test('invalid legacy plan_scale is preserved instead of being counted as migrated', () => {
const result = optimizePlans({
model_version: PLAN_MODEL_VERSION,
spaces: [{
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1],
plan_scale: 'broken', rooms: [],
}],
markers: [], settings: {},
}, {});
assert.equal(result.config.spaces[0].plan_scale, 'broken');
assert.equal(result.report.migrated, 0);
});
test('model version bookkeeping does not claim a data migration', () => {
const result = optimizePlans({
model_version: PLAN_MODEL_VERSION - 1,
spaces: [], markers: [], settings: {},
}, {});
assert.equal(result.changed, false);
assert.equal(result.config.model_version, PLAN_MODEL_VERSION - 1);
assert.equal(result.report.modelTo, PLAN_MODEL_VERSION - 1);
assert.equal(result.report.migrated, 0);
});
test('optimizer never downgrades a model from a newer client', () => {
const future = PLAN_MODEL_VERSION + 1;
const result = optimizePlans({
model_version: future,
spaces: [], markers: [], settings: {},
}, {});
assert.equal(result.changed, false);
assert.equal(result.config.model_version, future);
assert.equal(result.report.modelTo, future);
});
test('legacy filled shapes receive the canonical fill fields', () => {
const result = optimizePlans({
model_version: PLAN_MODEL_VERSION,
spaces: [{
id: 'f1', title: 'Floor', view_box: [0, 0, 1, 1], rooms: [],
decor: [{
id: 'r1', kind: 'rect', x: 0, y: 0, w: 0.2, h: 0.2,
color: '#123456', fill: true,
}],
}],
markers: [], settings: {},
}, {});
assert.equal(result.config.spaces[0].decor[0].fill_color, '#123456');
assert.equal(result.config.spaces[0].decor[0].fill_opacity, 0.25);
});
test('optimizer removes legacy self-controls but preserves external targets', () => {
const result = optimizePlans({
model_version: PLAN_MODEL_VERSION - 1,
spaces: [],
markers: [{
id: 'hood', binding: 'entity:switch.hood',
controls: ['switch.hood', 'light.mirror'],
}],
settings: {},
}, {});
assert.deepEqual(result.config.markers[0].controls, ['light.mirror']);
assert.equal(result.config.model_version, PLAN_MODEL_VERSION);
assert.equal(result.report.migrated, 1);
});