mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +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:
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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