Files
houseplan-card/test/sun.test.mjs
T
Matysh 02ae7f9588 DEV-EB173-01: a shaft of light fades along the wall's normal
Audit finding P2. At a grazing sun the wedge lost the two invariants it
was supposed to keep: one end of the GLASS started at opacity 0, and the
two sides of one shaft came out 5.41 and 84.19 long — the long one 31 %
LONGER than the pre-cut 64, not 30 % shorter.

The cause was the axis. The gradient ran along `dir` from the middle of
the window span, so the geometry had to be skewed (each end extruded by
a different amount) to make both far corners land on the same offset.
That buys the iso-alpha far edge with the other two requirements.

The light is a bundle of PARALLEL rays: the distance a point has
travelled from the glass is depth/cos, an affine function of the point,
whose level sets are lines PARALLEL TO THE WALL. So the correct linear
gradient runs along the wall's INWARD NORMAL, starts on the window line
and is `len·cos(incidence)` long — SunRay.normal / SunRay.depth. A point
`source + dir·u` then lands on offset u/len, whichever ray it rode in
on. All three invariants hold at once:

* the whole pane of glass is at depth 0 → peak alpha end to end;
* alpha depends only on how far that point's own ray has run;
* rayQuad() is an honest parallelogram again (both ends extruded by the
  same `len`), and its far edge — parallel to the wall — IS the
  gradient's last iso-alpha line, so a bright kerb is impossible by
  construction and the −30 % holds for every side of every wedge.

windowLit() gets a real threshold instead of the 1e-9 epsilon:
RAY_MIN_COS = 0.05, i.e. the sun must clear the plane of the wall by
~2.9°. Below it glass reflects nearly everything and the shaft would be
a sliver thinner than the wall it came through — nothing is drawn, and
the gradient axis can never degenerate to a point.

Tests: rayQuad now asserts equal, full-length sides and a wall-parallel
far edge; new unit tests replay the auditor's repro with his numbers
(both sides 44.8, offsets 0 at both ends of the glass, offset = travel /
len for arbitrary rays) and the RAY_MIN_COS cut-off. smoke_sun_soft
measures the same facts off the DOM gradient end to end and fails by
name on the old bundle (9 named failures). docs/SUN.md carries the new
contract and the finding.
2026-08-04 11:59:11 +03:00

