mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
feat(moon): the moon with any background, and its status in General settings (#718)
The owner decided on 30.09 that the moon is not part of the "Follow the Sun" environment but a switch of its own: with a static background (global or a space's own) the card showed no moon even with the switch on, and the switch said nothing about why the moon was missing right now. With a static background there is no environment, so the moon stands in its own layer, `.hp-moon-sky`: the first child of `.stage` / `.hp-static-stage`, the whole scene, no z-index, filter or will-change, under the plan by DOM order, fading with the #101 View weight. Inside is the very #661 element, so place, size, art and fades are unchanged, and a background switch moves it to its new parent in the same render without a flicker. The phase comes from the same `resolveDayCycle`, computed only while the moon is on and on View; without `sun.sun` both cards keep their 30 s clock ticker and re-render only when the phase changes (the environment is still compared by its whole fingerprint). General settings get a second caption line under the moon switch (`data-moon-status`): one snapshot per opening, judged by the lazy chunk as if the switch were on, first reason wins (no home, day, below 3°, under 3 %), numbers rounded and clamped below the threshold they missed. `moonStatus` decides "shown" with the same `moonShownAt` as the element. It lives in a WeakMap beside the draft, so it never makes the dialog dirty; a closed opening's result is dropped. The dialog loads the chunk through the gate's loader (`withMoon`), now shared by every caller while a load is in flight, so there is still one fingerprint check and one retry token. Bundle (same build, against origin/dev): initial View 300 072 -> 300 248 B gzip (+176 B, under the 500 B of the spec; budget and ceiling not raised); lazy editor 238 558 -> 238 991 B (+433 B, the line and English strings); lazy moon 11 385 -> 11 712 B (+327 B, layer CSS and status). `src/moon.ts` stays out of the initial and the editor graph; bundle-budget now refuses an editor/moon overlap. Monolith metrics: hostRefs 4 885 -> 4 888 — the three `host.` reads of `src/editors/moon-status.ts` (hass, `_settingsDialog`, requestUpdate) through its own three-member interface, not the editor port; the other five metrics are unchanged. houseplan-editor-runtime.ts grows by two lines (import, call). Tests: AC9/AC10/AC15 and the sky layer in test/moon.test.mjs (the #661 "static -> nothing" check inverted), AC14 and the opening lifecycle in test/moon-settings.test.mjs, smokes demo/smoke_moon_static.mjs (AC1-AC6; AC1 and AC3 were red on dev) and demo/smoke_moon_status.mjs (AC11/AC12), AC7 in smoke_daycycle_layer_budget. Golden: two new scenes (static-bg-moon-gibbous-white-light, static-bg-moon-crescent-south-dark, matrix v70), the harness checks the moon's parent by background and waits for the status line in the General settings frames. Four new mutants; the clock ticker one is a browser guard (201 at the guideline of 200). Issue: #718 User-Visible: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
+174
-11
@@ -1,6 +1,8 @@
|
||||
// #661: the moon over the "Follow the Sun" background — astronomy (AC1),
|
||||
// visibility rules (AC2), phase mask (AC3), fingerprint and ticker (AC4) and
|
||||
// the designer pack gate. Pure: test-build modules, no browser.
|
||||
// the designer pack gate. #718: the moon with any background — the status
|
||||
// function (AC9), its equivalence with the element (AC10) and the gate of the
|
||||
// static background's sky layer (AC15). Pure: test-build modules, no browser.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -8,11 +10,16 @@ import { readFileSync } from 'node:fs';
|
||||
import { nothing } from 'lit';
|
||||
import {
|
||||
MOON_ELEVATION_MIN, MOON_MIN_ILLUMINATION, MOON_R, MOON_SHARP_FROM,
|
||||
moonFingerprint, moonIllumination, moonPhasePath, moonPosition, moonShownAt, moonView,
|
||||
moonFingerprint, moonIllumination, moonPhasePath, moonPosition, moonShownAt, moonStatus, moonStatusOf,
|
||||
moonView,
|
||||
} from '../test-build/moon.js';
|
||||
import { MOON_TICK_MS, moonTick, renderMoon } from '../test-build/moon-runtime.js';
|
||||
import { moonLayer } from '../test-build/moon-gate.js';
|
||||
import { RAY_ELEVATION_MIN, RAY_FADE_MS, bgModeOf, resolveDayCycle } from '../test-build/sun.js';
|
||||
import { MOON_TICK_MS, moonTick, renderMoon, renderMoonSky } from '../test-build/moon-runtime.js';
|
||||
import { dayCycleClock, moonLayer, moonSkyState } from '../test-build/moon-gate.js';
|
||||
import { moonStatusText } from '../test-build/editors/moon-status.js';
|
||||
import { subst } from '../test-build/logic.js';
|
||||
import {
|
||||
RAY_ELEVATION_MIN, RAY_FADE_MS, bgModeOf, dayCycleFingerprint, dayCyclePhaseFromSun, resolveDayCycle, sunStateOf,
|
||||
} from '../test-build/sun.js';
|
||||
import { renderDayCycleEnvironment } from '../test-build/day-cycle-render.js';
|
||||
import { moonArtFromSvg, validateMoonPack } from '../scripts/generate-moon-assets.mjs';
|
||||
|
||||
@@ -76,7 +83,7 @@ test('#661 AC2: visibility thresholds on both sides', () => {
|
||||
assert.equal(moonShownAt('day', 45, 1), false);
|
||||
});
|
||||
|
||||
test('#661 AC2: only an explicit true, finite home coordinates and an environment show the moon', () => {
|
||||
test('#661 AC2: only an explicit true and finite home coordinates show the moon', () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
assert.equal(moonView(ON, 'night', MOSCOW, at).visible, true);
|
||||
for (const settings of [undefined, null, {}, { moon: false }, { moon: 'true' }, { moon: 1 }]) {
|
||||
@@ -87,13 +94,11 @@ test('#661 AC2: only an explicit true, finite home coordinates and an environmen
|
||||
assert.equal(moonView(ON, 'night', config, at).visible, false, JSON.stringify(config));
|
||||
}
|
||||
assert.equal(moonView(ON, 'day', MOSCOW, at).visible, false);
|
||||
// bg_mode static — global or per space — leaves no environment, so no moon.
|
||||
// #718 AC15: a static background no longer means «no moon» — its own sky
|
||||
// layer shows it (inverted in the #718 AC15 tests below); here only the
|
||||
// switch and the host still refuse it.
|
||||
const host = fakeHost(MOSCOW);
|
||||
const night = { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } };
|
||||
for (const [global, space] of [[{ ...ON, bg_mode: 'static' }, {}], [ON, { bg_mode: 'static' }]]) {
|
||||
const state = bgModeOf(global, space) === 'daynight' ? resolveDayCycle({ states: night }, at) : null;
|
||||
assert.equal(moonLayer(host, global, state), nothing);
|
||||
}
|
||||
assert.equal(moonLayer(host, { moon: false }, resolveDayCycle({ states: night }, at)), nothing);
|
||||
assert.equal(moonLayer(undefined, ON, resolveDayCycle({ states: night }, at)), nothing);
|
||||
});
|
||||
@@ -237,6 +242,127 @@ test('#661 pack: generated art is fresh, and the gate refuses what the brief for
|
||||
assert.throws(() => moonArtFromSvg(`${root}<path d="${'M0 0'.repeat(9000)}"/></svg>`), /larger/);
|
||||
});
|
||||
|
||||
/** The UX table of #718, written out here: the oracle is the specification, not the dictionary. */
|
||||
const UX = {
|
||||
en: {
|
||||
shown: ({ alt, pct }) => `Now: shown (${alt}° above the horizon, ${pct}% lit).`,
|
||||
day_sun: ({ sun }) => `Now: not shown (the sun is ${sun}° above the horizon; the moon shows once it is below 6°).`,
|
||||
low: ({ alt }) => `Now: not shown (the moon is at ${alt}°; it shows from 3° above the horizon).`,
|
||||
new: ({ pct }) => `Now: not shown (new moon, ${pct}% lit; it shows from 3%).`,
|
||||
},
|
||||
ru: {
|
||||
shown: ({ alt, pct }) => `Сейчас: показывается (на ${alt}° над горизонтом, освещено ${pct}\u00a0%).`,
|
||||
day_sun: ({ sun }) => `Сейчас: не показывается (Солнце на ${sun}° над горизонтом, луна видна, когда оно ниже 6°).`,
|
||||
low: ({ alt }) => `Сейчас: не показывается (луна на высоте ${alt}°, видна от 3° над горизонтом).`,
|
||||
new: ({ pct }) => `Сейчас: не показывается (новолуние, освещено ${pct}\u00a0%, видна от 3\u00a0%).`,
|
||||
},
|
||||
};
|
||||
const dictionaries = Object.fromEntries(['en', 'ru'].map((lang) => [lang,
|
||||
JSON.parse(readFileSync(new URL(`../src/i18n/settings/${lang}.json`, import.meta.url), 'utf8'))]));
|
||||
const stFor = (lang) => (key, vars) => subst(dictionaries[lang][key], vars);
|
||||
|
||||
test('#718 AC9: the status on ready numbers — the first reason, rounded and clamped', () => {
|
||||
const sunPhase = (elevation) => dayCyclePhaseFromSun({ elevation, rising: false });
|
||||
const rows = [
|
||||
// home, phase, source, sun, altitude, fraction → reason, numbers, texts (minus U+2212)
|
||||
[false, 'night', 'clock', null, 30, 0.5, { reason: 'no_home' }],
|
||||
[false, 'day', 'sun', 40, 30, 0.5, { reason: 'no_home' }],
|
||||
[true, sunPhase(25.4), 'sun', 25.4, 30, 0.5, { reason: 'day_sun', sun: 25 }, { sun: '25' }],
|
||||
[true, sunPhase(6.0), 'sun', 6.0, 30, 0.5, { reason: 'day_sun', sun: 6 }, { sun: '6' }],
|
||||
[true, sunPhase(5.9), 'sun', 5.9, 30, 0.5, { reason: 'shown', alt: 30, pct: 50 }, { alt: '30', pct: '50' }],
|
||||
[true, 'day', 'clock', null, 30, 0.5, { reason: 'day_clock' }],
|
||||
[true, 'night', 'clock', null, -12.3, 0.5, { reason: 'low', alt: -12 }, { alt: '\u221212' }],
|
||||
[true, 'night', 'clock', null, 2.6, 0.5, { reason: 'low', alt: 2 }, { alt: '2' }],
|
||||
[true, 'night', 'clock', null, -0.4, 0.5, { reason: 'low', alt: -0 }, { alt: '0' }],
|
||||
[true, 'night', 'clock', null, -3, 0.01, { reason: 'low', alt: -3 }, { alt: '\u22123' }],
|
||||
[true, 'night', 'clock', null, 30.6, 0.0163, { reason: 'new', pct: 2 }, { pct: '2' }],
|
||||
[true, 'night', 'clock', null, 30, 0.026, { reason: 'new', pct: 2 }, { pct: '2' }],
|
||||
[true, 'night', 'clock', null, 3.0, 0.03, { reason: 'shown', alt: 3, pct: 3 }, { alt: '3', pct: '3' }],
|
||||
[true, 'night', 'clock', null, 34.4, 0.618, { reason: 'shown', alt: 34, pct: 62 }, { alt: '34', pct: '62' }],
|
||||
];
|
||||
assert.equal(sunPhase(25.4), 'day');
|
||||
assert.equal(sunPhase(6.0), 'day', 'exactly 6° is day');
|
||||
assert.equal(sunPhase(5.9), 'dusk');
|
||||
for (const [home, phase, source, sun, altitude, fraction, expected, shown] of rows) {
|
||||
const label = JSON.stringify({ home, phase, source, sun, altitude, fraction });
|
||||
const status = moonStatusOf({ home, phase, source, sun, altitude, fraction });
|
||||
assert.deepEqual(status, expected, label);
|
||||
if (shown) {
|
||||
for (const lang of ['en', 'ru']) {
|
||||
assert.equal(moonStatusText(status, stFor(lang)), UX[lang][status.reason](shown), `${lang} ${label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The two literal reasons, exactly as the table has them.
|
||||
assert.equal(moonStatusText({ reason: 'no_home' }, stFor('ru')),
|
||||
'Сейчас: не показывается (в Home Assistant не заданы координаты дома).');
|
||||
assert.equal(moonStatusText({ reason: 'day_clock' }, stFor('en')),
|
||||
'Now: not shown (daytime by the clock, 08:00\u201318:00: Home Assistant has no sun.sun data).');
|
||||
});
|
||||
|
||||
test('#718 AC10: «shown» exactly when the element is visible — every hour of October, two homes', () => {
|
||||
const suns = [-12, 0, 5.9, 6, 40]
|
||||
.map((elevation) => ({ 'sun.sun': { attributes: { azimuth: 180, elevation, rising: false } } }))
|
||||
.concat([{}]);
|
||||
let visibleCount = 0;
|
||||
let total = 0;
|
||||
for (const place of [MOSCOW, SYDNEY]) {
|
||||
for (let hour = 0; hour < 31 * 24; hour++) {
|
||||
const now = new Date(Date.UTC(2026, 9, 1) + hour * 3_600_000);
|
||||
for (const states of suns) {
|
||||
const hass = { config: place, states };
|
||||
const state = resolveDayCycle(hass, now);
|
||||
const status = moonStatus(place, state, sunStateOf(hass)?.elevation ?? null, now);
|
||||
const view = moonView({ moon: true }, state.phase, place, now);
|
||||
const label = `${now.toISOString()} ${place.latitude} ${JSON.stringify(states)}`;
|
||||
assert.equal(status.reason === 'shown', view.visible, label);
|
||||
// K8: the shown per cent is the element's data-moon-k × 100.
|
||||
if (view.visible) assert.equal(status.pct, Math.round(view.k * 100), label);
|
||||
visibleCount += view.visible ? 1 : 0;
|
||||
total++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(visibleCount > 500 && visibleCount < total - 500, `${visibleCount} of ${total}: both answers occur`);
|
||||
// No home: never shown, whatever the sky.
|
||||
assert.equal(moonStatus({}, { phase: 'night', source: 'clock' }, null, new Date('2026-10-21T18:00:00Z')).reason, 'no_home');
|
||||
});
|
||||
|
||||
test('#718 K5: the clock ticker compares the environment whole, the moon’s sky by its phase', () => {
|
||||
const at = (hour, minute) => resolveDayCycle({}, hour * 60 + minute);
|
||||
assert.equal(dayCycleClock(at(18, 5), null)[1], dayCycleFingerprint(at(18, 5)));
|
||||
assert.notEqual(dayCycleClock(at(18, 5), null)[1], dayCycleClock(at(18, 6), null)[1], 'the environment’s sun moves');
|
||||
assert.equal(dayCycleClock(null, at(18, 5))[1], dayCycleClock(null, at(20, 59))[1], 'a phase is one key');
|
||||
assert.notEqual(dayCycleClock(null, at(17, 59))[1], dayCycleClock(null, at(18, 0))[1], '18:00 is a new key');
|
||||
assert.equal(dayCycleClock(null, at(18, 5))[0].phase, 'dusk');
|
||||
assert.deepEqual(dayCycleClock(null, null), [null, '']);
|
||||
});
|
||||
|
||||
const NIGHT_HASS = { config: MOSCOW, states: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } } };
|
||||
const DAY_HASS = { config: MOSCOW, states: { 'sun.sun': { attributes: { azimuth: 180, elevation: 40, rising: false } } } };
|
||||
const STATIC_ON = { moon: true, bg_mode: 'static' };
|
||||
|
||||
// Runs before the #661 C7 test loads the chunk: here nothing may ask for it.
|
||||
test('#718 AC15: the static sky computes and asks for nothing in the editor, with the moon off or by day', async () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
const host = fakeHost(MOSCOW);
|
||||
// The editor (#101 View weight 0) and a moon that is not explicitly on compute no phase.
|
||||
assert.equal(moonSkyState(STATIC_ON, false, 0, NIGHT_HASS, at), null);
|
||||
for (const settings of [{ bg_mode: 'static' }, { bg_mode: 'static', moon: false }, { bg_mode: 'static', moon: 'true' }]) {
|
||||
assert.equal(moonSkyState(settings, false, 1, NIGHT_HASS, at), null, JSON.stringify(settings));
|
||||
assert.equal(moonLayer(host, settings, resolveDayCycle(NIGHT_HASS, at), 1), nothing);
|
||||
}
|
||||
// Over "Follow the Sun" the environment carries the moon; the sky computes nothing.
|
||||
assert.equal(moonSkyState({ moon: true, bg_mode: 'daynight' }, true, 1, NIGHT_HASS, at), null);
|
||||
assert.equal(moonLayer(host, STATIC_ON, moonSkyState(STATIC_ON, false, 0, NIGHT_HASS, at), 0), nothing);
|
||||
// By day the chunk is not asked for (#661 C7).
|
||||
const day = moonSkyState(STATIC_ON, false, 1, DAY_HASS, at);
|
||||
assert.equal(day?.phase, 'day');
|
||||
assert.equal(moonLayer(host, STATIC_ON, day, 1), nothing);
|
||||
await new Promise((done) => setTimeout(done, 50));
|
||||
assert.equal(host.updates, 0, 'nothing was loaded');
|
||||
});
|
||||
|
||||
test('#661 C7: the chunk is asked for at night only; until it arrives there is no moon', async () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
const sunAt = (elevation) => resolveDayCycle({ states: { 'sun.sun': { attributes: { azimuth: 0, elevation, rising: false } } } }, at);
|
||||
@@ -251,3 +377,40 @@ test('#661 C7: the chunk is asked for at night only; until it arrives there is n
|
||||
assert.notEqual(element, nothing);
|
||||
forgetHost(host);
|
||||
});
|
||||
|
||||
test('#718 AC15: a static background — global or the space’s own — shows the moon in its sky layer', () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
const host = fakeHost(MOSCOW);
|
||||
for (const [global, space] of [[STATIC_ON, {}], [{ moon: true, bg_mode: 'daynight' }, { bg_mode: 'static' }]]) {
|
||||
const daynight = bgModeOf(global, space) === 'daynight';
|
||||
assert.equal(daynight, false);
|
||||
const sky = moonSkyState(global, daynight, 1, NIGHT_HASS, at);
|
||||
assert.equal(sky?.phase, 'night');
|
||||
const layer = moonLayer(host, global, sky, 1);
|
||||
assert.notEqual(layer, nothing, JSON.stringify({ global, space }));
|
||||
assert.match(layer.strings[0], /^<div class="hp-moon-sky" aria-hidden="true"/);
|
||||
}
|
||||
// Without `sky` the element goes into the environment, as in #661.
|
||||
const env = moonLayer(host, { moon: true, bg_mode: 'daynight' }, resolveDayCycle(NIGHT_HASS, at));
|
||||
assert.match(env.strings[0], /^<style>/);
|
||||
forgetHost(host);
|
||||
});
|
||||
|
||||
test('#718 K3/K4: the sky layer is scene-sized, unstacked, and fades with the View weight', () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
const host = fakeHost(MOSCOW);
|
||||
const sky = renderMoonSky(host, ON, 'night', 0.37, at);
|
||||
const inner = renderMoon(host, ON, 'night', at);
|
||||
const clamped = renderMoonSky(host, ON, 'night', 3, at);
|
||||
forgetHost(host);
|
||||
assert.match(sky.strings[0], /^<div class="hp-moon-sky" aria-hidden="true"\s+style="opacity:$/);
|
||||
assert.equal(sky.values[0], '0.3700');
|
||||
assert.equal(clamped.values[0], '1.0000');
|
||||
// Inside: the very element of the environment, and nothing else.
|
||||
assert.deepEqual(sky.values[1].strings, inner.strings);
|
||||
assert.equal(sky.strings.length, 3);
|
||||
assert.match(sky.strings[2], /^<\/div>$/);
|
||||
const css = inner.values[0];
|
||||
assert.match(css, /\.hp-day-cycle-env,\.hp-moon-sky\{container-type:size\}/);
|
||||
assert.match(css, /\.hp-moon-sky\{position:absolute;inset:0;overflow:hidden;pointer-events:none\}/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user