ether-to-astro 1.0.2 → 1.2.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 (60) hide show
  1. package/.github/ISSUE_TEMPLATE/bug-report.yml +87 -0
  2. package/.github/ISSUE_TEMPLATE/capability-request.yml +117 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  4. package/.github/ISSUE_TEMPLATE/paper-cut.yml +59 -0
  5. package/.github/pull_request_template.md +1 -0
  6. package/.github/workflows/release.yml +2 -2
  7. package/.github/workflows/test.yml +2 -2
  8. package/AGENTS.md +46 -1
  9. package/DEVELOPER.md +78 -0
  10. package/README.md +128 -75
  11. package/SETUP.md +100 -41
  12. package/dist/astro-service.d.ts +51 -2
  13. package/dist/astro-service.js +660 -56
  14. package/dist/cli.js +31 -0
  15. package/dist/entrypoint.d.ts +13 -0
  16. package/dist/entrypoint.js +78 -0
  17. package/dist/ephemeris.d.ts +15 -0
  18. package/dist/ephemeris.js +33 -0
  19. package/dist/formatter.d.ts +5 -1
  20. package/dist/formatter.js +4 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.js +63 -114
  23. package/dist/loader.d.ts +1 -1
  24. package/dist/loader.js +61 -23
  25. package/dist/mcp-alias.d.ts +2 -0
  26. package/dist/mcp-alias.js +8 -0
  27. package/dist/time-utils.d.ts +8 -0
  28. package/dist/time-utils.js +16 -0
  29. package/dist/tool-registry.js +111 -5
  30. package/dist/tool-result.d.ts +8 -0
  31. package/dist/tool-result.js +39 -0
  32. package/dist/types.d.ts +79 -1
  33. package/docs/product/adrs/0001-mcp-vs-skill-boundary.md +96 -0
  34. package/docs/product/architecture-boundaries.md +223 -0
  35. package/docs/product/product-tenets.md +174 -0
  36. package/docs/releases/1.2.0-draft.md +48 -0
  37. package/package.json +7 -7
  38. package/skills/.curated/daily-brief/SKILL.md +75 -0
  39. package/skills/.curated/electional-overlay/SKILL.md +67 -0
  40. package/skills/.curated/weekly-overview/SKILL.md +73 -0
  41. package/skills/.system/write-skill/SKILL.md +90 -0
  42. package/src/astro-service.ts +861 -60
  43. package/src/cli.ts +84 -0
  44. package/src/entrypoint.ts +118 -0
  45. package/src/ephemeris.ts +44 -0
  46. package/src/formatter.ts +13 -1
  47. package/src/index.ts +77 -121
  48. package/src/loader.ts +69 -25
  49. package/src/mcp-alias.ts +10 -0
  50. package/src/time-utils.ts +18 -0
  51. package/src/tool-registry.ts +129 -9
  52. package/src/tool-result.ts +44 -0
  53. package/src/types.ts +91 -1
  54. package/tests/unit/astro-service.test.ts +751 -5
  55. package/tests/unit/cli-commands.test.ts +13 -0
  56. package/tests/unit/entrypoint.test.ts +67 -0
  57. package/tests/unit/error-mapping.test.ts +20 -0
  58. package/tests/unit/formatter.test.ts +6 -0
  59. package/tests/unit/tool-registry.test.ts +114 -2
  60. package/setup.sh +0 -21
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { AstroService, parseDateOnlyInput } from '../../src/astro-service.js';
3
+ import type { McpStartupDefaults } from '../../src/entrypoint.js';
3
4
  import type { NatalChart, PlanetPosition, RiseSetTime } from '../../src/types.js';
4
5
 
5
6
  function makePlanet(planet: PlanetPosition['planet'], longitude: number): PlanetPosition {
@@ -28,12 +29,22 @@ function makeNatalChart(): NatalChart {
28
29
  };
29
30
  }
30
31
 
