ether-to-astro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.env.example +13 -0
  2. package/.github/pull_request_template.md +16 -0
  3. package/.github/workflows/release.yml +35 -0
  4. package/.github/workflows/test.yml +32 -0
  5. package/AGENTS.md +99 -0
  6. package/LICENSE +18 -0
  7. package/NOTICE.md +45 -0
  8. package/README.md +301 -0
  9. package/SETUP.md +70 -0
  10. package/TESTING_SUMMARY.md +238 -0
  11. package/TEST_SUITE_STATUS.md +218 -0
  12. package/biome.json +48 -0
  13. package/dist/astro-service.d.ts +98 -0
  14. package/dist/astro-service.js +496 -0
  15. package/dist/chart-types.d.ts +52 -0
  16. package/dist/chart-types.js +51 -0
  17. package/dist/charts.d.ts +125 -0
  18. package/dist/charts.js +324 -0
  19. package/dist/cli.d.ts +7 -0
  20. package/dist/cli.js +472 -0
  21. package/dist/constants.d.ts +81 -0
  22. package/dist/constants.js +76 -0
  23. package/dist/eclipses.d.ts +85 -0
  24. package/dist/eclipses.js +184 -0
  25. package/dist/ephemeris.d.ts +120 -0
  26. package/dist/ephemeris.js +379 -0
  27. package/dist/formatter.d.ts +2 -0
  28. package/dist/formatter.js +22 -0
  29. package/dist/houses.d.ts +82 -0
  30. package/dist/houses.js +169 -0
  31. package/dist/index.d.ts +14 -0
  32. package/dist/index.js +150 -0
  33. package/dist/loader.d.ts +2 -0
  34. package/dist/loader.js +31 -0
  35. package/dist/logger.d.ts +25 -0
  36. package/dist/logger.js +73 -0
  37. package/dist/profile-store.d.ts +48 -0
  38. package/dist/profile-store.js +156 -0
  39. package/dist/riseset.d.ts +82 -0
  40. package/dist/riseset.js +185 -0
  41. package/dist/storage.d.ts +10 -0
  42. package/dist/storage.js +40 -0
  43. package/dist/time-utils.d.ts +68 -0
  44. package/dist/time-utils.js +136 -0
  45. package/dist/tool-registry.d.ts +35 -0
  46. package/dist/tool-registry.js +307 -0
  47. package/dist/tool-result.d.ts +175 -0
  48. package/dist/tool-result.js +188 -0
  49. package/dist/transits.d.ts +108 -0
  50. package/dist/transits.js +263 -0
  51. package/dist/types.d.ts +450 -0
  52. package/dist/types.js +161 -0
  53. package/example-usage.md +131 -0
  54. package/natal-chart.json +187 -0
  55. package/package.json +61 -0
  56. package/scripts/download-ephemeris.js +115 -0
  57. package/setup.sh +21 -0
  58. package/src/astro-service.ts +710 -0
  59. package/src/chart-types.ts +125 -0
  60. package/src/charts.ts +399 -0
  61. package/src/cli.ts +694 -0
  62. package/src/constants.ts +89 -0
  63. package/src/eclipses.ts +226 -0
  64. package/src/ephemeris.ts +437 -0
  65. package/src/formatter.ts +25 -0
  66. package/src/houses.ts +202 -0
  67. package/src/index.ts +170 -0
  68. package/src/loader.ts +36 -0
  69. package/src/logger.ts +104 -0
  70. package/src/profile-store.ts +285 -0
  71. package/src/riseset.ts +229 -0
  72. package/src/time-utils.ts +167 -0
  73. package/src/tool-registry.ts +357 -0
  74. package/src/tool-result.ts +283 -0
  75. package/src/transits.ts +352 -0
  76. package/src/types.ts +547 -0
  77. package/tests/README.md +173 -0
  78. package/tests/TESTING_STRATEGY.md +178 -0
  79. package/tests/fixtures/bowen-yang-chart.ts +69 -0
  80. package/tests/fixtures/calculate-expected.ts +81 -0
  81. package/tests/fixtures/expected-results.ts +117 -0
  82. package/tests/fixtures/generate-expected-simple.ts +94 -0
  83. package/tests/helpers/date-fixtures.ts +15 -0
  84. package/tests/helpers/ephem.ts +11 -0
  85. package/tests/helpers/temp.ts +9 -0
  86. package/tests/setup.ts +11 -0
  87. package/tests/unit/astro-service.test.ts +323 -0
  88. package/tests/unit/chart-types.test.ts +18 -0
  89. package/tests/unit/charts-errors.test.ts +42 -0
  90. package/tests/unit/charts.test.ts +157 -0
  91. package/tests/unit/cli-commands.test.ts +82 -0
  92. package/tests/unit/cli-profiles.test.ts +128 -0
  93. package/tests/unit/cli.test.ts +191 -0
  94. package/tests/unit/constants.test.ts +26 -0
  95. package/tests/unit/correctness-critical.test.ts +408 -0
  96. package/tests/unit/eclipses.test.ts +108 -0
  97. package/tests/unit/ephemeris.test.ts +213 -0
  98. package/tests/unit/error-handling.test.ts +116 -0
  99. package/tests/unit/formatter.test.ts +29 -0
  100. package/tests/unit/houses-errors.test.ts +27 -0
  101. package/tests/unit/houses-validation.test.ts +164 -0
  102. package/tests/unit/houses.test.ts +205 -0
  103. package/tests/unit/profile-store.test.ts +163 -0
  104. package/tests/unit/real-user-charts.test.ts +148 -0
  105. package/tests/unit/riseset.test.ts +106 -0
  106. package/tests/unit/solver-edges.test.ts +197 -0
  107. package/tests/unit/time-utils-temporal.test.ts +303 -0
  108. package/tests/unit/time-utils.test.ts +173 -0
  109. package/tests/unit/tool-registry.test.ts +222 -0
  110. package/tests/unit/tool-result.test.ts +45 -0
  111. package/tests/unit/transit-correctness.test.ts +78 -0
  112. package/tests/unit/transits.test.ts +238 -0
  113. package/tests/validation/README.md +32 -0
  114. package/tests/validation/adapters/astrolog.ts +306 -0
  115. package/tests/validation/adapters/internal.ts +184 -0
  116. package/tests/validation/compare/eclipses.ts +47 -0
  117. package/tests/validation/compare/houses.ts +76 -0
  118. package/tests/validation/compare/positions.ts +104 -0
  119. package/tests/validation/compare/riseSet.ts +48 -0
  120. package/tests/validation/compare/roots.ts +90 -0
  121. package/tests/validation/compare/transits.ts +69 -0
  122. package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
  123. package/tests/validation/fixtures/eclipses/core.ts +14 -0
  124. package/tests/validation/fixtures/houses/core.ts +47 -0
  125. package/tests/validation/fixtures/positions/core.ts +159 -0
  126. package/tests/validation/fixtures/rise-set/core.ts +20 -0
  127. package/tests/validation/fixtures/roots/core.ts +47 -0
  128. package/tests/validation/fixtures/transits/core.ts +61 -0
  129. package/tests/validation/fixtures/transits/dst.ts +21 -0
  130. package/tests/validation/oracle.spec.ts +129 -0
  131. package/tests/validation/utils/denseRootOracle.ts +269 -0
  132. package/tests/validation/utils/fixtureTypes.ts +146 -0
  133. package/tests/validation/utils/report.ts +60 -0
  134. package/tests/validation/utils/tolerances.ts +23 -0
  135. package/tests/validation/validation.spec.ts +836 -0
  136. package/tools/color-picker.html +388 -0
  137. package/tsconfig.json +17 -0
  138. package/vitest.config.ts +31 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Critical correctness tests that catch real bugs
