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, RAY_MAX_ALPHA, raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS, RAY_LENGTH_K, RAY_FADE_END, rayStops, RAY_MIN_COS, rimStops, rimPeakAlpha, rayRimEdges, RIM_MAX_ALPHA, RIM_COLOR, SKY_SNAP_DEG, skyNeedsSnap, skyElevation, northDegOf, bgModeOf, sunRaysOn, sunStateOf, DAY_CYCLE_PALETTES, dayCycleSunOf, dayCyclePhaseFromSun, dayCyclePhaseFromMinutes, dayCyclePositionFromSun, dayCyclePositionFromMinutes, resolveDayCycle, dayCycleFingerprint, } 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: clockwise north and azimuth compose by addition', () => { const cases = [ { north: 0, azimuth: 90, expected: 90 }, { north: 90, azimuth: 0, expected: 90 }, { north: 90, azimuth: 90, expected: 180 }, { north: 270, azimuth: 90, expected: 0 }, { north: 350, azimuth: 20, expected: 10 }, ]; for (const { north, azimuth, expected } of cases) { assert.equal(planSunAngle(azimuth, north), expected, `north=${north}, azimuth=${azimuth}`); } 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}`); } // The arrow points to true north on the plan. If north is right, east is down. 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('day cycle: real sun selects exact four-phase boundaries', () => { assert.equal(dayCyclePhaseFromSun({ elevation: -6.1, rising: true }), 'night'); assert.equal(dayCyclePhaseFromSun({ elevation: -6, rising: true }), 'night'); assert.equal(dayCyclePhaseFromSun({ elevation: -5.9, rising: true }), 'dawn'); assert.equal(dayCyclePhaseFromSun({ elevation: 5.9, rising: true }), 'dawn'); assert.equal(dayCyclePhaseFromSun({ elevation: -5.9, rising: false }), 'dusk'); assert.equal(dayCyclePhaseFromSun({ elevation: 5.9, rising: false }), 'dusk'); assert.equal(dayCyclePhaseFromSun({ elevation: 6, rising: false }), 'day'); assert.equal(dayCyclePhaseFromSun({ elevation: 6.1, rising: false }), 'day'); }); test('day cycle: browser-local fallback has exact schedule boundaries', () => { const cases = [ [299, 'night'], [300, 'dawn'], [479, 'dawn'], [480, 'day'], [1079, 'day'], [1080, 'dusk'], [1259, 'dusk'], [1260, 'night'], [-1, 'night'], [1440 + 300, 'dawn'], ]; for (const [minutes, phase] of cases) assert.equal(dayCyclePhaseFromMinutes(minutes), phase); }); test('day cycle: strict sun snapshot keeps rays compatibility separate', () => { const hass = (attributes) => ({ states: { 'sun.sun': { attributes } } }); assert.deepEqual(dayCycleSunOf(hass({ azimuth: 450, elevation: 2, rising: true })), { azimuth: 90, elevation: 2, rising: true, }); for (const attributes of [ { elevation: 2, rising: true }, { azimuth: NaN, elevation: 2, rising: true }, { azimuth: 90, elevation: Infinity, rising: true }, { azimuth: 90, elevation: 2, rising: 'yes' }, ]) assert.equal(dayCycleSunOf(hass(attributes)), null); assert.equal(dayCycleSunOf(null), null); // Window rays never acquired the new rising requirement. assert.deepEqual(sunStateOf(hass({ azimuth: 90, elevation: 2 })), { azimuth: 90, elevation: 2 }); }); test('day cycle: real decorative position follows elevation and azimuth', () => { const east = dayCyclePositionFromSun({ azimuth: 90, elevation: 0, rising: true }); const south = dayCyclePositionFromSun({ azimuth: 180, elevation: 45, rising: true }); const west = dayCyclePositionFromSun({ azimuth: 270, elevation: 0, rising: false }); const north = dayCyclePositionFromSun({ azimuth: 0, elevation: 90, rising: false }); assert.ok(near(east.sunX, 8) && near(east.sunY, 78) && near(east.sunOpacity, 0.5)); assert.ok(near(south.sunX, 50) && near(south.sunY, 46) && near(south.sunOpacity, 1)); assert.ok(near(west.sunX, 92) && near(west.sunY, 78) && near(west.sunOpacity, 0.5)); assert.ok(near(north.sunX, 50) && near(north.sunY, 14) && near(north.sunOpacity, 1)); assert.equal(dayCyclePositionFromSun({ azimuth: 90, elevation: -6, rising: true }).sunOpacity, 0); }); test('day cycle: fallback arc and night visibility match the prototype', () => { const dawn = dayCyclePositionFromMinutes(300); const morning = dayCyclePositionFromMinutes(420); const noon = dayCyclePositionFromMinutes(780); const evening = dayCyclePositionFromMinutes(1140); const night = dayCyclePositionFromMinutes(1260); assert.ok(near(dawn.sunX, 8) && near(dawn.sunY, 78) && near(dawn.sunOpacity, 0.18)); assert.equal(morning.sunOpacity, 1); assert.ok(near(noon.sunX, 50) && near(noon.sunY, 14) && near(noon.sunOpacity, 1)); assert.equal(evening.sunOpacity, 1); assert.equal(night.sunOpacity, 0); }); test('day cycle: resolver is atomic and palette is complete', () => { const live = resolveDayCycle({ states: { 'sun.sun': { attributes: { azimuth: 90, elevation: 2, rising: false, } } } }, 600); assert.equal(live.source, 'sun'); assert.equal(live.phase, 'dusk'); const fallback = resolveDayCycle({ states: { 'sun.sun': { attributes: { azimuth: NaN, elevation: 20, rising: true, } } } }, 600); assert.equal(fallback.source, 'clock'); assert.equal(fallback.phase, 'day'); assert.match(dayCycleFingerprint(fallback), /^clock\|day\|/); assert.deepEqual(DAY_CYCLE_PALETTES, { dawn: { top: '#aabdd1', bottom: '#e8c8b7', horizon: 'rgba(255,201,156,.56)', sun: 'rgba(255,188,125,.78)', vignette: 'rgba(65,72,99,.21)', outlineNear: 'rgba(74,57,61,.25)', outlineMid: 'rgba(255,238,224,.40)', outlineFar: 'rgba(255,224,202,.18)', }, day: { top: '#dce9ef', bottom: '#cbdce3', horizon: 'rgba(255,245,220,.45)', sun: 'rgba(255,239,190,.72)', vignette: 'rgba(65,91,105,.16)', outlineNear: 'rgba(45,62,71,.28)', outlineMid: 'rgba(255,255,255,.42)', outlineFar: 'rgba(255,255,255,.20)', }, dusk: { top: '#48536c', bottom: '#9a7380', horizon: 'rgba(242,156,114,.34)', sun: 'rgba(255,167,113,.55)', vignette: 'rgba(20,26,44,.39)', outlineNear: 'rgba(238,219,225,.40)', outlineMid: 'rgba(229,207,218,.26)', outlineFar: 'rgba(215,190,205,.12)', }, night: { top: '#111a27', bottom: '#1f2f3e', horizon: 'rgba(79,120,151,.16)', sun: 'rgba(169,208,231,0)', vignette: 'rgba(3,8,14,.58)', outlineNear: 'rgba(218,238,249,.56)', outlineMid: 'rgba(174,215,238,.30)', outlineFar: 'rgba(136,194,226,.14)', }, }); for (const palette of Object.values(DAY_CYCLE_PALETTES)) { assert.match(palette.top, /^#[0-9a-f]{6}$/i); for (const key of ['horizon', 'sun', 'vignette', 'outlineNear', 'outlineMid', 'outlineFar']) { assert.match(palette[key], /^rgba\(/); } } }); 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'); }); // ---- the rim (owner 2026-08-04, docs/SUN.md «The rim») ----------------- test('rimStops: the rim dies on exactly the same curve as the fill', () => { const rim = rimStops(); // «ровно по той же кривой и тому же порогу» — identity, not a copy that can // drift: if the fill's easing is ever retuned the outline follows it. assert.deepEqual(rim, rayStops()); assert.ok(near(rim[0][0], 0) && near(rim[0][1], 1), 'brightest at the glass'); assert.ok(near(rim[rim.length - 1][0], 1), 'spans the FULL wedge, like the fill'); for (let i = 1; i < rim.length; i++) { assert.ok(rim[i][0] > rim[i - 1][0] || rim[i][0] === 1, 'offsets ascend'); assert.ok(rim[i][1] <= rim[i - 1][1], 'the rim never brightens inward'); } for (const [off, k] of rim) { if (off >= RAY_FADE_END) assert.equal(k, 0, 'no rim at/after ' + RAY_FADE_END); else assert.ok(k > 0, 'still drawn at ' + off); } // black, and visible on paper without becoming an ink contour on a dark scene assert.equal(RIM_COLOR, '#000000'); assert.ok(RIM_MAX_ALPHA >= 0.35 && RIM_MAX_ALPHA <= 0.5, 'the owner\'s 0.35..0.5 window'); assert.ok(near(rimPeakAlpha(), RIM_MAX_ALPHA)); }); test('rayRimEdges: the two SIDE edges only, cut exactly like the wedge', () => { // a west window in r1, a western sun square into it — the wedge stays well // inside the room, so both sides are whole const [ray] = computeSunRays(ROOMS, [WIN.west], 270, 60, 0); assert.ok(ray, 'the west window is lit'); const edges = rayRimEdges(ray); assert.equal(edges.length, 2, 'one line per side, no more'); const far = (s) => [s[0] + ray.dir[0] * ray.len, s[1] + ray.dir[1] * ray.len]; const same = (p, q) => near(p[0], q[0], 1e-6) && near(p[1], q[1], 1e-6); const has = (s, t) => edges.some(([p, q]) => (same(p, s) && same(q, t)) || (same(p, t) && same(q, s))); assert.ok(has(ray.a, far(ray.a)), 'the side from a runs the full reach'); assert.ok(has(ray.b, far(ray.b)), 'the side from b runs the full reach'); // never the glass (a-b) and never the far edge: every rim segment is // parallel to the ray, and both of them are the full length for (const [p, q] of edges) { const dx = q[0] - p[0]; const dy = q[1] - p[1]; const L = Math.hypot(dx, dy); assert.ok(near(L, ray.len, 1e-6), 'a whole side, not a wall of the room'); assert.ok(near((dx / L) * ray.dir[1] - (dy / L) * ray.dir[0], 0, 1e-9), 'parallel to the ray'); } // ...and the glass edge is NOT among them, however you orient it assert.ok(!has(ray.a, ray.b), 'the pane of glass is not a rim'); assert.ok(!has(far(ray.a), far(ray.b)), 'the far edge is not a rim either'); }); test('rayRimEdges: a room that cuts the shaft cuts the rim with it', () => { // the same window in a room only 30 units deep — the wedge (~46 long at 60°) // hits the far wall, and both rims must stop on it, not carry on in mid-air const narrow = [{ id: 'n1', poly: [[100, 100], [130, 100], [130, 500], [100, 500]] }]; const [ray] = computeSunRays(narrow, [WIN.west], 270, 60, 0); assert.ok(ray && ray.len > 30, 'the wedge really is longer than the room'); const edges = rayRimEdges(ray); assert.equal(edges.length, 2); for (const [p, q] of edges) { assert.ok(near(Math.hypot(q[0] - p[0], q[1] - p[1]), 30, 1e-6), 'clipped to the room'); assert.ok(Math.max(p[0], q[0]) <= 130 + 1e-6, 'nothing past the far wall'); } // and the shortened rim still starts at the glass assert.ok(edges.some(([p]) => near(p[0], 100, 1e-6) && near(p[1], 270, 1e-6))); assert.ok(edges.some(([p]) => near(p[0], 100, 1e-6) && near(p[1], 330, 1e-6))); }); test('rayRimEdges: collinear splinters merge, an empty wedge draws nothing', () => { const [ray] = computeSunRays(ROOMS, [WIN.west], 270, 60, 0); // polyclip readily splits a side at a touching vertex; the rim must still be // ONE line per side, not a string of them const poly = ray.polys[0]; const split = []; for (let i = 0; i < poly.length; i++) { const p = poly[i]; const q = poly[(i + 1) % poly.length]; split.push(p, [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]); } assert.equal(split.length, 8, 'every edge of the wedge is now two'); const cut = { ...ray, polys: [split] }; const merged = rayRimEdges(cut); assert.equal(merged.length, 2, 'still one line per side, not four'); for (const [p, q] of merged) { assert.ok(near(Math.hypot(q[0] - p[0], q[1] - p[1]), ray.len, 1e-6), 'the whole side'); } // a wedge clipped away to nothing has no rim at all assert.deepEqual(rayRimEdges({ ...ray, polys: [] }), []); }); 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('computeSunRays: a thick-wall ray starts at both room-side opening corners', () => { const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 }; const inner = { r1: [[110, 110], [490, 110], [490, 490], [110, 490]], }; // Oblique sunlight is intentional: the source must remain the full inner // aperture instead of shrinking or sliding away from either jamb corner. const [ray] = computeSunRays(ROOMS, [win], 240, 60, 0, inner, { wW: 20 }); assert.ok(ray, 'the west window is lit'); assert.ok(near(ray.a[0], 110) && near(ray.a[1], 260), 'first inner corner'); assert.ok(near(ray.b[0], 110) && near(ray.b[1], 340), 'second inner corner'); assert.ok(near(Math.hypot(ray.b[0] - ray.a[0], ray.b[1] - ray.a[1]), 80), 'full opening width'); for (const poly of ray.polys) for (const [x, y] of poly) { assert.ok(x >= 110 - 1e-6 && x <= 490 + 1e-6, 'clipped to the clean-floor contour'); assert.ok(y >= 110 - 1e-6 && y <= 490 + 1e-6, 'clipped to the clean-floor contour'); } }); 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: north right plus east sun selects the south window and shines inward', () => { // The N arrow points right. East is therefore down on the canvas, so the // lower window faces the sun and its light travels upward into the room. const rays = computeSunRays(ROOMS, ALL, 90, 5, 90); assert.deepEqual(rays.map((r) => r.openingId), ['wS']); assert.ok(near(rays[0].dir[0], 0, 1e-12) && near(rays[0].dir[1], -1, 1e-12)); // 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 }); test('raysVisible / rayPeakAlpha: the threshold and fixed 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 fixed: weather does not participate in sunlight rendering assert.ok(near(rayPeakAlpha(), RAY_MAX_ALPHA)); }); 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('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('sunStateOf: hass shapes, garbage-safe', () => { 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); });