Files
houseplan-card/test/moon-settings.test.mjs
T
Claudeandclaude[bot] b998b0b34a 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
2026-10-01 05:25:21 +00:00

158 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #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. #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 { 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));
test('#661 AC10: the switch sits in the «Sun and Moon» card under «Sunlight through windows»', () => {
const dialog = read('src/editors/general-settings-dialog.ts');
const sun = dialog.slice(dialog.indexOf("id: 'sun',"), dialog.indexOf("id: 'data',"));
assert.match(sun, /title: t\('gs\.sun_group'\)/);
const rays = sun.indexOf("id: 'gs-sun-rays'");
const moon = sun.indexOf("id: 'gs-moon'");
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'\),/);
// #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)');
});
test('#661 AC10: opening reads only an explicit true; saving writes true or removes the key', () => {
assert.equal(moonDraftOf({ moon: true }), true);
for (const settings of [undefined, null, {}, { moon: false }, { moon: 'true' }, { moon: 1 }]) {
assert.equal(moonDraftOf(settings), false, JSON.stringify(settings));
}
const on = { bg_mode: 'daynight' };
writeMoonSetting(on, true);
assert.deepEqual(on, { bg_mode: 'daynight', moon: true });
const off = { bg_mode: 'daynight', moon: true };
writeMoonSetting(off, false);
assert.deepEqual(off, { bg_mode: 'daynight' });
assert.equal('moon' in off, false, 'false is stored as absence');
});
test('#661 AC10: strings in all four dictionaries, section renamed', () => {
const group = { en: 'Sun and Moon', ru: 'Солнце и Луна', de: 'Sonne und Mond', fr: 'Soleil et Lune' };
for (const lang of ['en', 'ru', 'de', 'fr']) {
assert.equal(json(`src/i18n/${lang}.json`)['gs.sun_group'], group[lang], lang);
const settings = json(`src/i18n/settings/${lang}.json`);
for (const key of ['gs.moon', 'gs.moon_hint']) {
assert.ok(typeof settings[key] === 'string' && settings[key].trim(), `${lang} ${key}`);
}
}
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' });
});