ether-to-astro 1.2.0 → 1.3.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 (66) hide show
  1. package/README.md +15 -5
  2. package/dist/astro-service/chart-output-service.d.ts +44 -0
  3. package/dist/astro-service/chart-output-service.js +110 -0
  4. package/dist/astro-service/date-input.d.ts +14 -0
  5. package/dist/astro-service/date-input.js +30 -0
  6. package/dist/astro-service/electional-service.d.ts +45 -0
  7. package/dist/astro-service/electional-service.js +305 -0
  8. package/dist/astro-service/natal-service.d.ts +41 -0
  9. package/dist/astro-service/natal-service.js +179 -0
  10. package/dist/astro-service/rising-sign-service.d.ts +37 -0
  11. package/dist/astro-service/rising-sign-service.js +137 -0
  12. package/dist/astro-service/service-types.d.ts +82 -0
  13. package/dist/astro-service/service-types.js +1 -0
  14. package/dist/astro-service/shared.d.ts +65 -0
  15. package/dist/astro-service/shared.js +98 -0
  16. package/dist/astro-service/sky-service.d.ts +48 -0
  17. package/dist/astro-service/sky-service.js +144 -0
  18. package/dist/astro-service/transit-service.d.ts +82 -0
  19. package/dist/astro-service/transit-service.js +353 -0
  20. package/dist/astro-service.d.ts +101 -89
  21. package/dist/astro-service.js +162 -1042
  22. package/dist/tool-registry.js +1 -1
  23. package/docs/product/architecture-boundaries.md +8 -0
  24. package/docs/releases/1.3.0.md +51 -0
  25. package/docs/releases/README.md +17 -0
  26. package/package.json +4 -1
  27. package/src/astro-service/chart-output-service.ts +155 -0
  28. package/src/astro-service/date-input.ts +40 -0
  29. package/src/astro-service/electional-service.ts +395 -0
  30. package/src/astro-service/natal-service.ts +235 -0
  31. package/src/astro-service/rising-sign-service.ts +181 -0
  32. package/src/astro-service/service-types.ts +90 -0
  33. package/src/astro-service/shared.ts +128 -0
  34. package/src/astro-service/sky-service.ts +191 -0
  35. package/src/astro-service/transit-service.ts +507 -0
  36. package/src/astro-service.ts +177 -1386
  37. package/src/tool-registry.ts +1 -1
  38. package/tests/README.md +15 -0
  39. package/tests/property/electional-service.property.test.ts +67 -0
  40. package/tests/property/helpers/arbitraries.ts +126 -0
  41. package/tests/property/helpers/config.ts +52 -0
  42. package/tests/property/helpers/runtime.ts +12 -0
  43. package/tests/property/houses.property.test.ts +74 -0
  44. package/tests/property/rising-sign-service.property.test.ts +255 -0
  45. package/tests/property/service-transits.property.test.ts +154 -0
  46. package/tests/property/time-utils.property.test.ts +91 -0
  47. package/tests/property/transits.property.test.ts +113 -0
  48. package/tests/unit/astro-service/chart-output-service.test.ts +102 -0
  49. package/tests/unit/astro-service/electional-service.test.ts +182 -0
  50. package/tests/unit/astro-service/natal-service.test.ts +126 -0
  51. package/tests/unit/astro-service/rising-sign-service.test.ts +145 -0
  52. package/tests/unit/astro-service/sky-service.test.ts +130 -0
  53. package/tests/unit/astro-service/transit-service.test.ts +312 -0
  54. package/tests/unit/astro-service.test.ts +136 -781
  55. package/tests/unit/rising-sign-windows.test.ts +93 -0
  56. package/tests/unit/tool-registry.test.ts +11 -0
  57. package/tests/validation/README.md +14 -0
  58. package/tests/validation/adapters/internal.ts +234 -4
  59. package/tests/validation/compare/electional.ts +151 -0
  60. package/tests/validation/compare/rising-sign-windows.ts +347 -0
  61. package/tests/validation/compare/service-transits.ts +205 -0
  62. package/tests/validation/fixtures/electional/core.ts +88 -0
  63. package/tests/validation/fixtures/rising-sign-windows/core.ts +57 -0
  64. package/tests/validation/fixtures/service-transits/core.ts +89 -0
  65. package/tests/validation/utils/fixtureTypes.ts +139 -1
  66. package/tests/validation/validation.spec.ts +82 -0
