mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
#718 K7 takes the moon status once per opening of General settings, outside the draft. A warm remount revives the open dialog on a new card instance, but `_warmReviveDialog` restored only the draft: the new instance had no opening of its own, so the "Now: ..." line never came back. A revive is an opening too. The `settings` branch now asks for the status the way `_openSettingsDialog` does - through the lazy editor runtime (`_openMoonStatus` -> `openMoonStatus`): at once when the runtime is there (an editor revives after `_requestMode(..., adopt)` has installed it), after it loads in View; once per revive and only while that revived dialog is still open. The snapshot of now, `hass.config` and `sun.sun` is the revive's own, nothing of the dead instance's opening is carried over, the draft key and the dirty flag do not change. The View graph gets no static moon-status import; other dialog kinds never ask for the moon chunk. demo/smoke_moon_status.mjs gains the revive scenarios - View, the plan editor, a revive while the chunk is still loading, a space-dialog revive that must not load the chunk; the first three are red on dev. test/moon-settings.test.mjs executes the revive as a new opening; the wiring itself is proven by the smoke, not by reading the monolith as text (#624). docs/SUN.md and docs/WARM-REMOUNT.md say a revive is an opening; scripts/smoke-links.mjs links the two new symbols to the smoke. Issue: #731 User-Visible: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
194 lines
11 KiB
JavaScript
194 lines
11 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); #731: a warm
|
||
// revive of the dialog is an opening of its own. 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 {
|
||
generalDirty, generalDraftKey, moonDraftOf, rememberGeneralBaseline, writeMoonSetting,
|
||
} from '../test-build/editors/general-form-state.js';
|
||
import { restoreWarmDialogBaseline, warmDialogBaseline } from '../test-build/editors/dialog-baseline.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' });
|
||
});
|
||
|
||
test('#731 AC2: a warm revive is a new opening — its own snapshot, nothing carried over, the draft untouched', async () => {
|
||
const at = new Date('2026-10-21T18:00:00Z');
|
||
// The instance Lovelace replaced: opened at night, judged, its draft clean.
|
||
const dead = dialogHost(hassWith(NIGHT));
|
||
rememberGeneralBaseline(dead, dead._settingsDialog);
|
||
openMoonStatus(dead, at);
|
||
await settle();
|
||
assert.deepEqual(moonStatusOf(dead), { reason: 'shown', alt: 24, pct: 79 });
|
||
// Its successor, as `_warmReviveDialog` hands the dialog over — the draft
|
||
// copied, the baseline restored — after the sun has risen.
|
||
const live = dialogHost(hassWith({ azimuth: 180, elevation: 40, rising: false }));
|
||
live._settingsDialog = { ...dead._settingsDialog, busy: false };
|
||
restoreWarmDialogBaseline(live, 'settings', warmDialogBaseline(dead, 'settings'));
|
||
assert.equal(moonStatusOf(live), undefined, 'the dead opening’s result is not carried over');
|
||
const key = generalDraftKey(live._settingsDialog);
|
||
assert.equal(generalDirty(live, live._settingsDialog), false);
|
||
// The revive's own opening (the runtime's `_openMoonStatus`): the snapshot of now.
|
||
openMoonStatus(live, at);
|
||
assert.deepEqual(moonStatusOf(live), { reason: 'day_sun', sun: 40 });
|
||
assert.equal(live.updates, 1);
|
||
assert.equal(generalDraftKey(live._settingsDialog), key, 'the line stays outside the draft');
|
||
assert.equal(generalDirty(live, live._settingsDialog), false, 'the line does not make it dirty');
|
||
// A dirty draft travels the same way: the line does not clean it either.
|
||
live._settingsDialog = { ...live._settingsDialog, moon: false };
|
||
openMoonStatus(live, at);
|
||
assert.equal(generalDirty(live, live._settingsDialog), true);
|
||
// The same element re-attached: its earlier opening is replaced, not reused.
|
||
live.hass = hassWith(NIGHT);
|
||
openMoonStatus(live, at);
|
||
assert.deepEqual(moonStatusOf(live), { reason: 'shown', alt: 24, pct: 79 });
|
||
});
|