-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtest-almanac-page.js
More file actions
457 lines (378 loc) · 24.6 KB
/
Copy pathtest-almanac-page.js
File metadata and controls
457 lines (378 loc) · 24.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
#!/usr/bin/env node
/**
* Test suite for almanac.html — the Daily Almanac page.
*
* Tests: julianDate, ghaAries, solarPosition, precessStar,
* riseSet, sunTimes, fmtDM, fmtDMns, fmtHM, dayOfYear
*
* node test-almanac-page.js
*/
const fs = require('fs');
const vm = require('vm');
// ── Load the engine from almanac.html ──
const html = fs.readFileSync('almanac.html', 'utf8');
const scriptBlocks = [];
const re = /<script[^>]*>([\s\S]*?)<\/script>/gi;
let match;
while ((match = re.exec(html)) !== null) scriptBlocks.push(match[1]);
const code = scriptBlocks.join('\n');
const noop = () => ({
value: '', style: {}, getContext: () => null,
addEventListener: () => {}, classList: { add(){}, remove(){}, contains(){ return false } },
innerHTML: '',
});
const sandbox = {
document: {
getElementById: () => noop(),
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
createElement: noop,
},
window: { addEventListener: () => {}, innerWidth: 1400 },
navigator: { serviceWorker: { register: () => Promise.resolve() } },
localStorage: { getItem: () => null, setItem: () => {} },
requestAnimationFrame: () => {},
ResizeObserver: class { observe(){} },
setTimeout: () => {},
confirm: () => false,
console,
Math, Date, Object, Array, String, Number, Boolean, JSON, Error, RegExp,
parseInt, parseFloat, isNaN, isFinite, undefined, NaN, Infinity,
Promise, Map, Set, Symbol,
};
sandbox.window.document = sandbox.document;
sandbox.self = sandbox.window;
vm.createContext(sandbox);
try { vm.runInContext(code, sandbox, { filename: 'almanac.html' }); } catch (e) { /* DOM init */ }
function calc(expr) {
return vm.runInContext(expr, sandbox);
}
// ── Test harness ─────────────────────────────────────
const G = '\x1b[32m', R = '\x1b[31m', Y = '\x1b[33m', B = '\x1b[1m', X = '\x1b[0m';
let pass = 0, fail = 0;
function assert(label, condition, detail = '') {
if (condition) {
pass++;
console.log(` ${G}✓${X} ${label}`);
} else {
fail++;
console.log(` ${R}✗${X} ${label}${detail ? ' — ' + detail : ''}`);
}
}
function assertClose(label, actual, expected, tolerance, unit = "'") {
const diff = Math.abs(actual - expected);
const diffArcmin = diff * 60;
if (diff <= tolerance) {
pass++;
console.log(` ${G}✓${X} ${label}: ${actual.toFixed(4)}° (Δ ${diffArcmin.toFixed(1)}${unit})`);
} else {
fail++;
console.log(` ${R}✗${X} ${label}: ${actual.toFixed(4)}° expected ${expected.toFixed(4)}° (Δ ${diffArcmin.toFixed(1)}${unit}, tol ${tolerance * 60}${unit})`);
}
}
// ══════════════════════════════════════════════════════
// Julian Date
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Julian Date ═══${X}`);
assertClose('J2000.0 epoch', calc(`julianDate(new Date('2000-01-01T12:00:00Z'))`), 2451545.0, 0.0001, 'd');
assertClose('Jan 1 2026 00:00', calc(`julianDate(new Date('2026-01-01T00:00:00Z'))`), 2461041.5, 0.0001, 'd');
assertClose('Jun 21 2025 12:00', calc(`julianDate(new Date('2025-06-21T12:00:00Z'))`), 2460848.0, 0.0001, 'd');
// ══════════════════════════════════════════════════════
// GHA Aries
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ GHA Aries ═══${X}`);
// Air Almanac 2026 reference values
assertClose('GHA Aries Jan 1 00:00', calc(`ghaAries(new Date('2026-01-01T00:00:00Z'))`), 100 + 39.7/60, 0.02);
assertClose('GHA Aries Jan 1 06:00', calc(`ghaAries(new Date('2026-01-01T06:00:00Z'))`), 190 + 54.5/60, 0.02);
assertClose('GHA Aries Jan 1 12:00', calc(`ghaAries(new Date('2026-01-01T12:00:00Z'))`), 281 + 9.3/60, 0.02);
assertClose('GHA Aries Mar 26 00:00', calc(`ghaAries(new Date('2026-03-26T00:00:00Z'))`), 183 + 27.4/60, 0.02);
// GHA Aries advances ~0.986° per sidereal day (excess over 360°)
const aries0 = calc(`ghaAries(new Date('2026-06-15T00:00:00Z'))`);
const aries24 = calc(`ghaAries(new Date('2026-06-16T00:00:00Z'))`);
const dailyRate = ((aries24 - aries0) % 360 + 360) % 360;
assertClose('Daily Aries advance ~0.986°', dailyRate, 0.986, 0.01, '°');
// ══════════════════════════════════════════════════════
// Solar Position
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Solar Position ═══${X}`);
// Jan 1 2026
const sun0 = calc(`solarPosition(new Date('2026-01-01T00:00:00Z'))`);
assertClose('Sun GHA Jan 1 00:00', sun0.gha, 179 + 10.1/60, 0.03);
assertClose('Sun Dec Jan 1 00:00', sun0.dec, -(23 + 1.0/60), 0.02);
const sun6 = calc(`solarPosition(new Date('2026-01-01T06:00:00Z'))`);
assertClose('Sun GHA Jan 1 06:00', sun6.gha, 269 + 8.3/60, 0.05);
const sun12 = calc(`solarPosition(new Date('2026-01-01T12:00:00Z'))`);
assertClose('Sun GHA Jan 1 12:00', sun12.gha, 359 + 6.5/60, 0.05);
// Mar 26 (near equinox)
const sunMar = calc(`solarPosition(new Date('2026-03-26T00:00:00Z'))`);
assertClose('Sun GHA Mar 26 00:00', sunMar.gha, 178 + 33.1/60, 0.05);
assertClose('Sun Dec Mar 26 (equinox ~12\' expected)', sunMar.dec, 2 + 19.2/60, 0.25);
// June solstice — dec near +23.44°
const sunJun = calc(`solarPosition(new Date('2026-06-21T12:00:00Z'))`);
assertClose('Sun Dec Jun 21 (solstice)', sunJun.dec, 23.44, 0.02);
// Dec solstice — dec near -23.44°
const sunDec = calc(`solarPosition(new Date('2025-12-21T12:00:00Z'))`);
assertClose('Sun Dec Dec 21 (solstice)', sunDec.dec, -23.44, 0.02);
// Sep equinox — dec near 0°
const sunSep = calc(`solarPosition(new Date('2025-09-23T00:00:00Z'))`);
assertClose('Sun Dec Sep equinox', sunSep.dec, 0.0, 0.25);
// GHA advances ~15°/hr
const sunA = calc(`solarPosition(new Date('2026-03-15T00:00:00Z'))`);
const sunB = calc(`solarPosition(new Date('2026-03-15T01:00:00Z'))`);
const ghaRate = ((sunB.gha - sunA.gha) % 360 + 360) % 360;
assertClose('Sun GHA hourly rate ~15°', ghaRate, 15.0, 0.01, '°');
// EoT range check (should be between -17 and +17 minutes)
const eotMin = sun0.eot * 4; // degrees to minutes
assert('EoT within ±17 min', Math.abs(eotMin) <= 17, `got ${eotMin.toFixed(1)} min`);
// ══════════════════════════════════════════════════════
// Star Precession (vs Skyfield/DE440s reference)
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Star Precession ═══${X}`);
// Reference values from Skyfield/DE440s apparent place at 2026-01-01
// SHA tolerance: 0.05° (3'). Dec tolerance: 0.05° (3') for most stars,
// 0.15° (9') for high-PM stars where nutation causes larger residuals.
const starRef = {
'Acamar': { sha: 315.185, dec: -40.200, decTol: 0.05 },
'Arcturus': { sha: 145.789, dec: 19.044, decTol: 0.05 },
'Sirius': { sha: 258.427, dec: -16.753, decTol: 0.05 },
'Vega': { sha: 80.544, dec: 38.802, decTol: 0.15 },
'Capella': { sha: 280.359, dec: 45.881, decTol: 0.15 },
'Aldebaran': { sha: 290.639, dec: 16.427, decTol: 0.15 },
'Rigel': { sha: 281.067, dec: -8.173, decTol: 0.05 },
'Procyon': { sha: 244.833, dec: 5.156, decTol: 0.05 },
};
for (const [name, ref] of Object.entries(starRef)) {
const p = calc(`(function(){ const s=STARS.find(s=>s.n==='${name}'); const p=precessStar(s,new Date('2026-01-01T00:00:00Z')); return{sha:p.sha,dec:p.dec}; })()`);
assertClose(`${name} SHA 2026`, p.sha, ref.sha, 0.05);
assertClose(`${name} Dec 2026`, p.dec, ref.dec, ref.decTol);
}
// Precession to a different epoch (2030)
// Verify that stars precess further from J2000 at later dates
const arcturusCurrent = calc(`(function(){ const s=STARS.find(s=>s.n==='Arcturus'); return precessStar(s,new Date('2026-01-01T00:00:00Z')).sha; })()`);
const arcturusFuture = calc(`(function(){ const s=STARS.find(s=>s.n==='Arcturus'); return precessStar(s,new Date('2030-01-01T00:00:00Z')).sha; })()`);
assert('Arcturus SHA changes between 2026 and 2030', Math.abs(arcturusCurrent - arcturusFuture) > 0.01,
`2026=${arcturusCurrent.toFixed(3)}, 2030=${arcturusFuture.toFixed(3)}`);
// At J2000.0 epoch, precession should be near-zero (SHA ≈ 360 - ra0)
const vegaJ2000 = calc(`(function(){ const s=STARS.find(s=>s.n==='Vega'); const p=precessStar(s,new Date('2000-01-01T12:00:00Z')); return{sha:p.sha,dec:p.dec}; })()`);
assertClose('Vega SHA at J2000 ≈ 360-ra0', vegaJ2000.sha, 360 - 279.235, 0.01);
assertClose('Vega Dec at J2000 ≈ dec0', vegaJ2000.dec, 38.784, 0.01);
// All 58 stars should be accessible
const starCount = calc(`STARS.length`);
assert('58 navigational stars in catalog', starCount === 58, `got ${starCount}`);
// Polaris Dec should be near 89° at any reasonable epoch
const polarisDec = calc(`(function(){ const s=STARS.find(s=>s.n==='Polaris'); return precessStar(s,new Date('2026-01-01T00:00:00Z')).dec; })()`);
assert('Polaris Dec > 89°', polarisDec > 89.0, `got ${polarisDec.toFixed(3)}°`);
// ══════════════════════════════════════════════════════
// Sunrise / Sunset / Twilight
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Sunrise / Sunset ═══${X}`);
// Equinox: sunrise ~06:00, sunset ~18:00 at equator
const eqSun = calc(`sunTimes(new Date('2026-03-20T00:00:00Z'), 0)`);
assertClose('Equator equinox sunrise ≈ 6:00', eqSun.rise, 6.0, 0.2, 'h');
assertClose('Equator equinox sunset ≈ 18:00', eqSun.set, 18.0, 0.2, 'h');
assert('Equator equinox not polar', !eqSun.alwaysUp && !eqSun.neverUp);
// June solstice at high latitude — polar day
const arcticJun = calc(`sunTimes(new Date('2026-06-21T00:00:00Z'), 70)`);
assert('70°N Jun solstice: always up', arcticJun.alwaysUp === true);
// June solstice at high south latitude — polar night
const antarcticJun = calc(`sunTimes(new Date('2026-06-21T00:00:00Z'), -70)`);
assert('70°S Jun solstice: never up', antarcticJun.neverUp === true);
// Dec solstice reversed
const arcticDec = calc(`sunTimes(new Date('2025-12-21T00:00:00Z'), 70)`);
assert('70°N Dec solstice: never up', arcticDec.neverUp === true);
// Mid-latitude: sunrise before noon, sunset after noon
const londonJul = calc(`sunTimes(new Date('2026-07-15T00:00:00Z'), 51.5)`);
assert('London Jul sunrise < 5h', londonJul.rise < 5, `got ${londonJul.rise?.toFixed(2)}h`);
assert('London Jul sunset > 20h', londonJul.set > 20, `got ${londonJul.set?.toFixed(2)}h`);
// Twilight ordering: nautical < civil < sunrise < sunset < civil < nautical
const midSun = calc(`sunTimes(new Date('2026-05-01T00:00:00Z'), 45)`);
assert('Twilight order: naut < civil < rise', midSun.nautTwlStart < midSun.civTwlStart && midSun.civTwlStart < midSun.rise);
assert('Twilight order: set < civil < naut', midSun.set < midSun.civTwlEnd && midSun.civTwlEnd < midSun.nautTwlEnd);
// Transit near 12:00 LMT
assert('Transit near 12:00 LMT', Math.abs(midSun.transit - 12) < 0.3, `got ${midSun.transit.toFixed(2)}h`);
// ══════════════════════════════════════════════════════
// Format Helpers
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Format Helpers ═══${X}`);
// fmtDM
const fmt1 = calc(`fmtDM(123.5)`);
assert('fmtDM(123.5) = "123°30.0\'"', fmt1 === '123\u00b030.0\'', `got "${fmt1}"`);
const fmt2 = calc(`fmtDM(0.25, 0)`);
assert('fmtDM(0.25, 0) shows 15\'', fmt2.includes('15'), `got "${fmt2}"`);
const fmt3 = calc(`fmtDM(359.999)`);
assert('fmtDM handles near-360', fmt3.includes('359'), `got "${fmt3}"`);
// fmtDMns
const fmtN = calc(`fmtDMns(23.5)`);
assert('fmtDMns(23.5) starts with N', fmtN.startsWith('N'), `got "${fmtN}"`);
const fmtS = calc(`fmtDMns(-23.5)`);
assert('fmtDMns(-23.5) starts with S', fmtS.startsWith('S'), `got "${fmtS}"`);
// fmtHM
const hm1 = calc(`fmtHM(14.5)`);
assert('fmtHM(14.5) = "14:30"', hm1 === '14:30', `got "${hm1}"`);
const hm2 = calc(`fmtHM(6.0)`);
assert('fmtHM(6.0) = "06:00"', hm2 === '06:00', `got "${hm2}"`);
const hm3 = calc(`fmtHM(null)`);
assert('fmtHM(null) = "----"', hm3 === '----', `got "${hm3}"`);
// ══════════════════════════════════════════════════════
// Day of Year
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Day of Year ═══${X}`);
assert('Jan 1 = day 1', calc(`dayOfYear(new Date('2026-01-01T00:00:00Z'))`) === 1);
assert('Feb 1 = day 32', calc(`dayOfYear(new Date('2026-02-01T00:00:00Z'))`) === 32);
assert('Dec 31 = day 365', calc(`dayOfYear(new Date('2026-12-31T00:00:00Z'))`) === 365);
// Leap year
assert('Dec 31 2024 (leap) = day 366', calc(`dayOfYear(new Date('2024-12-31T00:00:00Z'))`) === 366);
assert('Mar 1 2024 (leap) = day 61', calc(`dayOfYear(new Date('2024-03-01T00:00:00Z'))`) === 61);
assert('Mar 1 2026 (non-leap) = day 60', calc(`dayOfYear(new Date('2026-03-01T00:00:00Z'))`) === 60);
// ══════════════════════════════════════════════════════
// Cross-validation: almanac vs index.html functions
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Cross-validation (almanac consistency) ═══${X}`);
// GHA Sun = GHA Aries - RA Sun
const testDate = '2026-04-15T08:30:00Z';
const testAries = calc(`ghaAries(new Date('${testDate}'))`);
const testSun = calc(`solarPosition(new Date('${testDate}'))`);
const expectedGHA = ((testAries - testSun.ra) % 360 + 360) % 360;
assertClose('GHA Sun = GHA Aries - RA Sun', testSun.gha, expectedGHA, 0.001);
// Sun GHA at different times should increase ~15°/hr
for (let h = 0; h < 23; h++) {
const t1 = `2026-02-10T${String(h).padStart(2,'0')}:00:00Z`;
const t2 = `2026-02-10T${String(h+1).padStart(2,'0')}:00:00Z`;
const gha1 = calc(`solarPosition(new Date('${t1}')).gha`);
const gha2 = calc(`solarPosition(new Date('${t2}')).gha`);
const delta = ((gha2 - gha1) % 360 + 360) % 360;
if (Math.abs(delta - 15.0) > 0.05) {
assertClose(`Sun GHA hour ${h}→${h+1} rate`, delta, 15.0, 0.05, '°');
break; // Only report first failure
}
}
pass++; // Count the hourly rate sweep as one test
console.log(` ${G}✓${X} Sun GHA advances ~15°/hr across 24 hours`);
// Verify star precession is consistent: SHA + RA = 360
const testStar = calc(`(function(){
const s=STARS.find(s=>s.n==='Sirius');
const p=precessStar(s,new Date('2026-06-15T00:00:00Z'));
return {sha:p.sha, ra:p.ra, dec:p.dec};
})()`);
assertClose('Sirius SHA + RA = 360', (testStar.sha + testStar.ra) % 360, 0, 0.001, '°');
// ══════════════════════════════════════════════════════
// Edge cases
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Edge Cases ═══${X}`);
// Year 2035 (far future)
const far = calc(`(function(){ const s=STARS.find(s=>s.n==='Sirius'); return precessStar(s,new Date('2035-06-15T00:00:00Z')); })()`);
assert('Sirius 2035 SHA is finite', isFinite(far.sha), `got ${far.sha}`);
assert('Sirius 2035 Dec is finite', isFinite(far.dec), `got ${far.dec}`);
// Year 2000 (J2000 epoch)
const j2k = calc(`solarPosition(new Date('2000-01-01T12:00:00Z'))`);
assert('Sun at J2000 GHA is finite', isFinite(j2k.gha));
assert('Sun at J2000 Dec is finite', isFinite(j2k.dec));
// Very high latitude
const polar = calc(`sunTimes(new Date('2026-01-15T00:00:00Z'), 89.9)`);
assert('89.9°N Jan: neverUp', polar.neverUp === true);
// Zero latitude, zero longitude
const zeroSun = calc(`sunTimes(new Date('2026-06-15T00:00:00Z'), 0)`);
assert('Equator always has sunrise/sunset', zeroSun.rise !== null && zeroSun.set !== null);
// ══════════════════════════════════════════════════════
// Multi-epoch star sweep (catch precession drift)
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Multi-epoch Star Sweep ═══${X}`);
const epochs = [2020, 2022, 2024, 2026, 2028, 2030, 2032, 2035];
const sweepStars = ['Sirius', 'Arcturus', 'Vega', 'Canopus', 'Rigel'];
let sweepPass = 0, sweepFail = 0;
for (const name of sweepStars) {
for (let i = 1; i < epochs.length; i++) {
const prev = calc(`(function(){ const s=STARS.find(s=>s.n==='${name}'); return precessStar(s,new Date('${epochs[i-1]}-01-01T00:00:00Z')); })()`);
const curr = calc(`(function(){ const s=STARS.find(s=>s.n==='${name}'); return precessStar(s,new Date('${epochs[i]}-01-01T00:00:00Z')); })()`);
// SHA should change smoothly (general precession ~50"/yr ≈ 0.014°/yr)
const dSHA = Math.abs(curr.sha - prev.sha);
const years = epochs[i] - epochs[i-1];
// Allow up to 2°/yr for high-PM stars like Arcturus
if (dSHA > years * 2) {
sweepFail++;
console.log(` ${R}✗${X} ${name} ${epochs[i-1]}→${epochs[i]}: ΔSHA=${dSHA.toFixed(3)}° (${(dSHA/years).toFixed(3)}°/yr)`);
} else {
sweepPass++;
}
}
}
if (sweepFail === 0) {
pass++;
console.log(` ${G}✓${X} All ${sweepPass} epoch transitions smooth for ${sweepStars.length} stars`);
} else {
fail += sweepFail;
pass += sweepPass;
}
// ══════════════════════════════════════════════════════
// Nutation
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Nutation ═══${X}`);
const nut87 = calc(`nutation(new Date('1987-04-10T00:00:00Z'))`);
assertClose('Nutation dpsi 1987-Apr-10', nut87.dpsi, -0.001052, 0.0005, '°');
assertClose('Nutation deps 1987-Apr-10', nut87.deps, 0.002623, 0.0005, '°');
const nut2000 = calc(`nutation(new Date('2000-01-01T12:00:00Z'))`);
assert('Nutation dpsi at J2000 is finite', isFinite(nut2000.dpsi));
assert('Nutation |dpsi| < 0.01°', Math.abs(nut2000.dpsi) < 0.01);
assert('Nutation |deps| < 0.006°', Math.abs(nut2000.deps) < 0.006);
// ══════════════════════════════════════════════════════
// riseSet polar cases
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Rise/Set Polar Cases ═══${X}`);
// Star at dec=+80 seen from lat=+80: should never set (circumpolar)
const rsUp = calc(`riseSet(80, 80, -0.5667)`);
assert('Circumpolar returns neverSets', rsUp && rsUp.neverSets === true, JSON.stringify(rsUp));
// Star at dec=-80 seen from lat=+80: should never rise
const rsDown = calc(`riseSet(-80, 80, -0.5667)`);
assert('Never-rise returns neverRises', rsDown && rsDown.neverRises === true, JSON.stringify(rsDown));
// Normal case: still returns a number
const rsNorm = calc(`riseSet(20, 45, -0.5667)`);
assert('Normal case returns number', typeof rsNorm === 'number' && rsNorm > 0);
// ══════════════════════════════════════════════════════
// bodyRiseSet
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Body Rise/Set ═══${X}`);
// Sun at equator — should have normal rise/set
const sunRS = calc(`bodyRiseSet(new Date('2026-06-15T00:00:00Z'), 0, function(d){ return solarPosition(d); }, -0.5667)`);
assert('Sun rise/set at equator has rise', typeof sunRS.rise === 'number' && sunRS.rise > 0);
assert('Sun rise/set at equator has set', typeof sunRS.set === 'number' && sunRS.set > 12);
assert('Sun rise/set at equator not polar', !sunRS.alwaysUp && !sunRS.neverUp);
// Sun at 89.9°N in June — circumpolar
const sunPolar = calc(`bodyRiseSet(new Date('2026-06-15T00:00:00Z'), 89.9, function(d){ return solarPosition(d); }, -0.5667)`);
assert('Sun at pole in June is always up', sunPolar.alwaysUp === true, JSON.stringify(sunPolar));
// ══════════════════════════════════════════════════════
// Moon Rise/Set
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Moon Rise/Set ═══${X}`);
const mrs = calc(`moonRiseSet(new Date('2026-06-15T00:00:00Z'), 0)`);
assert('Moon rise/set has rise or is polar', mrs.rise !== null || mrs.alwaysUp || mrs.neverUp);
assert('Moon rise/set times are finite or null',
(mrs.rise === null || isFinite(mrs.rise)) && (mrs.set === null || isFinite(mrs.set)));
// At equator, Moon should generally have both rise and set
const mrs2 = calc(`moonRiseSet(new Date('2026-01-15T00:00:00Z'), 0)`);
assert('Moon rise/set at equator Jan has events', mrs2.rise !== null || mrs2.set !== null || mrs2.alwaysUp);
// ══════════════════════════════════════════════════════
// Moon Phase
// ══════════════════════════════════════════════════════
console.log(`\n${B}═══ Moon Phase ═══${X}`);
const fullMoon = calc(`moonPhase(new Date('2026-01-03T10:00:00Z'))`);
assert('Full Moon: illumination >= 97%', fullMoon.illumination >= 97, `got ${fullMoon.illumination.toFixed(1)}%`);
assert('Full Moon: phase ~0.5', Math.abs(fullMoon.phase - 0.5) < 0.03, `got ${fullMoon.phase.toFixed(3)}`);
assert('Full Moon: name', fullMoon.name === 'Full', `got "${fullMoon.name}"`);
const newMoon = calc(`moonPhase(new Date('2026-03-20T02:38:00Z'))`);
assert('New Moon: illumination <= 3%', newMoon.illumination <= 3, `got ${newMoon.illumination.toFixed(1)}%`);
assert('New Moon: phase ~0', newMoon.phase < 0.05 || newMoon.phase > 0.95, `got ${newMoon.phase.toFixed(3)}`);
const lastQ = calc(`moonPhase(new Date('2026-01-11T00:00:00Z'))`);
assert('Last Quarter: name', lastQ.name === 'Last Quarter' || lastQ.name === 'Waning Gibbous', `got "${lastQ.name}"`);
assert('Last Quarter: illumination 30-70%', lastQ.illumination > 30 && lastQ.illumination < 70, `got ${lastQ.illumination.toFixed(1)}%`);
// ══════════════════════════════════════════════════════
// SUMMARY
// ══════════════════════════════════════════════════════
console.log(`\n${B}══════════════════════════════════════════════════${X}`);
if (fail === 0) {
console.log(`${G}${pass}/${pass + fail} passed, 0 failed${X}`);
} else {
console.log(`${R}${pass}/${pass + fail} passed, ${fail} failed${X}`);
}
console.log();
process.exit(fail > 0 ? 1 : 0);