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:
Claude
2026-10-01 05:25:21 +00:00
committed by claude[bot]
parent 712d41b6d6
commit b998b0b34a
33 changed files with 1553 additions and 146 deletions
+6
View File
@@ -304,6 +304,12 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
// lazy moon graph — the builder subtracts initial) is refused by name.
assert.throws(() => assertBundleBudget({ ...manifest, lazyMoonFiles: [] }, 1_000_000, undefined, ...lazyCeilings),
/bundle has no lazy moon graph/);
// #718 AC13: the status line reaches the chunk through the gate; the editor
// graph never holds the moon's own files.
assert.equal(manifest.lazyEditorFiles.some((path) => manifest.lazyMoonFiles.includes(path)), false);
assert.throws(() => assertBundleBudget({
...manifest, lazyEditorFiles: [...manifest.lazyEditorFiles, ...manifest.lazyMoonFiles],
}, 1_000_000, undefined, ...lazyCeilings), /lazy editor graph overlaps lazy moon graph/);
});
test('#486 Rollup names both stable entries explicitly', async () => {
+30 -2
View File
@@ -446,7 +446,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 69);
assert.equal(GOLDEN_MATRIX_VERSION, 70);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -940,7 +940,7 @@ test('issue 570 Stage 4 reuses the historical iso goldens for visual handoff cov
test('#673 Stage 6 designer acceptance scenes are canonical golden entries', () => {
const ids = STAGE6_ACCEPTANCE_SCENARIOS.map((scenario) => scenario.id);
assert.equal(GOLDEN_MATRIX_VERSION, 69);
assert.equal(GOLDEN_MATRIX_VERSION, 70);
assert.deepEqual(ids, [
'isometric-stage6-light-lightfloor',
'isometric-stage6-light-darkfloor',
@@ -1244,3 +1244,31 @@ test('#661 AC7 moon scenes: fixed home and clock, moon on, the old day-cycle fra
assert.equal(prepareGoldenFixture(scenario).config.settings?.moon, undefined, id);
}
});
test('#718 AC8 static-background moon scenes: the chosen colour, the sky layer, the dialog waits for the line', () => {
const byId = (id) => GOLDEN_SCENARIOS.find((scenario) => scenario.id === id);
const white = byId('static-bg-moon-gibbous-white-light');
const dark = byId('static-bg-moon-crescent-south-dark');
assert.deepEqual(white.moon, { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' });
assert.deepEqual(dark.moon, { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' });
for (const [scenario, theme, color] of [[white, 'light', '#ffffff'], [dark, 'dark', '#202126']]) {
assert.equal(scenario.capture, 'stage');
assert.equal(scenario.theme, theme);
assert.equal(scenario.bgMode, 'static');
const fixture = prepareGoldenFixture(scenario);
assert.equal(fixture.config.settings.moon, true);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(space.settings.bg_mode, 'static');
assert.equal(space.settings.bg_color, color);
}
// The harness checks the parent by background and waits for the status line
// in both General settings frames before the screenshot.
const harness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
assert.match(harness, /scenario\.bgMode === 'daynight'\s+\? card\.renderRoot\.querySelector\('\.hp-day-cycle-env'\)\s+: card\.renderRoot\.querySelector\('\.hp-moon-sky'\)/);
for (const dialog of ['general-color', 'general-help']) {
const branch = harness.slice(harness.indexOf(`scenario.dialog === '${dialog}'`));
assert.match(branch.slice(0, 200), /await moonStatusSettled\(card\);/, dialog);
}
assert.match(harness, /hp-dialog \[data-moon-status\]/);
assert.match(harness, /innerWidth <= 390/);
});
+108 -4
View File
@@ -1,11 +1,13 @@
// #661 AC10: the moon switch in General settings — «Sun and Moon» section,
// reset to on, save writes `true` or removes the key; its strings exist in all
// four dictionaries. The dialog is a lazy Lit module (its layout is read as a
// source contract); reading and writing the key are executed.
// four dictionaries. #718: the status line under it (AC14, K7). The dialog is
// a lazy Lit module (its layout is read as a source contract); reading and
// writing the key and the status of an opening are executed.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { moonDraftOf, writeMoonSetting } from '../test-build/editors/general-form-state.js';
import { generalDraftKey, moonDraftOf, writeMoonSetting } from '../test-build/editors/general-form-state.js';
import { moonStatusOf, openMoonStatus } from '../test-build/editors/moon-status.js';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const json = (path) => JSON.parse(read(path));
@@ -19,7 +21,10 @@ test('#661 AC10: the switch sits in the «Sun and Moon» card under «Sunlight t
assert.ok(rays > 0 && moon > rays, 'moon row follows the sun-rays row');
const row = sun.slice(moon, sun.indexOf('})}', moon));
assert.match(row, /icon: 'mdi:moon-waning-crescent'/);
assert.match(row, /title: st\('gs\.moon'\), caption: st\('gs\.moon_hint'\)/);
assert.match(row, /title: st\('gs\.moon'\),/);
// #718 K7: the hint alone until the chunk judged this opening, then the status as its second line.
assert.match(row, /caption: moonStatus\s+\? html`\$\{st\('gs\.moon_hint'\)\}<span style="display:block" data-moon-status=\$\{moonStatus\.reason\}>\$\{moonStatusText\(moonStatus, st\)\}<\/span>`\s+: st\('gs\.moon_hint'\)/);
assert.doesNotMatch(row, /aria-live/);
assert.match(row, /checked: d\.moon, onChange: \(v\) => set\(\{ moon: v \}\)/);
const reset = dialog.slice(dialog.indexOf("${t('gs.reset')}") - 600, dialog.indexOf("${t('gs.reset')}"));
assert.match(reset, /moon: true/, 'reset to defaults switches the moon on (new installations)');
@@ -51,3 +56,102 @@ test('#661 AC10: strings in all four dictionaries, section renamed', () => {
assert.equal(json('src/i18n/settings/ru.json')['gs.moon'], 'Луна на плане в сумерках и ночью');
assert.equal(json('src/i18n/settings/en.json')['gs.moon'], 'Moon over the plan at dusk and night');
});
/** #718: the seven keys, RU and EN verbatim from the specification. */
const MOON_KEYS = {
'gs.moon_hint': {
ru: 'Видна за планом при любом фоне, от 3° над горизонтом, кроме новолуния; положение считается по координатам дома из Home Assistant.',
en: 'Shown behind the plan with any background, from 3° above the horizon, except around new moon; computed from the home coordinates in Home Assistant.',
},
'gs.moon_status_shown': {
ru: 'Сейчас: показывается (на {alt}° над горизонтом, освещено {pct}\u00a0%).',
en: 'Now: shown ({alt}° above the horizon, {pct}% lit).',
},
'gs.moon_status_no_home': {
ru: 'Сейчас: не показывается (в Home Assistant не заданы координаты дома).',
en: 'Now: not shown (the home location is not set in Home Assistant).',
},
'gs.moon_status_day_sun': {
ru: 'Сейчас: не показывается (Солнце на {sun}° над горизонтом, луна видна, когда оно ниже 6°).',
en: 'Now: not shown (the sun is {sun}° above the horizon; the moon shows once it is below 6°).',
},
'gs.moon_status_day_clock': {
ru: 'Сейчас: не показывается (день по часам, 08:00\u201318:00: в Home Assistant нет данных sun.sun).',
en: 'Now: not shown (daytime by the clock, 08:00\u201318:00: Home Assistant has no sun.sun data).',
},
'gs.moon_status_low': {
ru: 'Сейчас: не показывается (луна на высоте {alt}°, видна от 3° над горизонтом).',
en: 'Now: not shown (the moon is at {alt}°; it shows from 3° above the horizon).',
},
'gs.moon_status_new': {
ru: 'Сейчас: не показывается (новолуние, освещено {pct}\u00a0%, видна от 3\u00a0%).',
en: 'Now: not shown (new moon, {pct}% lit; it shows from 3%).',
},
};
test('#718 AC14: seven keys in all four dictionaries, one placeholder set, RU and EN as specified', () => {
const placeholders = (text) => [...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort();
for (const [key, expected] of Object.entries(MOON_KEYS)) {
const reference = placeholders(expected.en);
for (const lang of ['en', 'ru', 'de', 'fr']) {
const text = json(`src/i18n/settings/${lang}.json`)[key];
assert.ok(typeof text === 'string' && text.trim(), `${lang} ${key}`);
assert.deepEqual(placeholders(text), reference, `${lang} ${key}: placeholders`);
}
assert.equal(json('src/i18n/settings/ru.json')[key], expected.ru, `ru ${key}`);
assert.equal(json('src/i18n/settings/en.json')[key], expected.en, `en ${key}`);
}
// #502: called by literal, never as `gs.moon_status_${reason}`.
const source = read('src/editors/moon-status.ts');
for (const reason of ['shown', 'no_home', 'day_sun', 'day_clock', 'low', 'new']) {
assert.match(source, new RegExp(`'gs\\.moon_status_${reason}'`));
}
assert.doesNotMatch(source, /gs\.moon_status_\$\{/);
});
function dialogHost(hass) {
return {
hass,
updates: 0,
_settingsDialog: { moon: true, bgMode: 'static', busy: false, glowRadiusInput: '3', northDegInput: '' },
requestUpdate() { this.updates++; },
};
}
const NIGHT = { azimuth: 0, elevation: -12, rising: false };
const hassWith = (sun) => ({
config: { latitude: 55.75, longitude: 37.62 },
states: sun ? { 'sun.sun': { attributes: sun } } : {},
});
const settle = () => new Promise((done) => setTimeout(done, 300));
test('#718 K7: one snapshot per opening, outside the draft; a closed opening’s result is dropped', async () => {
const at = new Date('2026-10-21T18:00:00Z');
const host = dialogHost(hassWith(NIGHT));
const draftKey = generalDraftKey(host._settingsDialog);
// First opening: the chunk is not here yet, and the dialog closes before it arrives.
openMoonStatus(host, at);
assert.equal(moonStatusOf(host), undefined, 'no line while the chunk loads');
host._settingsDialog = null;
await settle();
assert.equal(moonStatusOf(host), undefined, 'the closed opening’s result is dropped');
assert.equal(host.updates, 0);
// Second opening after the sun rose: its own snapshot, at once (the chunk is here now).
host._settingsDialog = dialogHost()._settingsDialog;
host.hass = hassWith({ azimuth: 180, elevation: 40, rising: false });
openMoonStatus(host, at);
assert.deepEqual(moonStatusOf(host), { reason: 'day_sun', sun: 40 });
assert.equal(host.updates, 1);
// The snapshot is the opening's: a later state update does not change it.
host.hass = hassWith(NIGHT);
assert.deepEqual(moonStatusOf(host), { reason: 'day_sun', sun: 40 });
// The line is not part of the draft: its key is unchanged, the dialog is not dirty.
assert.equal(generalDraftKey(host._settingsDialog), draftKey);
// Night and the switch off in the draft: the status is still what «on» would show.
host._settingsDialog = { ...host._settingsDialog, moon: false };
openMoonStatus(host, at);
assert.deepEqual(moonStatusOf(host), { reason: 'shown', alt: 24, pct: 79 });
// No sun.sun: the clock decides — midday is day by the clock.
host.hass = hassWith(null);
openMoonStatus(host, new Date(2026, 9, 21, 12, 0));
assert.deepEqual(moonStatusOf(host), { reason: 'day_clock' });
});
+174 -11
View File
@@ -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\}/);
});