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,205 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import { HouseCalculator } from '../../src/houses.js';
3
+ import { EphemerisCalculator } from '../../src/ephemeris.js';
4
+ import { bowenYangChart, polarChart } from '../fixtures/bowen-yang-chart.js';
5
+
6
+ describe('When calculating house cusps for a natal chart', () => {
7
+ let ephem: EphemerisCalculator;
8
+ let houseCalc: HouseCalculator;
9
+
10
+ beforeAll(async () => {
11
+ ephem = new EphemerisCalculator();
12
+ await ephem.init();
13
+ houseCalc = new HouseCalculator(ephem);
14
+ });
15
+
16
+ describe('Given Bowen Yang\'s birth data in Brisbane', () => {
17
+ it('should calculate Placidus houses', () => {
18
+ const birthDate = new Date(Date.UTC(
19
+ bowenYangChart.birthDate.year,
20
+ bowenYangChart.birthDate.month - 1,
21
+ bowenYangChart.birthDate.day,
22
+ bowenYangChart.birthDate.hour,
23
+ bowenYangChart.birthDate.minute
24
+ ));
25
+ const jd = ephem.dateToJulianDay(birthDate);
26
+
27
+ const houses = houseCalc.calculateHouses(
28
+ jd,
29
+ bowenYangChart.location.latitude,
30
+ bowenYangChart.location.longitude,
31
+ 'P'
32
+ );
33
+
34
+ expect(houses.cusps).toHaveLength(13); // 12 houses + duplicate of 1st
35
+ expect(houses.ascendant).toBeGreaterThanOrEqual(0);
36
+ expect(houses.ascendant).toBeLessThan(360);
37
+ expect(houses.mc).toBeGreaterThanOrEqual(0);
38
+ expect(houses.mc).toBeLessThan(360);
39
+ });
40
+
41
+ it('should calculate Koch house system', () => {
42
+ const birthDate = new Date(Date.UTC(
43
+ bowenYangChart.birthDate.year,
44
+ bowenYangChart.birthDate.month - 1,
45
+ bowenYangChart.birthDate.day,
46
+ bowenYangChart.birthDate.hour,
47
+ bowenYangChart.birthDate.minute
48
+ ));
49
+ const jd = ephem.dateToJulianDay(birthDate);
50
+
51
+ const houses = houseCalc.calculateHouses(
52
+ jd,
53
+ bowenYangChart.location.latitude,
54
+ bowenYangChart.location.longitude,
55
+ 'K'
56
+ );
57
+
58
+ expect(houses.cusps).toHaveLength(13);
59
+ expect(houses.ascendant).toBeDefined();
60
+ expect(houses.mc).toBeDefined();
61
+ });
62
+
63
+ it('should calculate Whole Sign houses', () => {
64
+ const birthDate = new Date(Date.UTC(
65
+ bowenYangChart.birthDate.year,
66
+ bowenYangChart.birthDate.month - 1,
67
+ bowenYangChart.birthDate.day,
68
+ bowenYangChart.birthDate.hour,
69
+ bowenYangChart.birthDate.minute
70
+ ));
71
+ const jd = ephem.dateToJulianDay(birthDate);
72
+
73
+ const houses = houseCalc.calculateHouses(
74
+ jd,
75
+ bowenYangChart.location.latitude,
76
+ bowenYangChart.location.longitude,
77
+ 'W'
78
+ );
79
+
80
+ expect(houses.cusps).toHaveLength(13);
81
+
82
+ // Whole sign houses should be 30° apart
83
+ for (let i = 1; i < 12; i++) {
84
+ const diff = (houses.cusps[i + 1] - houses.cusps[i] + 360) % 360;
85
+ expect(diff).toBeCloseTo(30, 0);
86
+ }
87
+ });
88
+
89
+ it('should calculate Equal houses', () => {
90
+ const birthDate = new Date(Date.UTC(
91
+ bowenYangChart.birthDate.year,
92
+ bowenYangChart.birthDate.month - 1,
93
+ bowenYangChart.birthDate.day,
94
+ bowenYangChart.birthDate.hour,
95
+ bowenYangChart.birthDate.minute
96
+ ));
97
+ const jd = ephem.dateToJulianDay(birthDate);
98
+
99
+ const houses = houseCalc.calculateHouses(
100
+ jd,
101
+ bowenYangChart.location.latitude,
102
+ bowenYangChart.location.longitude,
103
+ 'E'
104
+ );
105
+
106
+ expect(houses.cusps).toHaveLength(13);
107
+
108
+ // Equal houses should be exactly 30° apart
109
+ for (let i = 1; i < 12; i++) {
110
+ const diff = (houses.cusps[i + 1] - houses.cusps[i] + 360) % 360;
111
+ expect(diff).toBeCloseTo(30, 0);
112
+ }
113
+ });
114
+
115
+ it('should determine Ascendant correctly', () => {
116
+ const birthDate = new Date(Date.UTC(
117
+ bowenYangChart.birthDate.year,
118
+ bowenYangChart.birthDate.month - 1,
119
+ bowenYangChart.birthDate.day,
120
+ bowenYangChart.birthDate.hour,
121
+ bowenYangChart.birthDate.minute
122
+ ));
123
+ const jd = ephem.dateToJulianDay(birthDate);
124
+
125
+ const houses = houseCalc.calculateHouses(
126
+ jd,
127
+ bowenYangChart.location.latitude,
128
+ bowenYangChart.location.longitude,
129
+ 'P'
130
+ );
131
+
132
+ // Ascendant should equal the 1st house cusp
133
+ expect(houses.ascendant).toBeCloseTo(houses.cusps[1], 1);
134
+ });
135
+
136
+ it('should determine Midheaven (MC) correctly', () => {
137
+ const birthDate = new Date(Date.UTC(
138
+ bowenYangChart.birthDate.year,
139
+ bowenYangChart.birthDate.month - 1,
140
+ bowenYangChart.birthDate.day,
141
+ bowenYangChart.birthDate.hour,
142
+ bowenYangChart.birthDate.minute
143
+ ));
144
+ const jd = ephem.dateToJulianDay(birthDate);
145
+
146
+ const houses = houseCalc.calculateHouses(
147
+ jd,
148
+ bowenYangChart.location.latitude,
149
+ bowenYangChart.location.longitude,
150
+ 'P'
151
+ );
152
+
153
+ // MC should equal the 10th house cusp
154
+ expect(houses.mc).toBeCloseTo(houses.cusps[10], 1);
155
+ });
156
+ });
157
+
158
+ describe('When handling edge cases', () => {
159
+ it('should handle polar latitudes', () => {
160
+ const birthDate = new Date(Date.UTC(
161
+ polarChart.birthDate.year,
162
+ polarChart.birthDate.month - 1,
163
+ polarChart.birthDate.day,
164
+ polarChart.birthDate.hour,
165
+ polarChart.birthDate.minute
166
+ ));
167
+ const jd = ephem.dateToJulianDay(birthDate);
168
+
169
+ // Polar regions can have issues with some house systems
170
+ // Should not throw, but may have unusual values
171
+ expect(() => {
172
+ houseCalc.calculateHouses(
173
+ jd,
174
+ polarChart.location.latitude,
175
+ polarChart.location.longitude,
176
+ 'P'
177
+ );
178
+ }).not.toThrow();
179
+ });
180
+
181
+ it('should handle southern hemisphere correctly', () => {
182
+ const birthDate = new Date(Date.UTC(
183
+ bowenYangChart.birthDate.year,
184
+ bowenYangChart.birthDate.month - 1,
185
+ bowenYangChart.birthDate.day,
186
+ bowenYangChart.birthDate.hour,
187
+ bowenYangChart.birthDate.minute
188
+ ));
189
+ const jd = ephem.dateToJulianDay(birthDate);
190
+
191
+ // Brisbane is in southern hemisphere (negative latitude)
192
+ expect(bowenYangChart.location.latitude).toBeLessThan(0);
193
+
194
+ const houses = houseCalc.calculateHouses(
195
+ jd,
196
+ bowenYangChart.location.latitude,
197
+ bowenYangChart.location.longitude,
198
+ 'P'
199
+ );
200
+
201
+ expect(houses.cusps).toHaveLength(13);
202
+ expect(houses.ascendant).toBeGreaterThanOrEqual(0);
203
+ });
204
+ });
205
+ });
@@ -0,0 +1,163 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import {
6
+ ProfileStoreError,
7
+ resolveProfileFilePath,
8
+ loadResolvedProfileFile,
9
+ resolveProfileSelection,
10
+ } from '../../src/profile-store.js';
11
+
12
+ function sampleProfileFile(): string {
13
+ return JSON.stringify(
14
+ {
15
+ version: 1,
16
+ defaultProfile: 'elwyn',
17
+ profiles: {
18
+ elwyn: {
19
+ name: 'Elwyn',
20
+ year: 1988,
21
+ month: 6,
22
+ day: 12,
23
+ hour: 14,
24
+ minute: 35,
25
+ latitude: 37.7749,
26
+ longitude: -122.4194,
27
+ timezone: 'America/Los_Angeles',
28
+ house_system: 'W',
29
+ birth_time_disambiguation: 'reject',
30
+ },
31
+ nick: {
32
+ name: 'Nick',
33
+ year: 1987,
34
+ month: 3,
35
+ day: 4,
36
+ hour: 9,
37
+ minute: 15,
38
+ latitude: 37,
39
+ longitude: -122,
40
+ timezone: 'America/Los_Angeles',
41
+ },
42
+ },
43
+ },
44
+ null,
45
+ 2
46
+ );
47
+ }
48
+
49
+ async function makeDir(name: string): Promise<string> {
50
+ const dir = path.join(tmpdir(), `astro-profile-test-${name}-${Date.now()}`);
51
+ await mkdir(dir, { recursive: true });
52
+ return dir;
53
+ }
54
+
55
+ describe('profile store', () => {
56
+ it('resolves defaultProfile from local .astro.json', async () => {
57
+ const cwd = await makeDir('default');
58
+ await writeFile(path.join(cwd, '.astro.json'), sampleProfileFile(), 'utf8');
59
+
60
+ const resolved = await resolveProfileSelection({ cwd, homeDir: cwd });
61
+ expect(resolved?.profileName).toBe('elwyn');
62
+ expect(resolved?.profile.timezone).toBe('America/Los_Angeles');
63
+ });
64
+
65
+ it('applies profile precedence from ASTRO_PROFILE env', async () => {
66
+ const cwd = await makeDir('env-profile');
67
+ await writeFile(path.join(cwd, '.astro.json'), sampleProfileFile(), 'utf8');
68
+
69
+ const resolved = await resolveProfileSelection({
70
+ cwd,
71
+ homeDir: cwd,
72
+ env: { ...process.env, ASTRO_PROFILE: 'nick' },
73
+ });
74
+ expect(resolved?.profileName).toBe('nick');
75
+ });
76
+
77
+ it('uses explicit profileName over env and file default', async () => {
78
+ const cwd = await makeDir('explicit-precedence');
79
+ await writeFile(path.join(cwd, '.astro.json'), sampleProfileFile(), 'utf8');
80
+ const resolved = await resolveProfileSelection({
81
+ cwd,
82
+ homeDir: cwd,
83
+ profileName: 'nick',
84
+ env: { ...process.env, ASTRO_PROFILE: 'elwyn' },
85
+ });
86
+ expect(resolved?.profileName).toBe('nick');
87
+ });
88
+
89
+ it('prefers local profile file over home profile file', async () => {
90
+ const cwd = await makeDir('local-over-home-cwd');
91
+ const home = await makeDir('local-over-home-home');
92
+ await writeFile(path.join(home, '.astro.json'), sampleProfileFile().replaceAll('elwyn', 'home-default'), 'utf8');
93
+ await writeFile(path.join(cwd, '.astro.json'), sampleProfileFile(), 'utf8');
94
+ const pathResult = await resolveProfileFilePath({ cwd, homeDir: home }, true);
95
+ expect(pathResult).toBe(path.join(cwd, '.astro.json'));
96
+ });
97
+
98
+ it('throws PROFILE_NOT_FOUND for missing explicit profile', async () => {
99
+ const cwd = await makeDir('missing-profile');
100
+ await writeFile(path.join(cwd, '.astro.json'), sampleProfileFile(), 'utf8');
101
+
102
+ await expect(
103
+ resolveProfileSelection({ cwd, homeDir: cwd, profileName: 'does-not-exist' })
104
+ ).rejects.toMatchObject<Partial<ProfileStoreError>>({ code: 'PROFILE_NOT_FOUND' });
105
+ });
106
+
107
+ it('throws PROFILE_FILE_NOT_FOUND when profile file is required and missing', async () => {
108
+ const cwd = await makeDir('missing-file');
109
+
110
+ await expect(loadResolvedProfileFile({ cwd, homeDir: cwd })).rejects.toMatchObject<
111
+ Partial<ProfileStoreError>
112
+ >({ code: 'PROFILE_FILE_NOT_FOUND' });
113
+ });
114
+
115
+ it('throws INVALID_PROFILE_FILE for invalid JSON', async () => {
116
+ const cwd = await makeDir('invalid-json');
117
+ await writeFile(path.join(cwd, '.astro.json'), '{invalid json}', 'utf8');
118
+
119
+ await expect(loadResolvedProfileFile({ cwd, homeDir: cwd })).rejects.toMatchObject<
120
+ Partial<ProfileStoreError>
121
+ >({ code: 'INVALID_PROFILE_FILE' });
122
+ });
123
+
124
+ it('throws DEFAULT_PROFILE_NOT_FOUND when default profile key is invalid', async () => {
125
+ const cwd = await makeDir('bad-default');
126
+ await writeFile(
127
+ path.join(cwd, '.astro.json'),
128
+ JSON.stringify({
129
+ version: 1,
130
+ defaultProfile: 'ghost',
131
+ profiles: {
132
+ elwyn: JSON.parse(sampleProfileFile()).profiles.elwyn,
133
+ },
134
+ }),
135
+ 'utf8'
136
+ );
137
+
138
+ await expect(resolveProfileSelection({ cwd, homeDir: cwd })).rejects.toMatchObject<
139
+ Partial<ProfileStoreError>
140
+ >({ code: 'DEFAULT_PROFILE_NOT_FOUND' });
141
+ });
142
+
143
+ it('throws PROFILE_VALIDATION_FAILED for invalid enum fields', async () => {
144
+ const cwd = await makeDir('enum-invalid');
145
+ await writeFile(
146
+ path.join(cwd, '.astro.json'),
147
+ JSON.stringify({
148
+ version: 1,
149
+ profiles: {
150
+ bad: {
151
+ ...JSON.parse(sampleProfileFile()).profiles.elwyn,
152
+ house_system: 'Z',
153
+ },
154
+ },
155
+ }),
156
+ 'utf8'
157
+ );
158
+
159
+ await expect(loadResolvedProfileFile({ cwd, homeDir: cwd })).rejects.toMatchObject<
160
+ Partial<ProfileStoreError>
161
+ >({ code: 'PROFILE_VALIDATION_FAILED' });
162
+ });
163
+ });
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import { EphemerisCalculator } from '../../src/ephemeris.js';
3
+ import { HouseCalculator } from '../../src/houses.js';
4
+ import type { NatalChart } from '../../src/types.js';
5
+
6
+ /**
7
+ * Real user test case: Oct 17 1977, 1:06 PM EDT, Beaver Falls PA
8
+ *
9
+ * This test validates the exact chart from the user session that exposed
10
+ * multiple time handling bugs:
11
+ * - Bug 1: Moon was showing as Sagittarius instead of 0° Capricorn
12
+ * - Bug 2: Ascendant was showing as Gemini instead of 0° Capricorn
13
+ * - Bug 3: MC was incorrect due to 4-hour time offset
14
+ *
15
+ * User confirmed correct values:
16
+ * - Sun: 24° Libra
17
+ * - Moon: 0°09' Capricorn (0.15°)
18
+ * - Ascendant: 0° Capricorn
19
+ * - MC: 24° Libra
20
+ */
21
+ describe('Real user chart: Oct 17 1977, 1:06 PM EDT, Beaver Falls PA', () => {
22
+ let ephem: EphemerisCalculator;
23
+ let houseCalc: HouseCalculator;
24
+
25
+ beforeAll(async () => {
26
+ ephem = new EphemerisCalculator();
27
+ await ephem.init();
28
+ houseCalc = new HouseCalculator(ephem);
29
+ });
30
+
31
+ it('should calculate Moon at 0° Capricorn (not Sagittarius)', () => {
32
+ // User reported: Moon should be 0°09' Capricorn
33
+ // Bug: Was showing Sagittarius due to time conversion error
34
+ // 1:06 PM EDT = 17:06 UTC (EDT is UTC-4)
35
+
36
+ const birthDate = new Date(Date.UTC(1977, 9, 17, 17, 6)); // Oct 17, 5:06 PM UTC
37
+ const jd = ephem.dateToJulianDay(birthDate);
38
+ const positions = ephem.getAllPlanets(jd, [1]); // Moon
39
+ const moon = positions[0];
40
+
41
+ expect(moon.sign).toBe('Capricorn');
42
+ expect(moon.degree).toBeCloseTo(0.15, 0); // 0°09' ≈ 0.15°
43
+ });
44
+
45
+ it('should calculate Ascendant at ~0° Capricorn (not Gemini)', () => {
46
+ // User confirmed: Ascendant 0° Capricorn
47
+ // Bug: Was showing Gemini Ascendant (4 hours off)
48
+
49
+ const birthDate = new Date(Date.UTC(1977, 9, 17, 17, 6));
50
+ const jd = ephem.dateToJulianDay(birthDate);
51
+ const houses = houseCalc.calculateHouses(
52
+ jd,
53
+ 40.7509, // Beaver Falls, PA
54
+ -80.3198,
55
+ 'P'
56
+ );
57
+
58
+ // Ascendant should be in Capricorn (270-300°)
59
+ expect(houses.ascendant).toBeGreaterThanOrEqual(270);
60
+ expect(houses.ascendant).toBeLessThan(300);
61
+
62
+ // More specifically, should be very close to 0° Capricorn (270°)
63
+ expect(houses.ascendant).toBeCloseTo(270, 0);
64
+ });
65
+
66
+ it('should calculate MC at 24° Libra', () => {
67
+ // User confirmed: MC 24° Libra
68
+ // MC at 24° Libra = 204° (180° + 24°)
69
+
70
+ const birthDate = new Date(Date.UTC(1977, 9, 17, 17, 6));
71
+ const jd = ephem.dateToJulianDay(birthDate);
72
+ const houses = houseCalc.calculateHouses(
73
+ jd,
74
+ 40.7509,
75
+ -80.3198,
76
+ 'P'
77
+ );
78
+
79
+ // MC should be in Libra (180-210°)
80
+ expect(houses.mc).toBeGreaterThanOrEqual(180);
81
+ expect(houses.mc).toBeLessThan(210);
82
+
83
+ // More specifically, should be around 204° (24° Libra)
84
+ expect(houses.mc).toBeCloseTo(204, 1);
85
+ });
86
+
87
+ it('should calculate Sun at 24° Libra', () => {
88
+ // User confirmed: Sun 24° Libra
89
+
90
+ const birthDate = new Date(Date.UTC(1977, 9, 17, 17, 6));
91
+ const jd = ephem.dateToJulianDay(birthDate);
92
+ const positions = ephem.getAllPlanets(jd, [0]); // Sun
93
+ const sun = positions[0];
94
+
95
+ expect(sun.sign).toBe('Libra');
96
+ expect(sun.degree).toBeCloseTo(24.22, 1); // Actual: 24°13'
97
+ });
98
+
99
+ it('should have Moon conjunct Ascendant (both at 0° Capricorn)', () => {
100
+ // User noted: Moon conjunct Ascendant at 0° Capricorn
101
+ // This is a significant chart feature
102
+
103
+ const birthDate = new Date(Date.UTC(1977, 9, 17, 17, 6));
104
+ const jd = ephem.dateToJulianDay(birthDate);
105
+
106
+ const positions = ephem.getAllPlanets(jd, [1]); // Moon
107
+ const moon = positions[0];
108
+
109
+ const houses = houseCalc.calculateHouses(jd, 40.7509, -80.3198, 'P');
110
+
111
+ // Both should be in Capricorn
112
+ expect(moon.sign).toBe('Capricorn');
113
+ expect(houses.ascendant).toBeGreaterThanOrEqual(270);
114
+ expect(houses.ascendant).toBeLessThan(300);
115
+
116
+ // Moon longitude and Ascendant should be very close
117
+ const moonLon = moon.longitude;
118
+ const ascLon = houses.ascendant;
119
+ const diff = Math.abs(moonLon - ascLon);
120
+
121
+ expect(diff).toBeLessThan(5); // Within 5° conjunction
122
+ });
123
+
124
+ it('should have Sun conjunct MC (both at 24° Libra)', () => {
125
+ // User noted: Sun at 24° Libra, MC at 24° Libra
126
+ // Sun conjunct MC is a powerful chart signature
127
+
128
+ const birthDate = new Date(Date.UTC(1977, 9, 17, 17, 6));
129
+ const jd = ephem.dateToJulianDay(birthDate);
130
+
131
+ const positions = ephem.getAllPlanets(jd, [0]); // Sun
132
+ const sun = positions[0];
133
+
134
+ const houses = houseCalc.calculateHouses(jd, 40.7509, -80.3198, 'P');
135
+
136
+ // Both should be in Libra around 24°
137
+ expect(sun.sign).toBe('Libra');
138
+ expect(houses.mc).toBeGreaterThanOrEqual(180);
139
+ expect(houses.mc).toBeLessThan(210);
140
+
141
+ // Sun longitude and MC should be very close
142
+ const sunLon = sun.longitude;
143
+ const mcLon = houses.mc;
144
+ const diff = Math.abs(sunLon - mcLon);
145
+
146
+ expect(diff).toBeLessThan(2); // Within 2° conjunction
147
+ });
148
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { RiseSetCalculator } from '../../src/riseset.js';
3
+
4
+ describe('When calculating rise/set events', () => {
5
+ it('Given Swiss Ephemeris success and no-event flags, then rise/set fields map correctly', () => {
6
+ const riseTrans = vi
7
+ .fn()
8
+ .mockReturnValueOnce({ flag: 1, data: 2451545.25 })
9
+ .mockReturnValueOnce({ flag: -2 })
10
+ .mockReturnValueOnce({ flag: 1, data: 2451545.5 })
11
+ .mockReturnValueOnce({ flag: 1, data: 2451545.75 });
12
+
13
+ const ephem = {
14
+ eph: { rise_trans: riseTrans },
15
+ julianDayToDate: vi.fn((jd: number) => new Date((jd - 2440587.5) * 86400000)),
16
+ dateToJulianDay: vi.fn(() => 2451545),
17
+ };
18
+
19
+ const calc = new RiseSetCalculator(ephem as any);
20
+ const result = calc.calculateRiseSet(2451545, 0, 40, -74, 0);
21
+
22
+ expect(result.planet).toBe('Sun');
23
+ expect(result.rise).toBeInstanceOf(Date);
24
+ expect(result.set).toBeUndefined();
25
+ expect(result.upperMeridianTransit).toBeInstanceOf(Date);
26
+ });
27
+
28
+ it('Given a hard rise_trans failure flag, then calculation throws an error', () => {
29
+ const ephem = {
30
+ eph: { rise_trans: vi.fn(() => ({ flag: -1, error: 'boom' })) },
31
+ julianDayToDate: vi.fn(),
32
+ };
33
+ const calc = new RiseSetCalculator(ephem as any);
34
+ expect(() => calc.calculateRiseSet(2451545, 0, 40, -74)).toThrow(/calculation failed/i);
35
+ });
36
+
37
+ it('Given per-planet failures in getAllRiseSet, then remaining planets are still returned', async () => {
38
+ const calc = new RiseSetCalculator({
39
+ eph: {},
40
+ dateToJulianDay: vi.fn(() => 2451545),
41
+ } as any);
42
+ const spy = vi.spyOn(calc, 'calculateRiseSet');
43
+ spy.mockImplementation((jd, planetId) => {
44
+ if (planetId === 3) {
45
+ throw new Error('planet failed');
46
+ }
47
+ return { planet: String(planetId) } as any;
48
+ });
49
+
50
+ const results = await calc.getAllRiseSet(new Date('2024-03-26T00:00:00Z'), 40, -74);
51
+ expect(results.length).toBe(9);
52
+ });
53
+
54
+ it('Given invalid ranges or invalid date inputs, then validation errors are raised', async () => {
55
+ const calc = new RiseSetCalculator({
56
+ eph: { rise_trans: vi.fn() },
57
+ dateToJulianDay: vi.fn(() => 2451545),
58
+ julianDayToDate: vi.fn(),
59
+ } as any);
60
+
61
+ expect(() => calc.calculateRiseSet(2451545, 0, 99, -74)).toThrow(/Invalid latitude/);
62
+ await expect(calc.getAllRiseSet(new Date('invalid'), 40, -74)).rejects.toThrow(/Invalid date/);
63
+ });
64
+
65
+ it('Given getSunRiseSet, then current instant is used as the anchor and Sun is requested', async () => {
66
+ const ephem = {
67
+ eph: {},
68
+ dateToJulianDay: vi.fn(() => 2451545),
69
+ };
70
+ const calc = new RiseSetCalculator(ephem as any);
71
+ const spy = vi.spyOn(calc, 'calculateRiseSet').mockReturnValue({ planet: 'Sun' } as any);
72
+ const result = await calc.getSunRiseSet(40, -74);
73
+ expect(ephem.dateToJulianDay).toHaveBeenCalledTimes(1);
74
+ expect(spy).toHaveBeenCalledWith(2451545, 0, 40, -74, 0);
75
+ expect(result.planet).toBe('Sun');
76
+ });
77
+
78
+ it('Given uninitialized ephemeris, then all public methods throw not-initialized errors', async () => {
79
+ const calc = new RiseSetCalculator({ eph: null } as any);
80
+ expect(() => calc.calculateRiseSet(2451545, 0, 40, -74)).toThrow(/not initialized/i);
81
+ await expect(calc.getAllRiseSet(new Date(), 40, -74)).rejects.toThrow(/not initialized/i);
82
+ await expect(calc.getSunRiseSet(40, -74)).rejects.toThrow(/not initialized/i);
83
+ });
84
+
85
+ it('Given invalid longitude or altitude, then calculateRiseSet rejects the input', () => {
86
+ const calc = new RiseSetCalculator({
87
+ eph: { rise_trans: vi.fn(() => ({ flag: -2 })) },
88
+ julianDayToDate: vi.fn(),
89
+ } as any);
90
+ expect(() => calc.calculateRiseSet(2451545, 0, 40, Number.NaN)).toThrow(/Invalid longitude/);
91
+ expect(() => calc.calculateRiseSet(2451545, 0, 40, -74, Number.NaN)).toThrow(/Invalid altitude/);
92
+ });
93
+
94
+ it('Given a successful flag without finite event data, then event fields remain undefined', () => {
95
+ const ephem = {
96
+ eph: {
97
+ rise_trans: vi.fn(() => ({ flag: 1, data: Number.NaN })),
98
+ },
99
+ julianDayToDate: vi.fn(),
100
+ };
101
+ const calc = new RiseSetCalculator(ephem as any);
102
+ const result = calc.calculateRiseSet(2451545, 0, 40, -74);
103
+ expect(result.rise).toBeUndefined();
104
+ expect(result.set).toBeUndefined();
105
+ });
106
+ });