417 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
norm360, planSunAngle, sunDirOnPlan, dayPhase,
isExteriorWall, windowWallInfo, windowLit,
rayLength, rayQuad, clipToRoom, computeSunRays,
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
RAY_LENGTH_K, RAY_FADE_END, rayStops, RAY_MIN_COS,
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
} from '../test-build/sun.js';
const near = (a, b, eps = 1e-9) => Math.abs(a - b) < eps;
// ---- the test house: two rooms sharing the x=500 wall, windows on all four
// outer walls plus one on the shared (interior) wall --------------------
const ROOMS = [
{ id: 'r1', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] },
{ id: 'r2', poly: [[500, 100], [800, 100], [800, 500], [500, 500]] },
];
const WIN = {
north: { id: 'wN', x: 300, y: 100, angle: 0, length: 60 },
south: { id: 'wS', x: 300, y: 500, angle: 0, length: 60 },
west: { id: 'wW', x: 100, y: 300, angle: 90, length: 60 },
east: { id: 'wE', x: 800, y: 300, angle: 90, length: 60 },
inner: { id: 'wI', x: 500, y: 300, angle: 90, length: 60 },
};
const ALL = Object.values(WIN);
test('planSunAngle: plain subtraction, wraps around the circle (359→0)', () => {
assert.equal(planSunAngle(180, 0), 180);
assert.equal(planSunAngle(0, 1), 359);
assert.equal(planSunAngle(359, 359), 0);
assert.equal(planSunAngle(10, 350), 20);
assert.equal(norm360(-90), 270);
assert.equal(norm360(720), 0);
});
test('sunDirOnPlan: compass points map to canvas vectors (y grows down)', () => {
const cases = [
[0, [0, -1]], // north = canvas up
[90, [1, 0]], // east = right
[180, [0, 1]], // south = down
[270, [-1, 0]], // west = left
];
for (const [az, [x, y]] of cases) {
const d = sunDirOnPlan(az, 0);
assert.ok(near(d[0], x, 1e-12) && near(d[1], y, 1e-12), `az ${az}`);
}
// rotating the compass rotates the whole sky: east sun, north_deg=90 → up
const d = sunDirOnPlan(90, 90);
assert.ok(near(d[0], 0, 1e-12) && near(d[1], -1, 1e-12));
});
test('dayPhase: night is dark and dim, noon is white, sunrise is warm', () => {
const night = dayPhase(-20);
const dawn = dayPhase(2);
const noon = dayPhase(60);
assert.equal(night.bg, '#070c14');
assert.equal(noon.bg, '#ffffff');
assert.notEqual(dawn.bg, night.bg);
assert.notEqual(dawn.bg, noon.bg);
assert.ok(near(night.planDim, 0.1));
assert.equal(noon.planDim, 0);
assert.ok(dawn.planDim > 0 && dawn.planDim < 0.1);
assert.equal(night.warmth, 1);
assert.equal(noon.warmth, 0);
assert.ok(near(dawn.warmth, 0.8));
// garbage elevation never throws and stays inside the palette
assert.ok(dayPhase(NaN).bg.startsWith('#'));
});
test('windowWallInfo: exterior windows on all four sides get outward normals', () => {
const n = windowWallInfo(WIN.north, ROOMS);
const s = windowWallInfo(WIN.south, ROOMS);
const w = windowWallInfo(WIN.west, ROOMS);
const e = windowWallInfo(WIN.east, ROOMS);
assert.deepEqual(n.roomId, 'r1');
assert.ok(near(n.normal[0], 0, 1e-12) && near(n.normal[1], -1, 1e-12));
assert.ok(near(s.normal[0], 0, 1e-12) && near(s.normal[1], 1, 1e-12));
assert.ok(near(w.normal[0], -1, 1e-12) && near(w.normal[1], 0, 1e-12));
assert.equal(w.roomId, 'r1');
assert.ok(near(e.normal[0], 1, 1e-12) && near(e.normal[1], 0, 1e-12));
assert.equal(e.roomId, 'r2');
});
test('windowWallInfo: interior and orphan windows never participate', () => {
assert.equal(windowWallInfo(WIN.inner, ROOMS), null); // shared wall
assert.equal(windowWallInfo({ x: 300, y: 300, angle: 0 }, ROOMS), null); // mid-room
assert.equal(windowWallInfo({ x: 950, y: 950, angle: 0 }, ROOMS), null); // nowhere
});
test('isExteriorWall probes the outer side', () => {
assert.ok(isExteriorWall([300, 100], [0, -1], ROOMS));
assert.ok(!isExteriorWall([500, 300], [1, 0], ROOMS)); // r2 is outside r1 here
});
test('windowLit: above the horizon, facing the sun, and NOT along the wall', () => {
const east = [1, 0];
assert.ok(windowLit(east, sunDirOnPlan(90, 0), 10));
assert.ok(!windowLit(east, sunDirOnPlan(270, 0), 10)); // sun behind the house
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), 0)); // sunset moment
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), -5)); // night
// DEV-EB173-01: a sun sliding ALONG the wall lights nothing. The dot product
// is the cosine of the incidence angle: for this wall it is exactly sin(az).
assert.equal(RAY_MIN_COS, 0.05);
const cos = (az) => Math.sin((az * Math.PI) / 180);
assert.ok(cos(2) < RAY_MIN_COS && !windowLit(east, sunDirOnPlan(2, 0), 40));
assert.ok(cos(4) > RAY_MIN_COS && windowLit(east, sunDirOnPlan(4, 0), 40));
// ~87.1° of incidence, i.e. the sun ~2.9° clear of the wall's own plane
assert.ok(near((Math.acos(RAY_MIN_COS) * 180) / Math.PI, 87.13, 0.01));
});
test('rayLength: 30% shorter than v1.56 (owner 2026-08-04), same shape', () => {
// the old curve, kept here so the -30% stays a fact and not a memory
const before = (e) => 0.8 + 1.7 * Math.pow(1 - Math.min(90, Math.max(0, e)) / 90, 1.6);
assert.equal(RAY_LENGTH_K, 0.7);
assert.ok(near(rayLength(0), 1.75, 1e-9)); // was 2.5
assert.ok(near(rayLength(90), 0.56, 1e-9)); // was 0.8
for (const e of [-5, 0, 3, 10, 30, 45, 60, 89, 90, 120]) {
assert.ok(near(rayLength(e), before(e) * 0.7, 1e-12), 'exactly 70% at ' + e);
}
// the shape survives: a low sun still reaches much further than a high one
assert.ok(rayLength(10) > rayLength(30));
assert.ok(rayLength(30) > rayLength(60));
assert.ok(near(rayLength(-5), 1.75, 1e-9)); // clamped
});
test('rayStops: the shaft is fully dissolved BEFORE its own far edge', () => {
const stops = rayStops();
assert.ok(near(stops[0][0], 0) && near(stops[0][1], 1), 'brightest at the glass');
assert.equal(RAY_FADE_END, 0.85);
// offsets are sorted, alphas never rise, and the tail is a hard zero
for (let i = 1; i < stops.length; i++) {
assert.ok(stops[i][0] > stops[i - 1][0] || stops[i][0] === 1, 'offsets ascend');
assert.ok(stops[i][1] <= stops[i - 1][1], 'alpha never brightens inward');
}
assert.ok(near(stops[stops.length - 1][0], 1), 'the gradient spans the FULL wedge');
for (const [off, k] of stops) {
if (off >= RAY_FADE_END) assert.equal(k, 0, 'nothing left at/after ' + RAY_FADE_END);
else assert.ok(k > 0, 'still lit at ' + off);
}
// half gone well before the middle — the eye must not find a straight edge
const half = stops.find(([, k]) => k <= 0.5);
assert.ok(half[0] <= 0.65, 'past half-dark by two thirds of the way');
});
test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away', () => {
assert.equal(SKY_SNAP_DEG, 3);
assert.equal(skyNeedsSnap(null, 12), true); // nothing painted yet
assert.equal(skyNeedsSnap(NaN, 12), true);
assert.equal(skyNeedsSnap(12, 12), false);
assert.equal(skyNeedsSnap(12, 13), false); // a real 4-minute sun step
assert.equal(skyNeedsSnap(12, 14.9), false);
assert.equal(skyNeedsSnap(12, 15), true); // ~12 minutes unwatched
assert.equal(skyNeedsSnap(12, 9), true); // and in both directions
assert.equal(skyElevation(12.3456), 12.3);
assert.equal(skyElevation(-0.04), -0);
assert.equal(skyElevation('nonsense'), 0);
});
test('rayQuad: an honest parallelogram, both sides exactly `len` (DEV-EB173-01)', () => {
// «Не надо размывать их боковые грани» — the sides are hard lines, so the
// only thing that may dissolve a shaft is the gradient. That gradient runs
// along the wall's NORMAL (see SunRay.normal/depth), and ITS iso-alpha lines
// are parallel to the wall — which is exactly where an equal extrusion of
// both ends puts the far edge. So the wedge is a plain parallelogram again
// and every side is the full, promised reach.
const a = [100, 100];
const b = [100, 200]; // a window along +y
const len = 300;
for (const deg of [0, 20, 45, 70, -35, -60]) {
const rad = (deg * Math.PI) / 180;
const dir = [Math.cos(rad), Math.sin(rad)]; // oblique sun in most cases
const q = rayQuad(a, b, dir, len);
assert.equal(q.length, 4);
// the near edge is still the window itself
assert.deepEqual(q[0], [100, 100]);
assert.deepEqual(q[1], [100, 200]);
for (const [near0, far] of [[q[0], q[3]], [q[1], q[2]]]) {
const ex = far[0] - near0[0];
const ey = far[1] - near0[1];
// both sides run exactly along the ray — razor-sharp, never splayed
assert.ok(Math.abs(ex * dir[1] - ey * dir[0]) < 1e-9, 'side parallel to the ray at ' + deg);
assert.ok(ex * dir[0] + ey * dir[1] > 0, 'side runs away from the glass');
// ...and each is the FULL reach: the 30 % cut is a fact on every side,
// at every sun angle (the old skewed quad made one side 88 % longer)
assert.ok(near(Math.hypot(ex, ey), len, 1e-9), 'side is exactly len at ' + deg);
}
// the far edge is parallel to the wall — the gradient's last iso-alpha line
const fx = q[2][0] - q[3][0];
const fy = q[2][1] - q[3][1];
const sx = b[0] - a[0];
const sy = b[1] - a[1];
assert.ok(Math.abs(fx * sy - fy * sx) < 1e-6, 'far edge parallel to the wall at ' + deg);
}
// head-on sun: the classic parallelogram, unchanged
const straight = rayQuad(a, b, [1, 0], len);
assert.deepEqual(straight, [[100, 100], [100, 200], [400, 200], [400, 100]]);
});
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
const quad = rayQuad([100, 270], [100, 330], [1, 0], 1000); // way past the wall
const clipped = clipToRoom(quad, ROOMS[0].poly);
assert.equal(clipped.length, 1);
for (const [x, y] of clipped[0]) {
assert.ok(x >= 100 - 1e-6 && x <= 500 + 1e-6, 'x inside the room');
assert.ok(y >= 100 - 1e-6 && y <= 500 + 1e-6, 'y inside the room');
}
assert.ok(clipped[0].some(([x]) => near(x, 500, 1e-6)), 'reaches the far wall, not past it');
// a wedge fully outside the room clips to nothing
assert.equal(clipToRoom(rayQuad([900, 900], [960, 900], [0, 1], 50), ROOMS[0].poly).length, 0);
});
test('computeSunRays: morning east sun lights ONLY the east window', () => {
const rays = computeSunRays(ROOMS, ALL, 90, 5, 0);
assert.deepEqual(rays.map((r) => r.openingId), ['wE']);
assert.equal(rays[0].roomId, 'r2');
// light travels AWAY from the sun: westward into the room
assert.ok(near(rays[0].dir[0], -1, 1e-12) && near(rays[0].dir[1], 0, 1e-12));
for (const [x, y] of rays[0].polys[0]) {
assert.ok(x >= 500 - 1e-6 && x <= 800 + 1e-6 && y >= 100 - 1e-6 && y <= 500 + 1e-6);
}
});
test('computeSunRays: noon south sun → south window, short wedge', () => {
const rays = computeSunRays(ROOMS, ALL, 180, 60, 0);
assert.deepEqual(rays.map((r) => r.openingId), ['wS']);
assert.ok(near(rays[0].len, rayLength(60) * 60, 1e-9));
assert.ok(rays[0].len < computeSunRays(ROOMS, ALL, 90, 5, 0)[0].len);
});
test('computeSunRays: evening west sun → west window', () => {
const rays = computeSunRays(ROOMS, ALL, 270, 4, 0);
assert.deepEqual(rays.map((r) => r.openingId), ['wW']);
});
test('grazing sun: the auditor\'s repro, fixed by a normal-axis fade (DEV-EB173-01)', () => {
// The report's browser probe: a WEST window 80 render units long, elevation
// 90 (so the nominal reach is 0.56 · 80 = 44.8 — «на 30 % короче»), azimuth
// 190 at north_deg 0, i.e. the light enters the glass but travels only 10°
// off the wall's own direction. It measured sides of 5.408 and 84.192
// (ratio 15.57, the long one 31 % LONGER than the pre-cut 64) and source
// offsets of ±0.879 — one end of the glass already fully transparent,
// because rayStops() is dead from 0.85 on.
const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 };
const rays = computeSunRays(ROOMS, [win], 190, 90, 0);
assert.equal(rays.length, 1);
const r = rays[0];
assert.ok(near(r.dir[0], 0.17365, 1e-5) && near(r.dir[1], -0.98481, 1e-5));
assert.ok(near(r.len, 44.8, 1e-9), 'nominal reach is the 70 % one');
// 1) EQUAL sides, each exactly the nominal reach
const q = rayQuad([r.a[0], r.a[1]], [r.b[0], r.b[1]], r.dir, r.len);
const side = (p0, p1) => Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
const sides = [side(q[0], q[3]), side(q[1], q[2])];
assert.ok(near(sides[0], sides[1], 1e-9), 'sides equal (was a ratio of 15.57)');
for (const l of sides) assert.ok(near(l, 44.8, 1e-9), 'each side is 44.8 (was 5.41 / 84.19)');
// 2) the fade axis is the INWARD wall normal, len · cos(incidence) long
assert.ok(near(r.normal[0], 1, 1e-12) && near(r.normal[1], 0, 1e-12));
const cos = r.dir[0] * r.normal[0] + r.dir[1] * r.normal[1];
assert.ok(near(cos, 0.17365, 1e-5), 'a 10°-off-the-wall sun');
assert.ok(near(r.depth, 44.8 * cos, 1e-9));
assert.ok(near(r.depth, 7.7794, 1e-4));
// 3) offsets along THAT axis: the whole pane of glass at 0 (peak alpha at
// BOTH ends — the probe's ±0.879 is gone), the far edge exactly at 1
const mx = (r.a[0] + r.b[0]) / 2;
const my = (r.a[1] + r.b[1]) / 2;
const off = (p) => ((p[0] - mx) * r.normal[0] + (p[1] - my) * r.normal[1]) / r.depth;
assert.ok(near(off(r.a), 0, 1e-12) && near(off(r.b), 0, 1e-12), 'glass all at peak alpha');
assert.ok(near(off(q[2]), 1, 1e-12) && near(off(q[3]), 1, 1e-12), 'far edge on the last iso-alpha line');
// 4) ...and the offset of any point is exactly how far ITS ray has run
for (const u of [0, 0.25, 0.5, 0.85, 1]) {
for (const src of [r.a, r.b, [r.a[0], r.a[1] + 17]]) {
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
assert.ok(near(off(p), u, 1e-9), 'offset = travelled / len at u=' + u);
}
}
// 5) nothing drawn past the gradient, on the clipped geometry too
for (const poly of r.polys) for (const p of poly) {
assert.ok(off(p) >= -1e-6 && off(p) <= 1 + 1e-6, 'inside the gradient');
}
});
test('grazing sun: below RAY_MIN_COS a window casts nothing at all', () => {
// azimuth 182° at north_deg 0 puts the sun 2° off the west wall's plane:
// cos = sin(2°) = 0.035 < RAY_MIN_COS. 186° (0.105) still lights it.
const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 };
assert.deepEqual(computeSunRays(ROOMS, [win], 182, 90, 0), []);
assert.equal(computeSunRays(ROOMS, [win], 186, 90, 0).length, 1);
// the surviving wedge is never thinner than 5 % of its own reach
const r = computeSunRays(ROOMS, [win], 186, 90, 0)[0];
assert.ok(r.depth >= r.len * RAY_MIN_COS);
});
test('computeSunRays: night → nothing at all', () => {
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, 0, 0), []);
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, -10, 0), []);
});
test('computeSunRays: rotating the compass swings the light to another window', () => {
// the same morning east sun, but the plan is rotated 90°: what the canvas
// shows as "up" is now east → the NORTH-drawn window faces the sun
const rays = computeSunRays(ROOMS, ALL, 90, 5, 90);
assert.deepEqual(rays.map((r) => r.openingId), ['wN']);
// and the interior window still never lights up whatever the compass says
for (const nd of [0, 45, 90, 180, 270]) {
for (const az of [0, 90, 180, 270]) {
assert.ok(!computeSunRays(ROOMS, ALL, az, 5, nd).some((r) => r.openingId === 'wI'));
}
}
});
test('rayAlpha: nothing below 3°, full strength above (owner 2026-08-03)', () => {
// the old gradual ramp-in is gone: it is a threshold, not a fade
assert.equal(rayAlpha(-3), 0);
assert.equal(rayAlpha(0), 0);
assert.equal(rayAlpha(1), 0);
assert.equal(rayAlpha(2.99), 0);
assert.ok(near(rayAlpha(3), RAY_MAX_ALPHA)); // exactly at the threshold: on
assert.ok(near(rayAlpha(3.1), RAY_MAX_ALPHA));
assert.ok(near(rayAlpha(30), RAY_MAX_ALPHA));
assert.ok(near(rayAlpha(89), RAY_MAX_ALPHA)); // no elevation shaping at all
// clouds still scale it, rain still kills it
assert.ok(near(rayAlpha(30, 0.25), RAY_MAX_ALPHA * 0.25));
assert.equal(rayAlpha(30, 0), 0);
});
test('raysVisible / rayPeakAlpha: the threshold and the cloud-only ceiling', () => {
assert.equal(RAY_ELEVATION_MIN, 3);
assert.equal(RAY_FADE_MS, 2000); // «ровно 2 секунды», mirrored in styles.ts
assert.equal(raysVisible(2.9), false);
assert.equal(raysVisible(3), true);
assert.equal(raysVisible(45), true);
assert.equal(raysVisible(-10), false);
// the peak is what the gradient uses while the layer fades — cloud only
assert.ok(near(rayPeakAlpha(), RAY_MAX_ALPHA));
assert.ok(near(rayPeakAlpha(1), RAY_MAX_ALPHA));
assert.ok(near(rayPeakAlpha(0.4), RAY_MAX_ALPHA * 0.4));
assert.equal(rayPeakAlpha(0), 0);
});
test('RAY_MAX_ALPHA is the brighter 0.3 ceiling (owner 2026-08-03)', () => {
assert.equal(RAY_MAX_ALPHA, 0.3);
});
test('rayColor: warm at the horizon, neutral by day', () => {
assert.equal(rayColor(1), '#ff9a45');
assert.equal(rayColor(0), '#ffe9c2');
assert.notEqual(rayColor(0.5), rayColor(0));
});
test('cloudFactor: the state map, garbage-safe', () => {
assert.equal(cloudFactor('sunny'), 1);
assert.equal(cloudFactor('clear-night'), 1);
assert.equal(cloudFactor('partlycloudy'), 0.7);
assert.equal(cloudFactor('cloudy'), 0.4);
assert.equal(cloudFactor('overcast'), 0.25);
assert.equal(cloudFactor('fog'), 0.25);
assert.equal(cloudFactor('rainy'), 0);
assert.equal(cloudFactor('pouring'), 0);
assert.equal(cloudFactor('snowy'), 0);
assert.equal(cloudFactor('lightning-rainy'), 0);
assert.equal(cloudFactor('unknown'), 1);
assert.equal(cloudFactor('unavailable'), 1);
assert.equal(cloudFactor(null), 1);
assert.equal(cloudFactor(undefined), 1);
assert.equal(cloudFactor('CLOUDY'), 0.4);
});
test('northDegOf: space override wins, strict int 0–359, null = inert', () => {
assert.equal(northDegOf({ north_deg: 90 }, {}), 90);
assert.equal(northDegOf({ north_deg: 90 }, { north_deg: 0 }), 0); // 0 is a value, not "unset"
assert.equal(northDegOf({}, { north_deg: 359 }), 359);
assert.equal(northDegOf({}, {}), null);
assert.equal(northDegOf(null, undefined), null);
for (const bad of [360, -1, 1.5, '90', true, NaN]) {
assert.equal(northDegOf({ north_deg: bad }, {}), null, String(bad));
}
// a garbage override falls back to the valid global
assert.equal(northDegOf({ north_deg: 45 }, { north_deg: 999 }), 45);
});
test('bgModeOf: inherit chain with a static fallback', () => {
assert.equal(bgModeOf({}, {}), 'static');
assert.equal(bgModeOf({ bg_mode: 'daynight' }, {}), 'daynight');
assert.equal(bgModeOf({ bg_mode: 'daynight' }, { bg_mode: 'static' }), 'static');
assert.equal(bgModeOf({}, { bg_mode: 'daynight' }), 'daynight');
assert.equal(bgModeOf({ bg_mode: 'disco' }, {}), 'static');
});
test('sunRaysOn: default OFF, per-space tri-state inherit', () => {
assert.equal(sunRaysOn({}, {}), false);
assert.equal(sunRaysOn({ sun_rays: true }, {}), true);
assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: false }), false);
assert.equal(sunRaysOn({}, { sun_rays: true }), true);
assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: null }), true); // null = inherit
assert.equal(sunRaysOn({ sun_rays: 'yes' }, {}), false);
});
test('weatherEntityOf / sunStateOf: strings and hass shapes, garbage-safe', () => {
assert.equal(weatherEntityOf({ weather_entity: 'weather.home' }), 'weather.home');
assert.equal(weatherEntityOf({ weather_entity: ' ' }), null);
assert.equal(weatherEntityOf({}), null);
assert.deepEqual(
sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 120.5, elevation: -3 } } } }),
{ azimuth: 120.5, elevation: -3 },
);
assert.equal(sunStateOf({ states: {} }), null);
assert.equal(sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 'x', elevation: 1 } } } }), null);
assert.equal(sunStateOf(null), null);
});