mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
feat: make Glow follow door opening state (#20)
Issue: #20 User-Visible: yes
This commit is contained in:
@@ -354,7 +354,11 @@ test('doorway spill golden exposes the opaque-fill failure mode from issue 71',
|
||||
assert.equal(scenario.glowEnabled, true);
|
||||
assert.equal(scenario.sunRays, false);
|
||||
assert.equal(scenario.allLightsOff, true);
|
||||
assert.equal(scenario.extraOpenings?.filter((opening) => opening.type === 'door').length, 1);
|
||||
assert.equal(scenario.extraOpenings?.filter((opening) => opening.type === 'door').length, 2);
|
||||
assert.equal(scenario.extraOpenings?.find((opening) => opening.id === 'light-door-second')?.contact,
|
||||
'cover.golden_door_closed');
|
||||
assert.equal(scenario.extraOpenings?.find((opening) => opening.id === 'light-door-third')?.contact,
|
||||
'cover.golden_door_half');
|
||||
assert.equal(scenario.stateOverrides?.['light.golden_light_one']?.state, 'on');
|
||||
assert.deepEqual(scenario.layoutOverrides?.['golden-light-one'], {
|
||||
s: 'golden-lighting', x: 0.40, y: 0.48,
|
||||
@@ -710,7 +714,9 @@ test('golden harness applies doorway, state and layout overrides to a cloned fix
|
||||
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'lighting-opaque-glow-two-doorways-dark');
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(space.openings.filter((opening) => opening.type === 'door').length, 2);
|
||||
assert.equal(space.openings.filter((opening) => opening.type === 'door').length, 3);
|
||||
assert.equal(fixture.states['cover.golden_door_closed'].attributes.current_position, 0);
|
||||
assert.equal(fixture.states['cover.golden_door_half'].attributes.current_position, 50);
|
||||
assert.equal(fixture.states['light.golden_light_one'].state, 'on');
|
||||
assert.deepEqual(fixture.layout['golden-light-one'], {
|
||||
s: 'golden-lighting', x: 0.40, y: 0.48,
|
||||
@@ -765,10 +771,10 @@ test('a light source paints exactly one region: the floor it can see', () => {
|
||||
// Barriers are keyed by their own content: `_cfgEpoch` lags behind geometry
|
||||
// edited in place, and a stale barrier set lights straight through a wall.
|
||||
assert.doesNotMatch(glow, /_cfgEpoch/);
|
||||
assert.match(source, /const fingerprint = contentFingerprint\(\[/);
|
||||
assert.match(source, /contentFingerprint\(\[raw, this\._cellCm, this\._gridPitch\]\)/);
|
||||
assert.match(source, /const geometryFingerprint = contentFingerprint\(\[raw, this\._cellCm, this\._gridPitch\]\)/);
|
||||
assert.match(source, /const fingerprint = contentFingerprint\(\[geometryFingerprint, openingStateSignature\]\)/);
|
||||
assert.match(source, /const cacheKey = `\$\{space\.id\}\|\$\{fingerprint\}`/);
|
||||
assert.match(source, /sharedFingerprint === fingerprint/,
|
||||
assert.match(source, /sharedFingerprint === geometryFingerprint/,
|
||||
'an in-place edit must never recut stale shared masonry');
|
||||
});
|
||||
|
||||
|
||||
+43
-1
@@ -16,7 +16,11 @@ import {
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, openingEntityReferences, fillColorsOf, lerpColor, roomFillStyle, resolveEffectiveRoomFill, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, coverMoving,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall,
|
||||
openingAmount, openingLightApertureLength, openingLightStateSignature,
|
||||
openingShoulders, openingEntityReferences, fillColorsOf, lerpColor, roomFillStyle,
|
||||
resolveEffectiveRoomFill, stateIcon, lightColorOf, isAlarmState, parseRoomRef,
|
||||
diffNewDevices, poleOfInaccessibility, runServiceFor, coverMoving,
|
||||
normalizeDeviceDisplay, isAlarmCapable,
|
||||
liveText, liveTextValue, liveTextReference, liveTextToken,
|
||||
hassValue, valueWithUnit, decorTextScale, decorTextLines,
|
||||
@@ -492,6 +496,44 @@ test('openingAmount: door-like openings default open, windows closed; outages fr
|
||||
}
|
||||
});
|
||||
|
||||
test('openingAmount shares positioned cover progress with light apertures', () => {
|
||||
assert.equal(openingAmount('gate', 'open', false, 0), 0);
|
||||
assert.equal(openingAmount('gate', 'open', false, 1), 0.01);
|
||||
assert.equal(openingAmount('gate', 'open', false, 50), 0.5);
|
||||
assert.equal(openingAmount('gate', 'open', false, 100), 1);
|
||||
assert.equal(openingAmount('gate', 'open', false, -20), 0);
|
||||
assert.equal(openingAmount('gate', 'open', false, 140), 1);
|
||||
assert.equal(openingAmount('gate', 'open', false, '40'), 0.4);
|
||||
assert.equal(openingAmount('gate', 'open', false, ''), 1, 'empty position falls back to state');
|
||||
assert.equal(openingAmount('gate', 'off', false, 'bad'), 0, 'invalid position falls back to state');
|
||||
assert.equal(openingAmount('gate', 'open', true, 25), 0.75);
|
||||
assert.equal(openingAmount('gate', 'unavailable', true, 25), 1,
|
||||
'outage fallback ignores stale position and invert');
|
||||
assert.equal(openingAmount('passage', 'off', true, 0), 1);
|
||||
|
||||
assert.equal(openingLightApertureLength(80, 0.5), 40);
|
||||
assert.equal(openingLightApertureLength(80, -1), 0);
|
||||
assert.equal(openingLightApertureLength(80, 2), 80);
|
||||
assert.equal(openingLightApertureLength(NaN, 0.5), 0);
|
||||
});
|
||||
|
||||
test('opening light state signature is ordered, bounded and contact-only', () => {
|
||||
const a = [
|
||||
{ id: 'gate-b', type: 'gate', contact: 'cover.gate', amount: 0.5004 },
|
||||
{ id: 'door-a', type: 'door', contact: 'binary_sensor.door', amount: 1 },
|
||||
{ id: 'passage', type: 'passage', contact: null, amount: 1 },
|
||||
{ id: 'window', type: 'window', contact: 'binary_sensor.window', amount: 0 },
|
||||
{ id: 'unbound', type: 'door', contact: '', amount: 0 },
|
||||
];
|
||||
const expected = 'door-a:1.000|gate-b:0.500';
|
||||
assert.equal(openingLightStateSignature(a), expected);
|
||||
assert.equal(openingLightStateSignature([...a].reverse()), expected);
|
||||
assert.equal(openingLightStateSignature([
|
||||
{ id: 'low', type: 'door', contact: 'binary_sensor.low', amount: -2 },
|
||||
{ id: 'high', type: 'gate', contact: 'cover.high', amount: 2 },
|
||||
]), 'high:1.000|low:0.000');
|
||||
});
|
||||
|
||||
test('stale passage bindings stay inert while ordinary opening bindings remain live', () => {
|
||||
const stale = { type: 'passage', contact: 'binary_sensor.door', lock: 'lock.front' };
|
||||
assert.deepEqual(openingEntityReferences(stale), []);
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
BOOLEAN_COORD_QUANTUM, canonicalColumnAngle, columnBody, floorMinusBodies, geometryArea,
|
||||
directionalOccluders, intersectionPaths, partitionBody, pointInPhysicalBody,
|
||||
physicalBodyParts, physicalBodySet, pointInOpaquePlanBody, pointInPhysicalGeometry,
|
||||
normalizeBooleanBody, sameColumnPlacement, unionBodies,
|
||||
normalizeBooleanBody, sameColumnPlacement, scalePartitionOpeningCut, unionBodies,
|
||||
} from '../test-build/physical-geometry.js';
|
||||
import {
|
||||
polygonSegments, splitAtIntersections, visibilityPolygon,
|
||||
@@ -36,6 +36,37 @@ test('partition body keeps the centreline and requested physical width', () => {
|
||||
closeTo(geometryArea([[[...body, body[0]]]]), 0.5);
|
||||
});
|
||||
|
||||
test('partition light aperture scales around the same centre and keeps host depth', () => {
|
||||
const full = { hostId: 'wall', a: [2, 4], b: [10, 8], depth: 3 };
|
||||
assert.deepEqual(scalePartitionOpeningCut(full, 0.5), {
|
||||
hostId: 'wall', a: [4, 5], b: [8, 7], depth: 3,
|
||||
});
|
||||
assert.deepEqual(scalePartitionOpeningCut(full, 0), {
|
||||
hostId: 'wall', a: [6, 6], b: [6, 6], depth: 3,
|
||||
});
|
||||
assert.deepEqual(scalePartitionOpeningCut(full, 2), full);
|
||||
assert.deepEqual(full, { hostId: 'wall', a: [2, 4], b: [10, 8], depth: 3 },
|
||||
'scaling does not mutate canonical geometry');
|
||||
});
|
||||
|
||||
test('partition body uses the same full, partial and closed light aperture', () => {
|
||||
const space = {
|
||||
room_drafts: [], wall_columns: [],
|
||||
partitions: [{ id: 'host', a: [0, 0], b: [10, 0], cm: 10 }],
|
||||
};
|
||||
const full = { hostId: 'host', a: [4, 0], b: [6, 0], depth: 2 };
|
||||
const half = scalePartitionOpeningCut(full, 0.5);
|
||||
const fullBodies = physicalBodyParts(space, 5, 1, 1e-6, [full]).all;
|
||||
const halfBodies = physicalBodyParts(space, 5, 1, 1e-6, [half]).all;
|
||||
const closedBodies = physicalBodyParts(space, 5, 1, 1e-6, []).all;
|
||||
|
||||
assert.equal(pointInOpaquePlanBody([5, 0], [], fullBodies), false, 'full slot centre');
|
||||
assert.equal(pointInOpaquePlanBody([5.75, 0], [], fullBodies), false, 'full slot shoulder');
|
||||
assert.equal(pointInOpaquePlanBody([5, 0], [], halfBodies), false, 'half slot centre');
|
||||
assert.equal(pointInOpaquePlanBody([5.75, 0], [], halfBodies), true, 'half slot shoulder');
|
||||
assert.equal(pointInOpaquePlanBody([5, 0], [], closedBodies), true, 'closed slot is masonry');
|
||||
});
|
||||
|
||||
test('joined partitions fill straight and oblique endpoint teeth without changing flat free caps', () => {
|
||||
const base = {
|
||||
room_drafts: [], wall_columns: [],
|
||||
|
||||
Reference in New Issue
Block a user