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.
- package/.github/ISSUE_TEMPLATE/bug-report.yml +87 -0
- package/.github/ISSUE_TEMPLATE/capability-request.yml +117 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/.github/ISSUE_TEMPLATE/paper-cut.yml +59 -0
- package/.github/pull_request_template.md +1 -0
- package/.github/workflows/release.yml +2 -2
- package/.github/workflows/test.yml +2 -2
- package/AGENTS.md +46 -1
- package/DEVELOPER.md +78 -0
- package/README.md +128 -75
- package/SETUP.md +100 -41
- package/dist/astro-service.d.ts +51 -2
- package/dist/astro-service.js +660 -56
- package/dist/cli.js +31 -0
- package/dist/entrypoint.d.ts +13 -0
- package/dist/entrypoint.js +78 -0
- package/dist/ephemeris.d.ts +15 -0
- package/dist/ephemeris.js +33 -0
- package/dist/formatter.d.ts +5 -1
- package/dist/formatter.js +4 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +63 -114
- package/dist/loader.d.ts +1 -1
- package/dist/loader.js +61 -23
- package/dist/mcp-alias.d.ts +2 -0
- package/dist/mcp-alias.js +8 -0
- package/dist/time-utils.d.ts +8 -0
- package/dist/time-utils.js +16 -0
- package/dist/tool-registry.js +111 -5
- package/dist/tool-result.d.ts +8 -0
- package/dist/tool-result.js +39 -0
- package/dist/types.d.ts +79 -1
- package/docs/product/adrs/0001-mcp-vs-skill-boundary.md +96 -0
- package/docs/product/architecture-boundaries.md +223 -0
- package/docs/product/product-tenets.md +174 -0
- package/docs/releases/1.2.0-draft.md +48 -0
- package/package.json +7 -7
- package/skills/.curated/daily-brief/SKILL.md +75 -0
- package/skills/.curated/electional-overlay/SKILL.md +67 -0
- package/skills/.curated/weekly-overview/SKILL.md +73 -0
- package/skills/.system/write-skill/SKILL.md +90 -0
- package/src/astro-service.ts +861 -60
- package/src/cli.ts +84 -0
- package/src/entrypoint.ts +118 -0
- package/src/ephemeris.ts +44 -0
- package/src/formatter.ts +13 -1
- package/src/index.ts +77 -121
- package/src/loader.ts +69 -25
- package/src/mcp-alias.ts +10 -0
- package/src/time-utils.ts +18 -0
- package/src/tool-registry.ts +129 -9
- package/src/tool-result.ts +44 -0
- package/src/types.ts +91 -1
- package/tests/unit/astro-service.test.ts +751 -5
- package/tests/unit/cli-commands.test.ts +13 -0
- package/tests/unit/entrypoint.test.ts +67 -0
- package/tests/unit/error-mapping.test.ts +20 -0
- package/tests/unit/formatter.test.ts +6 -0
- package/tests/unit/tool-registry.test.ts +114 -2
- 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(() =>
|
|
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
|
|
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
|
-
|
|
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
|
});
|