3
+ *
4
+ * These 5 tests target the most important edge cases and failure modes:
5
+ * 1. Dual-target aspect exact-time test (+aspect vs -aspect)
6
+ * 2. Retrograde/station exact-time test
7
+ * 3. Multi-day upcoming transit dedupe test (best-hit selection)
8
+ * 4. Polar house fallback test
9
+ * 5. Render failure test (charts error properly on invalid data)
10
+ */
11
+
12
+ import { describe, it, expect, beforeAll } from 'vitest';
13
+ import { EphemerisCalculator } from '../../src/ephemeris.js';
14
+ import { TransitCalculator, deduplicateTransits } from '../../src/transits.js';
15
+ import { HouseCalculator } from '../../src/houses.js';
16
+ import { ChartRenderer } from '../../src/charts.js';
17
+ import { PLANETS, type NatalChart, type Transit } from '../../src/types.js';
18
+
19
+ describe('Critical correctness tests', () => {
20
+ let ephem: EphemerisCalculator;
21
+ let transitCalc: TransitCalculator;
22
+ let houseCalc: HouseCalculator;
23
+ let chartRenderer: ChartRenderer;
24
+
25
+ beforeAll(async () => {
26
+ ephem = new EphemerisCalculator();
27
+ await ephem.init();
28
+ transitCalc = new TransitCalculator(ephem);
29
+ houseCalc = new HouseCalculator(ephem);
30
+ chartRenderer = new ChartRenderer(ephem, houseCalc);
31
+ });
32
+
33
+ describe('1. Dual-target aspect exact-time test', () => {
34
+ it('should calculate exact times correctly for both +90° and +180° aspects from same transiting planet', () => {
35
+ // The bug this catches: exact-time search must resolve the correct target longitude
36
+ // Use real Mars ephemeris approaching two natal planets simultaneously
37
+ // Mars moving forward should produce exact times for both aspects
38
+
39
+ const testDate = new Date('2024-03-15T00:00:00Z');
40
+ const jd = ephem.dateToJulianDay(testDate);
41
+
42
+ // Get real Mars position
43
+ const marsData = ephem.getAllPlanets(jd, [4]); // Mars
44
+ const marsLon = marsData[0].longitude;
45
+
46
+ // Create natal planets at positions that will form aspects with Mars
47
+ // Square: +90° from Mars, Opposition: +180° from Mars
48
+ const natalPlanets = [
49
+ {
50
+ planetId: PLANETS.VENUS,
51
+ planet: 'Venus' as const,
52
+ longitude: (marsLon + 90) % 360,
53
+ latitude: 0,
54
+ distance: 1,
55
+ speed: 1,
56
+ sign: 'Cancer',
57
+ degree: 15,
58
+ isRetrograde: false
59
+ },
60
+ {
61
+ planetId: PLANETS.JUPITER,
62
+ planet: 'Jupiter' as const,
63
+ longitude: (marsLon + 180) % 360,
64
+ latitude: 0,
65
+ distance: 1,
66
+ speed: 0.1,
67
+ sign: 'Libra',
68
+ degree: 15,
69
+ isRetrograde: false
70
+ },
71
+ ];
72
+
73
+ // Shift Mars back by 1° to create approaching aspects with small orbs (< 2° threshold)
74
+ marsData[0].longitude = (marsLon - 1 + 360) % 360;
75
+
76
+ const transits = transitCalc.findTransits(marsData, natalPlanets, jd);
77
+
78
+ const square = transits.find(t => t.aspect === 'square' && t.natalPlanet === 'Venus');
79
+ const opposition = transits.find(t => t.aspect === 'opposition' && t.natalPlanet === 'Jupiter');
80
+
81
+ // Both aspects must be detected
82
+ expect(square).toBeDefined();
83
+ expect(opposition).toBeDefined();
84
+
85
+ // CRITICAL: Both should have exact times calculated
86
+ // Mars is moving (real ephemeris speed), orbs are small (~1°)
87
+ // If exact-time search uses wrong target longitude, these will be undefined
88
+ expect(square!.exactTime).toBeDefined();
89
+ expect(opposition!.exactTime).toBeDefined();
90
+
91
+ // Exact times should be reasonable (within search window)
92
+ const squareTime = square!.exactTime!.getTime();
93
+ const oppositionTime = opposition!.exactTime!.getTime();
94
+ const testTime = testDate.getTime();
95
+
96
+ // Should be within 5 days of test date (the search window)
97
+ expect(Math.abs(squareTime - testTime)).toBeLessThan(5 * 24 * 60 * 60 * 1000);
98
+ expect(Math.abs(oppositionTime - testTime)).toBeLessThan(5 * 24 * 60 * 60 * 1000);
99
+
100
+ // Both exact times should be close (Mars hits both targets around the same time)
101
+ const timeDiff = Math.abs(squareTime - oppositionTime);
102
+ expect(timeDiff).toBeLessThan(7 * 24 * 60 * 60 * 1000); // Within 7 days
103
+ });
104
+ });
105
+
106
+ describe('2. Retrograde/station exact-time test', () => {
107
+ it('should not fabricate exact times near actual Mercury station', () => {
108
+ // Real Mercury retrograde station: Dec 13, 2023 at ~21° Capricorn
109
+ // Near station, Mercury's speed approaches zero, making exact-time prediction unreliable
110
+ // This test ensures we don't fabricate confident exact times when the planet is barely moving
111
+
112
+ const stationDate = new Date('2023-12-13T00:00:00Z');
113
+ const jd = ephem.dateToJulianDay(stationDate);
114
+
115
+ // Get actual Mercury position near station
116
+ const mercuryPos = ephem.getAllPlanets(jd, [1]); // Mercury = 1
117
+
118
+ // Create natal Sun near Mercury's station position
119
+ const natalPlanets = [
120
+ {
121
+ planetId: PLANETS.SUN,
122
+ planet: 'Sun' as const,
123
+ longitude: mercuryPos[0].longitude + 2, // 2° ahead
124
+ latitude: 0,
125
+ distance: 1,
126
+ speed: 1,
127
+ sign: 'Capricorn',
128
+ degree: 23,
129
+ isRetrograde: false
130
+ },
131
+ ];
132
+
133
+ const transits = transitCalc.findTransits(mercuryPos, natalPlanets, jd);
134
+ const conjunction = transits.find(t => t.aspect === 'conjunction');
135
+
136
+ expect(conjunction).toBeDefined();
137
+ expect(conjunction!.orb).toBeLessThan(3); // Should detect the approaching conjunction
138
+
139
+ // Key assertion: near station, exact time should either:
140
+ // 1. Be undefined (no confident prediction), OR
141
+ // 2. Be far in the future (weeks/months away due to slow motion), OR
142
+ // 3. Be within a reasonable bound (not fabricated nonsense like years away)
143
+ if (conjunction!.exactTime) {
144
+ const daysUntilExact = (conjunction!.exactTime.getTime() - stationDate.getTime()) / (1000 * 60 * 60 * 24);
145
+ // If exact time exists, it should be within 60 days (not fabricated infinity)
146
+ expect(Math.abs(daysUntilExact)).toBeLessThan(60);
147
+ }
148
+ // If exactTime is undefined, that's correct behavior for near-station transits
149
+ });
150
+ });
151
+
152
+ describe('3. Multi-day upcoming transit dedupe test', () => {
153
+ it('should use production dedupe for best-hit selection (regression test)', () => {
154
+ // This test verifies the production deduplicateTransits() function is called
155
+ // and performs basic best-hit selection in a realistic multi-day scenario
156
+
157
+ const startDate = new Date('2024-03-10T12:00:00Z');
158
+ const jd = ephem.dateToJulianDay(startDate);
159
+
160
+ // Get real Mars position
161
+ const marsPos = ephem.getAllPlanets(jd, [4])[0];
162
+
163
+ // Place natal Venus 92° ahead (approaching square, 2° orb)
164
+ const natalPlanets = [{
165
+ planetId: PLANETS.VENUS,
166
+ planet: 'Venus' as const,
167
+ longitude: (marsPos.longitude + 92) % 360,
168
+ latitude: 0,
169
+ distance: 1,
170
+ speed: 1,
171
+ sign: 'Cancer',
172
+ degree: 15,
173
+ isRetrograde: false,
174
+ }];
175
+
176
+ // Collect transits over 10 days - Mars will approach, hit exact, and pass
177
+ const allTransits: Transit[] = [];
178
+ for (let day = 0; day <= 10; day++) {
179
+ const dayDate = new Date(startDate);
180
+ dayDate.setDate(dayDate.getDate() + day);
181
+ const dayJd = ephem.dateToJulianDay(dayDate);
182
+ const dayMars = ephem.getAllPlanets(dayJd, [4]);
183
+ const dayTransits = transitCalc.findTransits(dayMars, natalPlanets, dayJd);
184
+ allTransits.push(...dayTransits);
185
+ }
186
+
187
+ // Get all squares before dedupe
188
+ const allSquares = allTransits.filter(t => t.aspect === 'square');
189
+ expect(allSquares.length).toBeGreaterThan(2); // Multiple days captured it
190
+
191
+ // Test the production function
192
+ const deduplicated = deduplicateTransits(allTransits);
193
+ const keptSquares = deduplicated.filter(t => t.aspect === 'square');
194
+
195
+ // Should keep exactly one square
196
+ expect(keptSquares.length).toBe(1);
197
+
198
+ // This proves the production dedupe function is working for multi-day collection
199
+ });
200
+
201
+ it('deduplicateTransits should prefer exactTime over smaller orb', () => {
202
+ // Deterministic test for priority branch 1: exact > smallest orb
203
+ const base = {
204
+ transitingPlanet: 'Mars' as const,
205
+ natalPlanet: 'Venus' as const,
206
+ aspect: 'square' as const,
207
+ isApplying: true,
208
+ transitLongitude: 14,
209
+ natalLongitude: 105,
210
+ };
211
+
212
+ const exactButLargerOrb: Transit = {
213
+ ...base,
214
+ orb: 1.5,
215
+ exactTime: new Date('2024-03-12T12:00:00Z'),
216
+ };
217
+
218
+ const smallerOrbButNotExact: Transit = {
219
+ ...base,
220
+ orb: 0.2,
221
+ exactTime: undefined,
222
+ };
223
+
224
+ const result = deduplicateTransits([
225
+ smallerOrbButNotExact,
226
+ exactButLargerOrb,
227
+ ]);
228
+
229
+ expect(result).toHaveLength(1);
230
+ expect(result[0]).toBe(exactButLargerOrb);
231
+ });
232
+
233
+ it('deduplicateTransits should prefer earliest when exactness and orb are tied', () => {
234
+ // Deterministic test for priority branch 3: earliest (when exact and orb tied)
235
+ const base = {
236
+ transitingPlanet: 'Mars' as const,
237
+ natalPlanet: 'Venus' as const,
238
+ aspect: 'square' as const,
239
+ orb: 0.5,
240
+ isApplying: true,
241
+ transitLongitude: 14,
242
+ natalLongitude: 105,
243
+ };
244
+
245
+ const earlier: Transit = {
246
+ ...base,
247
+ exactTime: new Date('2024-03-10T12:00:00Z'),
248
+ };
249
+
250
+ const later: Transit = {
251
+ ...base,
252
+ exactTime: new Date('2024-03-11T12:00:00Z'),
253
+ };
254
+
255
+ const result = deduplicateTransits([later, earlier]);
256
+
257
+ expect(result).toHaveLength(1);
258
+ expect(result[0]).toBe(earlier);
259
+ });
260
+ });
261
+
262
+ describe('4. Polar house fallback test', () => {
263
+ it('should fall back to Whole Sign at polar latitudes when Placidus fails', () => {
264
+ // At 70°N, Placidus fails but Whole Sign works
265
+ const jd = ephem.dateToJulianDay(new Date('2024-06-21T12:00:00Z')); // Summer solstice
266
+ const latitude = 70; // Polar latitude
267
+ const longitude = 0;
268
+
269
+ // Request Placidus
270
+ const houses = houseCalc.calculateHouses(jd, latitude, longitude, 'P');
271
+
272
+ // Should return Whole Sign as fallback
273
+ expect(houses.system).toBe('W');
274
+ expect(houses.cusps.length).toBeGreaterThan(0);
275
+ expect(houses.ascendant).toBeGreaterThanOrEqual(0);
276
+ expect(houses.ascendant).toBeLessThan(360);
277
+ });
278
+
279
+ it('should handle extreme polar latitudes with Whole Sign', () => {
280
+ // At extreme polar (89.9°N), Whole Sign should still work
281
+ const jd = ephem.dateToJulianDay(new Date('2024-06-21T12:00:00Z'));
282
+ const latitude = 89.9; // Extreme polar
283
+ const longitude = 0;
284
+
285
+ // Whole Sign should work at all latitudes
286
+ const houses = houseCalc.calculateHouses(jd, latitude, longitude, 'W');
287
+ expect(houses.system).toBe('W');
288
+ expect(houses.cusps.length).toBeGreaterThan(0);
289
+ });
290
+ });
291
+
292
+ describe('5. Chart rendering correctness', () => {
293
+ it('should render minimal valid chart without crashing', async () => {
294
+ // Minimal chart with empty planets array is valid - should render successfully
295
+ const minimalChart: NatalChart = {
296
+ name: 'Minimal Test',
297
+ birthDate: {
298
+ year: 2000,
299
+ month: 1,
300
+ day: 1,
301
+ hour: 12,
302
+ minute: 0,
303
+ },
304
+ location: {
305
+ latitude: 0,
306
+ longitude: 0,
307
+ timezone: 'UTC',
308
+ },
309
+ planets: [], // Empty is valid
310
+ julianDay: 2451545,
311
+ houseSystem: 'P',
312
+ utcDateTime: {
313
+ year: 2000,
314
+ month: 1,
315
+ day: 1,
316
+ hour: 12,
317
+ minute: 0,
318
+ },
319
+ };
320
+
321
+ const result = await chartRenderer.generateNatalChart(minimalChart, 'light', 'svg');
322
+ expect(result).toBeDefined();
323
+ expect(typeof result).toBe('string');
324
+ expect(result.length).toBeGreaterThan(100); // Real SVG, not empty string
325
+ });
326
+
327
+ it('should throw on invalid house system, not fabricate data', () => {
328
+ const jd = ephem.dateToJulianDay(new Date('2024-01-01T12:00:00Z'));
329
+ const latitude = 40;
330
+ const longitude = -74;
331
+
332
+ // Invalid house system should throw with clear error message
333
+ expect(() => {
334
+ houseCalc.calculateHouses(jd, latitude, longitude, 'INVALID' as any);
335
+ }).toThrow(/Invalid house system/);
336
+ });
337
+
338
+ it('should throw on invalid chart data, not return placeholder SVG', async () => {
339
+ // This is the critical regression test: if rendering fails due to invalid data,
340
+ // the renderer should throw an error, not return a fake/placeholder SVG
341
+
342
+ // Create a chart with invalid/corrupted data that should cause rendering to fail
343
+ const invalidChart: NatalChart = {
344
+ name: 'Invalid Test',
345
+ birthDate: {
346
+ year: 2000,
347
+ month: 1,
348
+ day: 1,
349
+ hour: 12,
350
+ minute: 0,
351
+ },
352
+ location: {
353
+ latitude: 0,
354
+ longitude: 0,
355
+ timezone: 'UTC',
356
+ },
357
+ planets: [
358
+ // Invalid planet data - longitude out of range should cause issues
359
+ {
360
+ planetId: PLANETS.SUN,
361
+ planet: 'Sun' as const,
362
+ longitude: 999, // Invalid: should be 0-360
363
+ latitude: 0,
364
+ distance: 1,
365
+ speed: 1,
366
+ sign: 'Invalid',
367
+ degree: 999,
368
+ isRetrograde: false,
369
+ },
370
+ ],
371
+ julianDay: 2451545,
372
+ houseSystem: 'P',
373
+ utcDateTime: {
374
+ year: 2000,
375
+ month: 1,
376
+ day: 1,
377
+ hour: 12,
378
+ minute: 0,
379
+ },
380
+ };
381
+
382
+ // The renderer should either:
383
+ // 1. Throw an error (preferred), OR
384
+ // 2. Return a real SVG that handles the invalid data gracefully
385
+ // It should NOT return a short placeholder like "<svg>Error</svg>"
386
+
387
+ try {
388
+ const result = await chartRenderer.generateNatalChart(invalidChart, 'light', 'svg');
389
+
390
+ // If it didn't throw, it must be a real SVG (not a placeholder)
391
+ expect(typeof result).toBe('string');
392
+
393
+ if (typeof result === 'string') {
394
+ expect(result.length).toBeGreaterThan(500); // Substantial SVG
395
+ expect(result).toContain('<svg');
396
+ expect(result).toContain('</svg>');
397
+
398
+ // Should not contain error messages in the SVG content
399
+ expect(result.toLowerCase()).not.toContain('error');
400
+ expect(result.toLowerCase()).not.toContain('failed');
401
+ }
402
+ } catch (error) {
403
+ // Throwing is acceptable - proves it doesn't return placeholder
404
+ expect(error).toBeDefined();
405
+ }
406
+ });
407
+ });
408
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { EclipseCalculator } from '../../src/eclipses.js';
3
+
4
+ describe('When calculating eclipse events', () => {
5
+ it('Given a valid solar response, then next solar eclipse is returned with mapped type', () => {
6
+ const ephem = {
7
+ eph: {
8
+ sol_eclipse_when_glob: vi.fn(() => ({ flag: 4, error: '', data: [2451550] })),
9
+ lun_eclipse_when: vi.fn(() => ({ flag: 0, error: '', data: [2451560] })),
10
+ },
11
+ julianDayToDate: vi.fn(() => new Date('2024-04-08T18:00:00Z')),
12
+ };
13
+
14
+ const calc = new EclipseCalculator(ephem as any);
15
+ const solar = calc.findNextSolarEclipse(2451545);
16
+ expect(solar).not.toBeNull();
17
+ expect(solar?.type).toBe('solar');
18
+ expect(solar?.maxTime).toBeInstanceOf(Date);
19
+ });
20
+
21
+ it('Given invalid response shapes or error flags, then null is returned', () => {
22
+ const badShape = new EclipseCalculator({
23
+ eph: { sol_eclipse_when_glob: vi.fn(() => ({ nope: true })) },
24
+ julianDayToDate: vi.fn(),
25
+ } as any);
26
+ expect(badShape.findNextSolarEclipse(2451545)).toBeNull();
27
+
28
+ const withError = new EclipseCalculator({
29
+ eph: { lun_eclipse_when: vi.fn(() => ({ flag: 0, error: 'x', data: [2451550] })) },
30
+ julianDayToDate: vi.fn(),
31
+ } as any);
32
+ expect(withError.findNextLunarEclipse(2451545)).toBeNull();
33
+ });
34
+
35
+ it('Given solar/lunar bitmasks, then eclipse types map to human-readable variants', () => {
36
+ const calc = new EclipseCalculator({
37
+ eph: {
38
+ sol_eclipse_when_glob: vi.fn(() => ({ flag: 8, error: '', data: [2451550] })),
39
+ lun_eclipse_when: vi.fn(() => ({ flag: 64, error: '', data: [2451550] })),
40
+ },
41
+ julianDayToDate: vi.fn(() => new Date('2024-01-01T00:00:00Z')),
42
+ } as any);
43
+
44
+ expect(calc.findNextSolarEclipse(2451545)?.eclipseType).toBe('Annular');
45
+ expect(calc.findNextLunarEclipse(2451545)?.eclipseType).toBe('Penumbral');
46
+ });
47
+
48
+ it('Given no known type flags, then eclipse type defaults to Unknown', () => {
49
+ const calc = new EclipseCalculator({
50
+ eph: {
51
+ sol_eclipse_when_glob: vi.fn(() => ({ flag: 0, error: '', data: [2451550] })),
52
+ lun_eclipse_when: vi.fn(() => ({ flag: 0, error: '', data: [2451550] })),
53
+ },
54
+ julianDayToDate: vi.fn(() => new Date('2024-01-01T00:00:00Z')),
55
+ } as any);
56
+ expect(calc.findNextSolarEclipse(2451545)?.eclipseType).toBe('Unknown');
57
+ expect(calc.findNextLunarEclipse(2451545)?.eclipseType).toBe('Unknown');
58
+ });
59
+
60
+ it('Given solar and lunar eclipses, then getNextEclipses sorts them by date and returns null when both missing', async () => {
61
+ const calc = new EclipseCalculator({ eph: {} } as any);
62
+ vi.spyOn(calc, 'findNextSolarEclipse').mockReturnValue({
63
+ type: 'solar',
64
+ date: new Date('2024-10-01T00:00:00Z'),
65
+ eclipseType: 'Partial',
66
+ maxTime: new Date('2024-10-01T00:00:00Z'),
67
+ });
68
+ vi.spyOn(calc, 'findNextLunarEclipse').mockReturnValue({
69
+ type: 'lunar',
70
+ date: new Date('2024-04-01T00:00:00Z'),
71
+ eclipseType: 'Total',
72
+ maxTime: new Date('2024-04-01T00:00:00Z'),
73
+ });
74
+
75
+ const sorted = await calc.getNextEclipses(2451545);
76
+ expect(sorted?.[0].type).toBe('lunar');
77
+
78
+ (calc.findNextSolarEclipse as any).mockReturnValue(null);
79
+ (calc.findNextLunarEclipse as any).mockReturnValue(null);
80
+ expect(await calc.getNextEclipses(2451545)).toBeNull();
81
+ });
82
+
83
+ it('Given uninitialized ephemeris, then public methods throw not-initialized errors', async () => {
84
+ const calc = new EclipseCalculator({ eph: null } as any);
85
+ expect(() => calc.findNextSolarEclipse(2451545)).toThrow(/not initialized/i);
86
+ expect(() => calc.findNextLunarEclipse(2451545)).toThrow(/not initialized/i);
87
+ await expect(calc.getNextEclipses(2451545)).rejects.toThrow(/not initialized/i);
88
+ });
89
+
90
+ it('Given internal calculator exceptions, then methods return null via error handling paths', async () => {
91
+ const calc = new EclipseCalculator({
92
+ eph: {
93
+ sol_eclipse_when_glob: vi.fn(() => ({ flag: 4, error: '', data: [2451550] })),
94
+ lun_eclipse_when: vi.fn(() => {
95
+ throw new Error('lunar boom');
96
+ }),
97
+ },
98
+ julianDayToDate: vi.fn(() => new Date('2024-01-01T00:00:00Z')),
99
+ } as any);
100
+
101
+ expect(calc.findNextLunarEclipse(2451545)).toBeNull();
102
+
103
+ vi.spyOn(calc, 'findNextSolarEclipse').mockImplementation(() => {
104
+ throw new Error('top-level boom');
105
+ });
106
+ expect(await calc.getNextEclipses(2451545)).toBeNull();
107
+ });
108
+ });