ether-to-astro 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -0
- package/.github/pull_request_template.md +16 -0
- package/.github/workflows/release.yml +35 -0
- package/.github/workflows/test.yml +32 -0
- package/AGENTS.md +99 -0
- package/LICENSE +18 -0
- package/NOTICE.md +45 -0
- package/README.md +301 -0
- package/SETUP.md +70 -0
- package/TESTING_SUMMARY.md +238 -0
- package/TEST_SUITE_STATUS.md +218 -0
- package/biome.json +48 -0
- package/dist/astro-service.d.ts +98 -0
- package/dist/astro-service.js +496 -0
- package/dist/chart-types.d.ts +52 -0
- package/dist/chart-types.js +51 -0
- package/dist/charts.d.ts +125 -0
- package/dist/charts.js +324 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +472 -0
- package/dist/constants.d.ts +81 -0
- package/dist/constants.js +76 -0
- package/dist/eclipses.d.ts +85 -0
- package/dist/eclipses.js +184 -0
- package/dist/ephemeris.d.ts +120 -0
- package/dist/ephemeris.js +379 -0
- package/dist/formatter.d.ts +2 -0
- package/dist/formatter.js +22 -0
- package/dist/houses.d.ts +82 -0
- package/dist/houses.js +169 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +150 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +31 -0
- package/dist/logger.d.ts +25 -0
- package/dist/logger.js +73 -0
- package/dist/profile-store.d.ts +48 -0
- package/dist/profile-store.js +156 -0
- package/dist/riseset.d.ts +82 -0
- package/dist/riseset.js +185 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +40 -0
- package/dist/time-utils.d.ts +68 -0
- package/dist/time-utils.js +136 -0
- package/dist/tool-registry.d.ts +35 -0
- package/dist/tool-registry.js +307 -0
- package/dist/tool-result.d.ts +175 -0
- package/dist/tool-result.js +188 -0
- package/dist/transits.d.ts +108 -0
- package/dist/transits.js +263 -0
- package/dist/types.d.ts +450 -0
- package/dist/types.js +161 -0
- package/example-usage.md +131 -0
- package/natal-chart.json +187 -0
- package/package.json +61 -0
- package/scripts/download-ephemeris.js +115 -0
- package/setup.sh +21 -0
- package/src/astro-service.ts +710 -0
- package/src/chart-types.ts +125 -0
- package/src/charts.ts +399 -0
- package/src/cli.ts +694 -0
- package/src/constants.ts +89 -0
- package/src/eclipses.ts +226 -0
- package/src/ephemeris.ts +437 -0
- package/src/formatter.ts +25 -0
- package/src/houses.ts +202 -0
- package/src/index.ts +170 -0
- package/src/loader.ts +36 -0
- package/src/logger.ts +104 -0
- package/src/profile-store.ts +285 -0
- package/src/riseset.ts +229 -0
- package/src/time-utils.ts +167 -0
- package/src/tool-registry.ts +357 -0
- package/src/tool-result.ts +283 -0
- package/src/transits.ts +352 -0
- package/src/types.ts +547 -0
- package/tests/README.md +173 -0
- package/tests/TESTING_STRATEGY.md +178 -0
- package/tests/fixtures/bowen-yang-chart.ts +69 -0
- package/tests/fixtures/calculate-expected.ts +81 -0
- package/tests/fixtures/expected-results.ts +117 -0
- package/tests/fixtures/generate-expected-simple.ts +94 -0
- package/tests/helpers/date-fixtures.ts +15 -0
- package/tests/helpers/ephem.ts +11 -0
- package/tests/helpers/temp.ts +9 -0
- package/tests/setup.ts +11 -0
- package/tests/unit/astro-service.test.ts +323 -0
- package/tests/unit/chart-types.test.ts +18 -0
- package/tests/unit/charts-errors.test.ts +42 -0
- package/tests/unit/charts.test.ts +157 -0
- package/tests/unit/cli-commands.test.ts +82 -0
- package/tests/unit/cli-profiles.test.ts +128 -0
- package/tests/unit/cli.test.ts +191 -0
- package/tests/unit/constants.test.ts +26 -0
- package/tests/unit/correctness-critical.test.ts +408 -0
- package/tests/unit/eclipses.test.ts +108 -0
- package/tests/unit/ephemeris.test.ts +213 -0
- package/tests/unit/error-handling.test.ts +116 -0
- package/tests/unit/formatter.test.ts +29 -0
- package/tests/unit/houses-errors.test.ts +27 -0
- package/tests/unit/houses-validation.test.ts +164 -0
- package/tests/unit/houses.test.ts +205 -0
- package/tests/unit/profile-store.test.ts +163 -0
- package/tests/unit/real-user-charts.test.ts +148 -0
- package/tests/unit/riseset.test.ts +106 -0
- package/tests/unit/solver-edges.test.ts +197 -0
- package/tests/unit/time-utils-temporal.test.ts +303 -0
- package/tests/unit/time-utils.test.ts +173 -0
- package/tests/unit/tool-registry.test.ts +222 -0
- package/tests/unit/tool-result.test.ts +45 -0
- package/tests/unit/transit-correctness.test.ts +78 -0
- package/tests/unit/transits.test.ts +238 -0
- package/tests/validation/README.md +32 -0
- package/tests/validation/adapters/astrolog.ts +306 -0
- package/tests/validation/adapters/internal.ts +184 -0
- package/tests/validation/compare/eclipses.ts +47 -0
- package/tests/validation/compare/houses.ts +76 -0
- package/tests/validation/compare/positions.ts +104 -0
- package/tests/validation/compare/riseSet.ts +48 -0
- package/tests/validation/compare/roots.ts +90 -0
- package/tests/validation/compare/transits.ts +69 -0
- package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
- package/tests/validation/fixtures/eclipses/core.ts +14 -0
- package/tests/validation/fixtures/houses/core.ts +47 -0
- package/tests/validation/fixtures/positions/core.ts +159 -0
- package/tests/validation/fixtures/rise-set/core.ts +20 -0
- package/tests/validation/fixtures/roots/core.ts +47 -0
- package/tests/validation/fixtures/transits/core.ts +61 -0
- package/tests/validation/fixtures/transits/dst.ts +21 -0
- package/tests/validation/oracle.spec.ts +129 -0
- package/tests/validation/utils/denseRootOracle.ts +269 -0
- package/tests/validation/utils/fixtureTypes.ts +146 -0
- package/tests/validation/utils/report.ts +60 -0
- package/tests/validation/utils/tolerances.ts +23 -0
- package/tests/validation/validation.spec.ts +836 -0
- package/tools/color-picker.html +388 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +31 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createToolSpecIndex, getToolSpec, MCP_TOOL_SPECS } from '../../src/tool-registry.js';
|
|
3
|
+
|
|
4
|
+
function makeService() {
|
|
5
|
+
return {
|
|
6
|
+
setNatalChart: vi.fn(() => ({ data: { ok: true }, text: 'set', chart: { name: 'x' } })),
|
|
7
|
+
getTransits: vi.fn(() => ({ data: { transits: [] }, text: 'transits' })),
|
|
8
|
+
getHouses: vi.fn(() => ({ data: { system: 'P' }, text: 'houses' })),
|
|
9
|
+
getRetrogradePlanets: vi.fn(() => ({ data: { planets: [] }, text: 'retro' })),
|
|
10
|
+
getRiseSetTimes: vi.fn(async () => ({ data: { times: [] }, text: 'rise' })),
|
|
11
|
+
getAsteroidPositions: vi.fn(() => ({ data: { positions: [] }, text: 'asteroids' })),
|
|
12
|
+
getNextEclipses: vi.fn(() => ({ data: { eclipses: [] }, text: 'eclipses' })),
|
|
13
|
+
getServerStatus: vi.fn(() => ({ data: { ok: true }, text: 'status' })),
|
|
14
|
+
generateNatalChart: vi.fn(async () => ({ format: 'svg', text: 'natal', svg: '<svg />' })),
|
|
15
|
+
generateTransitChart: vi.fn(async () => ({ format: 'png', text: 'transit', image: { data: 'abc', mimeType: 'image/png' } })),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('When resolving tool specs from the registry', () => {
|
|
20
|
+
it('Given the registry map, then specs are indexed and retrievable by name', () => {
|
|
21
|
+
const index = createToolSpecIndex();
|
|
22
|
+
expect(index.size).toBe(MCP_TOOL_SPECS.length);
|
|
23
|
+
expect(getToolSpec('set_natal_chart')?.name).toBe('set_natal_chart');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('Given set_natal_chart execution, then state result includes natal chart', async () => {
|
|
27
|
+
const spec = getToolSpec('set_natal_chart');
|
|
28
|
+
expect(spec).toBeDefined();
|
|
29
|
+
const service = makeService();
|
|
30
|
+
const result = await spec!.execute(
|
|
31
|
+
{ service: service as any, natalChart: null },
|
|
32
|
+
{
|
|
33
|
+
name: 'A',
|
|
34
|
+
year: 2000,
|
|
35
|
+
month: 1,
|
|
36
|
+
day: 1,
|
|
37
|
+
hour: 1,
|
|
38
|
+
minute: 1,
|
|
39
|
+
latitude: 1,
|
|
40
|
+
longitude: 1,
|
|
41
|
+
timezone: 'UTC',
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
expect(result.kind).toBe('state');
|
|
45
|
+
if (result.kind === 'state') {
|
|
46
|
+
expect(result.natalChart).toBeDefined();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('Given simple state tools, then calls route to matching service methods', async () => {
|
|
51
|
+
const service = makeService();
|
|
52
|
+
const ctx = { service: service as any, natalChart: { name: 'chart' } as any };
|
|
53
|
+
const retro = await getToolSpec('get_retrograde_planets')!.execute(ctx, { timezone: 'UTC' });
|
|
54
|
+
const status = await getToolSpec('get_server_status')!.execute(ctx, {});
|
|
55
|
+
expect(retro.kind).toBe('state');
|
|
56
|
+
expect(status.kind).toBe('state');
|
|
57
|
+
expect(service.getRetrogradePlanets).toHaveBeenCalledWith('UTC');
|
|
58
|
+
expect(service.getServerStatus).toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('Given async state tool handlers, then they resolve to state payloads', async () => {
|
|
62
|
+
const service = makeService();
|
|
63
|
+
const ctx = { service: service as any, natalChart: { name: 'chart' } as any };
|
|
64
|
+
const rise = await getToolSpec('get_rise_set_times')!.execute(ctx, {});
|
|
65
|
+
const ast = await getToolSpec('get_asteroid_positions')!.execute(ctx, { timezone: 'UTC' });
|
|
66
|
+
const eclipse = await getToolSpec('get_next_eclipses')!.execute(ctx, { timezone: 'UTC' });
|
|
67
|
+
expect(rise.kind).toBe('state');
|
|
68
|
+
expect(ast.kind).toBe('state');
|
|
69
|
+
expect(eclipse.kind).toBe('state');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('Given natal chart SVG output, then content includes text plus SVG', async () => {
|
|
73
|
+
const service = makeService();
|
|
74
|
+
const result = await getToolSpec('generate_natal_chart')!.execute(
|
|
75
|
+
{ service: service as any, natalChart: { name: 'chart' } as any },
|
|
76
|
+
{}
|
|
77
|
+
);
|
|
78
|
+
expect(result.kind).toBe('content');
|
|
79
|
+
if (result.kind === 'content') {
|
|
80
|
+
expect(result.content).toHaveLength(2);
|
|
81
|
+
expect(result.content[1]).toMatchObject({ type: 'text' });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('Given transit chart output_path, then content includes text-only save confirmation', async () => {
|
|
86
|
+
const service = makeService();
|
|
87
|
+
service.generateTransitChart.mockResolvedValue({
|
|
88
|
+
format: 'png',
|
|
89
|
+
text: 'saved',
|
|
90
|
+
outputPath: '/tmp/out.png',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const result = await getToolSpec('generate_transit_chart')!.execute(
|
|
94
|
+
{ service: service as any, natalChart: { name: 'chart' } as any },
|
|
95
|
+
{}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(result.kind).toBe('content');
|
|
99
|
+
if (result.kind === 'content') {
|
|
100
|
+
expect(result.content).toHaveLength(1);
|
|
101
|
+
expect(result.content[0]).toMatchObject({ type: 'text', text: 'saved' });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('Given transit chart binary image output, then content includes image payload', async () => {
|
|
106
|
+
const service = makeService();
|
|
107
|
+
service.generateTransitChart.mockResolvedValue({
|
|
108
|
+
format: 'png',
|
|
109
|
+
text: 'transit',
|
|
110
|
+
image: { data: 'xyz', mimeType: 'image/png' },
|
|
111
|
+
});
|
|
112
|
+
const result = await getToolSpec('generate_transit_chart')!.execute(
|
|
113
|
+
{ service: service as any, natalChart: { name: 'chart' } as any },
|
|
114
|
+
{}
|
|
115
|
+
);
|
|
116
|
+
expect(result.kind).toBe('content');
|
|
117
|
+
if (result.kind === 'content') {
|
|
118
|
+
expect(result.content[1]).toMatchObject({ type: 'image', mimeType: 'image/png' });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('Given natal output_path and transit SVG output, then each branch returns the expected content shape', async () => {
|
|
123
|
+
const service = makeService();
|
|
124
|
+
service.generateNatalChart.mockResolvedValue({
|
|
125
|
+
format: 'png',
|
|
126
|
+
text: 'saved natal',
|
|
127
|
+
outputPath: '/tmp/natal.png',
|
|
128
|
+
});
|
|
129
|
+
service.generateTransitChart.mockResolvedValue({
|
|
130
|
+
format: 'svg',
|
|
131
|
+
text: 'transit svg',
|
|
132
|
+
svg: '<svg />',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const natal = await getToolSpec('generate_natal_chart')!.execute(
|
|
136
|
+
{ service: service as any, natalChart: { name: 'chart' } as any },
|
|
137
|
+
{}
|
|
138
|
+
);
|
|
139
|
+
const transit = await getToolSpec('generate_transit_chart')!.execute(
|
|
140
|
+
{ service: service as any, natalChart: { name: 'chart' } as any },
|
|
141
|
+
{}
|
|
142
|
+
);
|
|
143
|
+
expect(natal.kind).toBe('content');
|
|
144
|
+
expect(transit.kind).toBe('content');
|
|
145
|
+
if (natal.kind === 'content') {
|
|
146
|
+
expect(natal.content).toHaveLength(1);
|
|
147
|
+
}
|
|
148
|
+
if (transit.kind === 'content') {
|
|
149
|
+
expect(transit.content).toHaveLength(2);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('Given natal chart binary image output, then content includes image payload', async () => {
|
|
154
|
+
const service = makeService();
|
|
155
|
+
service.generateNatalChart.mockResolvedValue({
|
|
156
|
+
format: 'webp',
|
|
157
|
+
text: 'natal image',
|
|
158
|
+
image: { data: 'img', mimeType: 'image/webp' },
|
|
159
|
+
});
|
|
160
|
+
const result = await getToolSpec('generate_natal_chart')!.execute(
|
|
161
|
+
{ service: service as any, natalChart: { name: 'chart' } as any },
|
|
162
|
+
{}
|
|
163
|
+
);
|
|
164
|
+
expect(result.kind).toBe('content');
|
|
165
|
+
if (result.kind === 'content') {
|
|
166
|
+
expect(result.content[1]).toMatchObject({ type: 'image', mimeType: 'image/webp' });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('Given tool metadata, then natal-dependent tools are flagged correctly', () => {
|
|
171
|
+
const required = new Set(
|
|
172
|
+
MCP_TOOL_SPECS.filter((s) => s.requiresNatalChart).map((s) => s.name)
|
|
173
|
+
);
|
|
174
|
+
expect(required.has('get_transits')).toBe(true);
|
|
175
|
+
expect(required.has('get_houses')).toBe(true);
|
|
176
|
+
expect(required.has('get_rise_set_times')).toBe(true);
|
|
177
|
+
expect(required.has('generate_natal_chart')).toBe(true);
|
|
178
|
+
expect(required.has('generate_transit_chart')).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('Given chart tools return no payload, then execution throws explicit errors', async () => {
|
|
182
|
+
const service = makeService();
|
|
183
|
+
service.generateNatalChart.mockResolvedValue({ format: 'svg', text: 'oops' });
|
|
184
|
+
await expect(
|
|
185
|
+
getToolSpec('generate_natal_chart')!.execute(
|
|
186
|
+
{ service: service as any, natalChart: { name: 'chart' } as any },
|
|
187
|
+
{}
|
|
188
|
+
)
|
|
189
|
+
).rejects.toThrow(/no payload/i);
|
|
190
|
+
service.generateTransitChart.mockResolvedValue({ format: 'svg', text: 'oops' });
|
|
191
|
+
await expect(
|
|
192
|
+
getToolSpec('generate_transit_chart')!.execute(
|
|
193
|
+
{ service: service as any, natalChart: { name: 'chart' } as any },
|
|
194
|
+
{}
|
|
195
|
+
)
|
|
196
|
+
).rejects.toThrow(/no payload/i);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('Given timezone arguments and natal context, then non-natal tools apply timezone precedence correctly', async () => {
|
|
200
|
+
const service = makeService();
|
|
201
|
+
const retro = getToolSpec('get_retrograde_planets')!;
|
|
202
|
+
const asteroids = getToolSpec('get_asteroid_positions')!;
|
|
203
|
+
const eclipses = getToolSpec('get_next_eclipses')!;
|
|
204
|
+
|
|
205
|
+
await retro.execute(
|
|
206
|
+
{ service: service as any, natalChart: { location: { timezone: 'Asia/Tokyo' } } as any },
|
|
207
|
+
{}
|
|
208
|
+
);
|
|
209
|
+
await asteroids.execute(
|
|
210
|
+
{ service: service as any, natalChart: null },
|
|
211
|
+
{}
|
|
212
|
+
);
|
|
213
|
+
await eclipses.execute(
|
|
214
|
+
{ service: service as any, natalChart: { location: { timezone: 'America/New_York' } } as any },
|
|
215
|
+
{ timezone: 'UTC' }
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
expect(service.getRetrogradePlanets).toHaveBeenCalledWith('Asia/Tokyo');
|
|
219
|
+
expect(service.getAsteroidPositions).toHaveBeenCalledWith('UTC');
|
|
220
|
+
expect(service.getNextEclipses).toHaveBeenCalledWith('UTC');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
failure,
|
|
4
|
+
mapSweError,
|
|
5
|
+
mcpError,
|
|
6
|
+
mcpResult,
|
|
7
|
+
success,
|
|
8
|
+
type ToolIssue,
|
|
9
|
+
} from '../../src/tool-result.js';
|
|
10
|
+
|
|
11
|
+
describe('When building tool result envelopes', () => {
|
|
12
|
+
it('Given success and failure inputs, then wrapper helpers return the expected discriminated shapes', () => {
|
|
13
|
+
const warning: ToolIssue = { code: 'INVALID_INPUT', message: 'warn', retryable: true };
|
|
14
|
+
expect(success({ ok: 1 })).toEqual({ ok: true, data: { ok: 1 } });
|
|
15
|
+
expect(success({ ok: 1 }, [warning])).toEqual({ ok: true, data: { ok: 1 }, warnings: [warning] });
|
|
16
|
+
expect(failure(warning)).toEqual({ ok: false, error: warning });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('Given a successful MCP result with optional warnings, then content envelope includes expected fields', () => {
|
|
20
|
+
const withoutWarnings = mcpResult({ x: 1 }, 'ok');
|
|
21
|
+
const withWarnings = mcpResult({ x: 1 }, 'ok', [
|
|
22
|
+
{ code: 'INVALID_INPUT', message: 'warn', retryable: true },
|
|
23
|
+
]);
|
|
24
|
+
expect(withoutWarnings.content).toHaveLength(2);
|
|
25
|
+
expect(withWarnings.content).toHaveLength(2);
|
|
26
|
+
expect(withWarnings.content[0].text).toContain('"warnings"');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('Given an MCP error, then envelope marks isError and includes structured payload', () => {
|
|
30
|
+
const result = mcpError({ code: 'INTERNAL_ERROR', message: 'boom', retryable: false });
|
|
31
|
+
expect(result.isError).toBe(true);
|
|
32
|
+
expect(result.content[0].text).toContain('"ok": false');
|
|
33
|
+
expect(result.content[0].text).toContain('"INTERNAL_ERROR"');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('Given sweph initialization and generic errors, then mapSweError returns correct issue codes/details', () => {
|
|
37
|
+
const init = mapSweError('calc', new Error('not initialized'));
|
|
38
|
+
expect(init.code).toBe('EPHEMERIS_NOT_INITIALIZED');
|
|
39
|
+
expect(init.details).toBeUndefined();
|
|
40
|
+
|
|
41
|
+
const generic = mapSweError('houses', new Error('bad value'), { lat: 1 });
|
|
42
|
+
expect(generic.code).toBe('EPHEMERIS_COMPUTE_FAILED');
|
|
43
|
+
expect(generic.details).toMatchObject({ lat: 1, rawMessage: 'bad value' });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { TransitCalculator } from '../../src/transits.js';
|
|
3
|
+
import { EphemerisCalculator } from '../../src/ephemeris.js';
|
|
4
|
+
import { PLANETS, type PlanetPosition } from '../../src/types.js';
|
|
5
|
+
|
|
6
|
+
describe('Transit calculation correctness', () => {
|
|
7
|
+
let ephem: EphemerisCalculator;
|
|
8
|
+
let transitCalc: TransitCalculator;
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
ephem = new EphemerisCalculator();
|
|
12
|
+
await ephem.init();
|
|
13
|
+
transitCalc = new TransitCalculator(ephem);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('Dual-target exact time calculation', () => {
|
|
17
|
+
it('should find exact times for both sides of square aspect', () => {
|
|
18
|
+
const currentDate = new Date(Date.UTC(2024, 2, 26, 12, 0));
|
|
19
|
+
const currentJD = ephem.dateToJulianDay(currentDate);
|
|
20
|
+
const mars = ephem.getAllPlanets(currentJD, [PLANETS.MARS])[0];
|
|
21
|
+
const natalPlanets: PlanetPosition[] = [
|
|
22
|
+
{ ...mars, planetId: PLANETS.VENUS, planet: 'Venus', longitude: (mars.longitude + 90) % 360 },
|
|
23
|
+
];
|
|
24
|
+
const transits = transitCalc.findTransits([mars], natalPlanets, currentJD);
|
|
25
|
+
const squares = transits.filter(t => t.aspect === 'square');
|
|
26
|
+
expect(squares.length).toBeGreaterThan(0);
|
|
27
|
+
expect(squares[0].aspect).toBe('square');
|
|
28
|
+
expect(squares[0].orb).toBeLessThanOrEqual(7);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should search both target longitudes for trine', () => {
|
|
32
|
+
const currentDate = new Date(Date.UTC(2024, 2, 26, 12, 0));
|
|
33
|
+
const currentJD = ephem.dateToJulianDay(currentDate);
|
|
34
|
+
const jupiter = ephem.getAllPlanets(currentJD, [PLANETS.JUPITER])[0];
|
|
35
|
+
const natalPlanets: PlanetPosition[] = [
|
|
36
|
+
{ ...jupiter, planetId: PLANETS.SUN, planet: 'Sun', longitude: (jupiter.longitude + 120) % 360 },
|
|
37
|
+
];
|
|
38
|
+
const transits = transitCalc.findTransits([jupiter], natalPlanets, currentJD);
|
|
39
|
+
const trines = transits.filter(t => t.aspect === 'trine');
|
|
40
|
+
expect(trines.length).toBeGreaterThan(0);
|
|
41
|
+
expect(trines[0].aspect).toBe('trine');
|
|
42
|
+
expect(trines[0].orb).toBeLessThanOrEqual(7);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('Dynamic search window for slow movers', () => {
|
|
47
|
+
it('should use wider search window for slow-moving planets', () => {
|
|
48
|
+
// This is tested by checking if slow-mover exact times are found
|
|
49
|
+
const currentDate = new Date(Date.UTC(2024, 2, 26, 12, 0));
|
|
50
|
+
const currentJD = ephem.dateToJulianDay(currentDate);
|
|
51
|
+
const saturn = ephem.getAllPlanets(currentJD, [PLANETS.SATURN])[0];
|
|
52
|
+
const natalPlanets: PlanetPosition[] = [
|
|
53
|
+
{ ...saturn, planetId: PLANETS.SUN, planet: 'Sun', longitude: (saturn.longitude + 1) % 360 },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const transits = transitCalc.findTransits([saturn], natalPlanets, currentJD);
|
|
57
|
+
const closeTransits = transits.filter(t => t.orb < 2);
|
|
58
|
+
expect(closeTransits.length).toBeGreaterThan(0);
|
|
59
|
+
closeTransits.forEach((t) => {
|
|
60
|
+
expect(t.exactTimeStatus).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('Unknown planet handling', () => {
|
|
66
|
+
it('should return valid transit objects for supported non-classical bodies', () => {
|
|
67
|
+
const currentDate = new Date(Date.UTC(2024, 2, 26, 12, 0));
|
|
68
|
+
const currentJD = ephem.dateToJulianDay(currentDate);
|
|
69
|
+
const chiron = ephem.getAllPlanets(currentJD, [PLANETS.CHIRON])[0];
|
|
70
|
+
const natalPlanets: PlanetPosition[] = [
|
|
71
|
+
{ ...chiron, planetId: PLANETS.SUN, planet: 'Sun', longitude: chiron.longitude },
|
|
72
|
+
];
|
|
73
|
+
const transits = transitCalc.findTransits([chiron], natalPlanets, currentJD);
|
|
74
|
+
expect(transits.length).toBeGreaterThan(0);
|
|
75
|
+
expect(transits[0].transitingPlanet).toBe('Chiron');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { TransitCalculator } from '../../src/transits.js';
|
|
3
|
+
import { EphemerisCalculator } from '../../src/ephemeris.js';
|
|
4
|
+
import { PLANETS, ASPECTS } from '../../src/types.js';
|
|
5
|
+
import { bowenYangChart } from '../fixtures/bowen-yang-chart.js';
|
|
6
|
+
import { FIXED_TEST_DATE } from '../setup.js';
|
|
7
|
+
|
|
8
|
+
describe('When an AI asks "What transits is Bowen experiencing today?"', () => {
|
|
9
|
+
let ephem: EphemerisCalculator;
|
|
10
|
+
let transitCalc: TransitCalculator;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
ephem = new EphemerisCalculator();
|
|
14
|
+
await ephem.init();
|
|
15
|
+
transitCalc = new TransitCalculator(ephem);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('Given Bowen Yang\'s natal chart', () => {
|
|
19
|
+
it('should find transits between current planets and natal planets', () => {
|
|
20
|
+
const birthDate = new Date(Date.UTC(
|
|
21
|
+
bowenYangChart.birthDate.year,
|
|
22
|
+
bowenYangChart.birthDate.month - 1,
|
|
23
|
+
bowenYangChart.birthDate.day,
|
|
24
|
+
bowenYangChart.birthDate.hour,
|
|
25
|
+
bowenYangChart.birthDate.minute
|
|
26
|
+
));
|
|
27
|
+
const birthJD = ephem.dateToJulianDay(birthDate);
|
|
28
|
+
const currentJD = ephem.dateToJulianDay(FIXED_TEST_DATE);
|
|
29
|
+
|
|
30
|
+
const natalPlanets = ephem.getAllPlanets(birthJD, Object.values(PLANETS));
|
|
31
|
+
const transitingPlanets = ephem.getAllPlanets(currentJD, Object.values(PLANETS));
|
|
32
|
+
|
|
33
|
+
const transits = transitCalc.findTransits(transitingPlanets, natalPlanets, currentJD);
|
|
34
|
+
|
|
35
|
+
expect(Array.isArray(transits)).toBe(true);
|
|
36
|
+
expect(transits.length).toBeGreaterThan(0);
|
|
37
|
+
transits.forEach(transit => {
|
|
38
|
+
expect(typeof transit.transitingPlanet).toBe('string');
|
|
39
|
+
expect(typeof transit.natalPlanet).toBe('string');
|
|
40
|
+
expect(typeof transit.aspect).toBe('string');
|
|
41
|
+
expect(transit.orb).toBeGreaterThanOrEqual(0);
|
|
42
|
+
expect(typeof transit.isApplying).toBe('boolean');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should calculate applying vs separating aspects correctly', () => {
|
|
47
|
+
const birthDate = new Date(Date.UTC(
|
|
48
|
+
bowenYangChart.birthDate.year,
|
|
49
|
+
bowenYangChart.birthDate.month - 1,
|
|
50
|
+
bowenYangChart.birthDate.day,
|
|
51
|
+
bowenYangChart.birthDate.hour,
|
|
52
|
+
bowenYangChart.birthDate.minute
|
|
53
|
+
));
|
|
54
|
+
const birthJD = ephem.dateToJulianDay(birthDate);
|
|
55
|
+
const currentJD = ephem.dateToJulianDay(FIXED_TEST_DATE);
|
|
56
|
+
|
|
57
|
+
const natalPlanets = ephem.getAllPlanets(birthJD, Object.values(PLANETS));
|
|
58
|
+
const transitingPlanets = ephem.getAllPlanets(currentJD, Object.values(PLANETS));
|
|
59
|
+
|
|
60
|
+
const transits = transitCalc.findTransits(transitingPlanets, natalPlanets, currentJD);
|
|
61
|
+
|
|
62
|
+
transits.forEach(transit => {
|
|
63
|
+
expect(typeof transit.isApplying).toBe('boolean');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should respect aspect orbs from ASPECTS configuration', () => {
|
|
68
|
+
const birthDate = new Date(Date.UTC(
|
|
69
|
+
bowenYangChart.birthDate.year,
|
|
70
|
+
bowenYangChart.birthDate.month - 1,
|
|
71
|
+
bowenYangChart.birthDate.day,
|
|
72
|
+
bowenYangChart.birthDate.hour,
|
|
73
|
+
bowenYangChart.birthDate.minute
|
|
74
|
+
));
|
|
75
|
+
const birthJD = ephem.dateToJulianDay(birthDate);
|
|
76
|
+
const currentJD = ephem.dateToJulianDay(FIXED_TEST_DATE);
|
|
77
|
+
|
|
78
|
+
const natalPlanets = ephem.getAllPlanets(birthJD, Object.values(PLANETS));
|
|
79
|
+
const transitingPlanets = ephem.getAllPlanets(currentJD, Object.values(PLANETS));
|
|
80
|
+
|
|
81
|
+
const transits = transitCalc.findTransits(transitingPlanets, natalPlanets, currentJD);
|
|
82
|
+
|
|
83
|
+
transits.forEach(transit => {
|
|
84
|
+
const aspectConfig = ASPECTS.find(a => a.name === transit.aspect);
|
|
85
|
+
expect(aspectConfig).toBeDefined();
|
|
86
|
+
expect(transit.orb).toBeLessThanOrEqual(aspectConfig!.orb);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('When filtering for upcoming transits', () => {
|
|
92
|
+
it('should find transits approaching within 2 degrees', () => {
|
|
93
|
+
const birthDate = new Date(Date.UTC(
|
|
94
|
+
bowenYangChart.birthDate.year,
|
|
95
|
+
bowenYangChart.birthDate.month - 1,
|
|
96
|
+
bowenYangChart.birthDate.day,
|
|
97
|
+
bowenYangChart.birthDate.hour,
|
|
98
|
+
bowenYangChart.birthDate.minute
|
|
99
|
+
));
|
|
100
|
+
const birthJD = ephem.dateToJulianDay(birthDate);
|
|
101
|
+
const currentJD = ephem.dateToJulianDay(FIXED_TEST_DATE);
|
|
102
|
+
|
|
103
|
+
const natalPlanets = ephem.getAllPlanets(birthJD, Object.values(PLANETS));
|
|
104
|
+
const transitingPlanets = ephem.getAllPlanets(currentJD, Object.values(PLANETS));
|
|
105
|
+
|
|
106
|
+
const allTransits = transitCalc.findTransits(transitingPlanets, natalPlanets, currentJD);
|
|
107
|
+
const upcomingTransits = allTransits.filter(t => t.orb <= 2 && t.isApplying);
|
|
108
|
+
|
|
109
|
+
upcomingTransits.forEach(transit => {
|
|
110
|
+
expect(transit.orb).toBeLessThanOrEqual(2);
|
|
111
|
+
expect(transit.isApplying).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('When handling Moon transits (fast-moving planet)', () => {
|
|
117
|
+
it('should find Moon transits to natal planets', () => {
|
|
118
|
+
const birthDate = new Date(Date.UTC(
|
|
119
|
+
bowenYangChart.birthDate.year,
|
|
120
|
+
bowenYangChart.birthDate.month - 1,
|
|
121
|
+
bowenYangChart.birthDate.day,
|
|
122
|
+
bowenYangChart.birthDate.hour,
|
|
123
|
+
bowenYangChart.birthDate.minute
|
|
124
|
+
));
|
|
125
|
+
const birthJD = ephem.dateToJulianDay(birthDate);
|
|
126
|
+
const currentJD = ephem.dateToJulianDay(FIXED_TEST_DATE);
|
|
127
|
+
|
|
128
|
+
const moonPosition = ephem.getAllPlanets(currentJD, [PLANETS.MOON]);
|
|
129
|
+
const natalPlanets = moonPosition.map((moon) => ({
|
|
130
|
+
...moon,
|
|
131
|
+
planet: 'Sun' as const,
|
|
132
|
+
planetId: PLANETS.SUN,
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
const transits = transitCalc.findTransits(moonPosition, natalPlanets, currentJD);
|
|
136
|
+
|
|
137
|
+
expect(transits.length).toBeGreaterThan(0);
|
|
138
|
+
|
|
139
|
+
transits.forEach(transit => {
|
|
140
|
+
expect(transit.transitingPlanet).toBe('Moon');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('When handling outer planet transits (slow-moving planets)', () => {
|
|
146
|
+
it('should find outer planet transits with longer-lasting effects', () => {
|
|
147
|
+
const birthDate = new Date(Date.UTC(
|
|
148
|
+
bowenYangChart.birthDate.year,
|
|
149
|
+
bowenYangChart.birthDate.month - 1,
|
|
150
|
+
bowenYangChart.birthDate.day,
|
|
151
|
+
bowenYangChart.birthDate.hour,
|
|
152
|
+
bowenYangChart.birthDate.minute
|
|
153
|
+
));
|
|
154
|
+
const birthJD = ephem.dateToJulianDay(birthDate);
|
|
155
|
+
const currentJD = ephem.dateToJulianDay(FIXED_TEST_DATE);
|
|
156
|
+
|
|
157
|
+
const natalPlanets = ephem.getAllPlanets(birthJD, Object.values(PLANETS));
|
|
158
|
+
const outerPlanets = ephem.getAllPlanets(currentJD, [
|
|
159
|
+
PLANETS.JUPITER,
|
|
160
|
+
PLANETS.SATURN,
|
|
161
|
+
PLANETS.URANUS,
|
|
162
|
+
PLANETS.NEPTUNE,
|
|
163
|
+
PLANETS.PLUTO
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const transits = transitCalc.findTransits(outerPlanets, natalPlanets, currentJD);
|
|
167
|
+
|
|
168
|
+
transits.forEach(transit => {
|
|
169
|
+
expect(['Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto']).toContain(transit.transitingPlanet);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('When calculating exact transit times', () => {
|
|
175
|
+
it('should calculate when a transit becomes exact (0° orb)', () => {
|
|
176
|
+
const birthDate = new Date(Date.UTC(
|
|
177
|
+
bowenYangChart.birthDate.year,
|
|
178
|
+
bowenYangChart.birthDate.month - 1,
|
|
179
|
+
bowenYangChart.birthDate.day,
|
|
180
|
+
bowenYangChart.birthDate.hour,
|
|
181
|
+
bowenYangChart.birthDate.minute
|
|
182
|
+
));
|
|
183
|
+
const birthJD = ephem.dateToJulianDay(birthDate);
|
|
184
|
+
const currentJD = ephem.dateToJulianDay(FIXED_TEST_DATE);
|
|
185
|
+
|
|
186
|
+
const natalPlanets = ephem.getAllPlanets(birthJD, Object.values(PLANETS));
|
|
187
|
+
const transitingPlanets = ephem.getAllPlanets(currentJD, Object.values(PLANETS));
|
|
188
|
+
|
|
189
|
+
const transits = transitCalc.findTransits(transitingPlanets, natalPlanets, currentJD);
|
|
190
|
+
|
|
191
|
+
// Filter for close transits that might have exact times
|
|
192
|
+
const closeTransits = transits.filter(t => t.orb < 2);
|
|
193
|
+
expect(closeTransits.length).toBeGreaterThan(0);
|
|
194
|
+
closeTransits.forEach(transit => {
|
|
195
|
+
expect(transit.exactTime === undefined || transit.exactTime instanceof Date).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('When handling edge cases', () => {
|
|
201
|
+
it('should handle transits at zodiac boundaries (0° Aries, etc.)', () => {
|
|
202
|
+
// Create a scenario where planet is at 359° and another at 1°
|
|
203
|
+
const currentJD = ephem.dateToJulianDay(FIXED_TEST_DATE);
|
|
204
|
+
|
|
205
|
+
// This tests the angle calculation wrapping logic
|
|
206
|
+
const angle = ephem.calculateAspectAngle(359, 1);
|
|
207
|
+
expect(angle).toBe(2);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should handle retrograde planets correctly', () => {
|
|
211
|
+
const birthDate = new Date(Date.UTC(
|
|
212
|
+
bowenYangChart.birthDate.year,
|
|
213
|
+
bowenYangChart.birthDate.month - 1,
|
|
214
|
+
bowenYangChart.birthDate.day,
|
|
215
|
+
bowenYangChart.birthDate.hour,
|
|
216
|
+
bowenYangChart.birthDate.minute
|
|
217
|
+
));
|
|
218
|
+
const birthJD = ephem.dateToJulianDay(birthDate);
|
|
219
|
+
const currentJD = ephem.dateToJulianDay(FIXED_TEST_DATE);
|
|
220
|
+
|
|
221
|
+
const natalPlanets = ephem.getAllPlanets(birthJD, Object.values(PLANETS));
|
|
222
|
+
const transitingPlanets = ephem.getAllPlanets(currentJD, Object.values(PLANETS));
|
|
223
|
+
|
|
224
|
+
const transits = transitCalc.findTransits(transitingPlanets, natalPlanets, currentJD);
|
|
225
|
+
|
|
226
|
+
// Retrograde planets should still form transits
|
|
227
|
+
const retrogradeTransits = transits.filter(t => {
|
|
228
|
+
const planet = transitingPlanets.find(p => p.planet === t.transitingPlanet);
|
|
229
|
+
return planet && planet.speed < 0;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Retrograde transits should be separating (moving backward)
|
|
233
|
+
retrogradeTransits.forEach(transit => {
|
|
234
|
+
expect(typeof transit.isApplying).toBe('boolean');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Astro Validation Harness
|
|
2
|
+
|
|
3
|
+
Run the core harness:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm run validate:astro
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Run with optional Astrolog parity:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
VALIDATE_WITH_ASTROLOG=1 ASTROLOG_BIN=astrolog npm run validate:astro
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Notes:
|
|
16
|
+
|
|
17
|
+
- Core validation requires only Node + this repo.
|
|
18
|
+
- Astrolog checks are optional and auto-skipped unless enabled and available.
|
|
19
|
+
- Astrolog parity covers a curated 25-fixture corpus (positions, houses, transit snapshots, and edge UTC normalization cases).
|
|
20
|
+
- Dense-scan root oracle is independent from production `findExactTransitTimes()`.
|
|
21
|
+
- Hard failures fail the test run.
|
|
22
|
+
- Soft mismatches are logged as warnings.
|
|
23
|
+
- A machine-readable report is written to `/tmp/astro-validation-report.json`.
|
|
24
|
+
- Rise/set and eclipse sections are capability/smoke checks in this harness (exact parity references are not yet externalized there).
|
|
25
|
+
- Astrolog house parity treats Whole Sign ASC/MC cusp proxies as non-comparable; Whole Sign checks focus on cusp parity, while ASC/MC proxy warnings are reserved for non-Whole-Sign systems.
|
|
26
|
+
|
|
27
|
+
Tolerance summary:
|
|
28
|
+
|
|
29
|
+
- Position parity (same engine): `0.0001°` for longitude/latitude/speed
|
|
30
|
+
- Houses: `0.01°`
|
|
31
|
+
- Exact roots: preferred `<=2 min`, hard fail `>10 min`
|
|
32
|
+
- Rise/set and eclipse times (same engine references): `<=1 min`
|