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,323 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AstroService, parseDateOnlyInput } from '../../src/astro-service.js';
3
+ import type { NatalChart, PlanetPosition, RiseSetTime } from '../../src/types.js';
4
+
5
+ function makePlanet(planet: PlanetPosition['planet'], longitude: number): PlanetPosition {
6
+ return {
7
+ planetId: 0,
8
+ planet,
9
+ longitude,
10
+ latitude: 0,
11
+ distance: 1,
12
+ speed: 1,
13
+ sign: 'Aries',
14
+ degree: longitude % 30,
15
+ isRetrograde: false,
16
+ };
17
+ }
18
+
19
+ function makeNatalChart(): NatalChart {
20
+ return {
21
+ name: 'Test User',
22
+ birthDate: { year: 1990, month: 6, day: 12, hour: 14, minute: 35 },
23
+ location: { latitude: 37.7749, longitude: -122.4194, timezone: 'America/Los_Angeles' },
24
+ planets: [makePlanet('Sun', 10), makePlanet('Moon', 20)],
25
+ julianDay: 2451545,
26
+ houseSystem: 'P',
27
+ utcDateTime: { year: 1990, month: 6, day: 12, hour: 21, minute: 35 },
28
+ };
29
+ }
30
+
31
+ function makeService() {
32
+ const ephem = {
33
+ eph: {},
34
+ init: vi.fn(async () => {}),
35
+ dateToJulianDay: vi.fn(() => 2451545),
36
+ getAllPlanets: vi.fn(() => [makePlanet('Sun', 204), makePlanet('Moon', 270)]),
37
+ };
38
+ const houseCalc = {
39
+ calculateHouses: vi.fn(() => ({
40
+ ascendant: 270,
41
+ mc: 204,
42
+ cusps: [0, 270, 300, 330, 0, 30, 60, 90, 120, 150, 204, 240, 260],
43
+ system: 'W' as const,
44
+ })),
45
+ };
46
+ const transitCalc = {
47
+ findTransits: vi.fn(() => [
48
+ {
49
+ transitingPlanet: 'Mars',
50
+ natalPlanet: 'Sun',
51
+ aspect: 'square',
52
+ orb: 1.25,
53
+ isApplying: true,
54
+ exactTimeStatus: 'within_preview' as const,
55
+ transitLongitude: 100,
56
+ natalLongitude: 10,
57
+ exactTime: new Date('2024-03-27T12:00:00Z'),
58
+ },
59
+ ]),
60
+ };
61
+ const riseSetCalc = {
62
+ getAllRiseSet: vi.fn(async () => [
63
+ {
64
+ planet: 'Sun',
65
+ rise: new Date('2024-03-26T13:00:00Z'),
66
+ set: new Date('2024-03-27T01:00:00Z'),
67
+ },
68
+ ] as RiseSetTime[]),
69
+ };
70
+ const eclipseCalc = {
71
+ findNextSolarEclipse: vi.fn(() => ({
72
+ type: 'solar' as const,
73
+ date: new Date('2024-04-08T18:00:00Z'),
74
+ eclipseType: 'Total',
75
+ maxTime: new Date('2024-04-08T18:00:00Z'),
76
+ })),
77
+ findNextLunarEclipse: vi.fn(() => null),
78
+ };
79
+ const chartRenderer = {
80
+ generateNatalChart: vi.fn(async (_chart, _theme, format) => {
81
+ if (format === 'svg') return '<svg>ok</svg>';
82
+ return Buffer.from([1, 2, 3]);
83
+ }),
84
+ generateTransitChart: vi.fn(async (_chart, _date, _theme, format) => {
85
+ if (format === 'svg') return '<svg>transit</svg>';
86
+ return Buffer.from([4, 5, 6]);
87
+ }),
88
+ };
89
+ const writeFile = vi.fn(async () => {});
90
+ const now = vi.fn(() => new Date('2024-03-26T12:00:00Z'));
91
+
92
+ const service = new AstroService({
93
+ ephem: ephem as any,
94
+ houseCalc: houseCalc as any,
95
+ transitCalc: transitCalc as any,
96
+ riseSetCalc: riseSetCalc as any,
97
+ eclipseCalc: eclipseCalc as any,
98
+ chartRenderer: chartRenderer as any,
99
+ writeFile,
100
+ now,
101
+ });
102
+
103
+ return { service, ephem, houseCalc, transitCalc, riseSetCalc, eclipseCalc, chartRenderer, writeFile, now };
104
+ }
105
+
106
+ describe('When using AstroService', () => {
107
+ it('Given a date-only input, then it parses valid values and rejects invalid calendar parts', () => {
108
+ expect(parseDateOnlyInput('2024-03-26')).toEqual({
109
+ year: 2024,
110
+ month: 3,
111
+ day: 26,
112
+ hour: 12,
113
+ minute: 0,
114
+ });
115
+ expect(() => parseDateOnlyInput('2024-13-01')).toThrow(/Invalid month/);
116
+ expect(() => parseDateOnlyInput('2024-02-00')).toThrow(/Invalid day/);
117
+ });
118
+
119
+ it('Given injected dependencies, then init initializes ephemeris', async () => {
120
+ const { service, ephem } = makeService();
121
+ await service.init();
122
+ expect(ephem.init).toHaveBeenCalledTimes(1);
123
+ expect(service.isInitialized()).toBe(true);
124
+ });
125
+
126
+ it('Given a polar latitude chart, then setNatalChart returns fallback house system details', () => {
127
+ const { service, houseCalc } = makeService();
128
+ const result = service.setNatalChart({
129
+ name: 'Polar User',
130
+ year: 1990,
131
+ month: 6,
132
+ day: 12,
133
+ hour: 14,
134
+ minute: 35,
135
+ latitude: 78,
136
+ longitude: 15,
137
+ timezone: 'UTC',
138
+ house_system: 'P',
139
+ });
140
+
141
+ expect(houseCalc.calculateHouses).toHaveBeenCalled();
142
+ expect(result.chart.houseSystem).toBe('W');
143
+ expect(result.data).toMatchObject({
144
+ name: 'Polar User',
145
+ requestedHouseSystem: 'P',
146
+ resolvedHouseSystem: 'W',
147
+ isPolar: true,
148
+ });
149
+ });
150
+
151
+ it('Given missing Sun or Moon data, then setNatalChart throws a clear error', () => {
152
+ const { service, ephem } = makeService();
153
+ ephem.getAllPlanets.mockReturnValue([makePlanet('Sun', 200)]);
154
+ expect(() =>
155
+ service.setNatalChart({
156
+ name: 'No Moon',
157
+ year: 1990,
158
+ month: 1,
159
+ day: 1,
160
+ hour: 1,
161
+ minute: 1,
162
+ latitude: 1,
163
+ longitude: 1,
164
+ timezone: 'UTC',
165
+ })
166
+ ).toThrow(/Sun\/Moon/);
167
+ });
168
+
169
+ it('Given transit filters, then getTransits returns filtered data and optional mundane payload', () => {
170
+ const { service } = makeService();
171
+ const natal = makeNatalChart();
172
+ const result = service.getTransits(natal, {
173
+ include_mundane: true,
174
+ days_ahead: 1,
175
+ max_orb: 2,
176
+ exact_only: true,
177
+ applying_only: true,
178
+ });
179
+
180
+ expect(result.data).toHaveProperty('transits');
181
+ expect(result.data).toHaveProperty('mundane');
182
+ expect(result.text).toContain('Transits');
183
+ });
184
+
185
+ it('Given exact-time lookup metadata, then getTransits serializes exactTimeStatus', () => {
186
+ const { service, transitCalc } = makeService();
187
+ transitCalc.findTransits.mockReturnValue([
188
+ {
189
+ transitingPlanet: 'Mars',
190
+ natalPlanet: 'Sun',
191
+ aspect: 'square',
192
+ orb: 1.25,
193
+ isApplying: true,
194
+ exactTimeStatus: 'outside_preview',
195
+ transitLongitude: 100,
196
+ natalLongitude: 10,
197
+ exactTime: undefined,
198
+ },
199
+ ]);
200
+
201
+ const result = service.getTransits(makeNatalChart());
202
+ expect((result.data as any).transits[0]).toMatchObject({
203
+ exactTimeStatus: 'outside_preview',
204
+ exactTime: undefined,
205
+ });
206
+ });
207
+
208
+ it('Given a natal chart location, then getRiseSetTimes returns ISO payload and readable text', async () => {
209
+ const { service, riseSetCalc } = makeService();
210
+ const result = await service.getRiseSetTimes(makeNatalChart());
211
+ expect(riseSetCalc.getAllRiseSet).toHaveBeenCalledTimes(1);
212
+ expect(result.data).toMatchObject({
213
+ timezone: 'America/Los_Angeles',
214
+ });
215
+ expect(result.text).toContain('Rise/Set Times');
216
+ });
217
+
218
+ it('Given eclipse availability, then getNextEclipses returns summary or empty-state text', () => {
219
+ const { service, eclipseCalc } = makeService();
220
+ const withOne = service.getNextEclipses('UTC');
221
+ expect(withOne.data).toMatchObject({ timezone: 'UTC' });
222
+ expect(withOne.text).toContain('Upcoming Eclipses');
223
+
224
+ eclipseCalc.findNextSolarEclipse.mockReturnValue(null);
225
+ const none = service.getNextEclipses('UTC');
226
+ expect(none.text).toContain('No eclipses found');
227
+ });
228
+
229
+ it('Given current planetary motion, then getRetrogradePlanets returns retrograde payload', () => {
230
+ const { service, ephem } = makeService();
231
+ ephem.getAllPlanets.mockReturnValue([
232
+ { ...makePlanet('Mercury', 10), isRetrograde: true, speed: -0.2 },
233
+ { ...makePlanet('Venus', 20), isRetrograde: false, speed: 1.2 },
234
+ ]);
235
+
236
+ const result = service.getRetrogradePlanets('UTC');
237
+ expect(result.data).toMatchObject({ timezone: 'UTC' });
238
+ expect(result.text).toContain('Mercury');
239
+ });
240
+
241
+ it('Given output_path for natal chart generation, then it writes the file and returns path text', async () => {
242
+ const { service, writeFile } = makeService();
243
+ const result = await service.generateNatalChart(makeNatalChart(), {
244
+ format: 'svg',
245
+ output_path: '/tmp/chart.svg',
246
+ theme: 'light',
247
+ });
248
+ expect(writeFile).toHaveBeenCalledWith('/tmp/chart.svg', '<svg>ok</svg>', 'utf-8');
249
+ expect(result.outputPath).toBe('/tmp/chart.svg');
250
+ });
251
+
252
+ it('Given binary chart output, then it returns base64 image payload with mime type', async () => {
253
+ const { service } = makeService();
254
+ const result = await service.generateTransitChart(makeNatalChart(), {
255
+ format: 'png',
256
+ theme: 'dark',
257
+ });
258
+ expect(result.image?.mimeType).toBe('image/png');
259
+ expect(result.image?.data).toBe(Buffer.from([4, 5, 6]).toString('base64'));
260
+ });
261
+
262
+ it('Given natal chart presence or absence, then getServerStatus reports correct state', () => {
263
+ const { service } = makeService();
264
+ const empty = service.getServerStatus(null);
265
+ const loaded = service.getServerStatus(makeNatalChart());
266
+ expect(empty.data).toMatchObject({ hasNatalChart: false });
267
+ expect(loaded.data).toMatchObject({ hasNatalChart: true });
268
+ });
269
+
270
+ it('Given invalid transit filters or missing Julian day, then validation errors are thrown', () => {
271
+ const { service } = makeService();
272
+ expect(() => service.getTransits(makeNatalChart(), { days_ahead: -1 })).toThrow(/days_ahead/);
273
+ expect(() => service.getTransits(makeNatalChart(), { max_orb: -1 })).toThrow(/max_orb/);
274
+ expect(() => service.getHouses({ ...makeNatalChart(), julianDay: undefined })).toThrow(/missing julianDay/i);
275
+ });
276
+
277
+ it('Given empty transit/retrograde results, then user-facing empty-state text is returned', () => {
278
+ const { service, transitCalc, ephem } = makeService();
279
+ transitCalc.findTransits.mockReturnValue([]);
280
+ ephem.getAllPlanets.mockReturnValue([{ ...makePlanet('Sun', 10), isRetrograde: false, speed: 1 }]);
281
+ const transits = service.getTransits(makeNatalChart(), { include_mundane: false });
282
+ expect(transits.text).toContain('No transits found');
283
+ const retro = service.getRetrogradePlanets('UTC');
284
+ expect(retro.text).toContain('No planets are currently retrograde');
285
+ });
286
+
287
+ it('Given chart format/output variants, then chart generation returns the correct branch payload', async () => {
288
+ const { service, chartRenderer, writeFile } = makeService();
289
+ chartRenderer.generateNatalChart.mockResolvedValueOnce('<svg>inline</svg>');
290
+ const inlineSvg = await service.generateNatalChart(makeNatalChart(), { format: 'svg' });
291
+ expect(inlineSvg.svg).toBe('<svg>inline</svg>');
292
+
293
+ chartRenderer.generateNatalChart.mockResolvedValueOnce(Buffer.from([9, 9]));
294
+ const inlinePng = await service.generateNatalChart(makeNatalChart(), { format: 'png' });
295
+ expect(inlinePng.image?.mimeType).toBe('image/png');
296
+
297
+ chartRenderer.generateTransitChart.mockResolvedValueOnce('<svg>transit-inline</svg>');
298
+ const transitSvg = await service.generateTransitChart(makeNatalChart(), { format: 'svg', date: '2024-03-26' });
299
+ expect(transitSvg.svg).toContain('transit-inline');
300
+
301
+ chartRenderer.generateTransitChart.mockResolvedValueOnce(Buffer.from([8, 8]));
302
+ const saved = await service.generateTransitChart(makeNatalChart(), {
303
+ format: 'webp',
304
+ output_path: '/tmp/transit.webp',
305
+ date: '2024-03-26',
306
+ });
307
+ expect(writeFile).toHaveBeenCalledWith('/tmp/transit.webp', Buffer.from([8, 8]));
308
+ expect(saved.outputPath).toBe('/tmp/transit.webp');
309
+ });
310
+
311
+ it('Given both solar and lunar eclipses exist, then both are included in the response', () => {
312
+ const { service, eclipseCalc } = makeService();
313
+ eclipseCalc.findNextLunarEclipse.mockReturnValue({
314
+ type: 'lunar',
315
+ date: new Date('2024-09-18T00:00:00Z'),
316
+ eclipseType: 'Partial',
317
+ maxTime: new Date('2024-09-18T00:00:00Z'),
318
+ });
319
+ const result = service.getNextEclipses('UTC');
320
+ expect((result.data as any).eclipses).toHaveLength(2);
321
+ expect(result.text).toContain('Next Lunar Eclipse');
322
+ });
323
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getThemeSettings } from '../../src/chart-types.js';
3
+
4
+ describe('When selecting chart theme settings', () => {
5
+ it('Given dark theme and transparency options, then background colors are resolved correctly', () => {
6
+ const dark = getThemeSettings('dark', false);
7
+ const darkTransparent = getThemeSettings('dark', true);
8
+ expect(dark.COLOR_BACKGROUND).toBe('#282c34');
9
+ expect(darkTransparent.COLOR_BACKGROUND).toBe('transparent');
10
+ });
11
+
12
+ it('Given light theme and transparency options, then background colors are resolved correctly', () => {
13
+ const light = getThemeSettings('light', false);
14
+ const lightTransparent = getThemeSettings('light', true);
15
+ expect(light.COLOR_BACKGROUND).toBe('#ffffff');
16
+ expect(lightTransparent.COLOR_BACKGROUND).toBe('transparent');
17
+ });
18
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ChartRenderer } from '../../src/charts.js';
3
+
4
+ describe('When chart rendering inputs are invalid', () => {
5
+ it('Given a natal chart missing Julian Day, then renderer throws a clear error', async () => {
6
+ const renderer = new ChartRenderer(
7
+ { getAllPlanets: () => [] } as any,
8
+ { calculateHouses: () => ({ cusps: Array(13).fill(0) }) } as any
9
+ );
10
+ await expect(
11
+ renderer.generateNatalChart({
12
+ name: 'Missing JD',
13
+ birthDate: { year: 2000, month: 1, day: 1, hour: 0, minute: 0 },
14
+ location: { latitude: 0, longitude: 0, timezone: 'UTC' },
15
+ } as any)
16
+ ).rejects.toThrow(/missing Julian Day/i);
17
+ });
18
+
19
+ it('Given a missing chart container in DOM, then SVG extraction throws', () => {
20
+ const renderer = new ChartRenderer({} as any, {} as any);
21
+ (renderer as any).dom = {
22
+ window: {
23
+ document: {
24
+ getElementById: () => null,
25
+ },
26
+ },
27
+ };
28
+ expect(() => (renderer as any).extractSVG()).toThrow(/container not found/i);
29
+ });
30
+
31
+ it('Given rendered output without an SVG element, then SVG extraction throws', () => {
32
+ const renderer = new ChartRenderer({} as any, {} as any);
33
+ (renderer as any).dom = {
34
+ window: {
35
+ document: {
36
+ getElementById: () => ({ querySelector: () => null }),
37
+ },
38
+ },
39
+ };
40
+ expect(() => (renderer as any).extractSVG()).toThrow(/no SVG element/i);
41
+ });
42
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import { ChartRenderer } from '../../src/charts.js';
3
+ import { EphemerisCalculator } from '../../src/ephemeris.js';
4
+ import { HouseCalculator } from '../../src/houses.js';
5
+ import { bowenYangChart } from '../fixtures/bowen-yang-chart.js';
6
+
7
+ describe('When an AI requests "Generate a chart for Bowen"', () => {
8
+ let ephem: EphemerisCalculator;
9
+ let houseCalc: HouseCalculator;
10
+ let chartRenderer: ChartRenderer;
11
+
12
+ beforeAll(async () => {
13
+ ephem = new EphemerisCalculator();
14
+ await ephem.init();
15
+ houseCalc = new HouseCalculator(ephem);
16
+ chartRenderer = new ChartRenderer(ephem, houseCalc);
17
+ });
18
+
19
+ describe('Given a request for SVG natal chart', () => {
20
+ it('should generate SVG natal chart', async () => {
21
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'svg');
22
+
23
+ expect(typeof result).toBe('string');
24
+ expect(result).toContain('<svg');
25
+ expect(result).toContain('</svg>');
26
+ });
27
+
28
+ it('should include zodiac signs in SVG', async () => {
29
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'svg');
30
+
31
+ expect(result).toContain('astrology-radix-signs');
32
+ });
33
+
34
+ it('should include planet symbols in SVG', async () => {
35
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'svg');
36
+
37
+ expect(result).toContain('astrology');
38
+ });
39
+ });
40
+
41
+ describe('When generating PNG natal chart', () => {
42
+ it('should generate PNG buffer', async () => {
43
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'png');
44
+
45
+ expect(Buffer.isBuffer(result)).toBe(true);
46
+ expect((result as Buffer).length).toBeGreaterThan(0);
47
+ });
48
+ });
49
+
50
+ describe('When generating WebP natal chart', () => {
51
+ it('should generate WebP buffer', async () => {
52
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'webp');
53
+
54
+ expect(Buffer.isBuffer(result)).toBe(true);
55
+ expect((result as Buffer).length).toBeGreaterThan(0);
56
+ });
57
+ });
58
+
59
+ describe('When applying light theme', () => {
60
+ it('should use white background for light theme', async () => {
61
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'svg');
62
+
63
+ expect(result).toContain('#ffffff');
64
+ });
65
+
66
+ it('should apply light theme zodiac colors', async () => {
67
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'svg');
68
+
69
+ // Should contain custom light colors
70
+ expect(result).toContain('#ffffff'); // White
71
+ expect(result).toContain('#c1e6d1'); // Mint
72
+ });
73
+ });
74
+
75
+ describe('When applying dark theme', () => {
76
+ it('should use dark background for dark theme', async () => {
77
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'dark', 'svg');
78
+
79
+ expect(result).toContain('#282c34');
80
+ });
81
+
82
+ it('should apply dark theme zodiac colors', async () => {
83
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'dark', 'svg');
84
+
85
+ // Should contain custom dark colors
86
+ expect(result).toContain('#282c34'); // Dark gray
87
+ expect(result).toContain('#8545b0'); // Purple
88
+ });
89
+ });
90
+
91
+ describe('When rendering aspect lines', () => {
92
+ it('should include aspect lines in chart', async () => {
93
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'svg');
94
+
95
+ expect(result).toContain('aspects');
96
+ });
97
+
98
+ it('should render squares, trines, oppositions, and sextiles', async () => {
99
+ const result = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'svg');
100
+
101
+ // Aspect group should be present
102
+ expect(result).toContain('astrology-aspects');
103
+ });
104
+ });
105
+
106
+ describe('When generating transit chart overlay', () => {
107
+ it('should generate transit chart with current date', async () => {
108
+ const result = await chartRenderer.generateTransitChart(bowenYangChart, undefined, 'light', 'svg');
109
+
110
+ expect(typeof result).toBe('string');
111
+ expect(result).toContain('<svg');
112
+ });
113
+
114
+ it('should generate transit chart for specific date', async () => {
115
+ const transitDate = new Date('2024-01-01');
116
+ const result = await chartRenderer.generateTransitChart(bowenYangChart, transitDate, 'light', 'svg');
117
+
118
+ expect(typeof result).toBe('string');
119
+ expect(result).toContain('<svg');
120
+ });
121
+
122
+ it('should overlay transits on natal chart', async () => {
123
+ const result = await chartRenderer.generateTransitChart(bowenYangChart, undefined, 'light', 'svg');
124
+
125
+ // Should contain both natal and transit data
126
+ expect(result).toContain('radix');
127
+ });
128
+ });
129
+
130
+ describe('When handling different image formats', () => {
131
+ it('should convert SVG to PNG with correct background', async () => {
132
+ const pngResult = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'png');
133
+
134
+ expect(Buffer.isBuffer(pngResult)).toBe(true);
135
+ });
136
+
137
+ it('should convert SVG to WebP with correct background', async () => {
138
+ const webpResult = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'webp');
139
+
140
+ expect(Buffer.isBuffer(webpResult)).toBe(true);
141
+ });
142
+
143
+ it('should use theme-appropriate background in PNG conversion', async () => {
144
+ const lightPng = await chartRenderer.generateNatalChart(bowenYangChart, 'light', 'png');
145
+ const darkPng = await chartRenderer.generateNatalChart(bowenYangChart, 'dark', 'png');
146
+
147
+ expect(Buffer.isBuffer(lightPng)).toBe(true);
148
+ expect(Buffer.isBuffer(darkPng)).toBe(true);
149
+
150
+ // Dark and light should produce different results
151
+ expect(Buffer.isBuffer(lightPng) && Buffer.isBuffer(darkPng)).toBe(true);
152
+ if (Buffer.isBuffer(lightPng) && Buffer.isBuffer(darkPng)) {
153
+ expect(lightPng.equals(darkPng)).toBe(false);
154
+ }
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,82 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { runCli } from '../../src/cli.js';
5
+ import { makeTempDir } from '../helpers/temp.js';
6
+
7
+ const natalArgs = [
8
+ '--name', 'Tester',
9
+ '--year', '1990',
10
+ '--month', '6',
11
+ '--day', '12',
12
+ '--hour', '14',
13
+ '--minute', '35',
14
+ '--latitude', '37.7749',
15
+ '--longitude', '-122.4194',
16
+ '--timezone', 'UTC',
17
+ ];
18
+
19
+ describe.sequential('When exercising CLI command handlers end-to-end', () => {
20
+ it('Given inline natal arguments, then key command handlers execute successfully', async () => {
21
+ const io = { stdout: vi.fn(), stderr: vi.fn() };
22
+
23
+ const commands: string[][] = [
24
+ ['set-natal-chart', ...natalArgs],
25
+ ['get-retrograde-planets'],
26
+ ['get-asteroid-positions'],
27
+ ['get-next-eclipses'],
28
+ ['get-transits', ...natalArgs, '--categories', 'all', '--days-ahead', '1', '--max-orb', '5'],
29
+ ['get-houses', ...natalArgs, '--system', 'W'],
30
+ ['get-rise-set-times', ...natalArgs],
31
+ ['generate-natal-chart', ...natalArgs, '--format', 'svg'],
32
+ ['generate-transit-chart', ...natalArgs, '--format', 'svg', '--date', '2024-03-26'],
33
+ ];
34
+
35
+ for (const cmd of commands) {
36
+ const code = await runCli(cmd, io);
37
+ expect(code).toBe(0);
38
+ }
39
+ });
40
+
41
+ it('Given a valid profile file, then profiles list/show/validate all execute successfully', async () => {
42
+ const dir = await makeTempDir('cli-profile-commands');
43
+ const file = path.join(dir, '.astro.json');
44
+ await writeFile(
45
+ file,
46
+ JSON.stringify({
47
+ version: 1,
48
+ defaultProfile: 'default',
49
+ profiles: {
50
+ default: {
51
+ name: 'Test',
52
+ year: 1990,
53
+ month: 1,
54
+ day: 1,
55
+ hour: 1,
56
+ minute: 1,
57
+ latitude: 1,
58
+ longitude: 1,
59
+ timezone: 'UTC',
60
+ },
61
+ },
62
+ }),
63
+ 'utf8'
64
+ );
65
+
66
+ const io = { stdout: vi.fn(), stderr: vi.fn() };
67
+ expect(await runCli(['profiles', 'list', '--profile-file', file], io)).toBe(0);
68
+ expect(await runCli(['profiles', 'show', '--profile', 'default', '--profile-file', file], io)).toBe(0);
69
+ expect(await runCli(['profiles', 'validate', '--profile-file', file], io)).toBe(0);
70
+ });
71
+
72
+ it('Given malformed numeric arguments, then CLI returns a validation error payload', async () => {
73
+ const stderr: string[] = [];
74
+ const code = await runCli(
75
+ ['get-transits', ...natalArgs, '--days-ahead', 'nope'],
76
+ { stdout: vi.fn(), stderr: (m) => stderr.push(m) }
77
+ );
78
+ expect(code).toBe(1);
79
+ const payload = JSON.parse(stderr.join('\n')) as { code: string };
80
+ expect(payload.code).toBe('CLI_ERROR');
81
+ });
82
+ });