ether-to-astro 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/dist/astro-service/chart-output-service.d.ts +44 -0
- package/dist/astro-service/chart-output-service.js +110 -0
- package/dist/astro-service/date-input.d.ts +14 -0
- package/dist/astro-service/date-input.js +30 -0
- package/dist/astro-service/electional-service.d.ts +45 -0
- package/dist/astro-service/electional-service.js +305 -0
- package/dist/astro-service/natal-service.d.ts +41 -0
- package/dist/astro-service/natal-service.js +179 -0
- package/dist/astro-service/rising-sign-service.d.ts +37 -0
- package/dist/astro-service/rising-sign-service.js +137 -0
- package/dist/astro-service/service-types.d.ts +82 -0
- package/dist/astro-service/service-types.js +1 -0
- package/dist/astro-service/shared.d.ts +65 -0
- package/dist/astro-service/shared.js +98 -0
- package/dist/astro-service/sky-service.d.ts +48 -0
- package/dist/astro-service/sky-service.js +144 -0
- package/dist/astro-service/transit-service.d.ts +82 -0
- package/dist/astro-service/transit-service.js +353 -0
- package/dist/astro-service.d.ts +101 -89
- package/dist/astro-service.js +162 -1042
- package/dist/tool-registry.js +1 -1
- package/docs/product/architecture-boundaries.md +8 -0
- package/docs/releases/1.3.0.md +51 -0
- package/docs/releases/README.md +17 -0
- package/package.json +4 -1
- package/src/astro-service/chart-output-service.ts +155 -0
- package/src/astro-service/date-input.ts +40 -0
- package/src/astro-service/electional-service.ts +395 -0
- package/src/astro-service/natal-service.ts +235 -0
- package/src/astro-service/rising-sign-service.ts +181 -0
- package/src/astro-service/service-types.ts +90 -0
- package/src/astro-service/shared.ts +128 -0
- package/src/astro-service/sky-service.ts +191 -0
- package/src/astro-service/transit-service.ts +507 -0
- package/src/astro-service.ts +177 -1386
- package/src/tool-registry.ts +1 -1
- package/tests/README.md +15 -0
- package/tests/property/electional-service.property.test.ts +67 -0
- package/tests/property/helpers/arbitraries.ts +126 -0
- package/tests/property/helpers/config.ts +52 -0
- package/tests/property/helpers/runtime.ts +12 -0
- package/tests/property/houses.property.test.ts +74 -0
- package/tests/property/rising-sign-service.property.test.ts +255 -0
- package/tests/property/service-transits.property.test.ts +154 -0
- package/tests/property/time-utils.property.test.ts +91 -0
- package/tests/property/transits.property.test.ts +113 -0
- package/tests/unit/astro-service/chart-output-service.test.ts +102 -0
- package/tests/unit/astro-service/electional-service.test.ts +182 -0
- package/tests/unit/astro-service/natal-service.test.ts +126 -0
- package/tests/unit/astro-service/rising-sign-service.test.ts +145 -0
- package/tests/unit/astro-service/sky-service.test.ts +130 -0
- package/tests/unit/astro-service/transit-service.test.ts +312 -0
- package/tests/unit/astro-service.test.ts +136 -781
- package/tests/unit/rising-sign-windows.test.ts +93 -0
- package/tests/unit/tool-registry.test.ts +11 -0
- package/tests/validation/README.md +14 -0
- package/tests/validation/adapters/internal.ts +234 -4
- package/tests/validation/compare/electional.ts +151 -0
- package/tests/validation/compare/rising-sign-windows.ts +347 -0
- package/tests/validation/compare/service-transits.ts +205 -0
- package/tests/validation/fixtures/electional/core.ts +88 -0
- package/tests/validation/fixtures/rising-sign-windows/core.ts +57 -0
- package/tests/validation/fixtures/service-transits/core.ts +89 -0
- package/tests/validation/utils/fixtureTypes.ts +139 -1
- package/tests/validation/validation.spec.ts +82 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addLocalDays,
|
|
3
|
+
formatLocalTimestampWithOffset,
|
|
4
|
+
localToUTC,
|
|
5
|
+
} from '../../../src/time-utils.js';
|
|
6
|
+
import type { InternalValidationAdapter } from '../adapters/internal.js';
|
|
7
|
+
import type {
|
|
8
|
+
NormalizedRisingSignWindowResult,
|
|
9
|
+
RisingSignModeComparisonFixture,
|
|
10
|
+
RisingSignWindowsFixture,
|
|
11
|
+
} from '../utils/fixtureTypes.js';
|
|
12
|
+
import type { ValidationReport } from '../utils/report.js';
|
|
13
|
+
|
|
14
|
+
function getOffsetSuffix(timestamp: string): string {
|
|
15
|
+
return timestamp.slice(-6);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function collapseConsecutiveWindows(result: NormalizedRisingSignWindowResult) {
|
|
19
|
+
const collapsed: NormalizedRisingSignWindowResult['windows'] = [];
|
|
20
|
+
|
|
21
|
+
for (const window of result.windows) {
|
|
22
|
+
const previous = collapsed[collapsed.length - 1];
|
|
23
|
+
if (!previous || previous.sign !== window.sign) {
|
|
24
|
+
collapsed.push({ ...window });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
previous.end = window.end;
|
|
29
|
+
previous.durationMs += window.durationMs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return collapsed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findSubsequenceIndices(
|
|
36
|
+
sequence: string[],
|
|
37
|
+
candidateSubsequence: string[]
|
|
38
|
+
): number[] | null {
|
|
39
|
+
const indices: number[] = [];
|
|
40
|
+
let cursor = 0;
|
|
41
|
+
|
|
42
|
+
for (const sign of candidateSubsequence) {
|
|
43
|
+
while (cursor < sequence.length && sequence[cursor] !== sign) {
|
|
44
|
+
cursor += 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (cursor >= sequence.length) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
indices.push(cursor);
|
|
52
|
+
cursor += 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return indices;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function compareRisingSignWindows(
|
|
59
|
+
fixture: RisingSignWindowsFixture,
|
|
60
|
+
actual: NormalizedRisingSignWindowResult,
|
|
61
|
+
repeated: NormalizedRisingSignWindowResult,
|
|
62
|
+
report: ValidationReport
|
|
63
|
+
): void {
|
|
64
|
+
const dayStartUtc = localToUTC(
|
|
65
|
+
{
|
|
66
|
+
year: Number(fixture.input.date.slice(0, 4)),
|
|
67
|
+
month: Number(fixture.input.date.slice(5, 7)),
|
|
68
|
+
day: Number(fixture.input.date.slice(8, 10)),
|
|
69
|
+
hour: 0,
|
|
70
|
+
minute: 0,
|
|
71
|
+
second: 0,
|
|
72
|
+
},
|
|
73
|
+
fixture.input.timezone
|
|
74
|
+
);
|
|
75
|
+
const dayEndUtc = addLocalDays(
|
|
76
|
+
{
|
|
77
|
+
year: Number(fixture.input.date.slice(0, 4)),
|
|
78
|
+
month: Number(fixture.input.date.slice(5, 7)),
|
|
79
|
+
day: Number(fixture.input.date.slice(8, 10)),
|
|
80
|
+
hour: 0,
|
|
81
|
+
minute: 0,
|
|
82
|
+
second: 0,
|
|
83
|
+
},
|
|
84
|
+
fixture.input.timezone,
|
|
85
|
+
1
|
|
86
|
+
);
|
|
87
|
+
const expectedStart = formatLocalTimestampWithOffset(dayStartUtc, fixture.input.timezone);
|
|
88
|
+
const expectedEnd = formatLocalTimestampWithOffset(dayEndUtc, fixture.input.timezone);
|
|
89
|
+
|
|
90
|
+
if (JSON.stringify(actual) !== JSON.stringify(repeated)) {
|
|
91
|
+
report.addHard({
|
|
92
|
+
fixture: fixture.name,
|
|
93
|
+
subsystem: 'rising-sign-windows',
|
|
94
|
+
expected: actual,
|
|
95
|
+
actual: repeated,
|
|
96
|
+
delta: null,
|
|
97
|
+
tolerance: 'exact repeatability',
|
|
98
|
+
message: 'Rising-sign windows are not deterministic across repeated calls',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (actual.windows.length < (fixture.minWindows ?? 1)) {
|
|
103
|
+
report.addHard({
|
|
104
|
+
fixture: fixture.name,
|
|
105
|
+
subsystem: 'rising-sign-windows',
|
|
106
|
+
expected: `>= ${fixture.minWindows ?? 1}`,
|
|
107
|
+
actual: actual.windows.length,
|
|
108
|
+
delta: null,
|
|
109
|
+
tolerance: 'exact',
|
|
110
|
+
message: 'Window count below expected minimum',
|
|
111
|
+
details: actual,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (actual.windows[0]?.start !== expectedStart) {
|
|
116
|
+
report.addHard({
|
|
117
|
+
fixture: fixture.name,
|
|
118
|
+
subsystem: 'rising-sign-windows',
|
|
119
|
+
expected: expectedStart,
|
|
120
|
+
actual: actual.windows[0]?.start,
|
|
121
|
+
delta: null,
|
|
122
|
+
tolerance: 'exact',
|
|
123
|
+
message: 'First window does not start at local midnight',
|
|
124
|
+
details: actual,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (actual.windows.at(-1)?.end !== expectedEnd) {
|
|
129
|
+
report.addHard({
|
|
130
|
+
fixture: fixture.name,
|
|
131
|
+
subsystem: 'rising-sign-windows',
|
|
132
|
+
expected: expectedEnd,
|
|
133
|
+
actual: actual.windows.at(-1)?.end,
|
|
134
|
+
delta: null,
|
|
135
|
+
tolerance: 'exact',
|
|
136
|
+
message: 'Last window does not end at the next local midnight',
|
|
137
|
+
details: actual,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const offsets = new Set<string>();
|
|
142
|
+
|
|
143
|
+
for (const [index, window] of actual.windows.entries()) {
|
|
144
|
+
offsets.add(getOffsetSuffix(window.start));
|
|
145
|
+
offsets.add(getOffsetSuffix(window.end));
|
|
146
|
+
|
|
147
|
+
if (!Number.isFinite(window.durationMs)) {
|
|
148
|
+
report.addHard({
|
|
149
|
+
fixture: fixture.name,
|
|
150
|
+
subsystem: 'rising-sign-windows',
|
|
151
|
+
expected: 'finite number',
|
|
152
|
+
actual: window.durationMs,
|
|
153
|
+
delta: null,
|
|
154
|
+
tolerance: 'exact',
|
|
155
|
+
message: `Window ${index + 1} is missing a valid durationMs`,
|
|
156
|
+
details: window,
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (window.durationMs <= 0) {
|
|
162
|
+
report.addHard({
|
|
163
|
+
fixture: fixture.name,
|
|
164
|
+
subsystem: 'rising-sign-windows',
|
|
165
|
+
expected: '> 0',
|
|
166
|
+
actual: window.durationMs,
|
|
167
|
+
delta: null,
|
|
168
|
+
tolerance: 'exact',
|
|
169
|
+
message: `Window ${index + 1} has non-positive durationMs`,
|
|
170
|
+
details: window,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const startMs = new Date(window.start).getTime();
|
|
175
|
+
const endMs = new Date(window.end).getTime();
|
|
176
|
+
if (endMs <= startMs) {
|
|
177
|
+
report.addHard({
|
|
178
|
+
fixture: fixture.name,
|
|
179
|
+
subsystem: 'rising-sign-windows',
|
|
180
|
+
expected: 'end > start',
|
|
181
|
+
actual: { start: window.start, end: window.end },
|
|
182
|
+
delta: endMs - startMs,
|
|
183
|
+
tolerance: '> 0',
|
|
184
|
+
message: `Window ${index + 1} does not move forward in time`,
|
|
185
|
+
details: window,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (index > 0 && actual.windows[index - 1].end !== window.start) {
|
|
190
|
+
report.addHard({
|
|
191
|
+
fixture: fixture.name,
|
|
192
|
+
subsystem: 'rising-sign-windows',
|
|
193
|
+
expected: actual.windows[index - 1].end,
|
|
194
|
+
actual: window.start,
|
|
195
|
+
delta: null,
|
|
196
|
+
tolerance: 'exact adjacency',
|
|
197
|
+
message: `Window ${index} and ${index + 1} are not contiguous`,
|
|
198
|
+
details: {
|
|
199
|
+
previous: actual.windows[index - 1],
|
|
200
|
+
current: window,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const firstStartMs = new Date(actual.windows[0]?.start ?? expectedStart).getTime();
|
|
207
|
+
const lastEndMs = new Date(actual.windows.at(-1)?.end ?? expectedEnd).getTime();
|
|
208
|
+
const totalCoverageMinutes = Math.round((lastEndMs - firstStartMs) / 60000);
|
|
209
|
+
|
|
210
|
+
if (totalCoverageMinutes !== fixture.expectedTotalDurationMinutes) {
|
|
211
|
+
report.addHard({
|
|
212
|
+
fixture: fixture.name,
|
|
213
|
+
subsystem: 'rising-sign-windows',
|
|
214
|
+
expected: fixture.expectedTotalDurationMinutes,
|
|
215
|
+
actual: totalCoverageMinutes,
|
|
216
|
+
delta: totalCoverageMinutes - fixture.expectedTotalDurationMinutes,
|
|
217
|
+
tolerance: 'exact total minutes',
|
|
218
|
+
message: 'Window boundary timestamps do not cover the intended local day',
|
|
219
|
+
details: actual,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (fixture.expectOffsetChange && offsets.size < 2) {
|
|
224
|
+
report.addHard({
|
|
225
|
+
fixture: fixture.name,
|
|
226
|
+
subsystem: 'rising-sign-windows',
|
|
227
|
+
expected: 'multiple UTC offsets across the local day',
|
|
228
|
+
actual: Array.from(offsets),
|
|
229
|
+
delta: null,
|
|
230
|
+
tolerance: '>= 2 offsets',
|
|
231
|
+
message: 'DST-transition day did not serialize an offset change',
|
|
232
|
+
details: actual,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function compareRisingSignModePrecision(
|
|
238
|
+
fixture: RisingSignModeComparisonFixture,
|
|
239
|
+
approximate: NormalizedRisingSignWindowResult,
|
|
240
|
+
exact: NormalizedRisingSignWindowResult,
|
|
241
|
+
adapter: InternalValidationAdapter,
|
|
242
|
+
report: ValidationReport
|
|
243
|
+
): void {
|
|
244
|
+
const collapsedApproximate = collapseConsecutiveWindows(approximate);
|
|
245
|
+
const collapsedExact = collapseConsecutiveWindows(exact);
|
|
246
|
+
const approximateSigns = collapsedApproximate.map((window) => window.sign);
|
|
247
|
+
const exactSigns = collapsedExact.map((window) => window.sign);
|
|
248
|
+
const matchingIndices = findSubsequenceIndices(exactSigns, approximateSigns);
|
|
249
|
+
|
|
250
|
+
if (matchingIndices === null) {
|
|
251
|
+
report.addHard({
|
|
252
|
+
fixture: fixture.name,
|
|
253
|
+
subsystem: 'rising-sign-windows',
|
|
254
|
+
expected: approximateSigns,
|
|
255
|
+
actual: exactSigns,
|
|
256
|
+
delta: null,
|
|
257
|
+
tolerance: 'approximate sequence must be preserved within exact results',
|
|
258
|
+
message: 'Exact mode changed the coarse sign progression for the same day',
|
|
259
|
+
details: { approximate, exact },
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (let index = 0; index < collapsedApproximate.length - 1; index++) {
|
|
265
|
+
const exactFromIndex = matchingIndices[index];
|
|
266
|
+
const exactToIndex = matchingIndices[index + 1];
|
|
267
|
+
const exactBoundary = collapsedExact[exactFromIndex];
|
|
268
|
+
if (exactToIndex !== exactFromIndex + 1) {
|
|
269
|
+
report.addHard({
|
|
270
|
+
fixture: fixture.name,
|
|
271
|
+
subsystem: 'rising-sign-windows',
|
|
272
|
+
expected: 'adjacent exact transition for coarse approximate boundary',
|
|
273
|
+
actual: collapsedExact.slice(exactFromIndex, exactToIndex + 1).map((window) => window.sign),
|
|
274
|
+
delta: exactToIndex - exactFromIndex - 1,
|
|
275
|
+
tolerance: '0 inserted intermediate signs',
|
|
276
|
+
message:
|
|
277
|
+
'Exact mode inserted intermediate sign windows where approximate mode reported a single boundary',
|
|
278
|
+
details: {
|
|
279
|
+
approximateBoundary: {
|
|
280
|
+
leftSign: collapsedApproximate[index].sign,
|
|
281
|
+
rightSign: collapsedApproximate[index + 1].sign,
|
|
282
|
+
end: collapsedApproximate[index].end,
|
|
283
|
+
},
|
|
284
|
+
exactSegment: collapsedExact.slice(exactFromIndex, exactToIndex + 1),
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const approximateBoundaryMs = new Date(collapsedApproximate[index].end).getTime();
|
|
290
|
+
const exactBoundaryMs = new Date(exactBoundary.end).getTime();
|
|
291
|
+
const leftSign = collapsedApproximate[index].sign;
|
|
292
|
+
const rightSign = collapsedApproximate[index + 1].sign;
|
|
293
|
+
const searchStartMs = Math.min(approximateBoundaryMs, exactBoundaryMs) - 60 * 60 * 1000;
|
|
294
|
+
const searchEndMs = Math.max(approximateBoundaryMs, exactBoundaryMs) + 60 * 60 * 1000;
|
|
295
|
+
|
|
296
|
+
let actualTransitionMs: number | null = null;
|
|
297
|
+
for (let probeMs = searchStartMs; probeMs <= searchEndMs; probeMs += 60 * 1000) {
|
|
298
|
+
const currentSign = adapter.getAscendantSignAt(
|
|
299
|
+
new Date(probeMs).toISOString(),
|
|
300
|
+
fixture.baseInput.latitude,
|
|
301
|
+
fixture.baseInput.longitude
|
|
302
|
+
);
|
|
303
|
+
if (currentSign !== leftSign) {
|
|
304
|
+
actualTransitionMs = probeMs;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (actualTransitionMs === null) {
|
|
310
|
+
report.addHard({
|
|
311
|
+
fixture: fixture.name,
|
|
312
|
+
subsystem: 'rising-sign-windows',
|
|
313
|
+
expected: rightSign,
|
|
314
|
+
actual: 'no transition found in search window',
|
|
315
|
+
delta: null,
|
|
316
|
+
tolerance: 'transition must exist',
|
|
317
|
+
message: 'Unable to locate a real sign transition near the reported boundary',
|
|
318
|
+
details: {
|
|
319
|
+
leftSign,
|
|
320
|
+
rightSign,
|
|
321
|
+
approximateBoundary: collapsedApproximate[index].end,
|
|
322
|
+
exactBoundary: exactBoundary.end,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const approxErrorMs = Math.abs(approximateBoundaryMs - actualTransitionMs);
|
|
329
|
+
const exactErrorMs = Math.abs(exactBoundaryMs - actualTransitionMs);
|
|
330
|
+
if (exactErrorMs > approxErrorMs) {
|
|
331
|
+
report.addHard({
|
|
332
|
+
fixture: fixture.name,
|
|
333
|
+
subsystem: 'rising-sign-windows',
|
|
334
|
+
expected: `exact <= approximate (${approxErrorMs}ms)`,
|
|
335
|
+
actual: `${exactErrorMs}ms`,
|
|
336
|
+
delta: exactErrorMs - approxErrorMs,
|
|
337
|
+
tolerance: 'exact must be at least as precise',
|
|
338
|
+
message: 'Exact mode boundary was less precise than approximate mode',
|
|
339
|
+
details: {
|
|
340
|
+
actualTransitionIsoUtc: new Date(actualTransitionMs).toISOString(),
|
|
341
|
+
approximateBoundary: collapsedApproximate[index].end,
|
|
342
|
+
exactBoundary: exactBoundary.end,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NormalizedServiceTransit,
|
|
3
|
+
NormalizedServiceTransitResult,
|
|
4
|
+
ServiceTransitFixture,
|
|
5
|
+
} from '../utils/fixtureTypes.js';
|
|
6
|
+
import type { ValidationReport } from '../utils/report.js';
|
|
7
|
+
|
|
8
|
+
function findServiceTransit(
|
|
9
|
+
transits: NormalizedServiceTransit[],
|
|
10
|
+
expected: ServiceTransitFixture['expected']['expectTransit']
|
|
11
|
+
): NormalizedServiceTransit | undefined {
|
|
12
|
+
return transits.find(
|
|
13
|
+
(transit) =>
|
|
14
|
+
transit.transitingPlanet === expected.transitingPlanet &&
|
|
15
|
+
transit.natalPlanet === expected.natalPlanet &&
|
|
16
|
+
transit.aspect === expected.aspect
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function compareServiceTransitFixture(
|
|
21
|
+
fixture: ServiceTransitFixture,
|
|
22
|
+
actual: NormalizedServiceTransitResult,
|
|
23
|
+
report: ValidationReport
|
|
24
|
+
): void {
|
|
25
|
+
if (actual.mode !== fixture.expected.mode) {
|
|
26
|
+
report.addHard({
|
|
27
|
+
fixture: fixture.name,
|
|
28
|
+
subsystem: 'service-transits',
|
|
29
|
+
expected: fixture.expected.mode,
|
|
30
|
+
actual: actual.mode,
|
|
31
|
+
delta: null,
|
|
32
|
+
tolerance: 'exact',
|
|
33
|
+
message: 'Transit response mode mismatch',
|
|
34
|
+
details: actual,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (actual.timezone !== fixture.expected.timezone) {
|
|
39
|
+
report.addHard({
|
|
40
|
+
fixture: fixture.name,
|
|
41
|
+
subsystem: 'service-transits',
|
|
42
|
+
expected: fixture.expected.timezone,
|
|
43
|
+
actual: actual.timezone,
|
|
44
|
+
delta: null,
|
|
45
|
+
tolerance: 'exact',
|
|
46
|
+
message: 'Reporting timezone label mismatch',
|
|
47
|
+
details: actual,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (actual.calculationTimezone !== fixture.expected.calculationTimezone) {
|
|
52
|
+
report.addHard({
|
|
53
|
+
fixture: fixture.name,
|
|
54
|
+
subsystem: 'service-transits',
|
|
55
|
+
expected: fixture.expected.calculationTimezone,
|
|
56
|
+
actual: actual.calculationTimezone,
|
|
57
|
+
delta: null,
|
|
58
|
+
tolerance: 'exact',
|
|
59
|
+
message: 'Calculation timezone mismatch',
|
|
60
|
+
details: actual,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (actual.reportingTimezone !== fixture.expected.reportingTimezone) {
|
|
65
|
+
report.addHard({
|
|
66
|
+
fixture: fixture.name,
|
|
67
|
+
subsystem: 'service-transits',
|
|
68
|
+
expected: fixture.expected.reportingTimezone,
|
|
69
|
+
actual: actual.reportingTimezone,
|
|
70
|
+
delta: null,
|
|
71
|
+
tolerance: 'exact',
|
|
72
|
+
message: 'Explicit reporting_timezone field mismatch',
|
|
73
|
+
details: actual,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (fixture.expected.windowStart && actual.windowStart !== fixture.expected.windowStart) {
|
|
78
|
+
report.addHard({
|
|
79
|
+
fixture: fixture.name,
|
|
80
|
+
subsystem: 'service-transits',
|
|
81
|
+
expected: fixture.expected.windowStart,
|
|
82
|
+
actual: actual.windowStart,
|
|
83
|
+
delta: null,
|
|
84
|
+
tolerance: 'exact',
|
|
85
|
+
message: 'window_start mismatch',
|
|
86
|
+
details: actual,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (fixture.expected.windowEnd && actual.windowEnd !== fixture.expected.windowEnd) {
|
|
91
|
+
report.addHard({
|
|
92
|
+
fixture: fixture.name,
|
|
93
|
+
subsystem: 'service-transits',
|
|
94
|
+
expected: fixture.expected.windowEnd,
|
|
95
|
+
actual: actual.windowEnd,
|
|
96
|
+
delta: null,
|
|
97
|
+
tolerance: 'exact',
|
|
98
|
+
message: 'window_end mismatch',
|
|
99
|
+
details: actual,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (fixture.expected.forecastDays !== undefined) {
|
|
104
|
+
if (!actual.forecast) {
|
|
105
|
+
report.addHard({
|
|
106
|
+
fixture: fixture.name,
|
|
107
|
+
subsystem: 'service-transits',
|
|
108
|
+
expected: fixture.expected.forecastDays,
|
|
109
|
+
actual: 'missing forecast payload',
|
|
110
|
+
delta: null,
|
|
111
|
+
tolerance: 'exact',
|
|
112
|
+
message: 'Forecast response omitted forecast day groups',
|
|
113
|
+
details: actual,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (actual.forecast.length !== fixture.expected.forecastDays) {
|
|
119
|
+
report.addHard({
|
|
120
|
+
fixture: fixture.name,
|
|
121
|
+
subsystem: 'service-transits',
|
|
122
|
+
expected: fixture.expected.forecastDays,
|
|
123
|
+
actual: actual.forecast.length,
|
|
124
|
+
delta: actual.forecast.length - fixture.expected.forecastDays,
|
|
125
|
+
tolerance: 'exact',
|
|
126
|
+
message: 'Forecast day-group count mismatch',
|
|
127
|
+
details: actual,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const [index, day] of actual.forecast.entries()) {
|
|
132
|
+
if (!Array.isArray(day.transits)) {
|
|
133
|
+
report.addHard({
|
|
134
|
+
fixture: fixture.name,
|
|
135
|
+
subsystem: 'service-transits',
|
|
136
|
+
expected: 'array',
|
|
137
|
+
actual: typeof day.transits,
|
|
138
|
+
delta: null,
|
|
139
|
+
tolerance: 'exact',
|
|
140
|
+
message: `Forecast day ${index + 1} is missing a transits array`,
|
|
141
|
+
details: day,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const searchableTransits = actual.forecast
|
|
148
|
+
? actual.forecast.flatMap((day) => day.transits)
|
|
149
|
+
: (actual.transits ?? []);
|
|
150
|
+
const hit = findServiceTransit(searchableTransits, fixture.expected.expectTransit);
|
|
151
|
+
if (!hit) {
|
|
152
|
+
report.addHard({
|
|
153
|
+
fixture: fixture.name,
|
|
154
|
+
subsystem: 'service-transits',
|
|
155
|
+
expected: fixture.expected.expectTransit,
|
|
156
|
+
actual: searchableTransits,
|
|
157
|
+
delta: null,
|
|
158
|
+
tolerance: 'matching transit must exist',
|
|
159
|
+
message: 'Expected serialized service transit was not found',
|
|
160
|
+
details: actual,
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const enrichedFields = [
|
|
166
|
+
hit.transitSign,
|
|
167
|
+
hit.transitDegree,
|
|
168
|
+
hit.transitHouse,
|
|
169
|
+
hit.natalSign,
|
|
170
|
+
hit.natalDegree,
|
|
171
|
+
hit.natalHouse,
|
|
172
|
+
];
|
|
173
|
+
if (enrichedFields.some((field) => field === undefined)) {
|
|
174
|
+
report.addHard({
|
|
175
|
+
fixture: fixture.name,
|
|
176
|
+
subsystem: 'service-transits',
|
|
177
|
+
expected: 'all enriched placement fields defined',
|
|
178
|
+
actual: hit,
|
|
179
|
+
delta: null,
|
|
180
|
+
tolerance: 'exact',
|
|
181
|
+
message: 'Serialized service transit is missing enriched placement fields',
|
|
182
|
+
details: actual,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const [key, expectedValue] of Object.entries(fixture.expected.expectTransit)) {
|
|
187
|
+
if (expectedValue === undefined) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const actualValue = hit[key as keyof NormalizedServiceTransit];
|
|
192
|
+
if (actualValue !== expectedValue) {
|
|
193
|
+
report.addHard({
|
|
194
|
+
fixture: fixture.name,
|
|
195
|
+
subsystem: 'service-transits',
|
|
196
|
+
expected: expectedValue,
|
|
197
|
+
actual: actualValue,
|
|
198
|
+
delta: null,
|
|
199
|
+
tolerance: 'exact',
|
|
200
|
+
message: `Serialized transit field ${key} mismatch`,
|
|
201
|
+
details: hit,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ElectionalFixture } from '../../utils/fixtureTypes.js';
|
|
2
|
+
|
|
3
|
+
export const electionalFixtures: ElectionalFixture[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'san-francisco-clear-day',
|
|
6
|
+
input: {
|
|
7
|
+
date: '2026-03-28',
|
|
8
|
+
time: '13:00',
|
|
9
|
+
timezone: 'America/Los_Angeles',
|
|
10
|
+
latitude: 37.7749,
|
|
11
|
+
longitude: -122.4194,
|
|
12
|
+
include_ruler_basics: true,
|
|
13
|
+
},
|
|
14
|
+
expected: {
|
|
15
|
+
classification: 'day',
|
|
16
|
+
isDayChart: true,
|
|
17
|
+
houseSystem: 'P',
|
|
18
|
+
rawSunAltitudeSign: 'positive',
|
|
19
|
+
hasApplyingAspects: true,
|
|
20
|
+
hasMoonApplyingAspects: true,
|
|
21
|
+
hasRulerBasics: true,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'san-francisco-clear-night',
|
|
26
|
+
input: {
|
|
27
|
+
date: '2026-03-28',
|
|
28
|
+
time: '23:00',
|
|
29
|
+
timezone: 'America/Los_Angeles',
|
|
30
|
+
latitude: 37.7749,
|
|
31
|
+
longitude: -122.4194,
|
|
32
|
+
},
|
|
33
|
+
expected: {
|
|
34
|
+
classification: 'night',
|
|
35
|
+
isDayChart: false,
|
|
36
|
+
houseSystem: 'P',
|
|
37
|
+
rawSunAltitudeSign: 'negative',
|
|
38
|
+
hasApplyingAspects: true,
|
|
39
|
+
hasMoonApplyingAspects: true,
|
|
40
|
+
hasRulerBasics: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'san-francisco-horizon-boundary-negative-rounds-to-zero',
|
|
45
|
+
input: {
|
|
46
|
+
date: '2026-03-28',
|
|
47
|
+
time: '07:04:57',
|
|
48
|
+
timezone: 'America/Los_Angeles',
|
|
49
|
+
latitude: 37.7749,
|
|
50
|
+
longitude: -122.4194,
|
|
51
|
+
},
|
|
52
|
+
expected: {
|
|
53
|
+
classification: 'night',
|
|
54
|
+
isDayChart: false,
|
|
55
|
+
rawSunAltitudeSign: 'negative',
|
|
56
|
+
sunAltitudeDisplaysZero: true,
|
|
57
|
+
warningsContain: [
|
|
58
|
+
'Sun is near the horizon; day/night classification is close to the boundary.',
|
|
59
|
+
],
|
|
60
|
+
hasApplyingAspects: true,
|
|
61
|
+
hasMoonApplyingAspects: true,
|
|
62
|
+
hasRulerBasics: false,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'tromso-house-fallback-and-omitted-optionals',
|
|
67
|
+
input: {
|
|
68
|
+
date: '2026-06-21',
|
|
69
|
+
time: '12:00',
|
|
70
|
+
timezone: 'Europe/Oslo',
|
|
71
|
+
latitude: 69.6492,
|
|
72
|
+
longitude: 18.9553,
|
|
73
|
+
house_system: 'P',
|
|
74
|
+
include_planetary_applications: false,
|
|
75
|
+
include_ruler_basics: false,
|
|
76
|
+
},
|
|
77
|
+
expected: {
|
|
78
|
+
classification: 'day',
|
|
79
|
+
isDayChart: true,
|
|
80
|
+
houseSystem: 'W',
|
|
81
|
+
rawSunAltitudeSign: 'positive',
|
|
82
|
+
warningsContain: ['House calculation fell back from P to W for this location.'],
|
|
83
|
+
hasApplyingAspects: false,
|
|
84
|
+
hasMoonApplyingAspects: false,
|
|
85
|
+
hasRulerBasics: false,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RisingSignModeComparisonFixture,
|
|
3
|
+
RisingSignWindowsFixture,
|
|
4
|
+
} from '../../utils/fixtureTypes.js';
|
|
5
|
+
|
|
6
|
+
export const risingSignWindowFixtures: RisingSignWindowsFixture[] = [
|
|
7
|
+
{
|
|
8
|
+
name: 'new-york-ordinary-day-exact',
|
|
9
|
+
input: {
|
|
10
|
+
date: '2026-03-28',
|
|
11
|
+
latitude: 40.7128,
|
|
12
|
+
longitude: -74.006,
|
|
13
|
+
timezone: 'America/New_York',
|
|
14
|
+
mode: 'exact',
|
|
15
|
+
},
|
|
16
|
+
expectedTotalDurationMinutes: 1440,
|
|
17
|
+
minWindows: 4,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'los-angeles-spring-forward-day-exact',
|
|
21
|
+
input: {
|
|
22
|
+
date: '2026-03-08',
|
|
23
|
+
latitude: 34.0522,
|
|
24
|
+
longitude: -118.2437,
|
|
25
|
+
timezone: 'America/Los_Angeles',
|
|
26
|
+
mode: 'exact',
|
|
27
|
+
},
|
|
28
|
+
expectedTotalDurationMinutes: 1380,
|
|
29
|
+
minWindows: 4,
|
|
30
|
+
expectOffsetChange: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'los-angeles-fall-back-day-exact',
|
|
34
|
+
input: {
|
|
35
|
+
date: '2026-11-01',
|
|
36
|
+
latitude: 34.0522,
|
|
37
|
+
longitude: -118.2437,
|
|
38
|
+
timezone: 'America/Los_Angeles',
|
|
39
|
+
mode: 'exact',
|
|
40
|
+
},
|
|
41
|
+
expectedTotalDurationMinutes: 1500,
|
|
42
|
+
minWindows: 4,
|
|
43
|
+
expectOffsetChange: true,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export const risingSignModeComparisonFixtures: RisingSignModeComparisonFixture[] = [
|
|
48
|
+
{
|
|
49
|
+
name: 'new-york-ordinary-day-mode-precision',
|
|
50
|
+
baseInput: {
|
|
51
|
+
date: '2026-03-28',
|
|
52
|
+
latitude: 40.7128,
|
|
53
|
+
longitude: -74.006,
|
|
54
|
+
timezone: 'America/New_York',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
];
|