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.
- package/.env.example +13 -0
- package/.github/pull_request_template.md +16 -0
- package/.github/workflows/release.yml +35 -0
- package/.github/workflows/test.yml +32 -0
- package/AGENTS.md +99 -0
- package/LICENSE +18 -0
- package/NOTICE.md +45 -0
- package/README.md +301 -0
- package/SETUP.md +70 -0
- package/TESTING_SUMMARY.md +238 -0
- package/TEST_SUITE_STATUS.md +218 -0
- package/biome.json +48 -0
- package/dist/astro-service.d.ts +98 -0
- package/dist/astro-service.js +496 -0
- package/dist/chart-types.d.ts +52 -0
- package/dist/chart-types.js +51 -0
- package/dist/charts.d.ts +125 -0
- package/dist/charts.js +324 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +472 -0
- package/dist/constants.d.ts +81 -0
- package/dist/constants.js +76 -0
- package/dist/eclipses.d.ts +85 -0
- package/dist/eclipses.js +184 -0
- package/dist/ephemeris.d.ts +120 -0
- package/dist/ephemeris.js +379 -0
- package/dist/formatter.d.ts +2 -0
- package/dist/formatter.js +22 -0
- package/dist/houses.d.ts +82 -0
- package/dist/houses.js +169 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +150 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +31 -0
- package/dist/logger.d.ts +25 -0
- package/dist/logger.js +73 -0
- package/dist/profile-store.d.ts +48 -0
- package/dist/profile-store.js +156 -0
- package/dist/riseset.d.ts +82 -0
- package/dist/riseset.js +185 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +40 -0
- package/dist/time-utils.d.ts +68 -0
- package/dist/time-utils.js +136 -0
- package/dist/tool-registry.d.ts +35 -0
- package/dist/tool-registry.js +307 -0
- package/dist/tool-result.d.ts +175 -0
- package/dist/tool-result.js +188 -0
- package/dist/transits.d.ts +108 -0
- package/dist/transits.js +263 -0
- package/dist/types.d.ts +450 -0
- package/dist/types.js +161 -0
- package/example-usage.md +131 -0
- package/natal-chart.json +187 -0
- package/package.json +61 -0
- package/scripts/download-ephemeris.js +115 -0
- package/setup.sh +21 -0
- package/src/astro-service.ts +710 -0
- package/src/chart-types.ts +125 -0
- package/src/charts.ts +399 -0
- package/src/cli.ts +694 -0
- package/src/constants.ts +89 -0
- package/src/eclipses.ts +226 -0
- package/src/ephemeris.ts +437 -0
- package/src/formatter.ts +25 -0
- package/src/houses.ts +202 -0
- package/src/index.ts +170 -0
- package/src/loader.ts +36 -0
- package/src/logger.ts +104 -0
- package/src/profile-store.ts +285 -0
- package/src/riseset.ts +229 -0
- package/src/time-utils.ts +167 -0
- package/src/tool-registry.ts +357 -0
- package/src/tool-result.ts +283 -0
- package/src/transits.ts +352 -0
- package/src/types.ts +547 -0
- package/tests/README.md +173 -0
- package/tests/TESTING_STRATEGY.md +178 -0
- package/tests/fixtures/bowen-yang-chart.ts +69 -0
- package/tests/fixtures/calculate-expected.ts +81 -0
- package/tests/fixtures/expected-results.ts +117 -0
- package/tests/fixtures/generate-expected-simple.ts +94 -0
- package/tests/helpers/date-fixtures.ts +15 -0
- package/tests/helpers/ephem.ts +11 -0
- package/tests/helpers/temp.ts +9 -0
- package/tests/setup.ts +11 -0
- package/tests/unit/astro-service.test.ts +323 -0
- package/tests/unit/chart-types.test.ts +18 -0
- package/tests/unit/charts-errors.test.ts +42 -0
- package/tests/unit/charts.test.ts +157 -0
- package/tests/unit/cli-commands.test.ts +82 -0
- package/tests/unit/cli-profiles.test.ts +128 -0
- package/tests/unit/cli.test.ts +191 -0
- package/tests/unit/constants.test.ts +26 -0
- package/tests/unit/correctness-critical.test.ts +408 -0
- package/tests/unit/eclipses.test.ts +108 -0
- package/tests/unit/ephemeris.test.ts +213 -0
- package/tests/unit/error-handling.test.ts +116 -0
- package/tests/unit/formatter.test.ts +29 -0
- package/tests/unit/houses-errors.test.ts +27 -0
- package/tests/unit/houses-validation.test.ts +164 -0
- package/tests/unit/houses.test.ts +205 -0
- package/tests/unit/profile-store.test.ts +163 -0
- package/tests/unit/real-user-charts.test.ts +148 -0
- package/tests/unit/riseset.test.ts +106 -0
- package/tests/unit/solver-edges.test.ts +197 -0
- package/tests/unit/time-utils-temporal.test.ts +303 -0
- package/tests/unit/time-utils.test.ts +173 -0
- package/tests/unit/tool-registry.test.ts +222 -0
- package/tests/unit/tool-result.test.ts +45 -0
- package/tests/unit/transit-correctness.test.ts +78 -0
- package/tests/unit/transits.test.ts +238 -0
- package/tests/validation/README.md +32 -0
- package/tests/validation/adapters/astrolog.ts +306 -0
- package/tests/validation/adapters/internal.ts +184 -0
- package/tests/validation/compare/eclipses.ts +47 -0
- package/tests/validation/compare/houses.ts +76 -0
- package/tests/validation/compare/positions.ts +104 -0
- package/tests/validation/compare/riseSet.ts +48 -0
- package/tests/validation/compare/roots.ts +90 -0
- package/tests/validation/compare/transits.ts +69 -0
- package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
- package/tests/validation/fixtures/eclipses/core.ts +14 -0
- package/tests/validation/fixtures/houses/core.ts +47 -0
- package/tests/validation/fixtures/positions/core.ts +159 -0
- package/tests/validation/fixtures/rise-set/core.ts +20 -0
- package/tests/validation/fixtures/roots/core.ts +47 -0
- package/tests/validation/fixtures/transits/core.ts +61 -0
- package/tests/validation/fixtures/transits/dst.ts +21 -0
- package/tests/validation/oracle.spec.ts +129 -0
- package/tests/validation/utils/denseRootOracle.ts +269 -0
- package/tests/validation/utils/fixtureTypes.ts +146 -0
- package/tests/validation/utils/report.ts +60 -0
- package/tests/validation/utils/tolerances.ts +23 -0
- package/tests/validation/validation.spec.ts +836 -0
- package/tools/color-picker.html +388 -0
- package/tsconfig.json +17 -0
- 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
|
+
});
|