@@ -0,0 +1,154 @@
1
+ import fc from 'fast-check';
2
+ import { beforeAll, describe, expect, it } from 'vitest';
3
+ import type { GetTransitsInput } from '../../src/astro-service/service-types.js';
4
+ import { ASPECTS, PLANETS, ZODIAC_SIGNS } from '../../src/types.js';
5
+ import type { InternalValidationAdapter } from '../validation/adapters/internal.js';
6
+ import type { ServiceTransitNatalFixture } from '../validation/utils/fixtureTypes.js';
7
+ import {
8
+ dateOnlyArb,
9
+ longitudeArb,
10
+ nonPolarLatitudeArb,
11
+ timezoneArb,
12
+ } from './helpers/arbitraries.js';
13
+ import { propertyConfig } from './helpers/config.js';
14
+ import { getInternalValidationAdapter } from './helpers/runtime.js';
15
+
16
+ const SERVICE_TRANSIT_PLANET_IDS = [
17
+ PLANETS.SUN,
18
+ PLANETS.MOON,
19
+ PLANETS.MERCURY,
20
+ PLANETS.VENUS,
21
+ PLANETS.MARS,
22
+ PLANETS.JUPITER,
23
+ PLANETS.SATURN,
24
+ ] as const;
25
+
26
+ const serviceTransitOffsetArb = fc
27
+ .record({
28
+ transitingPlanetId: fc.constantFrom(...SERVICE_TRANSIT_PLANET_IDS),
29
+ natalPlanetId: fc.constantFrom(...SERVICE_TRANSIT_PLANET_IDS),
30
+ aspectAngle: fc.constantFrom(...ASPECTS.map((aspect) => aspect.angle)),
31
+ deviation: fc.double({
32
+ min: -1.5,
33
+ max: 1.5,
34
+ noNaN: true,
35
+ noDefaultInfinity: true,
36
+ }),
37
+ })
38
+ .map(({ transitingPlanetId, natalPlanetId, aspectAngle, deviation }) => ({
39
+ transitingPlanetId,
40
+ natalPlanetId,
41
+ natalOffsetDegrees: (aspectAngle + deviation + 360) % 360,
42
+ }));
43
+
44
+ const serviceTransitFixtureArb = fc
45
+ .record({
46
+ latitude: nonPolarLatitudeArb,
47
+ longitude: longitudeArb,
48
+ timezone: timezoneArb,
49
+ houseSystem: fc.constantFrom('P' as const, 'W' as const),
50
+ planetOffsets: fc.array(serviceTransitOffsetArb, { minLength: 2, maxLength: 4 }),
51
+ })
52
+ .map(
53
+ ({ latitude, longitude, timezone, houseSystem, planetOffsets }): ServiceTransitNatalFixture => ({
54
+ name: 'Property Transit Fixture',
55
+ latitude,
56
+ longitude,
57
+ timezone,
58
+ julianDayIsoUtc: '1990-06-12T21:35:00Z',
59
+ houseSystem,
60
+ planetOffsets,
61
+ })
62
+ );
63
+
64
+ describe('Property: service-level transit serialization', () => {
65
+ let adapter: InternalValidationAdapter;
66
+
67
+ beforeAll(async () => {
68
+ adapter = await getInternalValidationAdapter();
69
+ });
70
+
71
+ it('keeps snapshot transit payloads enriched and astrologically valid', async () => {
72
+ await fc.assert(
73
+ fc.property(serviceTransitFixtureArb, dateOnlyArb, timezoneArb, (natalChart, date, reportingTimezone) => {
74
+ const result = adapter.getServiceTransits({
75
+ natalChart,
76
+ transitInput: {
77
+ date,
78
+ mode: 'snapshot',
79
+ } satisfies GetTransitsInput,
80
+ startupDefaults: {
81
+ preferredTimezone: reportingTimezone,
82
+ },
83
+ });
84
+
85
+ expect(result.timezone).toBe(reportingTimezone);
86
+ expect(result.calculationTimezone).toBe(natalChart.timezone);
87
+ expect(result.reportingTimezone).toBe(reportingTimezone);
88
+
89
+ fc.pre((result.transits?.length ?? 0) > 0);
90
+ for (const transit of result.transits ?? []) {
91
+ expect(transit.transitSign).toBeDefined();
92
+ expect(transit.natalSign).toBeDefined();
93
+ expect(transit.transitDegree).toBeDefined();
94
+ expect(transit.natalDegree).toBeDefined();
95
+ expect(transit.transitHouse).toBeDefined();
96
+ expect(transit.natalHouse).toBeDefined();
97
+ expect(ZODIAC_SIGNS).toContain(transit.transitSign);
98
+ expect(ZODIAC_SIGNS).toContain(transit.natalSign);
99
+ expect(transit.transitDegree).toBeGreaterThanOrEqual(0);
100
+ expect(transit.transitDegree).toBeLessThan(30);
101
+ expect(transit.natalDegree).toBeGreaterThanOrEqual(0);
102
+ expect(transit.natalDegree).toBeLessThan(30);
103
+ expect(transit.transitHouse).toBeGreaterThanOrEqual(1);
104
+ expect(transit.transitHouse).toBeLessThanOrEqual(12);
105
+ expect(transit.natalHouse).toBeGreaterThanOrEqual(1);
106
+ expect(transit.natalHouse).toBeLessThanOrEqual(12);
107
+ }
108
+ }),
109
+ propertyConfig({ heavy: true })
110
+ );
111
+ });
112
+
113
+ it('keeps forecast grouping and timezone metadata stable', async () => {
114
+ await fc.assert(
115
+ fc.property(
116
+ serviceTransitFixtureArb,
117
+ dateOnlyArb,
118
+ timezoneArb,
119
+ fc.integer({ min: 0, max: 3 }),
120
+ (natalChart, date, reportingTimezone, daysAhead) => {
121
+ const result = adapter.getServiceTransits({
122
+ natalChart,
123
+ transitInput: {
124
+ date,
125
+ mode: 'forecast',
126
+ days_ahead: daysAhead,
127
+ } satisfies GetTransitsInput,
128
+ startupDefaults: {
129
+ preferredTimezone: reportingTimezone,
130
+ },
131
+ });
132
+
133
+ expect(result.mode).toBe('forecast');
134
+ expect(result.timezone).toBe(reportingTimezone);
135
+ expect(result.calculationTimezone).toBe(natalChart.timezone);
136
+ expect(result.reportingTimezone).toBe(reportingTimezone);
137
+ expect(result.forecast).toHaveLength(daysAhead + 1);
138
+
139
+ for (const day of result.forecast ?? []) {
140
+ expect(day.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
141
+ for (const transit of day.transits) {
142
+ expect(transit.transitSign).toBeDefined();
143
+ expect(transit.natalSign).toBeDefined();
144
+ expect(transit.transitDegree).toBeGreaterThanOrEqual(0);
145
+ expect(transit.transitDegree).toBeLessThan(30);
146
+ }
147
+ }
148
+ }
149
+ ),
150
+ propertyConfig({ heavy: true })
151
+ );
152
+ });
153
+ });
154
+
@@ -0,0 +1,91 @@
1
+ import fc from 'fast-check';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ formatLocalTimestampWithOffset,
5
+ getTimezoneOffset,
6
+ localToUTC,
7
+ utcToLocal,
8
+ } from '../../src/time-utils.js';
9
+ import { propertyConfig } from './helpers/config.js';
10
+ import {
11
+ NON_HOUR_OFFSET_TIMEZONES,
12
+ timezoneArb,
13
+ utcDateArb,
14
+ validLocalDateTimeArb,
15
+ nonHourLocalDateTimeArb,
16
+ } from './helpers/arbitraries.js';
17
+
18
+ const DST_REJECT_FIXTURES = [
19
+ {
20
+ timezone: 'America/Los_Angeles',
21
+ local: { year: 2024, month: 3, day: 10, hour: 2, minute: 30, second: 0 },
22
+ },
23
+ {
24
+ timezone: 'America/New_York',
25
+ local: { year: 2024, month: 3, day: 10, hour: 2, minute: 30, second: 0 },
26
+ },
27
+ {
28
+ timezone: 'America/Los_Angeles',
29
+ local: { year: 2024, month: 11, day: 3, hour: 1, minute: 30, second: 0 },
30
+ },
31
+ {
32
+ timezone: 'America/New_York',
33
+ local: { year: 2024, month: 11, day: 3, hour: 1, minute: 30, second: 0 },
34
+ },
35
+ ] as const;
36
+
37
+ function formatExpectedOffset(offsetMinutes: number): string {
38
+ const sign = offsetMinutes >= 0 ? '+' : '-';
39
+ const absoluteOffset = Math.abs(offsetMinutes);
40
+ const hours = String(Math.floor(absoluteOffset / 60)).padStart(2, '0');
41
+ const minutes = String(absoluteOffset % 60).padStart(2, '0');
42
+ return `${sign}${hours}:${minutes}`;
43
+ }
44
+
45
+ describe('Property: time utils', () => {
46
+ it('round-trips valid local datetimes through UTC for representative zones', async () => {
47
+ await fc.assert(
48
+ fc.property(validLocalDateTimeArb, ({ timezone, local }) => {
49
+ const utc = localToUTC(local, timezone, 'reject');
50
+ expect(utcToLocal(utc, timezone)).toEqual(local);
51
+ }),
52
+ propertyConfig()
53
+ );
54
+ });
55
+
56
+ it('keeps formatted offsets aligned with getTimezoneOffset()', async () => {
57
+ await fc.assert(
58
+ fc.property(utcDateArb, timezoneArb, (instant, timezone) => {
59
+ const offsetMinutes = getTimezoneOffset(instant, timezone);
60
+ expect(Number.isInteger(offsetMinutes)).toBe(true);
61
+
62
+ const formatted = formatLocalTimestampWithOffset(instant, timezone);
63
+ expect(formatted.slice(-6)).toBe(formatExpectedOffset(offsetMinutes));
64
+ }),
65
+ propertyConfig()
66
+ );
67
+ });
68
+
69
+ it('preserves non-hour offset zones through round-trip conversion', async () => {
70
+ await fc.assert(
71
+ fc.property(nonHourLocalDateTimeArb, ({ timezone, local }) => {
72
+ expect(NON_HOUR_OFFSET_TIMEZONES).toContain(timezone);
73
+
74
+ const utc = localToUTC(local, timezone, 'reject');
75
+ expect(utcToLocal(utc, timezone)).toEqual(local);
76
+ expect(getTimezoneOffset(utc, timezone) % 60).not.toBe(0);
77
+ }),
78
+ propertyConfig()
79
+ );
80
+ });
81
+
82
+ it('rejects curated ambiguous and nonexistent DST-edge local times', async () => {
83
+ await fc.assert(
84
+ fc.property(fc.constantFrom(...DST_REJECT_FIXTURES), ({ timezone, local }) => {
85
+ expect(() => localToUTC(local, timezone, 'reject')).toThrow(RangeError);
86
+ }),
87
+ propertyConfig()
88
+ );
89
+ });
90
+ });
91
+
@@ -0,0 +1,113 @@
1
+ import fc from 'fast-check';
2
+ import { beforeAll, describe, expect, it } from 'vitest';
3
+ import { deduplicateTransits } from '../../src/transits.js';
4
+ import { ASPECTS, PLANETS } from '../../src/types.js';
5
+ import type { Transit } from '../../src/types.js';
6
+ import type { InternalValidationAdapter } from '../validation/adapters/internal.js';
7
+ import { dateOnlyArb, utcDateArb } from './helpers/arbitraries.js';
8
+ import { propertyConfig } from './helpers/config.js';
9
+ import { getInternalValidationAdapter } from './helpers/runtime.js';
10
+
11
+ const PLANET_NAMES = [
12
+ 'Sun',
13
+ 'Moon',
14
+ 'Mercury',
15
+ 'Venus',
16
+ 'Mars',
17
+ 'Jupiter',
18
+ 'Saturn',
19
+ ] as const;
20
+
21
+ const ASPECT_ORB_BY_NAME = new Map(ASPECTS.map((aspect) => [aspect.name, aspect.orb]));
22
+ const PLANET_IDS = [PLANETS.SUN, PLANETS.MOON, PLANETS.MERCURY, PLANETS.VENUS, PLANETS.MARS] as const;
23
+
24
+ const transitArb: fc.Arbitrary<Transit> = fc.record({
25
+ transitingPlanet: fc.constantFrom(...PLANET_NAMES),
26
+ natalPlanet: fc.constantFrom(...PLANET_NAMES),
27
+ aspect: fc.constantFrom(...ASPECTS.map((aspect) => aspect.name)),
28
+ orb: fc.double({ min: 0, max: 8, noNaN: true, noDefaultInfinity: true }),
29
+ exactTime: fc.option(utcDateArb, { nil: undefined }),
30
+ exactTimeStatus: fc.option(
31
+ fc.constantFrom('within_preview', 'outside_preview', 'not_found', 'unsupported_body'),
32
+ { nil: undefined }
33
+ ),
34
+ isApplying: fc.boolean(),
35
+ transitLongitude: fc.double({ min: 0, max: 359.999, noNaN: true, noDefaultInfinity: true }),
36
+ natalLongitude: fc.double({ min: 0, max: 359.999, noNaN: true, noDefaultInfinity: true }),
37
+ });
38
+
39
+ const transitOffsetArb = fc.record({
40
+ transitingPlanetId: fc.constantFrom(...PLANET_IDS),
41
+ natalPlanetId: fc.constantFrom(...PLANET_IDS),
42
+ date: dateOnlyArb,
43
+ aspectAngle: fc.constantFrom(...ASPECTS.map((aspect) => aspect.angle)),
44
+ deviation: fc.double({ min: -1.5, max: 1.5, noNaN: true, noDefaultInfinity: true }),
45
+ });
46
+
47
+ function canonicalizeDeduped(transits: Transit[]) {
48
+ return deduplicateTransits(transits)
49
+ .map((transit) => ({
50
+ key: `${transit.transitingPlanet}-${transit.natalPlanet}-${transit.aspect}`,
51
+ orb: transit.orb,
52
+ exactTime: transit.exactTime?.toISOString(),
53
+ exactTimeStatus: transit.exactTimeStatus,
54
+ transitLongitude: transit.transitLongitude,
55
+ natalLongitude: transit.natalLongitude,
56
+ isApplying: transit.isApplying,
57
+ }))
58
+ .sort((left, right) => left.key.localeCompare(right.key));
59
+ }
60
+
61
+ describe('Property: transit engine', () => {
62
+ let adapter: InternalValidationAdapter;
63
+
64
+ beforeAll(async () => {
65
+ adapter = await getInternalValidationAdapter();
66
+ });
67
+
68
+ it('keeps deduplication idempotent and order-independent', async () => {
69
+ await fc.assert(
70
+ fc.property(fc.array(transitArb, { minLength: 1, maxLength: 20 }), (transits) => {
71
+ const firstPass = canonicalizeDeduped(transits);
72
+ const secondPass = canonicalizeDeduped(deduplicateTransits(transits));
73
+ const reversedPass = canonicalizeDeduped([...transits].reverse());
74
+
75
+ expect(secondPass).toEqual(firstPass);
76
+ expect(reversedPass).toEqual(firstPass);
77
+ expect(new Set(firstPass.map((transit) => transit.key)).size).toBe(firstPass.length);
78
+ }),
79
+ propertyConfig()
80
+ );
81
+ });
82
+
83
+ it('keeps live transits within configured aspect orbs and exact-time status rules', async () => {
84
+ await fc.assert(
85
+ fc.property(transitOffsetArb, ({ transitingPlanetId, natalPlanetId, date, aspectAngle, deviation }) => {
86
+ const transits = adapter.getTransitsFromOffsets({
87
+ currentIsoUtc: `${date}T12:00:00Z`,
88
+ transitingPlanetId,
89
+ natalPlanetId,
90
+ natalOffsetDegrees: (aspectAngle + deviation + 360) % 360,
91
+ });
92
+
93
+ fc.pre(transits.length > 0);
94
+ for (const transit of transits) {
95
+ expect(transit.orb).toBeLessThanOrEqual(ASPECT_ORB_BY_NAME.get(transit.aspect) ?? Number.POSITIVE_INFINITY);
96
+
97
+ if (transit.exactTime) {
98
+ expect(transit.exactTimeStatus).toBe('within_preview');
99
+ }
100
+
101
+ if (
102
+ transit.exactTimeStatus === 'outside_preview' ||
103
+ transit.exactTimeStatus === 'not_found' ||
104
+ transit.exactTimeStatus === 'unsupported_body'
105
+ ) {
106
+ expect(transit.exactTime).toBeUndefined();
107
+ }
108
+ }
109
+ }),
110
+ propertyConfig({ heavy: true })
111
+ );
112
+ });
113
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ChartOutputService } from '../../../src/astro-service/chart-output-service.js';
3
+ import type { NatalChart, PlanetPosition } 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 makeChartOutputService() {
32
+ const chartRenderer = {
33
+ generateNatalChart: vi.fn(async (_chart, _theme, format) => {
34
+ if (format === 'svg') return '<svg>ok</svg>';
35
+ return Buffer.from([1, 2, 3]);
36
+ }),
37
+ generateTransitChart: vi.fn(async (_chart, _date, _theme, format) => {
38
+ if (format === 'svg') return '<svg>transit</svg>';
39
+ return Buffer.from([4, 5, 6]);
40
+ }),
41
+ };
42
+ const now = vi.fn(() => new Date('2024-03-26T12:00:00Z'));
43
+ const writeFile = vi.fn(async () => {});
44
+
45
+ const chartOutputService = new ChartOutputService({
46
+ chartRenderer: chartRenderer as any,
47
+ now,
48
+ writeFile,
49
+ });
50
+
51
+ return { chartOutputService, chartRenderer, now, writeFile };
52
+ }
53
+
54
+ describe('When using the extracted ChartOutputService', () => {
55
+ it('Given inline render requests, then it preserves SVG and binary output serialization', async () => {
56
+ const { chartOutputService } = makeChartOutputService();
57
+
58
+ const inlineSvg = await chartOutputService.generateNatalChart(makeNatalChart(), { format: 'svg' });
59
+ expect(inlineSvg).toMatchObject({
60
+ format: 'svg',
61
+ text: 'Natal Chart for Test User:',
62
+ svg: '<svg>ok</svg>',
63
+ });
64
+
65
+ const inlinePng = await chartOutputService.generateNatalChart(makeNatalChart(), { format: 'png' });
66
+ expect(inlinePng).toMatchObject({
67
+ format: 'png',
68
+ image: {
69
+ data: Buffer.from([1, 2, 3]).toString('base64'),
70
+ mimeType: 'image/png',
71
+ },
72
+ });
73
+ });
74
+
75
+ it('Given transit chart output paths and dates, then it preserves saved-file and label behavior', async () => {
76
+ const { chartOutputService, writeFile } = makeChartOutputService();
77
+
78
+ const saved = await chartOutputService.generateTransitChart(makeNatalChart(), {
79
+ format: 'webp',
80
+ output_path: '/tmp/test.webp',
81
+ date: '2024-03-26',
82
+ });
83
+
84
+ expect(writeFile).toHaveBeenCalledWith('/tmp/test.webp', Buffer.from([4, 5, 6]));
85
+ expect(saved.text).toContain('Transit Chart for Test User');
86
+ expect(saved.text).toContain('/tmp/test.webp');
87
+ });
88
+
89
+ it('Given transit SVG output, then it preserves the inline SVG branch', async () => {
90
+ const { chartOutputService } = makeChartOutputService();
91
+
92
+ const result = await chartOutputService.generateTransitChart(makeNatalChart(), {
93
+ format: 'svg',
94
+ date: '2024-03-26',
95
+ });
96
+
97
+ expect(result).toMatchObject({
98
+ format: 'svg',
99
+ svg: '<svg>transit</svg>',
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ElectionalService } from '../../../src/astro-service/electional-service.js';
3
+ import type { PlanetPosition } 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 makeElectionalService() {
20
+ const ephem = {
21
+ dateToJulianDay: vi.fn((date: Date) => date.getTime() / 86400000 + 2440587.5),
22
+ calculateAspectAngle: vi.fn((a: number, b: number) => {
23
+ const diff = Math.abs(a - b);
24
+ return diff > 180 ? 360 - diff : diff;
25
+ }),
26
+ getHorizontalCoordinates: vi.fn(() => ({
27
+ azimuth: 180,
28
+ trueAltitude: 25,
29
+ apparentAltitude: 25,
30
+ })),
31
+ getAllPlanets: vi.fn(() => [
32
+ { ...makePlanet('Sun', 0), sign: 'Aries', speed: 1 },
33
+ { ...makePlanet('Moon', 58), sign: 'Taurus', speed: 13 },
34
+ { ...makePlanet('Mercury', 120), sign: 'Leo', speed: 1.2 },
35
+ { ...makePlanet('Venus', 180), sign: 'Libra', speed: 1.1 },
36
+ { ...makePlanet('Mars', 240), sign: 'Sagittarius', speed: 0.7 },
37
+ { ...makePlanet('Jupiter', 300), sign: 'Aquarius', speed: 0.2 },
38
+ { ...makePlanet('Saturn', 315), sign: 'Aquarius', speed: 0.05, isRetrograde: true },
39
+ { ...makePlanet('Uranus', 30), sign: 'Taurus', speed: 0.03 },
40
+ { ...makePlanet('Neptune', 330), sign: 'Pisces', speed: 0.02 },
41
+ { ...makePlanet('Pluto', 270), sign: 'Capricorn', speed: 0.01 },
42
+ ]),
43
+ };
44
+ const houseCalc = {
45
+ calculateHouses: vi.fn(() => ({
46
+ ascendant: 270,
47
+ mc: 204,
48
+ cusps: [0, 270, 300, 330, 0, 30, 60, 90, 120, 150, 204, 240, 260],
49
+ system: 'W' as const,
50
+ })),
51
+ };
52
+
53
+ const electionalService = new ElectionalService({
54
+ ephem: ephem as any,
55
+ houseCalc: houseCalc as any,
56
+ });
57
+
58
+ return { electionalService, ephem, houseCalc };
59
+ }
60
+
61
+ describe('When using the extracted ElectionalService', () => {
62
+ it('Given deterministic inputs, then it preserves electional context payloads and optional summaries', () => {
63
+ const { electionalService } = makeElectionalService();
64
+
65
+ const result = electionalService.getElectionalContext({
66
+ date: '2026-03-28',
67
+ time: '09:30',
68
+ timezone: 'America/Los_Angeles',
69
+ latitude: 37.7749,
70
+ longitude: -122.4194,
71
+ include_ruler_basics: true,
72
+ });
73
+
74
+ expect(result.data).toMatchObject({
75
+ input: {
76
+ date: '2026-03-28',
77
+ time: '09:30',
78
+ timezone: 'America/Los_Angeles',
79
+ house_system: 'W',
80
+ },
81
+ ascendant: {
82
+ sign: 'Capricorn',
83
+ },
84
+ sect: {
85
+ is_day_chart: true,
86
+ classification: 'day',
87
+ sun_altitude_degrees: 25,
88
+ },
89
+ moon: {
90
+ sign: 'Taurus',
91
+ phase_name: 'crescent',
92
+ is_void_of_course: null,
93
+ },
94
+ ruler_basics: {
95
+ asc_sign_ruler: {
96
+ body: 'Saturn',
97
+ sign: 'Aquarius',
98
+ is_retrograde: true,
99
+ },
100
+ },
101
+ });
102
+ expect((result.data as any).applying_aspects).toEqual(
103
+ expect.arrayContaining([
104
+ expect.objectContaining({
105
+ from_body: 'Sun',
106
+ to_body: 'Moon',
107
+ aspect: 'sextile',
108
+ applying: true,
109
+ }),
110
+ ])
111
+ );
112
+ expect((result.data as any).meta.warnings).toContain(
113
+ 'House calculation fell back from P to W for this location.'
114
+ );
115
+ expect(result.text).toContain('Applying Aspects:');
116
+ });
117
+
118
+ it('Given DST-overlap or invalid orb inputs, then it preserves strict validation semantics', () => {
119
+ const { electionalService } = makeElectionalService();
120
+
121
+ expect(() =>
122
+ electionalService.getElectionalContext({
123
+ date: '2026-11-01',
124
+ time: '01:30',
125
+ timezone: 'America/Los_Angeles',
126
+ latitude: 37.7749,
127
+ longitude: -122.4194,
128
+ })
129
+ ).toThrow(/ambiguous or nonexistent due to a DST transition/);
130
+
131
+ expect(() =>
132
+ electionalService.getElectionalContext({
133
+ date: '2026-03-28',
134
+ time: '09:30',
135
+ timezone: 'UTC',
136
+ latitude: 37.7749,
137
+ longitude: -122.4194,
138
+ orb_degrees: 11,
139
+ })
140
+ ).toThrow(/Invalid orb_degrees/);
141
+ });
142
+
143
+ it('Given applying-aspect toggles and invalid clock input, then it preserves the raw electional contract', () => {
144
+ const { electionalService, ephem } = makeElectionalService();
145
+ ephem.getAllPlanets.mockReturnValue([
146
+ { ...makePlanet('Sun', 0), sign: 'Aries', speed: 1 },
147
+ { ...makePlanet('Moon', 120), sign: 'Leo', speed: 1 },
148
+ { ...makePlanet('Mercury', 210), sign: 'Scorpio', speed: 1 },
149
+ { ...makePlanet('Venus', 300), sign: 'Aquarius', speed: 1 },
150
+ { ...makePlanet('Mars', 45), sign: 'Taurus', speed: 1 },
151
+ { ...makePlanet('Jupiter', 90), sign: 'Cancer', speed: 0.1 },
152
+ { ...makePlanet('Saturn', 180), sign: 'Libra', speed: 0.1 },
153
+ { ...makePlanet('Uranus', 240), sign: 'Sagittarius', speed: 0.1 },
154
+ { ...makePlanet('Neptune', 270), sign: 'Capricorn', speed: 0.1 },
155
+ { ...makePlanet('Pluto', 330), sign: 'Pisces', speed: 0.1 },
156
+ ]);
157
+
158
+ const result = electionalService.getElectionalContext({
159
+ date: '2026-03-28',
160
+ time: '09:30:15',
161
+ timezone: 'UTC',
162
+ latitude: 40.7,
163
+ longitude: -74,
164
+ include_planetary_applications: false,
165
+ });
166
+
167
+ expect((result.data as any).applying_aspects).toBeUndefined();
168
+ expect((result.data as any).moon.applying_aspects).toBeUndefined();
169
+ expect((result.data as any).ruler_basics).toBeUndefined();
170
+ expect(result.text).not.toContain('Applying Aspects:');
171
+
172
+ expect(() =>
173
+ electionalService.getElectionalContext({
174
+ date: '2026-03-28',
175
+ time: '25:61',
176
+ timezone: 'UTC',
177
+ latitude: 40.7,
178
+ longitude: -74,
179
+ })
180
+ ).toThrow(/Invalid clock time/);
181
+ });
182
+ });