mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
v1.59.0-beta.10: unify device visuals and wall refinements
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
combineVisualSamples,
|
||||
edgeActivity,
|
||||
entityVisualSample,
|
||||
} from '../test-build/device-visual.js';
|
||||
|
||||
const hass = (states) => ({
|
||||
states: Object.fromEntries(Object.entries(states).map(([eid, value]) => [eid, {
|
||||
state: value.state,
|
||||
attributes: value.attributes || {},
|
||||
}])),
|
||||
});
|
||||
|
||||
test('actual work is yellow/running, but an enabled automation is neutral', () => {
|
||||
const h = hass({
|
||||
'switch.relay': { state: 'on' },
|
||||
'climate.office': { state: 'heat', attributes: { hvac_action: 'idle' } },
|
||||
'climate.bedroom': { state: 'heat', attributes: { hvac_action: 'heating' } },
|
||||
'automation.night': { state: 'on' },
|
||||
'script.scene': { state: 'on' },
|
||||
});
|
||||
assert.deepEqual(
|
||||
entityVisualSample(h, 'switch.relay'),
|
||||
{ eid: 'switch.relay', state: 'on', availability: 'available', status: 'working', activity: 'running', edge: 'none' },
|
||||
);
|
||||
assert.equal(entityVisualSample(h, 'climate.office').status, 'neutral');
|
||||
assert.equal(entityVisualSample(h, 'climate.bedroom').status, 'working');
|
||||
assert.equal(entityVisualSample(h, 'automation.night').status, 'neutral');
|
||||
assert.equal(entityVisualSample(h, 'script.scene').status, 'working');
|
||||
});
|
||||
|
||||
test('open state, cover travel and presence are separate meanings', () => {
|
||||
const h = hass({
|
||||
'binary_sensor.window': { state: 'on', attributes: { device_class: 'window' } },
|
||||
'binary_sensor.room': { state: 'on', attributes: { device_class: 'occupancy' } },
|
||||
'cover.curtain': { state: 'opening' },
|
||||
'lock.front': { state: 'unlocked' },
|
||||
});
|
||||
assert.equal(entityVisualSample(h, 'binary_sensor.window').status, 'open');
|
||||
assert.equal(entityVisualSample(h, 'binary_sensor.room').activity, 'presence');
|
||||
assert.deepEqual(
|
||||
(({ status, activity }) => ({ status, activity }))(entityVisualSample(h, 'cover.curtain')),
|
||||
{ status: 'neutral', activity: 'transition' },
|
||||
);
|
||||
assert.equal(entityVisualSample(h, 'lock.front').status, 'open');
|
||||
});
|
||||
|
||||
test('alarm outranks work and unavailable suppresses unavailable-only markers', () => {
|
||||
const h = hass({
|
||||
'switch.pump': { state: 'on' },
|
||||
'binary_sensor.leak': { state: 'on', attributes: { device_class: 'moisture' } },
|
||||
'alarm_control_panel.home': { state: 'triggered' },
|
||||
'sensor.dead': { state: 'unavailable' },
|
||||
});
|
||||
const alarm = combineVisualSamples([
|
||||
entityVisualSample(h, 'switch.pump'),
|
||||
entityVisualSample(h, 'binary_sensor.leak'),
|
||||
]);
|
||||
assert.deepEqual(alarm, { availability: 'available', status: 'alarm', activity: 'none' });
|
||||
assert.equal(entityVisualSample(h, 'alarm_control_panel.home').status, 'alarm');
|
||||
assert.deepEqual(
|
||||
combineVisualSamples([entityVisualSample(h, 'sensor.dead')]),
|
||||
{ availability: 'unavailable', status: 'neutral', activity: 'none' },
|
||||
);
|
||||
});
|
||||
|
||||
test('edges suppress first load/recovery and recognise semantic events', () => {
|
||||
const motionOn = entityVisualSample(
|
||||
hass({ 'binary_sensor.motion': { state: 'on', attributes: { device_class: 'motion' } } }),
|
||||
'binary_sensor.motion',
|
||||
);
|
||||
assert.equal(edgeActivity(undefined, motionOn), null);
|
||||
assert.equal(edgeActivity('unavailable', motionOn), null);
|
||||
assert.equal(edgeActivity('off', motionOn), 'event');
|
||||
assert.equal(edgeActivity('on', motionOn), null);
|
||||
|
||||
const coverOpen = entityVisualSample(
|
||||
hass({ 'cover.curtain': { state: 'open' } }),
|
||||
'cover.curtain',
|
||||
);
|
||||
assert.equal(edgeActivity('closed', coverOpen), 'transition');
|
||||
assert.equal(edgeActivity('opening', coverOpen), null);
|
||||
|
||||
const button = entityVisualSample(
|
||||
hass({ 'event.doorbell': { state: '2026-08-05T18:00:00+00:00' } }),
|
||||
'event.doorbell',
|
||||
);
|
||||
assert.equal(edgeActivity('2026-08-05T17:59:00+00:00', button), 'event');
|
||||
});
|
||||
+52
-2
@@ -16,7 +16,8 @@ import {
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf,
|
||||
liveText, liveTextValue, hassValue, valueWithUnit, decorTextScale, decorTextLines,
|
||||
liveText, liveTextValue, liveTextReference, liveTextToken,
|
||||
hassValue, valueWithUnit, decorTextScale, decorTextLines,
|
||||
LIVE_TEXT_DASH, LIVE_TEXT_VALUE_MAX, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX } from '../test-build/logic.js';
|
||||
import {
|
||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||
@@ -663,10 +664,12 @@ test('lightColorOf: rgb of an on light; off/unavailable/no-color → null', () =
|
||||
assert.equal(lightColorOf(undefined), null);
|
||||
});
|
||||
|
||||
test('isAlarmState: leak/smoke/gas/siren fire; doors and outages do not', () => {
|
||||
test('isAlarmState: safety sensors, siren and triggered alarm panel fire; doors and outages do not', () => {
|
||||
assert.ok(isAlarmState('binary_sensor', 'moisture', 'on'));
|
||||
assert.ok(isAlarmState('binary_sensor', 'smoke', 'on'));
|
||||
assert.ok(isAlarmState('siren', undefined, 'on'));
|
||||
assert.ok(isAlarmState('alarm_control_panel', undefined, 'triggered'));
|
||||
assert.ok(!isAlarmState('alarm_control_panel', undefined, 'armed_away'));
|
||||
assert.ok(!isAlarmState('binary_sensor', 'door', 'on'));
|
||||
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'off'));
|
||||
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'unavailable'));
|
||||
@@ -779,6 +782,26 @@ test('doorSector: sector through a door, clamped and guarded', () => {
|
||||
assert.equal(doorSector(s, [0, 0], [10, 2], 50), null);
|
||||
});
|
||||
|
||||
test('doorSector: thick doorway is clipped by both tunnel jamb faces', () => {
|
||||
const src = [0, 3];
|
||||
const a = [10, -2];
|
||||
const b = [10, 2];
|
||||
const thin = doorSector(src, a, b, 50);
|
||||
const thick = doorSector(src, a, b, 50, 170, 4);
|
||||
assert.ok(thin && thick);
|
||||
assert.deepEqual(doorSector(src, a, b, 50, 170, 0), thin);
|
||||
|
||||
// The vertical wall occupies x=8..12. Every limiting ray must remain inside
|
||||
// the clear opening y=-2..2 at BOTH faces, not only at the centreline.
|
||||
const yAtX = (p, x) => src[1] + (p[1] - src[1]) * ((x - src[0]) / (p[0] - src[0]));
|
||||
for (const p of [thick[1], thick.at(-1)]) {
|
||||
for (const x of [8, 12]) assert.ok(Math.abs(yAtX(p, x)) <= 2 + 1e-9);
|
||||
}
|
||||
|
||||
const spreadAtFarFace = (sector) => Math.abs(yAtX(sector[1], 12) - yAtX(sector.at(-1), 12));
|
||||
assert.ok(spreadAtFarFace(thick) < spreadAtFarFace(thin));
|
||||
});
|
||||
|
||||
test('hasRoomBehind: neighbour room yes, street no', () => {
|
||||
const neighbour = [[10, -5], [20, -5], [20, 5], [10, 5]];
|
||||
// дверь в стене x=10 (стена вертикальна: угол 90°), источник слева в (5,0)
|
||||
@@ -1232,6 +1255,33 @@ test('liveText: no entity = the text is untouched, byte for byte', () => {
|
||||
assert.equal(liveText('', null, hassLive), '');
|
||||
});
|
||||
|
||||
test('liveText: inline variables resolve every state and attribute in the text', () => {
|
||||
assert.equal(
|
||||
liveText('Бак {sensor.tank}; климат {climate.hall:current_temperature}', null, hassLive),
|
||||
'Бак 68 %; климат 21.5',
|
||||
);
|
||||
assert.equal(
|
||||
liveText('{sensor.plain} / {sensor.tank} / {sensor.plain}', null, hassLive),
|
||||
'17.4 / 68 % / 17.4',
|
||||
);
|
||||
});
|
||||
|
||||
test('liveText: hand-written dotted attributes work; invalid braces stay literal', () => {
|
||||
assert.equal(liveText('{climate.hall.current_temperature}', null, hassLive), '21.5');
|
||||
assert.equal(liveText('Обычный {текст}', null, hassLive), 'Обычный {текст}');
|
||||
assert.equal(liveText('{sensor.nope}', null, hassLive), LIVE_TEXT_DASH);
|
||||
});
|
||||
|
||||
test('liveTextToken writes the canonical editor syntax', () => {
|
||||
assert.deepEqual(liveTextReference('climate.hall.current_temperature'), {
|
||||
entity: 'climate.hall', attr: 'current_temperature',
|
||||
});
|
||||
assert.equal(liveTextToken('sensor.tank'), '{sensor.tank}');
|
||||
assert.equal(liveTextToken('climate.hall', 'current_temperature'),
|
||||
'{climate.hall:current_temperature}');
|
||||
assert.equal(liveTextToken('not-an-entity'), '');
|
||||
});
|
||||
|
||||
test('liveText: the placeholder is where the value lands', () => {
|
||||
assert.equal(liveText('Бак {}', { entity: 'sensor.tank' }, hassLive), 'Бак 68 %');
|
||||
assert.equal(liveText('{} в баке', { entity: 'sensor.tank' }, hassLive), '68 % в баке');
|
||||
|
||||
@@ -141,6 +141,26 @@ describe('open-spans', () => {
|
||||
assert.ok(Math.max(sg[1], sg[3]) <= 4 + 1e-6, JSON.stringify(sg));
|
||||
});
|
||||
|
||||
it('adjacent virtual pieces of the same room pair collapse to one span', () => {
|
||||
const rooms = [
|
||||
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },
|
||||
{ id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] },
|
||||
];
|
||||
const spans = [
|
||||
spanToEntry([5, 0], [5, 2], 1),
|
||||
spanToEntry([5, 4], [5, 2], 1),
|
||||
];
|
||||
const clipped = clipOpenSpansToShared(spans, rooms, 1, eps);
|
||||
assert.equal(clipped.length, 1);
|
||||
const sg = entryToSeg(clipped[0], 1);
|
||||
assert.deepEqual(
|
||||
[Math.min(sg[1], sg[3]), Math.max(sg[1], sg[3])],
|
||||
[0, 4],
|
||||
);
|
||||
assert.equal(resolveOpenCuts(rooms, spans, 1, eps).length, 1,
|
||||
'legacy fragmented storage must render as one selectable span too');
|
||||
});
|
||||
|
||||
it('clipOpenSpansToShared keeps both room-pair pieces after Split', () => {
|
||||
const rooms = [
|
||||
{ id: 'left_top', poly: [[0, 0], [5, 0], [5, 2], [0, 2]] },
|
||||
|
||||
@@ -169,6 +169,28 @@ test('partial shared wall: a pre-atomic whole-edge key still covers both pieces'
|
||||
assert.ok(ivs.every((iv) => iv.cm === 30), 'an existing plan must not lose thickness');
|
||||
});
|
||||
|
||||
test('equal solid atomic pieces compact back to one whole-wall key', () => {
|
||||
const rooms = partialRooms();
|
||||
const walls = [
|
||||
{ key: wallKey([5, 0], [5, 4], pitch), cm: 30 },
|
||||
{ key: wallKey([5, 4], [5, 10], pitch), cm: 30 },
|
||||
];
|
||||
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
|
||||
assert.deepEqual(next, [{ key: wallKey([5, 0], [5, 10], pitch), cm: 30 }]);
|
||||
});
|
||||
|
||||
test('different solid thicknesses remain separate atomic keys', () => {
|
||||
const rooms = partialRooms();
|
||||
const walls = [
|
||||
{ key: wallKey([5, 0], [5, 4], pitch), cm: 30 },
|
||||
{ key: wallKey([5, 4], [5, 10], pitch), cm: 20 },
|
||||
];
|
||||
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
|
||||
assert.equal(next.length, 2);
|
||||
assert.deepEqual(new Set(next.map((w) => w.cm)), new Set([20, 30]));
|
||||
assert.ok(!next.some((w) => w.key === wallKey([5, 0], [5, 10], pitch)));
|
||||
});
|
||||
|
||||
// An open span that does NOT contain the parent edge's midpoint used to leave
|
||||
// the key in place, so the wall body stayed solid straight across the passage.
|
||||
test('open span away from the edge midpoint clears only its own interval', () => {
|
||||
|
||||
Reference in New Issue
Block a user