mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
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
158 lines
8.8 KiB
JavaScript
158 lines
8.8 KiB
JavaScript
// #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' });
|
||
});
|