mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
v1.60.0: harden background editing and device state
This commit is contained in:
@@ -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
@@ -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) ----------
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user