feat(view): the moon in its phase over the "Follow the Sun" background (#661)

At dawn, dusk and night the environment shows the moon in the top-left
corner of the scene, behind the plan: computed in the card from the home
coordinates and the browser clock (short Meeus series + topocentric
parallax, within 1.4° / 1.8 pp of JPL Horizons), one designer image under
a continuous phase mask with the lit side always on the left (owner
2026-09-29), a feathered terminator, 3°/3 % thresholds and the 2 s fade of
the window rays. Everything but a small gate lives in the lazy
moon-runtime chunk, with its own 30 s ticker. General settings: "Sun"
becomes "Sun and Moon" with one switch, on for new installations
(DEFAULT_CONFIG), off for existing ones.

The initial View graph sat 728 B under its budget: the gate is paid for by
moving fifteen dialog-only strings of General settings into the lazy
settings dictionary (#459) and by one build fingerprint literal instead of
three, so the graph ends 4 B above dev. Golden: two new moon scenes, and
the two General settings help frames show «Sun and Moon»; the WSL artifact
test fixture now models scenes whose first capture awaits acceptance.

Issue: #661
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:
Sergey Matyunin
2026-09-30 12:25:46 +03:00
co-authored by Claude Opus 5.5
parent e515bdaab8
commit c8c470ed57
58 changed files with 1682 additions and 126 deletions
+27 -5
View File
@@ -191,7 +191,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
'houseplan-assets/de-HASH.js', 'houseplan-assets/iso-scene-render-HASH.js',
'houseplan-assets/furniture-plan-art.generated-HASH.js',
'houseplan-assets/pdf-export-HASH.js',
'houseplan-assets/pdf-export-HASH.js', 'houseplan-assets/moon-runtime-HASH.js',
],
},
'shared.js': {
@@ -232,6 +232,11 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
code: 'PDF export runtime', isEntry: false, imports: ['shared.js'], dynamicImports: [],
modules: { '/repo/src/pdf/pdf-export.ts': {} },
},
'houseplan-assets/moon-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/moon-runtime-HASH.js',
code: 'moon runtime', isEntry: false, imports: ['shared.js'], dynamicImports: [],
modules: { '/repo/src/moon-runtime.ts': {}, '/repo/src/moon-art.generated.ts': {} },
},
}, 'fingerprint');
assert.equal(manifest.entry, 'houseplan-card.js');
assert.equal(manifest.panelEntry, 'houseplan-panel.js');
@@ -252,6 +257,9 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
assert.deepEqual(manifest.lazyIsometricFiles, ['houseplan-assets/iso-scene-render-HASH.js']);
assert.deepEqual(manifest.lazyFurnitureArtFiles, ['houseplan-assets/furniture-plan-art.generated-HASH.js']);
assert.deepEqual(manifest.lazyPdfFiles, ['houseplan-assets/pdf-export-HASH.js']);
// #661 AC8: the moon (math, art, template) is its own lazy graph, never initial.
assert.deepEqual(manifest.lazyMoonFiles, ['houseplan-assets/moon-runtime-HASH.js']);
assert.equal(manifest.initialViewFiles.includes('houseplan-assets/moon-runtime-HASH.js'), false);
assert.deepEqual(manifest.lazyEditorFiles, ['houseplan-assets/editor.js', 'houseplan-assets/furniture-plan-art.generated-HASH.js']);
// #627: found by module, not as a root of the initial graph; own graph.
assert.deepEqual(manifest.lazyNamespaceLocaleFiles, NAMESPACE_LOCALE_CHUNKS.map(namespaceLocaleChunkPath).sort());
@@ -263,6 +271,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
'houseplan-assets/furniture-plan-art.generated-HASH.js',
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
'houseplan-assets/iso-scene-render-HASH.js',
'houseplan-assets/moon-runtime-HASH.js',
'houseplan-assets/pdf-export-HASH.js',
]);
// Этот тест про РАЗДЕЛЕНИЕ графов, а не про их размеры: потолки ленивых
@@ -291,6 +300,10 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
assert.throws(() => assertBundleBudget({
...manifest, lazyNamespaceLocaleFiles: manifest.lazyNamespaceLocaleFiles.slice(1),
}, 1_000_000, undefined, ...lazyCeilings), /lazy namespace locale graph has 8 files, expected 9/);
// #661 AC8: a moon pulled into the first frame (a static import leaves no
// 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/);
});
test('#486 Rollup names both stable entries explicitly', async () => {
@@ -518,7 +531,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
+ 'new URL("__HOUSEPLAN_DE_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_FR_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_PDF_RETRY_ASSET__", import.meta.url)', modules: {},
+ 'new URL("__HOUSEPLAN_PDF_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_MOON_RETRY_ASSET__", import.meta.url)', modules: {},
},
// #627: the lazy namespace chunk that owns the nine second-attempt tokens.
'houseplan-assets/backdrop-pick-HASH.js': {
@@ -554,6 +568,10 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
type: 'chunk', fileName: 'houseplan-assets/pdf-export-HASH.js', code: '',
modules: { '/repo/src/pdf/pdf-export.ts': {} },
},
'houseplan-assets/moon-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/moon-runtime-HASH.js', code: '',
modules: { '/repo/src/moon-runtime.ts': {} },
},
};
plugin.generateBundle({}, bundle);
assert.equal(
@@ -564,7 +582,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
+ 'new URL("./de-HASH.js", import.meta.url);'
+ 'new URL("./fr-HASH.js", import.meta.url);'
+ 'new URL("./furniture-plan-art.generated-HASH.js", import.meta.url);'
+ 'new URL("./pdf-export-HASH.js", import.meta.url)',
+ 'new URL("./pdf-export-HASH.js", import.meta.url);'
+ 'new URL("./moon-runtime-HASH.js", import.meta.url)',
);
assert.equal(
bundle['houseplan-assets/backdrop-pick-HASH.js'].code,
@@ -577,7 +596,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi
const bundle = {
'houseplan-assets/houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js', modules: {},
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF']
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF', 'MOON']
.map((name) => `"__HOUSEPLAN_${name}_RETRY_ASSET__"`).join(';'),
},
...Object.fromEntries([
@@ -585,7 +604,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi
['houseplan-onboarding-runtime', '/src/houseplan-onboarding-runtime.ts'],
['iso-scene-render', '/src/iso-scene-render.ts'], ['de', '/src/i18n/de.ts'],
['fr', '/src/i18n/fr.ts'], ['furniture-plan-art.generated', '/src/furniture-plan-art.generated.ts'],
['pdf-export', '/src/pdf/pdf-export.ts'],
['pdf-export', '/src/pdf/pdf-export.ts'], ['moon-runtime', '/src/moon-runtime.ts'],
].map(([name, module]) => [`houseplan-assets/${name}-HASH.js`, {
type: 'chunk', fileName: `houseplan-assets/${name}-HASH.js`, code: '', modules: { [`/repo${module}`]: {} },
}])),
@@ -979,6 +998,7 @@ const runBudgetCli = (initialViewGzipBytes) => {
writeFileSync(join(dir, 'dist/isometric.js'), 'lazy isometric runtime');
writeFileSync(join(dir, 'dist/furniture-art.js'), 'lazy furniture artwork');
writeFileSync(join(dir, 'dist/pdf.js'), 'lazy pdf writer');
writeFileSync(join(dir, 'dist/moon.js'), 'lazy moon runtime');
writeFileSync(join(dir, 'dist/houseplan-assets.json'), JSON.stringify({
schema: 1,
fingerprint: 'f'.repeat(64),
@@ -1008,6 +1028,8 @@ const runBudgetCli = (initialViewGzipBytes) => {
lazyFurnitureArtGzipBytes: LAZY_FURNITURE_ART_GZIP_CEILING - 1_000,
lazyPdfFiles: ['pdf.js'],
lazyPdfGzipBytes: 100,
lazyMoonFiles: ['moon.js'],
lazyMoonGzipBytes: 100,
lazyOnboardingFiles: ['onboarding.js'],
lazyOnboardingGzipBytes: LAZY_ONBOARDING_GZIP_CEILING - 1_000,
lazyNamespaceLocaleFiles: NAMESPACE_LOCALE_CHUNKS
+20 -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, 67);
assert.equal(GOLDEN_MATRIX_VERSION, 68);
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, 67);
assert.equal(GOLDEN_MATRIX_VERSION, 68);
assert.deepEqual(ids, [
'isometric-stage6-light-lightfloor',
'isometric-stage6-light-darkfloor',
@@ -1226,3 +1226,21 @@ test('issue 43 support dialog has the six reviewed responsive states', () => {
assert.equal(support.filter((scenario) => scenario.viewport.width >= 900).length, 3);
for (const scenario of support) assert.equal(scenario.capture, 'page', scenario.id);
});
test('#661 AC7 moon scenes: fixed home and clock, moon on, the old day-cycle frames untouched', () => {
const byId = (id) => GOLDEN_SCENARIOS.find((scenario) => scenario.id === id);
const gibbous = byId('day-cycle-night-moon-gibbous-dark');
const crescent = byId('day-cycle-dusk-moon-crescent-south-dark');
assert.deepEqual(gibbous.moon, { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' });
assert.deepEqual(crescent.moon, { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' });
for (const scenario of [gibbous, crescent]) {
assert.equal(scenario.capture, 'stage');
assert.equal(scenario.bgMode, 'daynight');
assert.equal(prepareGoldenFixture(scenario).config.settings.moon, true);
}
for (const id of ['day-cycle-dawn-dark', 'day-cycle-day-dark', 'day-cycle-dusk-dark', 'day-cycle-night-dark']) {
const scenario = byId(id);
assert.equal(scenario.moon, undefined, id);
assert.equal(prepareGoldenFixture(scenario).config.settings?.moon, undefined, id);
}
});
+13 -3
View File
@@ -42,15 +42,25 @@ const toolchain = () => {
};
};
/** Matrix scenarios whose first capture still awaits acceptance (declared as new). */
const PENDING_NEW = Object.freeze(GOLDEN_SCENARIOS.map((scenario) => scenario.id)
.filter((id) => !existsSync(resolve(BASELINES, `${id}.png`))));
const ACCEPTED = GOLDEN_SCENARIOS.map((scenario) => scenario.id).filter((id) => !PENDING_NEW.includes(id));
function artifact() {
const dir = mkdtempSync(resolve(tmpdir(), 'hp-golden-wsl-'));
const actualRoot = resolve(dir, 'actual');
mkdirSync(actualRoot, { recursive: true });
const index = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
const results = GOLDEN_SCENARIOS.map((scenario) => {
const baseline = resolve(BASELINES, `${scenario.id}.png`);
assert.equal(existsSync(baseline), true, `test fixture needs reviewed baseline ${scenario.id}`);
const actual = resolve(actualRoot, `${scenario.id}.png`);
// #661: a scenario added to the matrix has no reviewed baseline until its
// first Linux capture is accepted — exactly what a real run reports then.
if (PENDING_NEW.includes(scenario.id)) {
copyFileSync(resolve(BASELINES, `${ACCEPTED[0]}.png`), actual);
return { id: scenario.id, status: 'missing-baseline', actualSha256: digest(readFileSync(actual)) };
}
const baseline = resolve(BASELINES, `${scenario.id}.png`);
copyFileSync(baseline, actual);
const sha = digest(readFileSync(actual));
return { id: scenario.id, status: 'passed', actualSha256: sha, baselineSha256: sha };
@@ -72,7 +82,7 @@ function artifact() {
return dir;
}
const intent = Object.freeze({ expectChange: [], expectNew: [], noWitnesses: false, reason: '' });
const intent = Object.freeze({ expectChange: [], expectNew: PENDING_NEW, noWitnesses: false, reason: '' });
test('#641: repository and WSL/ext4 preconditions fail closed', () => {
assert.equal(repositoryRefusal(source), null);
+53
View File
@@ -0,0 +1,53 @@
// #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.
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';
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'\), caption: st\('gs\.moon_hint'\)/);
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');
});
+253
View File
@@ -0,0 +1,253 @@
// #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.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
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,
} 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 { renderDayCycleEnvironment } from '../test-build/day-cycle-render.js';
import { moonArtFromSvg, validateMoonPack } from '../scripts/generate-moon-assets.mjs';
const MOSCOW = { latitude: 55.75, longitude: 37.62 };
const SYDNEY = { latitude: -33.87, longitude: 151.21 };
const ON = { moon: true, bg_mode: 'daynight' };
/**
* AC1 reference: JPL Horizons, observer table, `CENTER='coord@399'` with
* `SITE_COORD='<lon>,<lat>,0'`, `QUANTITIES='4,10'` (apparent az/el and
* illuminated fraction), `APPARENT='AIRLESS'`, `TIME_TYPE=UT`, one row per
* epoch below. Moscow 55.75 N 37.62 E, Sydney 33.87 S 151.21 E, October 2026.
*/
const HORIZONS = [
['2026-10-01T18:00:00Z', MOSCOW, 9.052, 70.03],
['2026-10-02T18:00:00Z', MOSCOW, 3.018, 59.01],
['2026-10-03T18:00:00Z', MOSCOW, -3.347, 47.57],
['2026-10-10T18:00:00Z', MOSCOW, -32.212, 0.176],
['2026-10-18T18:00:00Z', MOSCOW, 4.050, 50.44],
['2026-10-21T18:00:00Z', MOSCOW, 23.279, 78.16],
['2026-10-25T18:00:00Z', MOSCOW, 37.066, 99.67],
['2026-10-26T20:00:00Z', MOSCOW, 48.978, 99.27],
['2026-10-31T18:00:00Z', MOSCOW, -0.096, 62.05],
['2026-10-12T09:00:00Z', SYDNEY, 8.150, 3.00],
['2026-10-14T09:00:00Z', SYDNEY, 30.692, 13.20],
['2026-10-20T09:00:00Z', SYDNEY, 72.716, 66.31],
];
test('#661 AC1: altitude within 1.5° and illumination within 2.5 pp of JPL Horizons', () => {
for (const [utc, place, altitude, percent] of HORIZONS) {
const date = new Date(utc);
const position = moonPosition(date, place.latitude, place.longitude);
const { fraction } = moonIllumination(date);
assert.ok(Math.abs(position.altitude - altitude) <= 1.5,
`${utc} ${place.latitude}: altitude ${position.altitude.toFixed(3)} vs ${altitude}`);
assert.ok(Math.abs(fraction * 100 - percent) <= 2.5,
`${utc} ${place.latitude}: illumination ${(fraction * 100).toFixed(2)} vs ${percent}`);
assert.ok(position.azimuth >= 0 && position.azimuth < 360);
}
});
test('#661 AC1: waxing follows the lunation (first quarter grows, last quarter shrinks)', () => {
assert.equal(moonIllumination(new Date('2026-10-14T09:00:00Z')).waxing, true);
assert.equal(moonIllumination(new Date('2026-10-21T18:00:00Z')).waxing, true);
assert.equal(moonIllumination(new Date('2026-10-31T18:00:00Z')).waxing, false);
});
test('#661 C1: the moon keeps the rays’ horizon and a 3 % new-moon threshold', () => {
assert.equal(MOON_ELEVATION_MIN, RAY_ELEVATION_MIN);
assert.equal(MOON_ELEVATION_MIN, 3);
assert.equal(MOON_MIN_ILLUMINATION, 0.03);
});
test('#661 AC2: visibility thresholds on both sides', () => {
for (const phase of ['dawn', 'dusk', 'night']) {
assert.equal(moonShownAt(phase, 2.9, 0.5), false, `${phase} 2.9°`);
assert.equal(moonShownAt(phase, 3.0, 0.5), true, `${phase} 3.0°`);
assert.equal(moonShownAt(phase, 30, 0.029), false, `${phase} k 0.029`);
assert.equal(moonShownAt(phase, 30, 0.03), true, `${phase} k 0.03`);
}
assert.equal(moonShownAt('day', 45, 1), false);
});
test('#661 AC2: only an explicit true, finite home coordinates and an environment 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 }]) {
assert.equal(moonView(settings, 'night', MOSCOW, at).visible, false, JSON.stringify(settings));
}
for (const config of [undefined, null, {}, { latitude: '55.75', longitude: 37.62 },
{ latitude: Number.NaN, longitude: 37.62 }, { latitude: 55.75, longitude: null }]) {
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.
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);
});
/** Decode `A rx ry rot large sweep x y` of the terminator from the mask path. */
function terminatorOf(path) {
const arcs = [...path.matchAll(/A([\d.]+) ([\d.]+) 0 0 ([01]) ([\d.]+) ([\d.]+)/g)]
.map((match) => match.slice(1).map(Number));
return { outer: arcs[0], terminator: arcs[1] ?? null };
}
test('#661 AC3: the mask is the left half plus or minus the terminator half-ellipse', () => {
for (const k of [0.03, 0.25, 0.5, 0.75, 0.97]) {
const path = moonPhasePath(k);
const { outer, terminator } = terminatorOf(path);
// The lit half's outer arc runs along the box (r 256), counter-clockwise
// from the top: through the LEFT edge, never along the limb.
assert.deepEqual(outer, [256, 256, 0, 256, 512], `${k}: outer arc`);
const rx = MOON_R * Math.abs(2 * k - 1);
if (rx < 0.5) {
assert.equal(terminator, null, `${k}: a half moon has a straight terminator`);
assert.match(path, /L256 496L256 16L256 0Z$/);
continue;
}
const [trx, try_, sweep, x, y] = terminator;
assert.ok(Math.abs(trx - rx) < 0.01, `${k}: rx ${trx} vs ${rx}`);
assert.equal(try_, MOON_R);
assert.deepEqual([x, y], [256, 16], `${k}: the terminator ends at the north pole`);
// sweep 0 bulges right, into the dark side: the lit area grows past half.
const litShare = 0.5 + (sweep === 0 ? 1 : -1) * (trx / MOON_R) / 2;
assert.ok(Math.abs(litShare - k) < 1e-3, `${k}: lit share ${litShare}`);
}
});
test('#661 AC3 (owner 2026-09-29): one lit side in both hemispheres, waxing or waning', () => {
// Sydney 2026-10-14 09:00Z: waxing crescent, 14 % — lit on the LEFT, like Moscow.
const at = new Date('2026-10-14T09:00:00Z');
const hosts = [fakeHost(SYDNEY), fakeHost(MOSCOW)];
const sydney = renderMoon(hosts[0], ON, 'dusk', at);
const moscow = renderMoon(hosts[1], ON, 'night', at);
hosts.forEach(forgetHost);
const pathOf = (result) => result.values.find((value) => typeof value === 'string' && value.startsWith('M256 0A256'));
assert.equal(pathOf(sydney), pathOf(moscow));
assert.equal(pathOf(sydney), moonPhasePath(0.14));
// Waxing and waning at the same k draw the same state backwards.
const waxing = new Date('2026-10-21T18:00:00Z');
const waning = new Date('2026-10-29T12:00:00Z');
assert.equal(moonIllumination(waxing).waxing, true);
assert.equal(moonIllumination(waning).waxing, false);
assert.equal(moonPhasePath(0.78), moonPhasePath(Math.round(0.78 * 100) / 100));
});
test('#661 C5: the feathered terminator stops only for a full disc', () => {
assert.equal(MOON_SHARP_FROM, 0.995);
const host = fakeHost(MOSCOW);
const filterOf = (at) => renderMoon(host, ON, 'night', new Date(at)).values
.find((value) => value === nothing || value === 'url(#hp-moon-soft)');
assert.equal(filterOf('2026-10-21T18:00:00Z'), 'url(#hp-moon-soft)');
forgetHost(host);
});
function fakeHost(config) {
return {
isConnected: true,
updates: 0,
updateComplete: Promise.resolve(true),
hass: { config },
requestUpdate() { this.updates++; },
};
}
function forgetHost(host) {
host.isConnected = false;
moonTick(host);
}
test('#661 AC4: three ticks over an unchanged moon cost no render', () => {
const host = fakeHost(MOSCOW);
const start = new Date('2026-10-21T18:00:00Z');
renderMoon(host, ON, 'night', start);
for (let tick = 1; tick <= 3; tick++) moonTick(host, new Date(start.getTime() + tick * MOON_TICK_MS));
assert.equal(host.updates, 0);
assert.equal(
moonFingerprint(moonView(ON, 'night', MOSCOW, start)),
moonFingerprint(moonView(ON, 'night', MOSCOW, new Date(start.getTime() + 3 * MOON_TICK_MS))),
);
forgetHost(host);
});
test('#661 AC4: the tick asks for exactly one render when the moon sets below 3°', async () => {
// Moscow 2026-10-21: 3.026° at 22:37Z, 2.902° at 22:38Z (the setting side).
const host = fakeHost(MOSCOW);
renderMoon(host, ON, 'night', new Date('2026-10-21T22:30:00Z'));
moonTick(host, new Date('2026-10-21T22:45:00Z'));
moonTick(host, new Date('2026-10-21T22:45:30Z'));
assert.equal(host.updates, 1);
// The host re-rendered without a moon (switched off): its ticker is dropped.
await host.updateComplete;
await Promise.resolve();
moonTick(host, new Date('2026-10-22T12:00:00Z'));
assert.equal(host.updates, 1);
});
test('#661 C4: the element fades on the background curve for two seconds', () => {
const host = fakeHost(MOSCOW);
const result = renderMoon(host, ON, 'night', new Date('2026-10-21T18:00:00Z'));
forgetHost(host);
const css = result.values[0];
assert.match(css, new RegExp(`transition:opacity ${RAY_FADE_MS}ms cubic-bezier\\(\\.22,\\.61,\\.36,1\\)`));
assert.match(css, /prefers-reduced-motion:reduce\)\{\.hp-moon\{transition:none\}/);
assert.match(css, /--hp-moon-box:min\(200px,25cqmin\)/);
assert.match(css, /pointer-events:none/);
assert.doesNotMatch(css, /filter|will-change|z-index/);
});
test('#661 C6: the moon is the environment’s last child — above the phases, below the plan', () => {
const state = resolveDayCycle({ states: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } } });
const moon = { moon: 'marker' };
const env = renderDayCycleEnvironment(state, 1, moon);
const at = env.values.indexOf(moon);
assert.ok(at > 0, 'the environment carries the moon');
assert.equal(env.strings[at + 1].trim(), '</div>', 'the environment closes right after the moon');
assert.match(env.strings[0], /class="hp-day-cycle-env"/);
});
test('#661 pack: generated art is fresh, and the gate refuses what the brief forbids', () => {
const run = spawnSync(process.execPath, ['scripts/generate-moon-assets.mjs', '--check'], { encoding: 'utf8' });
assert.equal(run.status, 0, run.stderr);
const pack = JSON.parse(readFileSync(new URL('../assets/moon/houseplan-1.0.0/pack.json', import.meta.url), 'utf8'));
assert.doesNotThrow(() => validateMoonPack(pack));
assert.throws(() => validateMoonPack({ ...pack, mirror: true }), /mirror/);
assert.throws(() => validateMoonPack({ ...pack, disc: { cx: 256, cy: 256, r: 256 } }), /disc/);
const svg = readFileSync(new URL('../assets/moon/houseplan-1.0.0/moon.svg', import.meta.url), 'utf8');
const art = moonArtFromSvg(svg);
assert.doesNotMatch(art, /<title|id="[abc]"/);
assert.match(art, /url\(#hp-moon-a\)/);
const root = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">';
assert.throws(() => moonArtFromSvg(`${root}<filter id="f"><feGaussianBlur/></filter></svg>`), /<filter>/);
assert.throws(() => moonArtFromSvg(`${root}<image href="x.png"/></svg>`), /outside itself|<image>/);
assert.throws(() => moonArtFromSvg(`${root.replace('512 512', '512 400')}</svg>`), /viewBox/);
assert.throws(() => moonArtFromSvg(`${root}<path d="${'M0 0'.repeat(9000)}"/></svg>`), /larger/);
});
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);
const host = fakeHost(MOSCOW);
assert.equal(moonLayer(host, ON, sunAt(40)), nothing, 'daytime: nothing and no load');
await new Promise((done) => setTimeout(done, 50));
assert.equal(host.updates, 0, 'nothing was loaded by day');
assert.equal(moonLayer(host, ON, sunAt(-12)), nothing, 'the first night render has no moon yet');
await new Promise((done) => setTimeout(done, 200));
assert.equal(host.updates, 1, 'the host that asked re-renders when the chunk arrives');
const element = moonLayer(host, ON, sunAt(-12));
assert.notEqual(element, nothing);
forgetHost(host);
});