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,836 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { localToUTC } from '../../src/time-utils.js';
|
|
4
|
+
import { PLANETS, type PlanetPosition } from '../../src/types.js';
|
|
5
|
+
import { getAstrologHouses, getAstrologPositions, probeAstrolog } from './adapters/astrolog.js';
|
|
6
|
+
import { InternalValidationAdapter } from './adapters/internal.js';
|
|
7
|
+
import { compareHouses } from './compare/houses.js';
|
|
8
|
+
import { comparePositions } from './compare/positions.js';
|
|
9
|
+
import { compareRoots } from './compare/roots.js';
|
|
10
|
+
import { assertTransitStatus, findTransit } from './compare/transits.js';
|
|
11
|
+
import {
|
|
12
|
+
astrologEdgeParityFixtures,
|
|
13
|
+
astrologHouseParityFixtures,
|
|
14
|
+
astrologPositionParityFixtures,
|
|
15
|
+
astrologTransitSnapshotFixtures,
|
|
16
|
+
} from './fixtures/astrolog-parity/core.js';
|
|
17
|
+
import { eclipseFixtures } from './fixtures/eclipses/core.js';
|
|
18
|
+
import { houseFixtures } from './fixtures/houses/core.js';
|
|
19
|
+
import { positionFixtures } from './fixtures/positions/core.js';
|
|
20
|
+
import { riseSetFixtures } from './fixtures/rise-set/core.js';
|
|
21
|
+
import { rootFixtures } from './fixtures/roots/core.js';
|
|
22
|
+
import { transitFixtures } from './fixtures/transits/core.js';
|
|
23
|
+
import { dstFixtures } from './fixtures/transits/dst.js';
|
|
24
|
+
import { denseScanRootOracleWithDebug } from './utils/denseRootOracle.js';
|
|
25
|
+
import { formatMismatch, ValidationReport } from './utils/report.js';
|
|
26
|
+
import { TOLERANCES } from './utils/tolerances.js';
|
|
27
|
+
|
|
28
|
+
function assertNoHardFailures(report: ValidationReport): void {
|
|
29
|
+
if (report.hardFailures.length === 0) return;
|
|
30
|
+
const details = report.hardFailures.map(formatMismatch).join('\n');
|
|
31
|
+
expect(details).toBe('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeLongitudeDelta(a: number, b: number): number {
|
|
35
|
+
const diff = Math.abs(a - b) % 360;
|
|
36
|
+
return diff > 180 ? 360 - diff : diff;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function shortestDiff(longitude: number, targetLongitude: number): number {
|
|
40
|
+
let diff = (((longitude % 360) + 360) % 360) - (((targetLongitude % 360) + 360) % 360);
|
|
41
|
+
if (diff > 180) diff -= 360;
|
|
42
|
+
if (diff < -180) diff += 360;
|
|
43
|
+
return diff;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('Astro Validation Harness', () => {
|
|
47
|
+
let adapter: InternalValidationAdapter;
|
|
48
|
+
const aggregateReport = new ValidationReport();
|
|
49
|
+
const astrologProbe = probeAstrolog();
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
adapter = await InternalValidationAdapter.create();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
const wallClockMs = Math.round(performance.timeOrigin + performance.now());
|
|
57
|
+
aggregateReport.generatedAt = new Date(wallClockMs).toISOString();
|
|
58
|
+
aggregateReport.flushWarningsToConsole();
|
|
59
|
+
writeFileSync('/tmp/astro-validation-report.json', aggregateReport.toJson(), 'utf8');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('A. Planet positions', () => {
|
|
63
|
+
it('validates normalized internal positions against curated fixtures', () => {
|
|
64
|
+
const report = new ValidationReport();
|
|
65
|
+
for (const fixture of positionFixtures) {
|
|
66
|
+
const actual = adapter.getPositions(fixture.isoUtc, fixture.planetIds);
|
|
67
|
+
comparePositions(fixture.name, fixture.expected, actual, report);
|
|
68
|
+
}
|
|
69
|
+
aggregateReport.hardFailures.push(...report.hardFailures);
|
|
70
|
+
aggregateReport.warnings.push(...report.warnings);
|
|
71
|
+
assertNoHardFailures(report);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const astrologIt = astrologProbe.enabled && astrologProbe.available ? it : it.skip;
|
|
75
|
+
astrologIt('runs expanded Astrolog parity fixtures when enabled and installed', () => {
|
|
76
|
+
const report = new ValidationReport();
|
|
77
|
+
|
|
78
|
+
for (const fixture of astrologPositionParityFixtures) {
|
|
79
|
+
const internal = adapter.getPositions(fixture.isoUtc, fixture.planetIds);
|
|
80
|
+
const astrolog = getAstrologPositions(fixture.isoUtc, astrologProbe);
|
|
81
|
+
if (!astrolog.ok || !astrolog.positions) {
|
|
82
|
+
report.addWarning({
|
|
83
|
+
fixture: fixture.name,
|
|
84
|
+
subsystem: 'astrolog-positions',
|
|
85
|
+
expected: 'parsed positions',
|
|
86
|
+
actual: astrolog.reason ?? 'unavailable',
|
|
87
|
+
delta: null,
|
|
88
|
+
tolerance: 'n/a',
|
|
89
|
+
message: 'Astrolog parity skipped for this fixture',
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const row of internal) {
|
|
95
|
+
const ext = astrolog.positions.find((p) => p.body === row.body);
|
|
96
|
+
if (!ext) {
|
|
97
|
+
report.addHard({
|
|
98
|
+
fixture: fixture.name,
|
|
99
|
+
subsystem: 'astrolog-positions',
|
|
100
|
+
expected: row.body,
|
|
101
|
+
actual: 'missing',
|
|
102
|
+
delta: null,
|
|
103
|
+
tolerance: 'exact',
|
|
104
|
+
message: `${row.body} missing in Astrolog output`,
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const delta = normalizeLongitudeDelta(row.longitude, ext.longitude);
|
|
109
|
+
if (delta > TOLERANCES.astrologPositionLongitudeDeg) {
|
|
110
|
+
report.addHard({
|
|
111
|
+
fixture: fixture.name,
|
|
112
|
+
subsystem: 'astrolog-positions',
|
|
113
|
+
expected: row.longitude,
|
|
114
|
+
actual: ext.longitude,
|
|
115
|
+
delta,
|
|
116
|
+
tolerance: TOLERANCES.astrologPositionLongitudeDeg,
|
|
117
|
+
message: `${row.body} longitude differs by >${TOLERANCES.astrologPositionLongitudeDeg}° vs Astrolog`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (typeof ext.retrograde === 'boolean' && row.retrograde !== ext.retrograde) {
|
|
121
|
+
report.addHard({
|
|
122
|
+
fixture: fixture.name,
|
|
123
|
+
subsystem: 'astrolog-positions',
|
|
124
|
+
expected: row.retrograde,
|
|
125
|
+
actual: ext.retrograde,
|
|
126
|
+
delta: null,
|
|
127
|
+
tolerance: 'exact',
|
|
128
|
+
message: `${row.body} retrograde flag mismatch vs Astrolog`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const fixture of astrologHouseParityFixtures) {
|
|
135
|
+
const internal = adapter.getHouseResult(
|
|
136
|
+
fixture.isoUtc,
|
|
137
|
+
fixture.latitude,
|
|
138
|
+
fixture.longitude,
|
|
139
|
+
fixture.houseSystem
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (fixture.expectFallbackToWholeSign) {
|
|
143
|
+
if (internal.system !== 'W') {
|
|
144
|
+
report.addHard({
|
|
145
|
+
fixture: fixture.name,
|
|
146
|
+
subsystem: 'astrolog-houses',
|
|
147
|
+
expected: 'W',
|
|
148
|
+
actual: internal.system,
|
|
149
|
+
delta: null,
|
|
150
|
+
tolerance: 'exact',
|
|
151
|
+
message: 'Expected high-latitude fallback to Whole Sign',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const astrolog = getAstrologHouses(
|
|
157
|
+
{
|
|
158
|
+
isoUtc: fixture.isoUtc,
|
|
159
|
+
latitude: fixture.latitude,
|
|
160
|
+
longitude: fixture.longitude,
|
|
161
|
+
houseSystem: fixture.expectFallbackToWholeSign ? 'W' : fixture.houseSystem,
|
|
162
|
+
},
|
|
163
|
+
astrologProbe
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (!astrolog.ok || !astrolog.houses) {
|
|
167
|
+
report.addWarning({
|
|
168
|
+
fixture: fixture.name,
|
|
169
|
+
subsystem: 'astrolog-houses',
|
|
170
|
+
expected: 'parsed houses',
|
|
171
|
+
actual: astrolog.reason ?? 'unavailable',
|
|
172
|
+
delta: null,
|
|
173
|
+
tolerance: 'n/a',
|
|
174
|
+
message: 'Astrolog house parity skipped for this fixture',
|
|
175
|
+
});
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
astrolog.houses.system !== (fixture.expectFallbackToWholeSign ? 'W' : fixture.houseSystem)
|
|
181
|
+
) {
|
|
182
|
+
report.addHard({
|
|
183
|
+
fixture: fixture.name,
|
|
184
|
+
subsystem: 'astrolog-houses',
|
|
185
|
+
expected: fixture.expectFallbackToWholeSign ? 'W' : fixture.houseSystem,
|
|
186
|
+
actual: astrolog.houses.system,
|
|
187
|
+
delta: null,
|
|
188
|
+
tolerance: 'exact',
|
|
189
|
+
message: 'Astrolog reported unexpected house system label',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (let i = 0; i < 12; i++) {
|
|
194
|
+
const delta = normalizeLongitudeDelta(internal.cusps[i], astrolog.houses.cusps[i]);
|
|
195
|
+
if (delta > TOLERANCES.astrologHouseDeg) {
|
|
196
|
+
report.addHard({
|
|
197
|
+
fixture: fixture.name,
|
|
198
|
+
subsystem: 'astrolog-houses',
|
|
199
|
+
expected: internal.cusps[i],
|
|
200
|
+
actual: astrolog.houses.cusps[i],
|
|
201
|
+
delta,
|
|
202
|
+
tolerance: TOLERANCES.astrologHouseDeg,
|
|
203
|
+
message: `House cusp ${i + 1} differs by >${TOLERANCES.astrologHouseDeg}° vs Astrolog`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const effectiveHouseSystem = fixture.expectFallbackToWholeSign ? 'W' : fixture.houseSystem;
|
|
209
|
+
if (effectiveHouseSystem !== 'W') {
|
|
210
|
+
const ascDelta = normalizeLongitudeDelta(internal.ascendant, astrolog.houses.ascendant);
|
|
211
|
+
if (ascDelta > TOLERANCES.astrologHouseDeg) {
|
|
212
|
+
report.addWarning({
|
|
213
|
+
fixture: fixture.name,
|
|
214
|
+
subsystem: 'astrolog-houses',
|
|
215
|
+
expected: internal.ascendant,
|
|
216
|
+
actual: astrolog.houses.ascendant,
|
|
217
|
+
delta: ascDelta,
|
|
218
|
+
tolerance: TOLERANCES.astrologHouseDeg,
|
|
219
|
+
message: 'ASC proxy differs from Astrolog cusp-1 proxy',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const mcDelta = normalizeLongitudeDelta(internal.mc, astrolog.houses.mc);
|
|
224
|
+
if (mcDelta > TOLERANCES.astrologHouseDeg) {
|
|
225
|
+
report.addWarning({
|
|
226
|
+
fixture: fixture.name,
|
|
227
|
+
subsystem: 'astrolog-houses',
|
|
228
|
+
expected: internal.mc,
|
|
229
|
+
actual: astrolog.houses.mc,
|
|
230
|
+
delta: mcDelta,
|
|
231
|
+
tolerance: TOLERANCES.astrologHouseDeg,
|
|
232
|
+
message: 'MC proxy differs from Astrolog cusp-10 proxy',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const fixture of astrologTransitSnapshotFixtures) {
|
|
239
|
+
const planetIds = [
|
|
240
|
+
PLANETS.SUN,
|
|
241
|
+
PLANETS.MOON,
|
|
242
|
+
PLANETS.MERCURY,
|
|
243
|
+
PLANETS.VENUS,
|
|
244
|
+
PLANETS.MARS,
|
|
245
|
+
PLANETS.JUPITER,
|
|
246
|
+
PLANETS.SATURN,
|
|
247
|
+
PLANETS.URANUS,
|
|
248
|
+
PLANETS.NEPTUNE,
|
|
249
|
+
PLANETS.PLUTO,
|
|
250
|
+
];
|
|
251
|
+
const internalPositions = adapter.getPositions(fixture.currentIsoUtc, planetIds);
|
|
252
|
+
const astrolog = getAstrologPositions(fixture.currentIsoUtc, astrologProbe);
|
|
253
|
+
if (!astrolog.ok || !astrolog.positions) {
|
|
254
|
+
report.addWarning({
|
|
255
|
+
fixture: fixture.name,
|
|
256
|
+
subsystem: 'astrolog-transits',
|
|
257
|
+
expected: 'parsed positions',
|
|
258
|
+
actual: astrolog.reason ?? 'unavailable',
|
|
259
|
+
delta: null,
|
|
260
|
+
tolerance: 'n/a',
|
|
261
|
+
message: 'Astrolog transit snapshot skipped for this fixture',
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const row of internalPositions) {
|
|
267
|
+
const ext = astrolog.positions.find((p) => p.body === row.body);
|
|
268
|
+
if (!ext) {
|
|
269
|
+
report.addHard({
|
|
270
|
+
fixture: fixture.name,
|
|
271
|
+
subsystem: 'astrolog-transits',
|
|
272
|
+
expected: row.body,
|
|
273
|
+
actual: 'missing',
|
|
274
|
+
delta: null,
|
|
275
|
+
tolerance: 'exact',
|
|
276
|
+
message: `${row.body} missing in Astrolog output`,
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const delta = normalizeLongitudeDelta(row.longitude, ext.longitude);
|
|
281
|
+
if (delta > TOLERANCES.astrologPositionLongitudeDeg) {
|
|
282
|
+
report.addHard({
|
|
283
|
+
fixture: fixture.name,
|
|
284
|
+
subsystem: 'astrolog-transits',
|
|
285
|
+
expected: row.longitude,
|
|
286
|
+
actual: ext.longitude,
|
|
287
|
+
delta,
|
|
288
|
+
tolerance: TOLERANCES.astrologPositionLongitudeDeg,
|
|
289
|
+
message: `${row.body} transit longitude differs by >${TOLERANCES.astrologPositionLongitudeDeg}°`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const internalTransits = adapter.getTransitsFromOffsets({
|
|
295
|
+
currentIsoUtc: fixture.currentIsoUtc,
|
|
296
|
+
transitingPlanetId: fixture.transitingPlanetId,
|
|
297
|
+
natalPlanetId: fixture.natalPlanetId,
|
|
298
|
+
natalOffsetDegrees: fixture.natalOffsetDegrees,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const currentJD = adapter.ephem.dateToJulianDay(new Date(fixture.currentIsoUtc));
|
|
302
|
+
const transitingName = adapter.ephem.getPlanetPosition(
|
|
303
|
+
fixture.transitingPlanetId,
|
|
304
|
+
currentJD
|
|
305
|
+
).planet;
|
|
306
|
+
const natalName = adapter.ephem.getPlanetPosition(fixture.natalPlanetId, currentJD).planet;
|
|
307
|
+
const hit = findTransit(
|
|
308
|
+
internalTransits,
|
|
309
|
+
transitingName,
|
|
310
|
+
natalName,
|
|
311
|
+
fixture.expectedAspect
|
|
312
|
+
);
|
|
313
|
+
if (!hit) {
|
|
314
|
+
report.addHard({
|
|
315
|
+
fixture: fixture.name,
|
|
316
|
+
subsystem: 'astrolog-transits',
|
|
317
|
+
expected: fixture.expectedAspect,
|
|
318
|
+
actual: 'not found',
|
|
319
|
+
delta: null,
|
|
320
|
+
tolerance: 'exact',
|
|
321
|
+
message: 'Expected transit aspect missing in snapshot',
|
|
322
|
+
});
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (hit.orb > fixture.maxOrb) {
|
|
326
|
+
report.addHard({
|
|
327
|
+
fixture: fixture.name,
|
|
328
|
+
subsystem: 'astrolog-transits',
|
|
329
|
+
expected: `<= ${fixture.maxOrb}`,
|
|
330
|
+
actual: hit.orb,
|
|
331
|
+
delta: null,
|
|
332
|
+
tolerance: 'exact',
|
|
333
|
+
message: 'Transit orb exceeds fixture sanity threshold',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const fixture of astrologEdgeParityFixtures) {
|
|
339
|
+
let isoUtc = fixture.isoUtc;
|
|
340
|
+
if (fixture.local && fixture.timezone) {
|
|
341
|
+
const resolved = localToUTC(
|
|
342
|
+
fixture.local,
|
|
343
|
+
fixture.timezone,
|
|
344
|
+
fixture.disambiguation ?? 'compatible'
|
|
345
|
+
).toISOString();
|
|
346
|
+
if (resolved !== fixture.isoUtc) {
|
|
347
|
+
report.addHard({
|
|
348
|
+
fixture: fixture.name,
|
|
349
|
+
subsystem: 'astrolog-edge',
|
|
350
|
+
expected: fixture.isoUtc,
|
|
351
|
+
actual: resolved,
|
|
352
|
+
delta: null,
|
|
353
|
+
tolerance: 'exact',
|
|
354
|
+
message: 'Resolved UTC does not match fixture canonical UTC',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
isoUtc = resolved;
|
|
358
|
+
}
|
|
359
|
+
const internal = adapter.getPositions(isoUtc, fixture.planetIds);
|
|
360
|
+
const astrolog = getAstrologPositions(isoUtc, astrologProbe);
|
|
361
|
+
if (!astrolog.ok || !astrolog.positions) {
|
|
362
|
+
report.addWarning({
|
|
363
|
+
fixture: fixture.name,
|
|
364
|
+
subsystem: 'astrolog-edge',
|
|
365
|
+
expected: 'parsed positions',
|
|
366
|
+
actual: astrolog.reason ?? 'unavailable',
|
|
367
|
+
delta: null,
|
|
368
|
+
tolerance: 'n/a',
|
|
369
|
+
message: 'Astrolog edge parity skipped for this fixture',
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
for (const row of internal) {
|
|
374
|
+
const ext = astrolog.positions.find((p) => p.body === row.body);
|
|
375
|
+
if (!ext) {
|
|
376
|
+
report.addHard({
|
|
377
|
+
fixture: fixture.name,
|
|
378
|
+
subsystem: 'astrolog-edge',
|
|
379
|
+
expected: row.body,
|
|
380
|
+
actual: 'missing',
|
|
381
|
+
delta: null,
|
|
382
|
+
tolerance: 'exact',
|
|
383
|
+
message: `${row.body} missing in Astrolog output`,
|
|
384
|
+
});
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const delta = normalizeLongitudeDelta(row.longitude, ext.longitude);
|
|
388
|
+
if (delta > TOLERANCES.astrologPositionLongitudeDeg) {
|
|
389
|
+
report.addHard({
|
|
390
|
+
fixture: fixture.name,
|
|
391
|
+
subsystem: 'astrolog-edge',
|
|
392
|
+
expected: row.longitude,
|
|
393
|
+
actual: ext.longitude,
|
|
394
|
+
delta,
|
|
395
|
+
tolerance: TOLERANCES.astrologPositionLongitudeDeg,
|
|
396
|
+
message: `${row.body} edge-case longitude differs by >${TOLERANCES.astrologPositionLongitudeDeg}°`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
aggregateReport.hardFailures.push(...report.hardFailures);
|
|
403
|
+
aggregateReport.warnings.push(...report.warnings);
|
|
404
|
+
assertNoHardFailures(report);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe('B. Houses', () => {
|
|
409
|
+
it('compares house cusps, ASC, and MC with polar fallback semantics', () => {
|
|
410
|
+
const report = new ValidationReport();
|
|
411
|
+
for (const fixture of houseFixtures) {
|
|
412
|
+
const actual = adapter.getHouseResult(
|
|
413
|
+
fixture.isoUtc,
|
|
414
|
+
fixture.latitude,
|
|
415
|
+
fixture.longitude,
|
|
416
|
+
fixture.houseSystem
|
|
417
|
+
);
|
|
418
|
+
compareHouses(fixture.name, fixture.expected, actual, report);
|
|
419
|
+
}
|
|
420
|
+
aggregateReport.hardFailures.push(...report.hardFailures);
|
|
421
|
+
aggregateReport.warnings.push(...report.warnings);
|
|
422
|
+
assertNoHardFailures(report);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('C. Transit detection + statuses', () => {
|
|
427
|
+
it('verifies expected aspects, applying/separating behavior, and exactTimeStatus semantics', () => {
|
|
428
|
+
const report = new ValidationReport();
|
|
429
|
+
for (const fixture of transitFixtures) {
|
|
430
|
+
const transits = adapter.getTransitsFromOffsets({
|
|
431
|
+
currentIsoUtc: fixture.currentIsoUtc,
|
|
432
|
+
transitingPlanetId: fixture.transitingPlanetId,
|
|
433
|
+
natalPlanetId: fixture.natalPlanetId,
|
|
434
|
+
natalOffsetDegrees: fixture.natalOffsetDegrees,
|
|
435
|
+
});
|
|
436
|
+
const transiting = adapter.ephem.getPlanetPosition(
|
|
437
|
+
fixture.transitingPlanetId,
|
|
438
|
+
adapter.ephem.dateToJulianDay(new Date(fixture.currentIsoUtc))
|
|
439
|
+
);
|
|
440
|
+
const natalName = adapter.ephem.getPlanetPosition(
|
|
441
|
+
fixture.natalPlanetId,
|
|
442
|
+
adapter.ephem.dateToJulianDay(new Date(fixture.currentIsoUtc))
|
|
443
|
+
).planet;
|
|
444
|
+
|
|
445
|
+
const hit = findTransit(transits, transiting.planet, natalName, fixture.expectedAspect);
|
|
446
|
+
assertTransitStatus(
|
|
447
|
+
fixture.name,
|
|
448
|
+
hit,
|
|
449
|
+
fixture.expectExactTimeStatus ?? 'undefined',
|
|
450
|
+
report
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
if (
|
|
454
|
+
fixture.expectedIsApplying != null &&
|
|
455
|
+
hit &&
|
|
456
|
+
hit.isApplying !== fixture.expectedIsApplying
|
|
457
|
+
) {
|
|
458
|
+
report.addHard({
|
|
459
|
+
fixture: fixture.name,
|
|
460
|
+
subsystem: 'transits',
|
|
461
|
+
expected: fixture.expectedIsApplying,
|
|
462
|
+
actual: hit.isApplying,
|
|
463
|
+
delta: null,
|
|
464
|
+
tolerance: 'exact',
|
|
465
|
+
message: 'Applying/separating mismatch',
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
aggregateReport.hardFailures.push(...report.hardFailures);
|
|
470
|
+
aggregateReport.warnings.push(...report.warnings);
|
|
471
|
+
assertNoHardFailures(report);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe('D. Exact root solver vs dense oracle', () => {
|
|
476
|
+
it('validates root count/order/timing against independent dense-scan oracle', () => {
|
|
477
|
+
const report = new ValidationReport();
|
|
478
|
+
|
|
479
|
+
for (const fixture of rootFixtures) {
|
|
480
|
+
const startJD = adapter.ephem.dateToJulianDay(new Date(fixture.startIsoUtc));
|
|
481
|
+
const endJD = adapter.ephem.dateToJulianDay(new Date(fixture.endIsoUtc));
|
|
482
|
+
|
|
483
|
+
let targetLongitude = fixture.targetLongitude;
|
|
484
|
+
if (fixture.targetFromStartLongitude) {
|
|
485
|
+
targetLongitude = adapter.ephem.getPlanetPosition(fixture.planetId, startJD).longitude;
|
|
486
|
+
}
|
|
487
|
+
if (fixture.targetFromSampledMinimum) {
|
|
488
|
+
let minLon = Number.POSITIVE_INFINITY;
|
|
489
|
+
const samples = fixture.targetFromSampledMinimum.samples;
|
|
490
|
+
for (let i = 0; i <= samples; i++) {
|
|
491
|
+
const jd = startJD + (i * (endJD - startJD)) / samples;
|
|
492
|
+
const lon = adapter.ephem.getPlanetPosition(fixture.planetId, jd).longitude;
|
|
493
|
+
if (lon < minLon) minLon = lon;
|
|
494
|
+
}
|
|
495
|
+
targetLongitude = minLon;
|
|
496
|
+
}
|
|
497
|
+
if (targetLongitude == null) {
|
|
498
|
+
throw new Error(`Fixture ${fixture.name} must define target longitude strategy`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const productionRoots = adapter.getExactRoots(
|
|
502
|
+
fixture.planetId,
|
|
503
|
+
targetLongitude,
|
|
504
|
+
fixture.startIsoUtc,
|
|
505
|
+
fixture.endIsoUtc
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const oracleResult = denseScanRootOracleWithDebug(
|
|
509
|
+
(jd) => adapter.ephem.getPlanetPosition(fixture.planetId, jd).longitude,
|
|
510
|
+
targetLongitude,
|
|
511
|
+
startJD,
|
|
512
|
+
endJD,
|
|
513
|
+
{
|
|
514
|
+
toIsoUtc: (jd) => adapter.ephem.julianDayToDate(jd).toISOString(),
|
|
515
|
+
}
|
|
516
|
+
);
|
|
517
|
+
const oracleRoots = oracleResult.roots.map((jd) => ({
|
|
518
|
+
jd,
|
|
519
|
+
isoUtc: adapter.ephem.julianDayToDate(jd).toISOString(),
|
|
520
|
+
}));
|
|
521
|
+
|
|
522
|
+
const rootDetails = {
|
|
523
|
+
planetId: fixture.planetId,
|
|
524
|
+
targetLongitude,
|
|
525
|
+
startIsoUtc: fixture.startIsoUtc,
|
|
526
|
+
endIsoUtc: fixture.endIsoUtc,
|
|
527
|
+
startJD,
|
|
528
|
+
endJD,
|
|
529
|
+
toleranceDeg: oracleResult.debug.toleranceDeg,
|
|
530
|
+
sampleStepDays: oracleResult.debug.sampleStepDays,
|
|
531
|
+
dedupeEpsilonDays: oracleResult.debug.dedupeEpsilonDays,
|
|
532
|
+
sanityWarnings: oracleResult.debug.sanityWarnings,
|
|
533
|
+
productionRoots: productionRoots.map((r) => ({
|
|
534
|
+
...r,
|
|
535
|
+
residualAbsDeg: Math.abs(
|
|
536
|
+
shortestDiff(
|
|
537
|
+
adapter.ephem.getPlanetPosition(fixture.planetId, r.jd).longitude,
|
|
538
|
+
targetLongitude
|
|
539
|
+
)
|
|
540
|
+
),
|
|
541
|
+
})),
|
|
542
|
+
oracleRoots: oracleRoots.map((r) => ({
|
|
543
|
+
...r,
|
|
544
|
+
residualAbsDeg: Math.abs(
|
|
545
|
+
shortestDiff(
|
|
546
|
+
adapter.ephem.getPlanetPosition(fixture.planetId, r.jd).longitude,
|
|
547
|
+
targetLongitude
|
|
548
|
+
)
|
|
549
|
+
),
|
|
550
|
+
})),
|
|
551
|
+
sampledTrace: oracleResult.debug.samples,
|
|
552
|
+
crossings: oracleResult.debug.crossings,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
compareRoots(fixture.name, productionRoots, oracleRoots, report, rootDetails);
|
|
556
|
+
|
|
557
|
+
if (fixture.expectedMinRoots != null && productionRoots.length < fixture.expectedMinRoots) {
|
|
558
|
+
report.addHard({
|
|
559
|
+
fixture: fixture.name,
|
|
560
|
+
subsystem: 'roots',
|
|
561
|
+
expected: `>= ${fixture.expectedMinRoots}`,
|
|
562
|
+
actual: productionRoots.length,
|
|
563
|
+
delta: null,
|
|
564
|
+
tolerance: 'exact',
|
|
565
|
+
message: 'Root count below expected minimum',
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (fixture.expectedMaxRoots != null && productionRoots.length > fixture.expectedMaxRoots) {
|
|
570
|
+
report.addHard({
|
|
571
|
+
fixture: fixture.name,
|
|
572
|
+
subsystem: 'roots',
|
|
573
|
+
expected: `<= ${fixture.expectedMaxRoots}`,
|
|
574
|
+
actual: productionRoots.length,
|
|
575
|
+
delta: null,
|
|
576
|
+
tolerance: 'exact',
|
|
577
|
+
message: 'Root count above expected maximum',
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
aggregateReport.hardFailures.push(...report.hardFailures);
|
|
583
|
+
aggregateReport.warnings.push(...report.warnings);
|
|
584
|
+
assertNoHardFailures(report);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe('E. Transit root selection policy', () => {
|
|
589
|
+
it('selects earliest future for applying and latest past for separating', () => {
|
|
590
|
+
const nowIso = '2024-03-15T00:00:00Z';
|
|
591
|
+
const nowJD = adapter.ephem.dateToJulianDay(new Date(nowIso));
|
|
592
|
+
const mars = adapter.ephem.getPlanetPosition(PLANETS.MARS, nowJD);
|
|
593
|
+
|
|
594
|
+
const mkNatal = (offset: number): PlanetPosition => ({
|
|
595
|
+
planetId: PLANETS.VENUS,
|
|
596
|
+
planet: 'Venus',
|
|
597
|
+
longitude: (mars.longitude + offset + 360) % 360,
|
|
598
|
+
latitude: 0,
|
|
599
|
+
distance: 1,
|
|
600
|
+
speed: 1,
|
|
601
|
+
sign: 'Aries',
|
|
602
|
+
degree: 0,
|
|
603
|
+
isRetrograde: false,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const spy = vi
|
|
607
|
+
.spyOn(adapter.ephem, 'findExactTransitTimes')
|
|
608
|
+
.mockReturnValue([nowJD - 2, nowJD + 3]);
|
|
609
|
+
|
|
610
|
+
const applying = adapter
|
|
611
|
+
.getTransits([mars], [mkNatal(92)], nowIso)
|
|
612
|
+
.find((t) => t.aspect === 'square');
|
|
613
|
+
const separating = adapter
|
|
614
|
+
.getTransits([mars], [mkNatal(88)], nowIso)
|
|
615
|
+
.find((t) => t.aspect === 'square');
|
|
616
|
+
spy.mockRestore();
|
|
617
|
+
|
|
618
|
+
expect(applying?.exactTime).toBe(adapter.ephem.julianDayToDate(nowJD + 3).toISOString());
|
|
619
|
+
expect(applying?.isApplying).toBe(true);
|
|
620
|
+
expect(separating?.exactTime).toBe(adapter.ephem.julianDayToDate(nowJD - 2).toISOString());
|
|
621
|
+
expect(separating?.isApplying).toBe(false);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('outside_preview suppresses exactTime while preserving status', () => {
|
|
625
|
+
const nowIso = '2024-03-15T00:00:00Z';
|
|
626
|
+
const nowJD = adapter.ephem.dateToJulianDay(new Date(nowIso));
|
|
627
|
+
const mars = adapter.ephem.getPlanetPosition(PLANETS.MARS, nowJD);
|
|
628
|
+
const natal: PlanetPosition = {
|
|
629
|
+
planetId: PLANETS.VENUS,
|
|
630
|
+
planet: 'Venus',
|
|
631
|
+
longitude: mars.longitude,
|
|
632
|
+
latitude: 0,
|
|
633
|
+
distance: 1,
|
|
634
|
+
speed: 1,
|
|
635
|
+
sign: 'Aries',
|
|
636
|
+
degree: 0,
|
|
637
|
+
isRetrograde: false,
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const spy = vi.spyOn(adapter.ephem, 'findExactTransitTimes').mockReturnValue([nowJD + 120]);
|
|
641
|
+
const transit = adapter
|
|
642
|
+
.getTransits([mars], [natal], nowIso)
|
|
643
|
+
.find((t) => t.aspect === 'conjunction');
|
|
644
|
+
spy.mockRestore();
|
|
645
|
+
|
|
646
|
+
expect(transit?.exactTimeStatus).toBe('outside_preview');
|
|
647
|
+
expect(transit?.exactTime).toBeUndefined();
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('unsupported_body and not_found statuses are surfaced honestly', () => {
|
|
651
|
+
const nowIso = '2024-03-15T00:00:00Z';
|
|
652
|
+
const nowJD = adapter.ephem.dateToJulianDay(new Date(nowIso));
|
|
653
|
+
const mars = adapter.ephem.getPlanetPosition(PLANETS.MARS, nowJD);
|
|
654
|
+
|
|
655
|
+
const natal: PlanetPosition = {
|
|
656
|
+
planetId: PLANETS.VENUS,
|
|
657
|
+
planet: 'Venus',
|
|
658
|
+
longitude: mars.longitude,
|
|
659
|
+
latitude: 0,
|
|
660
|
+
distance: 1,
|
|
661
|
+
speed: 1,
|
|
662
|
+
sign: 'Aries',
|
|
663
|
+
degree: 0,
|
|
664
|
+
isRetrograde: false,
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const unsupportedTransit: PlanetPosition = { ...mars, planetId: 9999 };
|
|
668
|
+
const unsupported = adapter
|
|
669
|
+
.getTransits([unsupportedTransit], [natal], nowIso)
|
|
670
|
+
.find((t) => t.aspect === 'conjunction');
|
|
671
|
+
expect(unsupported?.exactTimeStatus).toBe('unsupported_body');
|
|
672
|
+
|
|
673
|
+
const spy = vi.spyOn(adapter.ephem, 'findExactTransitTimes').mockReturnValue([]);
|
|
674
|
+
const notFound = adapter
|
|
675
|
+
.getTransits([mars], [natal], nowIso)
|
|
676
|
+
.find((t) => t.aspect === 'conjunction');
|
|
677
|
+
spy.mockRestore();
|
|
678
|
+
expect(notFound?.exactTimeStatus).toBe('not_found');
|
|
679
|
+
expect(notFound?.exactTime).toBeUndefined();
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe('F. Rise/set semantics', () => {
|
|
684
|
+
it('treats outputs as next events after instant and handles high-latitude no-event cases', () => {
|
|
685
|
+
if (!adapter.canComputeRiseSet()) {
|
|
686
|
+
aggregateReport.addWarning({
|
|
687
|
+
fixture: 'rise-set-capability',
|
|
688
|
+
subsystem: 'rise-set',
|
|
689
|
+
expected: 'supported Swiss-Eph rise/set functions',
|
|
690
|
+
actual: 'not available in current runtime',
|
|
691
|
+
delta: null,
|
|
692
|
+
tolerance: 'n/a',
|
|
693
|
+
message: 'Rise/set validation skipped',
|
|
694
|
+
capability: 'unavailable',
|
|
695
|
+
validation: 'skipped_intentionally',
|
|
696
|
+
details: {
|
|
697
|
+
missingExports: ['rise_trans'],
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const report = new ValidationReport();
|
|
703
|
+
|
|
704
|
+
// Next-event semantics check for same location with different anchors.
|
|
705
|
+
const laEarly = adapter.getRiseSet('2024-03-26T00:00:00Z', PLANETS.SUN, 34.0522, -118.2437);
|
|
706
|
+
const laLate = adapter.getRiseSet('2024-03-26T20:30:00Z', PLANETS.SUN, 34.0522, -118.2437);
|
|
707
|
+
if (laEarly.rise && laLate.rise) {
|
|
708
|
+
expect(new Date(laLate.rise).getTime()).toBeGreaterThanOrEqual(
|
|
709
|
+
new Date(laEarly.rise).getTime()
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const baseline = adapter.getRiseSet(
|
|
714
|
+
riseSetFixtures[0].isoUtc,
|
|
715
|
+
riseSetFixtures[0].planetId,
|
|
716
|
+
riseSetFixtures[0].latitude,
|
|
717
|
+
riseSetFixtures[0].longitude
|
|
718
|
+
);
|
|
719
|
+
expect(baseline.body).toBe('Sun');
|
|
720
|
+
const eventCount = [
|
|
721
|
+
baseline.rise,
|
|
722
|
+
baseline.set,
|
|
723
|
+
baseline.upperMeridianTransit,
|
|
724
|
+
baseline.lowerMeridianTransit,
|
|
725
|
+
].filter(Boolean).length;
|
|
726
|
+
if (eventCount === 0) {
|
|
727
|
+
report.addHard({
|
|
728
|
+
fixture: riseSetFixtures[0].name,
|
|
729
|
+
subsystem: 'rise-set',
|
|
730
|
+
expected: 'at least one event',
|
|
731
|
+
actual: 0,
|
|
732
|
+
delta: null,
|
|
733
|
+
tolerance: '>= 1',
|
|
734
|
+
message: 'Rise/set smoke check produced zero events',
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const polar = adapter.getRiseSet(
|
|
739
|
+
riseSetFixtures[1].isoUtc,
|
|
740
|
+
riseSetFixtures[1].planetId,
|
|
741
|
+
riseSetFixtures[1].latitude,
|
|
742
|
+
riseSetFixtures[1].longitude
|
|
743
|
+
);
|
|
744
|
+
if (riseSetFixtures[1].expectedNoRiseSet) {
|
|
745
|
+
expect(polar.rise).toBeUndefined();
|
|
746
|
+
expect(polar.set).toBeUndefined();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
aggregateReport.hardFailures.push(...report.hardFailures);
|
|
750
|
+
aggregateReport.warnings.push(...report.warnings);
|
|
751
|
+
assertNoHardFailures(report);
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
describe('G. Eclipses', () => {
|
|
756
|
+
it('validates next eclipse type/subtype/maxTime sanity', () => {
|
|
757
|
+
if (!adapter.canComputeEclipses()) {
|
|
758
|
+
aggregateReport.addWarning({
|
|
759
|
+
fixture: 'eclipse-capability',
|
|
760
|
+
subsystem: 'eclipses',
|
|
761
|
+
expected: 'supported Swiss-Eph eclipse functions',
|
|
762
|
+
actual: 'not available in current runtime',
|
|
763
|
+
delta: null,
|
|
764
|
+
tolerance: 'n/a',
|
|
765
|
+
message: 'Eclipse validation skipped',
|
|
766
|
+
capability: 'unavailable',
|
|
767
|
+
validation: 'skipped_intentionally',
|
|
768
|
+
details: {
|
|
769
|
+
missingExports: ['sol_eclipse_when_glob', 'lun_eclipse_when'],
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const report = new ValidationReport();
|
|
775
|
+
for (const fixture of eclipseFixtures) {
|
|
776
|
+
const actual = adapter.getNextEclipse(fixture.startIsoUtc, fixture.type);
|
|
777
|
+
if (!actual) {
|
|
778
|
+
report.addHard({
|
|
779
|
+
fixture: fixture.name,
|
|
780
|
+
subsystem: 'eclipses',
|
|
781
|
+
expected: fixture.type,
|
|
782
|
+
actual: null,
|
|
783
|
+
delta: null,
|
|
784
|
+
tolerance: 'non-null',
|
|
785
|
+
message: 'Eclipse smoke check returned null',
|
|
786
|
+
});
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (actual.type !== fixture.type) {
|
|
790
|
+
report.addHard({
|
|
791
|
+
fixture: fixture.name,
|
|
792
|
+
subsystem: 'eclipses',
|
|
793
|
+
expected: fixture.type,
|
|
794
|
+
actual: actual.type,
|
|
795
|
+
delta: null,
|
|
796
|
+
tolerance: 'exact',
|
|
797
|
+
message: 'Eclipse type mismatch',
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
if (!actual.eclipseType?.trim()) {
|
|
801
|
+
report.addHard({
|
|
802
|
+
fixture: fixture.name,
|
|
803
|
+
subsystem: 'eclipses',
|
|
804
|
+
expected: 'non-empty eclipse subtype',
|
|
805
|
+
actual: actual.eclipseType,
|
|
806
|
+
delta: null,
|
|
807
|
+
tolerance: 'exact',
|
|
808
|
+
message: 'Eclipse subtype is missing',
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (Number.isNaN(new Date(actual.maxTime).getTime())) {
|
|
812
|
+
report.addHard({
|
|
813
|
+
fixture: fixture.name,
|
|
814
|
+
subsystem: 'eclipses',
|
|
815
|
+
expected: 'valid ISO datetime',
|
|
816
|
+
actual: actual.maxTime,
|
|
817
|
+
delta: null,
|
|
818
|
+
tolerance: 'exact',
|
|
819
|
+
message: 'Eclipse maxTime is not a valid ISO datetime',
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
aggregateReport.hardFailures.push(...report.hardFailures);
|
|
824
|
+
aggregateReport.warnings.push(...report.warnings);
|
|
825
|
+
assertNoHardFailures(report);
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
describe('DST scenario fixtures', () => {
|
|
830
|
+
it('covers ambiguous and nonexistent local times under reject policy', () => {
|
|
831
|
+
for (const fixture of dstFixtures) {
|
|
832
|
+
expect(() => localToUTC(fixture.local, fixture.timezone, 'reject')).toThrow();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
});
|