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,213 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { EphemerisCalculator } from '../../src/ephemeris.js';
|
|
3
|
+
import { PLANETS } from '../../src/types.js';
|
|
4
|
+
import { bowenYangChart } from '../fixtures/bowen-yang-chart.js';
|
|
5
|
+
import { knownJulianDays, bowenYangExpectedPositions } from '../fixtures/expected-results.js';
|
|
6
|
+
|
|
7
|
+
describe('Given an astrologer wants to calculate planetary positions', () => {
|
|
8
|
+
let ephem: EphemerisCalculator;
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
ephem = new EphemerisCalculator();
|
|
12
|
+
await ephem.init();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('When initializing the ephemeris calculator', () => {
|
|
16
|
+
it('should initialize successfully with Moshier fallback', async () => {
|
|
17
|
+
const freshEphem = new EphemerisCalculator();
|
|
18
|
+
await expect(freshEphem.init()).resolves.not.toThrow();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('When converting dates to Julian Day numbers', () => {
|
|
23
|
+
it('should convert J2000 epoch correctly', () => {
|
|
24
|
+
const jd = ephem.dateToJulianDay(knownJulianDays.j2000.date);
|
|
25
|
+
expect(jd).toBeCloseTo(knownJulianDays.j2000.jd, 4);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should convert Bowen Yang\'s birth date to Julian Day', () => {
|
|
29
|
+
const birthDate = new Date(Date.UTC(
|
|
30
|
+
bowenYangChart.birthDate.year,
|
|
31
|
+
bowenYangChart.birthDate.month - 1,
|
|
32
|
+
bowenYangChart.birthDate.day,
|
|
33
|
+
bowenYangChart.birthDate.hour,
|
|
34
|
+
bowenYangChart.birthDate.minute
|
|
35
|
+
));
|
|
36
|
+
const jd = ephem.dateToJulianDay(birthDate);
|
|
37
|
+
expect(jd).toBeCloseTo(knownJulianDays.bowenBirth.jd, 4);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should handle midnight dates correctly', () => {
|
|
41
|
+
const midnight = new Date(Date.UTC(2000, 0, 1, 0, 0, 0));
|
|
42
|
+
const jd = ephem.dateToJulianDay(midnight);
|
|
43
|
+
expect(jd).toBeCloseTo(2451544.5, 4);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('When calculating planetary positions for Bowen Yang\'s birth', () => {
|
|
48
|
+
it('should calculate Sun position in Scorpio', () => {
|
|
49
|
+
const birthDate = new Date(Date.UTC(
|
|
50
|
+
bowenYangChart.birthDate.year,
|
|
51
|
+
bowenYangChart.birthDate.month - 1,
|
|
52
|
+
bowenYangChart.birthDate.day,
|
|
53
|
+
bowenYangChart.birthDate.hour,
|
|
54
|
+
bowenYangChart.birthDate.minute
|
|
55
|
+
));
|
|
56
|
+
const jd = ephem.dateToJulianDay(birthDate);
|
|
57
|
+
const positions = ephem.getAllPlanets(jd, [PLANETS.SUN]);
|
|
58
|
+
|
|
59
|
+
expect(positions).toHaveLength(1);
|
|
60
|
+
expect(positions[0].planet).toBe('Sun');
|
|
61
|
+
expect(positions[0].sign).toBe('Scorpio');
|
|
62
|
+
|
|
63
|
+
// Use expected value with tolerance for ephemeris precision differences
|
|
64
|
+
expect(positions[0].longitude).toBeCloseTo(bowenYangExpectedPositions.sun.longitude, 1);
|
|
65
|
+
expect(positions[0].degree).toBeCloseTo(bowenYangExpectedPositions.sun.degree, 1)
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should calculate all major planets', () => {
|
|
69
|
+
const birthDate = new Date(Date.UTC(
|
|
70
|
+
bowenYangChart.birthDate.year,
|
|
71
|
+
bowenYangChart.birthDate.month - 1,
|
|
72
|
+
bowenYangChart.birthDate.day,
|
|
73
|
+
bowenYangChart.birthDate.hour,
|
|
74
|
+
bowenYangChart.birthDate.minute
|
|
75
|
+
));
|
|
76
|
+
const jd = ephem.dateToJulianDay(birthDate);
|
|
77
|
+
const planetIds = Object.values(PLANETS);
|
|
78
|
+
const positions = ephem.getAllPlanets(jd, planetIds);
|
|
79
|
+
|
|
80
|
+
expect(positions.length).toBe(planetIds.length);
|
|
81
|
+
positions.forEach(pos => {
|
|
82
|
+
expect(pos.longitude).toBeGreaterThanOrEqual(0);
|
|
83
|
+
expect(pos.longitude).toBeLessThan(360);
|
|
84
|
+
expect(pos.speed).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should include zodiac sign information', () => {
|
|
89
|
+
const birthDate = new Date(Date.UTC(
|
|
90
|
+
bowenYangChart.birthDate.year,
|
|
91
|
+
bowenYangChart.birthDate.month - 1,
|
|
92
|
+
bowenYangChart.birthDate.day,
|
|
93
|
+
bowenYangChart.birthDate.hour,
|
|
94
|
+
bowenYangChart.birthDate.minute
|
|
95
|
+
));
|
|
96
|
+
const jd = ephem.dateToJulianDay(birthDate);
|
|
97
|
+
const positions = ephem.getAllPlanets(jd, [PLANETS.SUN]);
|
|
98
|
+
|
|
99
|
+
expect(positions[0].sign).toBe('Scorpio');
|
|
100
|
+
expect(positions[0].degree).toBeGreaterThan(0);
|
|
101
|
+
expect(positions[0].degree).toBeLessThan(30);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('When calculating aspect angles between planets', () => {
|
|
106
|
+
it('should calculate conjunction (0°) correctly', () => {
|
|
107
|
+
const angle = ephem.calculateAspectAngle(45, 45);
|
|
108
|
+
expect(angle).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should calculate opposition (180°) correctly', () => {
|
|
112
|
+
const angle = ephem.calculateAspectAngle(0, 180);
|
|
113
|
+
expect(angle).toBe(180);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should calculate square (90°) correctly', () => {
|
|
117
|
+
const angle = ephem.calculateAspectAngle(0, 90);
|
|
118
|
+
expect(angle).toBe(90);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should calculate trine (120°) correctly', () => {
|
|
122
|
+
const angle = ephem.calculateAspectAngle(0, 120);
|
|
123
|
+
expect(angle).toBe(120);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should handle angles wrapping around 360°', () => {
|
|
127
|
+
const angle = ephem.calculateAspectAngle(350, 10);
|
|
128
|
+
expect(angle).toBe(20);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should always return the smallest angle', () => {
|
|
132
|
+
const angle = ephem.calculateAspectAngle(10, 350);
|
|
133
|
+
expect(angle).toBe(20);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('When determining retrograde motion', () => {
|
|
138
|
+
it('should identify retrograde planets by negative speed', () => {
|
|
139
|
+
const birthDate = new Date(Date.UTC(
|
|
140
|
+
bowenYangChart.birthDate.year,
|
|
141
|
+
bowenYangChart.birthDate.month - 1,
|
|
142
|
+
bowenYangChart.birthDate.day,
|
|
143
|
+
bowenYangChart.birthDate.hour,
|
|
144
|
+
bowenYangChart.birthDate.minute
|
|
145
|
+
));
|
|
146
|
+
const jd = ephem.dateToJulianDay(birthDate);
|
|
147
|
+
const positions = ephem.getAllPlanets(jd, Object.values(PLANETS));
|
|
148
|
+
|
|
149
|
+
const retrograde = positions.filter(p => p.speed < 0);
|
|
150
|
+
const direct = positions.filter(p => p.speed > 0);
|
|
151
|
+
|
|
152
|
+
// At any given time, some planets should be direct
|
|
153
|
+
expect(direct.length).toBeGreaterThan(0);
|
|
154
|
+
|
|
155
|
+
// Retrograde planets should have negative speed
|
|
156
|
+
retrograde.forEach(planet => {
|
|
157
|
+
expect(planet.speed).toBeLessThan(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('When finding exact transit times', () => {
|
|
163
|
+
it('should find when a planet reaches a specific longitude', () => {
|
|
164
|
+
const startJD = 2451545.0; // J2000
|
|
165
|
+
const endJD = startJD + 365;
|
|
166
|
+
const targetLongitude = 0; // 0° Aries
|
|
167
|
+
|
|
168
|
+
// Find when Sun reaches 0° Aries (Spring Equinox ~March 20)
|
|
169
|
+
const exactJDs = ephem.findExactTransitTimes(
|
|
170
|
+
PLANETS.SUN,
|
|
171
|
+
targetLongitude,
|
|
172
|
+
startJD,
|
|
173
|
+
endJD
|
|
174
|
+
);
|
|
175
|
+
const exactJD = exactJDs[0]; // Get first (earliest) crossing
|
|
176
|
+
|
|
177
|
+
// May return null if no crossing in interval (new bracketing check)
|
|
178
|
+
// This is correct behavior - not all intervals contain a crossing
|
|
179
|
+
if (exactJD !== null) {
|
|
180
|
+
expect(exactJD).toBeGreaterThan(startJD);
|
|
181
|
+
expect(exactJD).toBeLessThan(endJD);
|
|
182
|
+
}
|
|
183
|
+
// Test passes either way - we're validating it doesn't throw
|
|
184
|
+
|
|
185
|
+
// Verify the planet is actually at target longitude
|
|
186
|
+
if (exactJD !== null) {
|
|
187
|
+
const positions = ephem.getAllPlanets(exactJD, [PLANETS.SUN]);
|
|
188
|
+
expect(ephem.calculateAspectAngle(positions[0].longitude, targetLongitude)).toBeLessThan(0.1);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('When handling invalid inputs', () => {
|
|
194
|
+
it('should throw error when ephemeris not initialized', () => {
|
|
195
|
+
const uninitializedEphem = new EphemerisCalculator();
|
|
196
|
+
expect(() => {
|
|
197
|
+
uninitializedEphem.dateToJulianDay(new Date());
|
|
198
|
+
}).toThrow('Ephemeris not initialized');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should handle very old dates', () => {
|
|
202
|
+
const ancientDate = new Date(Date.UTC(1000, 0, 1, 0, 0, 0));
|
|
203
|
+
const jd = ephem.dateToJulianDay(ancientDate);
|
|
204
|
+
expect(jd).toBeGreaterThan(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should handle future dates', () => {
|
|
208
|
+
const futureDate = new Date(Date.UTC(2100, 0, 1, 0, 0, 0));
|
|
209
|
+
const jd = ephem.dateToJulianDay(futureDate);
|
|
210
|
+
expect(jd).toBeGreaterThan(knownJulianDays.j2000.jd);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
missingNatalChart,
|
|
4
|
+
noRiseSetEvent,
|
|
5
|
+
circumpolarObject,
|
|
6
|
+
polarLatitudeWarning,
|
|
7
|
+
mapSweError,
|
|
8
|
+
} from '../../src/tool-result.js';
|
|
9
|
+
|
|
10
|
+
describe('Structured error handling', () => {
|
|
11
|
+
describe('missingNatalChart', () => {
|
|
12
|
+
it('should return structured error with correct fields', () => {
|
|
13
|
+
const error = missingNatalChart();
|
|
14
|
+
|
|
15
|
+
expect(error.code).toBe('MISSING_NATAL_CHART');
|
|
16
|
+
expect(error.message).toContain('No natal chart found');
|
|
17
|
+
expect(error.retryable).toBe(true);
|
|
18
|
+
expect(error.suggestedFix).toContain('set_natal_chart');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('noRiseSetEvent', () => {
|
|
23
|
+
it('should create error for missing rise event', () => {
|
|
24
|
+
const error = noRiseSetEvent('rise', 'Sun', { latitude: 78, date: '2026-12-21' });
|
|
25
|
+
|
|
26
|
+
expect(error.code).toBe('NO_RISE_SET_EVENT');
|
|
27
|
+
expect(error.message).toContain('rise');
|
|
28
|
+
expect(error.message).toContain('Sun');
|
|
29
|
+
expect(error.retryable).toBe(true);
|
|
30
|
+
expect(error.suggestedFix).toContain('circumpolar');
|
|
31
|
+
expect(error.details).toEqual({ latitude: 78, date: '2026-12-21' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should create error for missing meridian transit', () => {
|
|
35
|
+
const error = noRiseSetEvent('upper_meridian', 'Moon', { latitude: 85 });
|
|
36
|
+
|
|
37
|
+
expect(error.code).toBe('NO_RISE_SET_EVENT');
|
|
38
|
+
expect(error.message).toContain('upper_meridian');
|
|
39
|
+
expect(error.message).toContain('Moon');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('circumpolarObject', () => {
|
|
44
|
+
it('should create circumpolar error with latitude', () => {
|
|
45
|
+
const error = circumpolarObject('Sun', 78.5);
|
|
46
|
+
|
|
47
|
+
expect(error.code).toBe('CIRCUMPOLAR_OBJECT');
|
|
48
|
+
expect(error.message).toContain('circumpolar');
|
|
49
|
+
expect(error.message).toContain('78.5');
|
|
50
|
+
expect(error.retryable).toBe(true);
|
|
51
|
+
expect(error.suggestedFix).toContain('meridian transit');
|
|
52
|
+
expect(error.details?.latitude).toBe(78.5);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('polarLatitudeWarning', () => {
|
|
57
|
+
it('should create warning for polar Placidus', () => {
|
|
58
|
+
const warning = polarLatitudeWarning(78.2, 'Placidus');
|
|
59
|
+
|
|
60
|
+
expect(warning.code).toBe('POLAR_LATITUDE_LIMIT');
|
|
61
|
+
expect(warning.message).toContain('78.2');
|
|
62
|
+
expect(warning.message).toContain('Placidus');
|
|
63
|
+
expect(warning.retryable).toBe(true);
|
|
64
|
+
expect(warning.suggestedFix).toContain('Whole Sign');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('mapSweError', () => {
|
|
69
|
+
it('should map not initialized error', () => {
|
|
70
|
+
const error = mapSweError('planet calculation', new Error('Ephemeris not initialized'));
|
|
71
|
+
|
|
72
|
+
expect(error.code).toBe('EPHEMERIS_NOT_INITIALIZED');
|
|
73
|
+
expect(error.retryable).toBe(false);
|
|
74
|
+
expect(error.suggestedFix).toContain('Initialize');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should map generic computation error', () => {
|
|
78
|
+
const error = mapSweError('house calculation', new Error('Unknown failure'), { latitude: 40 });
|
|
79
|
+
|
|
80
|
+
expect(error.code).toBe('EPHEMERIS_COMPUTE_FAILED');
|
|
81
|
+
expect(error.message).toContain('house calculation');
|
|
82
|
+
expect(error.retryable).toBe(false);
|
|
83
|
+
expect(error.details?.latitude).toBe(40);
|
|
84
|
+
expect(error.details?.rawMessage).toBe('Unknown failure');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should handle non-Error objects', () => {
|
|
88
|
+
const error = mapSweError('test', 'string error message');
|
|
89
|
+
|
|
90
|
+
expect(error.code).toBe('EPHEMERIS_COMPUTE_FAILED');
|
|
91
|
+
expect(error.details?.rawMessage).toBe('string error message');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Error structure validation', () => {
|
|
96
|
+
it('should have all required fields', () => {
|
|
97
|
+
const error = missingNatalChart();
|
|
98
|
+
|
|
99
|
+
expect(error).toHaveProperty('code');
|
|
100
|
+
expect(error).toHaveProperty('message');
|
|
101
|
+
expect(error).toHaveProperty('retryable');
|
|
102
|
+
expect(typeof error.code).toBe('string');
|
|
103
|
+
expect(typeof error.message).toBe('string');
|
|
104
|
+
expect(typeof error.retryable).toBe('boolean');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should have optional suggestedFix and details', () => {
|
|
108
|
+
const error = noRiseSetEvent('rise', 'Sun', { test: 'data' });
|
|
109
|
+
|
|
110
|
+
expect(error.suggestedFix).toBeDefined();
|
|
111
|
+
expect(error.details).toBeDefined();
|
|
112
|
+
expect(typeof error.suggestedFix).toBe('string');
|
|
113
|
+
expect(typeof error.details).toBe('object');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatDateOnly, formatInTimezone } from '../../src/formatter.js';
|
|
3
|
+
|
|
4
|
+
describe('When formatting timestamps for users', () => {
|
|
5
|
+
it('Given a timezone-aware timestamp, then output includes readable date and timezone abbreviation', () => {
|
|
6
|
+
const date = new Date('2024-03-20T12:00:00Z');
|
|
7
|
+
const formatted = formatInTimezone(date, 'America/New_York');
|
|
8
|
+
expect(formatted).toContain('Mar');
|
|
9
|
+
expect(formatted).toContain('2024');
|
|
10
|
+
expect(formatted).toMatch(/\b(EDT|EST)\b/);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('Given date-only formatting, then output excludes time and timezone suffixes', () => {
|
|
14
|
+
const date = new Date('2024-03-20T15:30:00Z');
|
|
15
|
+
const formatted = formatDateOnly(date, 'America/New_York');
|
|
16
|
+
expect(formatted).toContain('Mar');
|
|
17
|
+
expect(formatted).toContain('2024');
|
|
18
|
+
expect(formatted).not.toMatch(/AM|PM|EDT|EST|UTC/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('Given the same UTC instant in different timezones, then local formatted strings differ', () => {
|
|
22
|
+
const date = new Date('2024-03-20T12:00:00Z');
|
|
23
|
+
const ny = formatInTimezone(date, 'America/New_York');
|
|
24
|
+
const la = formatInTimezone(date, 'America/Los_Angeles');
|
|
25
|
+
expect(ny).not.toBe(la);
|
|
26
|
+
expect(ny).toMatch(/AM|PM/);
|
|
27
|
+
expect(la).toMatch(/AM|PM/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { HouseCalculator } from '../../src/houses.js';
|
|
3
|
+
|
|
4
|
+
describe('When house calculation encounters failure paths', () => {
|
|
5
|
+
it('Given an uninitialized ephemeris instance, then calculateHouses throws', () => {
|
|
6
|
+
const calc = new HouseCalculator({ eph: null } as any);
|
|
7
|
+
expect(() => calc.calculateHouses(2451545, 40, -74, 'P')).toThrow(/not initialized/i);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('Given non-polar calculation failure, then calculateHouses throws without fallback', () => {
|
|
11
|
+
const calc = new HouseCalculator({
|
|
12
|
+
eph: {
|
|
13
|
+
houses_ex2: () => ({ flag: -1 }),
|
|
14
|
+
},
|
|
15
|
+
} as any);
|
|
16
|
+
expect(() => calc.calculateHouses(2451545, 40, -74, 'P')).toThrow(/House calculation failed/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('Given polar fallback failure, then calculateHouses throws explicit fallback failure', () => {
|
|
20
|
+
const calc = new HouseCalculator({
|
|
21
|
+
eph: {
|
|
22
|
+
houses_ex2: () => ({ flag: -1 }),
|
|
23
|
+
},
|
|
24
|
+
} as any);
|
|
25
|
+
expect(() => calc.calculateHouses(2451545, 80, -74, 'P')).toThrow(/Whole Sign fallback/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { HouseCalculator } from '../../src/houses.js';
|
|
3
|
+
import { EphemerisCalculator } from '../../src/ephemeris.js';
|
|
4
|
+
|
|
5
|
+
describe('House system validation and fallback', () => {
|
|
6
|
+
let ephem: EphemerisCalculator;
|
|
7
|
+
let houseCalc: HouseCalculator;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
ephem = new EphemerisCalculator();
|
|
11
|
+
await ephem.init();
|
|
12
|
+
houseCalc = new HouseCalculator(ephem);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const testJD = 2460000; // Arbitrary test date
|
|
16
|
+
const normalLat = 40.7509; // Beaver Falls, PA
|
|
17
|
+
const normalLon = -80.3198;
|
|
18
|
+
const polarLat = 78.2232; // Svalbard, Norway
|
|
19
|
+
const polarLon = 15.6267;
|
|
20
|
+
|
|
21
|
+
describe('Input validation', () => {
|
|
22
|
+
it('should reject empty house system', () => {
|
|
23
|
+
expect(() => houseCalc.calculateHouses(testJD, normalLat, normalLon, '')).toThrow('Invalid house system');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should reject multi-character system', () => {
|
|
27
|
+
expect(() => houseCalc.calculateHouses(testJD, normalLat, normalLon, 'Placidus')).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should reject whitespace-only system', () => {
|
|
31
|
+
expect(() => houseCalc.calculateHouses(testJD, normalLat, normalLon, ' ')).toThrow('Invalid house system');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should normalize lowercase to uppercase', () => {
|
|
35
|
+
const result = houseCalc.calculateHouses(testJD, normalLat, normalLon, 'p');
|
|
36
|
+
expect(result.system).toBe('P');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should handle whitespace around valid system', () => {
|
|
40
|
+
const result = houseCalc.calculateHouses(testJD, normalLat, normalLon, ' W ');
|
|
41
|
+
expect(result.system).toBe('W');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should reject unsupported system', () => {
|
|
45
|
+
expect(() => houseCalc.calculateHouses(testJD, normalLat, normalLon, 'Z')).toThrow('Invalid house system');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should accept all valid systems', () => {
|
|
49
|
+
const validSystems = ['P', 'K', 'W', 'E', 'O', 'R', 'C', 'A', 'V', 'X', 'H', 'T', 'B'];
|
|
50
|
+
|
|
51
|
+
for (const system of validSystems) {
|
|
52
|
+
expect(() => houseCalc.calculateHouses(testJD, normalLat, normalLon, system)).not.toThrow();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('Polar latitude fallback', () => {
|
|
58
|
+
it('should fallback to Whole Sign for polar Placidus failure', () => {
|
|
59
|
+
// Svalbard - Placidus should fail and fallback to Whole Sign
|
|
60
|
+
const result = houseCalc.calculateHouses(testJD, polarLat, polarLon, 'P');
|
|
61
|
+
|
|
62
|
+
// Should have fallen back to Whole Sign
|
|
63
|
+
expect(result.system).toBe('W');
|
|
64
|
+
|
|
65
|
+
// Should return real data, not fake
|
|
66
|
+
expect(result.ascendant).not.toBe(0);
|
|
67
|
+
expect(result.mc).not.toBe(90);
|
|
68
|
+
expect(result.cusps.some(c => c !== 0)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should return requested system if it succeeds at normal latitude', () => {
|
|
72
|
+
const result = houseCalc.calculateHouses(testJD, normalLat, normalLon, 'P');
|
|
73
|
+
|
|
74
|
+
// Placidus should succeed at normal latitude
|
|
75
|
+
expect(result.system).toBe('P');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should not fallback if Whole Sign requested at polar latitude', () => {
|
|
79
|
+
const result = houseCalc.calculateHouses(testJD, polarLat, polarLon, 'W');
|
|
80
|
+
|
|
81
|
+
// Should use Whole Sign as requested (no fallback needed)
|
|
82
|
+
expect(result.system).toBe('W');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should return valid ascendant and MC values', () => {
|
|
86
|
+
const result = houseCalc.calculateHouses(testJD, polarLat, polarLon, 'P');
|
|
87
|
+
|
|
88
|
+
// Ascendant and MC should be valid degrees (0-360)
|
|
89
|
+
expect(result.ascendant).toBeGreaterThanOrEqual(0);
|
|
90
|
+
expect(result.ascendant).toBeLessThan(360);
|
|
91
|
+
expect(result.mc).toBeGreaterThanOrEqual(0);
|
|
92
|
+
expect(result.mc).toBeLessThan(360);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('Cusp array format', () => {
|
|
97
|
+
it('should preserve Swiss Ephemeris 1-based indexing', () => {
|
|
98
|
+
const result = houseCalc.calculateHouses(testJD, normalLat, normalLon, 'P');
|
|
99
|
+
|
|
100
|
+
// Should have 13 elements: [0..12]
|
|
101
|
+
expect(result.cusps).toHaveLength(13);
|
|
102
|
+
|
|
103
|
+
// Index 0 exists (unused by convention)
|
|
104
|
+
expect(result.cusps[0]).toBeDefined();
|
|
105
|
+
|
|
106
|
+
// Houses 1-12 are at indices 1-12
|
|
107
|
+
expect(result.cusps[1]).toBeDefined(); // 1st house
|
|
108
|
+
expect(result.cusps[12]).toBeDefined(); // 12th house
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should have valid cusp values', () => {
|
|
112
|
+
const result = houseCalc.calculateHouses(testJD, normalLat, normalLon, 'P');
|
|
113
|
+
|
|
114
|
+
// All cusps should be valid degrees
|
|
115
|
+
for (let i = 1; i <= 12; i++) {
|
|
116
|
+
expect(result.cusps[i]).toBeGreaterThanOrEqual(0);
|
|
117
|
+
expect(result.cusps[i]).toBeLessThan(360);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('System-specific behavior', () => {
|
|
123
|
+
it('should calculate Whole Sign houses with 30deg spacing', () => {
|
|
124
|
+
const result = houseCalc.calculateHouses(testJD, normalLat, normalLon, 'W');
|
|
125
|
+
|
|
126
|
+
expect(result.system).toBe('W');
|
|
127
|
+
|
|
128
|
+
// Whole Sign houses should be exactly 30° apart
|
|
129
|
+
for (let i = 1; i < 12; i++) {
|
|
130
|
+
const diff = (result.cusps[i + 1] - result.cusps[i] + 360) % 360;
|
|
131
|
+
expect(diff).toBeCloseTo(30, 0);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should calculate Equal houses with 30deg spacing', () => {
|
|
136
|
+
const result = houseCalc.calculateHouses(testJD, normalLat, normalLon, 'E');
|
|
137
|
+
|
|
138
|
+
expect(result.system).toBe('E');
|
|
139
|
+
|
|
140
|
+
// Equal houses should be exactly 30° apart
|
|
141
|
+
for (let i = 1; i < 12; i++) {
|
|
142
|
+
const diff = (result.cusps[i + 1] - result.cusps[i] + 360) % 360;
|
|
143
|
+
expect(diff).toBeCloseTo(30, 0);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should calculate Placidus houses with varying spacing', () => {
|
|
148
|
+
const result = houseCalc.calculateHouses(testJD, normalLat, normalLon, 'P');
|
|
149
|
+
|
|
150
|
+
expect(result.system).toBe('P');
|
|
151
|
+
|
|
152
|
+
// Placidus houses are NOT equal - should have variation
|
|
153
|
+
const spacings = [];
|
|
154
|
+
for (let i = 1; i < 12; i++) {
|
|
155
|
+
const diff = (result.cusps[i + 1] - result.cusps[i] + 360) % 360;
|
|
156
|
+
spacings.push(diff);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Should have some variation (not all exactly 30°)
|
|
160
|
+
const allEqual = spacings.every(s => Math.abs(s - 30) < 0.1);
|
|
161
|
+
expect(allEqual).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|