31
- function makeService() {
32
+ function makeService(mcpStartupDefaults: McpStartupDefaults = {}) {
32
33
  const ephem = {
33
34
  eph: {},
34
35
  init: vi.fn(async () => {}),
35
- dateToJulianDay: vi.fn(() => 2451545),
36
+ dateToJulianDay: vi.fn((date: Date) => date.getTime() / 86400000 + 2440587.5),
37
+ calculateAspectAngle: vi.fn((a: number, b: number) => {
38
+ const diff = Math.abs(a - b);
39
+ return diff > 180 ? 360 - diff : diff;
40
+ }),
41
+ getHorizontalCoordinates: vi.fn(() => ({
42
+ azimuth: 180,
43
+ trueAltitude: 25,
44
+ apparentAltitude: 25,
45
+ })),
36
46
  getAllPlanets: vi.fn(() => [makePlanet('Sun', 204), makePlanet('Moon', 270)]),
47
+ getPlanetPosition: vi.fn((planetId: number) => makePlanet(planetId === 1 ? 'Moon' : 'Sun', 100)),
37
48
  };
38
49
  const houseCalc = {
39
50
  calculateHouses: vi.fn(() => ({
@@ -96,6 +107,7 @@ function makeService() {
96
107
  riseSetCalc: riseSetCalc as any,
97
108
  eclipseCalc: eclipseCalc as any,
98
109
  chartRenderer: chartRenderer as any,
110
+ mcpStartupDefaults,
99
111
  writeFile,
100
112
  now,
101
113
  });
@@ -166,9 +178,15 @@ describe('When using AstroService', () => {
166
178
  ).toThrow(/Sun\/Moon/);
167
179
  });
168
180
 
169
- it('Given transit filters, then getTransits returns filtered data and optional mundane payload', () => {
170
- const { service } = makeService();
181
+ it('Given transit filters, then getTransits returns filtered data plus mundane aspects and weather for date ranges', () => {
182
+ const { service, ephem } = makeService();
171
183
  const natal = makeNatalChart();
184
+ ephem.getAllPlanets.mockReturnValue([
185
+ { ...makePlanet('Sun', 0), speed: 1 },
186
+ { ...makePlanet('Moon', 90), speed: 1 },
187
+ { ...makePlanet('Mars', 120), speed: 1 },
188
+ ]);
189
+
172
190
  const result = service.getTransits(natal, {
173
191
  include_mundane: true,
174
192
  days_ahead: 1,
@@ -179,7 +197,407 @@ describe('When using AstroService', () => {
179
197
 
180
198
  expect(result.data).toHaveProperty('transits');
181
199
  expect(result.data).toHaveProperty('mundane');
182
- expect(result.text).toContain('Transits');
200
+ const mundane = (result.data as any).mundane;
201
+ expect(mundane).toMatchObject({
202
+ date: '2024-03-26',
203
+ timezone: 'America/Los_Angeles',
204
+ });
205
+ expect(mundane.aspects).toEqual(
206
+ expect.arrayContaining([
207
+ expect.objectContaining({
208
+ planetA: 'Sun',
209
+ planetB: 'Moon',
210
+ aspect: 'square',
211
+ orb: 0,
212
+ isApplying: false,
213
+ }),
214
+ expect.objectContaining({
215
+ planetA: 'Sun',
216
+ planetB: 'Mars',
217
+ aspect: 'trine',
218
+ orb: 0,
219
+ isApplying: false,
220
+ }),
221
+ ])
222
+ );
223
+ expect(mundane.days).toHaveLength(2);
224
+ expect(mundane.days[1]).toMatchObject({
225
+ date: '2024-03-27',
226
+ timezone: 'America/Los_Angeles',
227
+ });
228
+ expect(mundane.days[1].aspects).toEqual(
229
+ expect.arrayContaining([
230
+ expect.objectContaining({
231
+ planetA: 'Sun',
232
+ planetB: 'Moon',
233
+ aspect: 'square',
234
+ }),
235
+ ])
236
+ );
237
+ expect(result.text).toContain('Current Planetary Positions');
238
+ });
239
+
240
+ it('Given stateless electional inputs, then getElectionalContext returns deterministic ascendant, sect, moon, and optional ruler basics', () => {
241
+ const { service, ephem } = makeService();
242
+ ephem.getAllPlanets.mockReturnValue([
243
+ { ...makePlanet('Sun', 0), sign: 'Aries', speed: 1 },
244
+ { ...makePlanet('Moon', 58), sign: 'Taurus', speed: 13 },
245
+ { ...makePlanet('Mercury', 120), sign: 'Leo', speed: 1.2 },
246
+ { ...makePlanet('Venus', 180), sign: 'Libra', speed: 1.1 },
247
+ { ...makePlanet('Mars', 240), sign: 'Sagittarius', speed: 0.7 },
248
+ { ...makePlanet('Jupiter', 300), sign: 'Aquarius', speed: 0.2 },
249
+ { ...makePlanet('Saturn', 315), sign: 'Aquarius', speed: 0.05, isRetrograde: true },
250
+ { ...makePlanet('Uranus', 30), sign: 'Taurus', speed: 0.03 },
251
+ { ...makePlanet('Neptune', 330), sign: 'Pisces', speed: 0.02 },
252
+ { ...makePlanet('Pluto', 270), sign: 'Capricorn', speed: 0.01 },
253
+ ]);
254
+
255
+ const result = service.getElectionalContext({
256
+ date: '2026-03-28',
257
+ time: '09:30',
258
+ timezone: 'America/Los_Angeles',
259
+ latitude: 37.7749,
260
+ longitude: -122.4194,
261
+ include_ruler_basics: true,
262
+ });
263
+
264
+ expect(result.data).toMatchObject({
265
+ input: {
266
+ date: '2026-03-28',
267
+ time: '09:30',
268
+ timezone: 'America/Los_Angeles',
269
+ house_system: 'W',
270
+ },
271
+ ascendant: {
272
+ sign: 'Capricorn',
273
+ },
274
+ sect: {
275
+ is_day_chart: true,
276
+ classification: 'day',
277
+ sun_altitude_degrees: 25,
278
+ },
279
+ moon: {
280
+ sign: 'Taurus',
281
+ phase_name: 'crescent',
282
+ is_void_of_course: null,
283
+ },
284
+ ruler_basics: {
285
+ asc_sign_ruler: {
286
+ body: 'Saturn',
287
+ sign: 'Aquarius',
288
+ is_retrograde: true,
289
+ },
290
+ },
291
+ meta: {
292
+ deterministic: true,
293
+ requires_natal: false,
294
+ },
295
+ });
296
+ expect((result.data as any).applying_aspects).toEqual(
297
+ expect.arrayContaining([
298
+ expect.objectContaining({
299
+ from_body: 'Sun',
300
+ to_body: 'Moon',
301
+ aspect: 'sextile',
302
+ applying: true,
303
+ }),
304
+ ])
305
+ );
306
+ expect((result.data as any).meta.warnings).toContain(
307
+ 'Moon void-of-course is deferred in this slice and returns null.'
308
+ );
309
+ expect(result.text).toContain('Electional context');
310
+ });
311
+
312
+ it('Given electional toggles and invalid inputs, then getElectionalContext honors the raw contract and validates clearly', () => {
313
+ const { service, ephem } = makeService();
314
+ ephem.getAllPlanets.mockReturnValue([
315
+ { ...makePlanet('Sun', 0), sign: 'Aries', speed: 1 },
316
+ { ...makePlanet('Moon', 120), sign: 'Leo', speed: 1 },
317
+ { ...makePlanet('Mercury', 210), sign: 'Scorpio', speed: 1 },
318
+ { ...makePlanet('Venus', 300), sign: 'Aquarius', speed: 1 },
319
+ { ...makePlanet('Mars', 45), sign: 'Taurus', speed: 1 },
320
+ { ...makePlanet('Jupiter', 90), sign: 'Cancer', speed: 0.1 },
321
+ { ...makePlanet('Saturn', 180), sign: 'Libra', speed: 0.1 },
322
+ { ...makePlanet('Uranus', 240), sign: 'Sagittarius', speed: 0.1 },
323
+ { ...makePlanet('Neptune', 270), sign: 'Capricorn', speed: 0.1 },
324
+ { ...makePlanet('Pluto', 330), sign: 'Pisces', speed: 0.1 },
325
+ ]);
326
+
327
+ const result = service.getElectionalContext({
328
+ date: '2026-03-28',
329
+ time: '09:30:15',
330
+ timezone: 'UTC',
331
+ latitude: 40.7,
332
+ longitude: -74,
333
+ include_planetary_applications: false,
334
+ });
335
+
336
+ expect((result.data as any).applying_aspects).toBeUndefined();
337
+ expect((result.data as any).moon.applying_aspects).toBeUndefined();
338
+ expect((result.data as any).ruler_basics).toBeUndefined();
339
+ expect(result.text).not.toContain('Applying Aspects:');
340
+
341
+ expect(() =>
342
+ service.getElectionalContext({
343
+ date: '2026-03-28',
344
+ time: '25:61',
345
+ timezone: 'UTC',
346
+ latitude: 40.7,
347
+ longitude: -74,
348
+ })
349
+ ).toThrow(/Invalid clock time/);
350
+
351
+ expect(() =>
352
+ service.getElectionalContext({
353
+ date: '2026-03-28',
354
+ time: '09:30',
355
+ timezone: 'UTC',
356
+ latitude: 40.7,
357
+ longitude: -74,
358
+ orb_degrees: 11,
359
+ })
360
+ ).toThrow(/Invalid orb_degrees/);
361
+ });
362
+
363
+ it('Given DST-gap or overlap electional times, then getElectionalContext rejects ambiguous local instants', () => {
364
+ const { service } = makeService();
365
+
366
+ expect(() =>
367
+ service.getElectionalContext({
368
+ date: '2026-03-08',
369
+ time: '02:30',
370
+ timezone: 'America/Los_Angeles',
371
+ latitude: 37.7749,
372
+ longitude: -122.4194,
373
+ })
374
+ ).toThrow(/ambiguous or nonexistent due to a DST transition/);
375
+
376
+ expect(() =>
377
+ service.getElectionalContext({
378
+ date: '2026-11-01',
379
+ time: '01:30',
380
+ timezone: 'America/Los_Angeles',
381
+ latitude: 37.7749,
382
+ longitude: -122.4194,
383
+ })
384
+ ).toThrow(/ambiguous or nonexistent due to a DST transition/);
385
+ });
386
+
387
+ it('Given omitted mode and days_ahead 0, then getTransits resolves to snapshot semantics', () => {
388
+ const { service, transitCalc } = makeService();
389
+ const result = service.getTransits(makeNatalChart(), {});
390
+
391
+ expect(transitCalc.findTransits).toHaveBeenCalledTimes(1);
392
+ expect(result.data).toMatchObject({
393
+ mode: 'snapshot',
394
+ mode_source: 'legacy_default',
395
+ days_ahead: 0,
396
+ window_start: '2024-03-26',
397
+ window_end: '2024-03-26',
398
+ });
399
+ expect(result.text).toContain('Transit snapshot');
400
+ });
401
+
402
+ it('Given omitted mode and days_ahead > 0, then getTransits resolves to best_hit semantics', () => {
403
+ const { service, transitCalc } = makeService();
404
+ const result = service.getTransits(makeNatalChart(), { days_ahead: 2 });
405
+
406
+ expect(transitCalc.findTransits).toHaveBeenCalledTimes(3);
407
+ expect(result.data).toMatchObject({
408
+ mode: 'best_hit',
409
+ mode_source: 'legacy_default',
410
+ days_ahead: 2,
411
+ window_start: '2024-03-26',
412
+ window_end: '2024-03-28',
413
+ });
414
+ expect(result.text).toContain('Best-hit transits');
415
+ });
416
+
417
+ it('Given explicit snapshot mode with days_ahead, then only one day is queried and reported', () => {
418
+ const { service, transitCalc } = makeService();
419
+ const result = service.getTransits(makeNatalChart(), { mode: 'snapshot', days_ahead: 5 });
420
+
421
+ expect(transitCalc.findTransits).toHaveBeenCalledTimes(1);
422
+ expect(result.data).toMatchObject({
423
+ mode: 'snapshot',
424
+ mode_source: 'explicit',
425
+ days_ahead: 0,
426
+ window_start: '2024-03-26',
427
+ window_end: '2024-03-26',
428
+ });
429
+ });
430
+
431
+ it('Given transit placement metadata, then all transit modes include additive sign, degree, and house fields', () => {
432
+ const { service } = makeService();
433
+
434
+ const snapshot = service.getTransits(makeNatalChart(), { mode: 'snapshot' });
435
+ const bestHit = service.getTransits(makeNatalChart(), { mode: 'best_hit', days_ahead: 1 });
436
+ const forecast = service.getTransits(makeNatalChart(), { mode: 'forecast', days_ahead: 1 });
437
+
438
+ expect((snapshot.data as any).transits[0]).toMatchObject({
439
+ transitSign: 'Cancer',
440
+ transitDegree: 10,
441
+ transitHouse: 7,
442
+ natalSign: 'Aries',
443
+ natalDegree: 10,
444
+ natalHouse: 4,
445
+ transitLongitude: 100,
446
+ natalLongitude: 10,
447
+ });
448
+ expect((bestHit.data as any).transits[0]).toMatchObject({
449
+ transitSign: 'Cancer',
450
+ transitDegree: 10,
451
+ transitHouse: 7,
452
+ natalSign: 'Aries',
453
+ natalDegree: 10,
454
+ natalHouse: 4,
455
+ });
456
+ expect((forecast.data as any).forecast[0].transits[0]).toMatchObject({
457
+ transitSign: 'Cancer',
458
+ transitDegree: 10,
459
+ transitHouse: 7,
460
+ natalSign: 'Aries',
461
+ natalDegree: 10,
462
+ natalHouse: 4,
463
+ });
464
+ });
465
+
466
+ it('Given a transit near a sign boundary, then placement degree never rounds up to 30.00 within the same sign', () => {
467
+ const { service, transitCalc } = makeService();
468
+ transitCalc.findTransits.mockReturnValue([
469
+ {
470
+ transitingPlanet: 'Sun',
471
+ natalPlanet: 'Sun',
472
+ aspect: 'conjunction',
473
+ orb: 0.01,
474
+ isApplying: true,
475
+ exactTimeStatus: 'within_preview',
476
+ transitLongitude: 29.999,
477
+ natalLongitude: 10,
478
+ exactTime: undefined,
479
+ },
480
+ ]);
481
+
482
+ const result = service.getTransits(makeNatalChart(), { mode: 'snapshot' });
483
+
484
+ expect((result.data as any).transits[0]).toMatchObject({
485
+ transitSign: 'Taurus',
486
+ transitDegree: 0,
487
+ });
488
+ });
489
+
490
+ it('Given forecast mode across multiple days, then response preserves day-grouped transits with per-day dedupe', () => {
491
+ const { service, transitCalc } = makeService();
492
+ transitCalc.findTransits
493
+ .mockReturnValueOnce([
494
+ {
495
+ transitingPlanet: 'Mars',
496
+ natalPlanet: 'Sun',
497
+ aspect: 'square',
498
+ orb: 1.25,
499
+ isApplying: true,
500
+ exactTimeStatus: 'within_preview',
501
+ transitLongitude: 100,
502
+ natalLongitude: 10,
503
+ exactTime: new Date('2024-03-26T12:00:00Z'),
504
+ },
505
+ {
506
+ transitingPlanet: 'Mars',
507
+ natalPlanet: 'Sun',
508
+ aspect: 'square',
509
+ orb: 0.75,
510
+ isApplying: true,
511
+ exactTimeStatus: 'within_preview',
512
+ transitLongitude: 100.5,
513
+ natalLongitude: 10,
514
+ exactTime: new Date('2024-03-26T13:00:00Z'),
515
+ },
516
+ ])
517
+ .mockReturnValueOnce([
518
+ {
519
+ transitingPlanet: 'Mars',
520
+ natalPlanet: 'Sun',
521
+ aspect: 'square',
522
+ orb: 0.5,
523
+ isApplying: false,
524
+ exactTimeStatus: 'within_preview',
525
+ transitLongitude: 101,
526
+ natalLongitude: 10,
527
+ exactTime: new Date('2024-03-27T12:00:00Z'),
528
+ },
529
+ ]);
530
+
531
+ const result = service.getTransits(makeNatalChart(), { mode: 'forecast', days_ahead: 1 });
532
+
533
+ expect(result.data).toMatchObject({
534
+ mode: 'forecast',
535
+ mode_source: 'explicit',
536
+ days_ahead: 1,
537
+ window_start: '2024-03-26',
538
+ window_end: '2024-03-27',
539
+ forecast: [
540
+ { date: '2024-03-26', transits: [{ orb: 0.75 }] },
541
+ { date: '2024-03-27', transits: [{ orb: 0.5 }] },
542
+ ],
543
+ });
544
+ expect(((result.data as any).forecast[0].transits as Array<unknown>)).toHaveLength(1);
545
+ expect(result.text).toContain('Forecast transits');
546
+ expect(result.text).toContain('2024-03-26');
547
+ expect(result.text).toContain('2024-03-27');
548
+ });
549
+
550
+ it('Given non-finite transit numeric filters, then getTransits throws validation errors before mundane expansion', () => {
551
+ const { service } = makeService();
552
+ const natal = makeNatalChart();
553
+
554
+ expect(() =>
555
+ service.getTransits(natal, {
556
+ include_mundane: true,
557
+ days_ahead: Number.NaN,
558
+ })
559
+ ).toThrow(/days_ahead must be a finite number >= 0/);
560
+
561
+ expect(() =>
562
+ service.getTransits(natal, {
563
+ max_orb: Number.NaN,
564
+ })
565
+ ).toThrow(/max_orb must be a finite number >= 0/);
566
+ });
567
+
568
+ it('Given a resolved whole-sign natal house system, then transit placement uses that same system for natal and transit houses', () => {
569
+ const { service, houseCalc } = makeService();
570
+ const polarNatal = {
571
+ ...makeNatalChart(),
572
+ location: { latitude: 78, longitude: 15, timezone: 'UTC' },
573
+ houseSystem: 'W' as const,
574
+ julianDay: 2451545,
575
+ };
576
+ houseCalc.calculateHouses.mockReturnValue({
577
+ ascendant: 120,
578
+ mc: 210,
579
+ cusps: [0, 120, 150, 180, 210, 240, 270, 300, 330, 0, 30, 60, 90],
580
+ system: 'W' as const,
581
+ });
582
+
583
+ const result = service.getTransits(polarNatal, { mode: 'snapshot' });
584
+
585
+ expect(houseCalc.calculateHouses).toHaveBeenCalledWith(
586
+ polarNatal.julianDay,
587
+ polarNatal.location.latitude,
588
+ polarNatal.location.longitude,
589
+ 'W'
590
+ );
591
+ expect(houseCalc.calculateHouses).toHaveBeenCalledWith(
592
+ expect.any(Number),
593
+ polarNatal.location.latitude,
594
+ polarNatal.location.longitude,
595
+ 'W'
596
+ );
597
+ expect((result.data as any).transits[0]).toMatchObject({
598
+ transitHouse: 12,
599
+ natalHouse: 9,
600
+ });
183
601
  });
184
602
 
185
603
  it('Given exact-time lookup metadata, then getTransits serializes exactTimeStatus', () => {
@@ -215,6 +633,205 @@ describe('When using AstroService', () => {
215
633
  expect(result.text).toContain('Rise/Set Times');
216
634
  });
217
635
 
636
+ it('Given MCP startup defaults, then reporting timezone fallback and status expose deterministic config', () => {
637
+ const { service } = makeService({
638
+ preferredTimezone: 'America/New_York',
639
+ preferredHouseStyle: 'W',
640
+ weekdayLabels: true,
641
+ });
642
+
643
+ expect(service.resolveReportingTimezone(undefined, undefined)).toBe('America/New_York');
644
+ expect(service.resolveReportingTimezone(undefined, 'America/Los_Angeles')).toBe(
645
+ 'America/New_York'
646
+ );
647
+
648
+ const status = service.getServerStatus(null);
649
+ expect(status.data).toMatchObject({
650
+ startupDefaults: {
651
+ preferredTimezone: 'America/New_York',
652
+ preferredHouseStyle: 'W',
653
+ weekdayLabels: true,
654
+ },
655
+ });
656
+ });
657
+
658
+ it('Given preferred house style and weekday labels, then deterministic defaults apply when the chart did not explicitly request a system', () => {
659
+ const { service, houseCalc } = makeService({
660
+ preferredTimezone: 'America/New_York',
661
+ preferredHouseStyle: 'W',
662
+ weekdayLabels: true,
663
+ });
664
+
665
+ const houses = service.getHouses(makeNatalChart());
666
+ const eclipses = service.getNextEclipses();
667
+
668
+ expect(houseCalc.calculateHouses).toHaveBeenLastCalledWith(
669
+ makeNatalChart().julianDay,
670
+ makeNatalChart().location.latitude,
671
+ makeNatalChart().location.longitude,
672
+ 'W'
673
+ );
674
+ expect((houses.data as any).system).toBe('W');
675
+ expect((eclipses.data as any).timezone).toBe('America/New_York');
676
+ expect(eclipses.text).toMatch(/\b(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\b/);
677
+ });
678
+
679
+ it('Given no house system on the chart, then preferred house style is used as the fallback', () => {
680
+ const { service, houseCalc } = makeService({
681
+ preferredHouseStyle: 'W',
682
+ });
683
+ const natalChart = { ...makeNatalChart(), houseSystem: undefined };
684
+
685
+ service.getHouses(natalChart);
686
+
687
+ expect(houseCalc.calculateHouses).toHaveBeenLastCalledWith(
688
+ natalChart.julianDay,
689
+ natalChart.location.latitude,
690
+ natalChart.location.longitude,
691
+ 'W'
692
+ );
693
+ });
694
+
695
+ it('Given no explicit chart house system, then preferred house style overrides the stored default', () => {
696
+ const { service, houseCalc } = makeService({
697
+ preferredHouseStyle: 'W',
698
+ });
699
+ const natalChart = {
700
+ ...makeNatalChart(),
701
+ houseSystem: 'P' as const,
702
+ requestedHouseSystem: undefined,
703
+ };
704
+
705
+ service.getHouses(natalChart);
706
+
707
+ expect(houseCalc.calculateHouses).toHaveBeenLastCalledWith(
708
+ natalChart.julianDay,
709
+ natalChart.location.latitude,
710
+ natalChart.location.longitude,
711
+ 'W'
712
+ );
713
+ });
714
+
715
+ it('Given an explicit chart house system, then preferred house style does not override it', () => {
716
+ const { service, houseCalc } = makeService({
717
+ preferredHouseStyle: 'W',
718
+ });
719
+ const natalChart = {
720
+ ...makeNatalChart(),
721
+ houseSystem: 'P' as const,
722
+ requestedHouseSystem: 'P' as const,
723
+ };
724
+
725
+ service.getHouses(natalChart);
726
+
727
+ expect(houseCalc.calculateHouses).toHaveBeenLastCalledWith(
728
+ natalChart.julianDay,
729
+ natalChart.location.latitude,
730
+ natalChart.location.longitude,
731
+ 'P'
732
+ );
733
+ });
734
+
735
+ it('Given a preferred reporting timezone, then rise/set text renders in that timezone', async () => {
736
+ const { service } = makeService({
737
+ preferredTimezone: 'America/New_York',
738
+ });
739
+
740
+ const result = await service.getRiseSetTimes(makeNatalChart());
741
+
742
+ expect(result.data).toMatchObject({
743
+ timezone: 'America/Los_Angeles',
744
+ });
745
+ expect(result.text).toContain('EDT');
746
+ });
747
+
748
+ it('Given a preferred reporting timezone, then transit exact-time text uses it ahead of natal timezone', () => {
749
+ const { service } = makeService({
750
+ preferredTimezone: 'America/New_York',
751
+ });
752
+
753
+ const result = service.getTransits(makeNatalChart());
754
+
755
+ expect(result.text).toContain('EDT');
756
+ });
757
+
758
+ it('Given a preferred reporting timezone that crosses midnight, then transit labels use reporting time while payload keeps both zones', () => {
759
+ const { service } = makeService({
760
+ preferredTimezone: 'Asia/Tokyo',
761
+ });
762
+
763
+ const result = service.getTransits(makeNatalChart(), {
764
+ date: '2024-03-26',
765
+ mode: 'forecast',
766
+ });
767
+
768
+ expect(result.data).toMatchObject({
769
+ timezone: 'Asia/Tokyo',
770
+ calculation_timezone: 'America/Los_Angeles',
771
+ reporting_timezone: 'Asia/Tokyo',
772
+ window_start: '2024-03-27',
773
+ window_end: '2024-03-27',
774
+ forecast: [{ date: '2024-03-27' }],
775
+ });
776
+ });
777
+
778
+ it('Given an exact transit time, then transit house placement is calculated from that event time', () => {
779
+ const { service, ephem, houseCalc } = makeService();
780
+ ephem.dateToJulianDay.mockImplementation((date: Date) => {
781
+ if (date.toISOString() === '2024-03-27T12:00:00.000Z') {
782
+ return 9999;
783
+ }
784
+ return date.getTime() / 86400000 + 2440587.5;
785
+ });
786
+
787
+ service.getTransits(makeNatalChart(), { mode: 'snapshot' });
788
+
789
+ expect(houseCalc.calculateHouses).toHaveBeenCalledWith(
790
+ 9999,
791
+ makeNatalChart().location.latitude,
792
+ makeNatalChart().location.longitude,
793
+ 'P'
794
+ );
795
+ });
796
+
797
+ it('Given an exact transit time, then house assignment uses exact-time longitude instead of sampled longitude', () => {
798
+ const { service, ephem, transitCalc, houseCalc } = makeService();
799
+ transitCalc.findTransits.mockReturnValue([
800
+ {
801
+ transitingPlanet: 'Moon',
802
+ natalPlanet: 'Sun',
803
+ aspect: 'square',
804
+ orb: 0.1,
805
+ isApplying: true,
806
+ exactTimeStatus: 'within_preview',
807
+ transitLongitude: 99.5,
808
+ natalLongitude: 10,
809
+ exactTime: new Date('2024-03-27T12:00:00Z'),
810
+ },
811
+ ]);
812
+ ephem.dateToJulianDay.mockImplementation((date: Date) => {
813
+ if (date.toISOString() === '2024-03-27T12:00:00.000Z') {
814
+ return 9999;
815
+ }
816
+ return date.getTime() / 86400000 + 2440587.5;
817
+ });
818
+ ephem.getPlanetPosition.mockReturnValue(makePlanet('Moon', 100.5));
819
+ houseCalc.calculateHouses.mockReturnValue({
820
+ ascendant: 0,
821
+ mc: 90,
822
+ cusps: [0, 0, 30, 60, 90, 100, 150, 180, 210, 240, 270, 300, 330],
823
+ system: 'P' as const,
824
+ });
825
+
826
+ const result = service.getTransits(makeNatalChart(), { mode: 'snapshot' });
827
+
828
+ expect((result.data as any).transits[0]).toMatchObject({
829
+ transitHouse: 5,
830
+ transitLongitude: 99.5,
831
+ });
832
+ expect(ephem.getPlanetPosition).toHaveBeenCalledWith(1, 9999);
833
+ });
834
+
218
835
  it('Given eclipse availability, then getNextEclipses returns summary or empty-state text', () => {
219
836
  const { service, eclipseCalc } = makeService();
220
837
  const withOne = service.getNextEclipses('UTC');
@@ -271,6 +888,10 @@ describe('When using AstroService', () => {
271
888
  const { service } = makeService();
272
889
  expect(() => service.getTransits(makeNatalChart(), { days_ahead: -1 })).toThrow(/days_ahead/);
273
890
  expect(() => service.getTransits(makeNatalChart(), { max_orb: -1 })).toThrow(/max_orb/);
891
+ expect(() => service.getTransits(makeNatalChart(), { mode: 'weekly' as any })).toThrow(/mode/);
892
+ expect(() =>
893
+ service.getTransits({ ...makeNatalChart(), julianDay: undefined })
894
+ ).toThrow(/missing julianDay/i);
274
895
  expect(() => service.getHouses({ ...makeNatalChart(), julianDay: undefined })).toThrow(/missing julianDay/i);
275
896
  });
276
897
 
@@ -320,4 +941,129 @@ describe('When using AstroService', () => {
320
941
  expect((result.data as any).eclipses).toHaveLength(2);
321
942
  expect(result.text).toContain('Next Lunar Eclipse');
322
943
  });
944
+
945
+ it('Given date/location inputs, then getRisingSignWindows returns deterministic sign intervals', () => {
946
+ const { service, houseCalc } = makeService();
947
+ houseCalc.calculateHouses.mockImplementation((jd: number) => {
948
+ const dayFraction = ((jd % 1) + 1) % 1;
949
+ const ascendant = (dayFraction * 360 * 12) % 360;
950
+ return {
951
+ ascendant,
952
+ mc: 204,
953
+ cusps: [0, 270, 300, 330, 0, 30, 60, 90, 120, 150, 204, 240, 260],
954
+ system: 'P' as const,
955
+ };
956
+ });
957
+
958
+ const result = service.getRisingSignWindows({
959
+ date: '2026-03-28',
960
+ latitude: 40.7128,
961
+ longitude: -74.006,
962
+ timezone: 'America/New_York',
963
+ mode: 'exact',
964
+ });
965
+
966
+ const windows = (result.data as any).windows;
967
+ expect((result.data as any)).toMatchObject({
968
+ date: '2026-03-28',
969
+ timezone: 'America/New_York',
970
+ mode: 'exact',
971
+ });
972
+ expect(windows.length).toBeGreaterThan(0);
973
+ expect(windows[0]).toMatchObject({
974
+ sign: expect.any(String),
975
+ start: expect.any(String),
976
+ end: expect.any(String),
977
+ durationMinutes: expect.any(Number),
978
+ });
979
+ expect(windows[0].start).toMatch(/[-+]\d{2}:\d{2}$/);
980
+ expect(windows[0].start.endsWith('Z')).toBe(false);
981
+ expect(result.text).toContain('Rising Sign Windows');
982
+ expect(result.text.toLowerCase()).not.toContain('best');
983
+ });
984
+
985
+ it('Given multiple sign transitions inside one scan bucket, then exact mode emits all boundary windows', () => {
986
+ const { service, houseCalc } = makeService();
987
+ const transitions = [
988
+ Date.parse('2026-03-28T00:10:00Z'),
989
+ Date.parse('2026-03-28T00:30:00Z'),
990
+ Date.parse('2026-03-28T00:50:00Z'),
991
+ ];
992
+ houseCalc.calculateHouses.mockImplementation((jd: number) => {
993
+ const date = new Date((jd - 2440587.5) * 86400000);
994
+ const millis = date.getTime();
995
+ const signIndex =
996
+ transitions.filter((transitionMs) => millis >= transitionMs).length % 2 === 0 ? 0 : 1;
997
+ return {
998
+ ascendant: signIndex * 30 + 0.1,
999
+ mc: 204,
1000
+ cusps: [0, 270, 300, 330, 0, 30, 60, 90, 120, 150, 204, 240, 260],
1001
+ system: 'P' as const,
1002
+ };
1003
+ });
1004
+
1005
+ const result = service.getRisingSignWindows({
1006
+ date: '2026-03-28',
1007
+ latitude: 40.7128,
1008
+ longitude: -74.006,
1009
+ timezone: 'UTC',
1010
+ mode: 'exact',
1011
+ });
1012
+
1013
+ const windows = (result.data as any).windows as Array<{ start: string; end: string; sign: string }>;
1014
+ const firstHourWindows = windows.filter((window) => window.start < '2026-03-28T01:00:00+00:00');
1015
+
1016
+ expect(firstHourWindows).toHaveLength(4);
1017
+ expect(firstHourWindows.map((window) => window.sign)).toEqual(['Aries', 'Taurus', 'Aries', 'Taurus']);
1018
+ });
1019
+
1020
+ it('Given invalid rising-sign inputs, then getRisingSignWindows throws clear validation errors', () => {
1021
+ const { service } = makeService();
1022
+
1023
+ expect(() =>
1024
+ service.getRisingSignWindows({
1025
+ date: '2026-03-28',
1026
+ latitude: 95,
1027
+ longitude: -74,
1028
+ timezone: 'America/New_York',
1029
+ })
1030
+ ).toThrow(/Invalid latitude/);
1031
+
1032
+ expect(() =>
1033
+ service.getRisingSignWindows({
1034
+ date: '2026-03-28',
1035
+ latitude: 40,
1036
+ longitude: -190,
1037
+ timezone: 'America/New_York',
1038
+ })
1039
+ ).toThrow(/Invalid longitude/);
1040
+
1041
+ expect(() =>
1042
+ service.getRisingSignWindows({
1043
+ date: '2026/03/28',
1044
+ latitude: 40,
1045
+ longitude: -74,
1046
+ timezone: 'America/New_York',
1047
+ })
1048
+ ).toThrow(/Invalid date format/);
1049
+
1050
+ expect(() =>
1051
+ service.getRisingSignWindows({
1052
+ date: '2026-03-28',
1053
+ latitude: 40,
1054
+ longitude: -74,
1055
+ timezone: 'Nope/Not-A-Timezone',
1056
+ })
1057
+ ).toThrow(/Invalid timezone/);
1058
+
1059
+ expect(() =>
1060
+ service.getRisingSignWindows({
1061
+ date: '2026-03-28',
1062
+ latitude: 40,
1063
+ longitude: -74,
1064
+ timezone: 'UTC',
1065
+ mode: 'fast' as any,
1066
+ })
1067
+ ).toThrow(/Invalid mode/);
1068
+ });
323
1069
  });