- New src/lib/api/moon.js: fetches daily moon_phase from Open-Meteo (independent of the NWS/Open-Meteo weather source, since NWS has no moon data), plus pure helpers: moonName (8 phases), illumination fraction, and nextMoonPhase, which unwraps the ~29.5-day cycle and interpolates the next quarter boundary (new/first quarter/full/last quarter), returning its name, date, and days until it. - New MoonPhaseCard.svelte: renders an SVG moon (two-circle terminator that correctly shows crescent/quarter/gibbous shapes), the current phase name, percent illuminated, and the next phase with date. Caches per-location via an effect; degrades gracefully when unavailable. - Wired into WeatherDetail between Sun and UV and Temperature Range. - 11 unit tests for moonName/illumination/interpolation/nextMoonPhase. - Verified in headless Chromium: card renders, moon shape correct, no errors.
85 lines
2.9 KiB
JavaScript
85 lines
2.9 KiB
JavaScript
import { describe, it, expect } from 'vitest'
|
|
import { moonName, illumination, nextMoonPhase, interpolateDate, diffDays } from '../../../src/lib/api/moon.js'
|
|
|
|
describe('moonName', () => {
|
|
it('names the four principal phases', () => {
|
|
expect(moonName(0.0)).toBe('New Moon')
|
|
expect(moonName(0.25)).toBe('First Quarter')
|
|
expect(moonName(0.5)).toBe('Full Moon')
|
|
expect(moonName(0.75)).toBe('Last Quarter')
|
|
})
|
|
|
|
it('names the intermediate phases', () => {
|
|
expect(moonName(0.125)).toBe('Waxing Crescent')
|
|
expect(moonName(0.375)).toBe('Waxing Gibbous')
|
|
expect(moonName(0.625)).toBe('Waning Gibbous')
|
|
expect(moonName(0.875)).toBe('Waning Crescent')
|
|
})
|
|
|
|
it('wraps 1.0 back to new moon', () => {
|
|
expect(moonName(1.0)).toBe('New Moon')
|
|
expect(moonName(0.98)).toBe('Waning Crescent')
|
|
})
|
|
})
|
|
|
|
describe('illumination', () => {
|
|
it('is 0 at new moon and 1 at full', () => {
|
|
expect(illumination(0)).toBeCloseTo(0, 5)
|
|
expect(illumination(0.5)).toBeCloseTo(1, 5)
|
|
})
|
|
|
|
it('is symmetric about full: quarter both ~0.5', () => {
|
|
expect(illumination(0.25)).toBeCloseTo(0.5, 5)
|
|
expect(illumination(0.75)).toBeCloseTo(0.5, 5)
|
|
})
|
|
|
|
it('near new gives small illumination', () => {
|
|
expect(illumination(0.912)).toBeLessThan(0.1)
|
|
})
|
|
})
|
|
|
|
describe('interpolateDate / diffDays', () => {
|
|
it('interpolates dates', () => {
|
|
expect(interpolateDate('2026-09-10', '2026-09-12', 0.5)).toBe('2026-09-11')
|
|
expect(interpolateDate('2026-09-10', '2026-09-12', 0)).toBe('2026-09-10')
|
|
expect(interpolateDate('2026-09-10', '2026-09-12', 1)).toBe('2026-09-12')
|
|
})
|
|
|
|
it('computes whole days between dates', () => {
|
|
expect(diffDays('2026-09-08', '2026-09-10')).toBe(2)
|
|
expect(diffDays('2026-09-08', '2026-09-08')).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('nextMoonPhase', () => {
|
|
const mk = (arr) => arr.map(([date, phase]) => ({ date, phase }))
|
|
|
|
it('finds next new moon from a waning-crescent sample', () => {
|
|
const days = mk([
|
|
['2026-09-08', 0.912], ['2026-09-09', 0.948], ['2026-09-10', 0.985],
|
|
['2026-09-11', 0.020], ['2026-09-12', 0.054], ['2026-09-13', 0.088],
|
|
['2026-09-14', 0.121], ['2026-09-15', 0.153],
|
|
])
|
|
const r = nextMoonPhase(days)
|
|
expect(r.name).toBe('New Moon')
|
|
expect(r.daysUntil).toBe(2)
|
|
expect(r.date).toBe('2026-09-10')
|
|
})
|
|
|
|
it('finds next first quarter from a new-moon sample', () => {
|
|
const days = mk([
|
|
['2026-09-11', 0.020], ['2026-09-12', 0.054], ['2026-09-13', 0.088],
|
|
['2026-09-14', 0.121], ['2026-09-15', 0.153], ['2026-09-16', 0.184],
|
|
['2026-09-17', 0.215], ['2026-09-18', 0.245], ['2026-09-19', 0.276],
|
|
])
|
|
const r = nextMoonPhase(days)
|
|
expect(r.name).toBe('First Quarter')
|
|
expect(r.daysUntil).toBe(7)
|
|
})
|
|
|
|
it('returns null with too few samples', () => {
|
|
expect(nextMoonPhase(null)).toBeNull()
|
|
expect(nextMoonPhase(mk([['2026-09-08', 0.5]]))).toBeNull()
|
|
})
|
|
})
|