ether-to-astro 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +15 -5
  2. package/dist/astro-service/chart-output-service.d.ts +44 -0
  3. package/dist/astro-service/chart-output-service.js +110 -0
  4. package/dist/astro-service/date-input.d.ts +14 -0
  5. package/dist/astro-service/date-input.js +30 -0
  6. package/dist/astro-service/electional-service.d.ts +45 -0
  7. package/dist/astro-service/electional-service.js +305 -0
  8. package/dist/astro-service/natal-service.d.ts +41 -0
  9. package/dist/astro-service/natal-service.js +179 -0
  10. package/dist/astro-service/rising-sign-service.d.ts +37 -0
  11. package/dist/astro-service/rising-sign-service.js +137 -0
  12. package/dist/astro-service/service-types.d.ts +82 -0
  13. package/dist/astro-service/service-types.js +1 -0
  14. package/dist/astro-service/shared.d.ts +65 -0
  15. package/dist/astro-service/shared.js +98 -0
  16. package/dist/astro-service/sky-service.d.ts +48 -0
  17. package/dist/astro-service/sky-service.js +144 -0
  18. package/dist/astro-service/transit-service.d.ts +82 -0
  19. package/dist/astro-service/transit-service.js +353 -0
  20. package/dist/astro-service.d.ts +101 -89
  21. package/dist/astro-service.js +162 -1042
  22. package/dist/tool-registry.js +1 -1
  23. package/docs/product/architecture-boundaries.md +8 -0
  24. package/docs/releases/1.3.0.md +51 -0
  25. package/docs/releases/README.md +17 -0
  26. package/package.json +4 -1
  27. package/src/astro-service/chart-output-service.ts +155 -0
  28. package/src/astro-service/date-input.ts +40 -0
  29. package/src/astro-service/electional-service.ts +395 -0
  30. package/src/astro-service/natal-service.ts +235 -0
  31. package/src/astro-service/rising-sign-service.ts +181 -0
  32. package/src/astro-service/service-types.ts +90 -0
  33. package/src/astro-service/shared.ts +128 -0
  34. package/src/astro-service/sky-service.ts +191 -0
  35. package/src/astro-service/transit-service.ts +507 -0
  36. package/src/astro-service.ts +177 -1386
  37. package/src/tool-registry.ts +1 -1
  38. package/tests/README.md +15 -0
  39. package/tests/property/electional-service.property.test.ts +67 -0
  40. package/tests/property/helpers/arbitraries.ts +126 -0
  41. package/tests/property/helpers/config.ts +52 -0
  42. package/tests/property/helpers/runtime.ts +12 -0
  43. package/tests/property/houses.property.test.ts +74 -0
  44. package/tests/property/rising-sign-service.property.test.ts +255 -0
  45. package/tests/property/service-transits.property.test.ts +154 -0
  46. package/tests/property/time-utils.property.test.ts +91 -0
  47. package/tests/property/transits.property.test.ts +113 -0
  48. package/tests/unit/astro-service/chart-output-service.test.ts +102 -0
  49. package/tests/unit/astro-service/electional-service.test.ts +182 -0
  50. package/tests/unit/astro-service/natal-service.test.ts +126 -0
  51. package/tests/unit/astro-service/rising-sign-service.test.ts +145 -0
  52. package/tests/unit/astro-service/sky-service.test.ts +130 -0
  53. package/tests/unit/astro-service/transit-service.test.ts +312 -0
  54. package/tests/unit/astro-service.test.ts +136 -781
  55. package/tests/unit/rising-sign-windows.test.ts +93 -0
  56. package/tests/unit/tool-registry.test.ts +11 -0
  57. package/tests/validation/README.md +14 -0
  58. package/tests/validation/adapters/internal.ts +234 -4
  59. package/tests/validation/compare/electional.ts +151 -0
  60. package/tests/validation/compare/rising-sign-windows.ts +347 -0
  61. package/tests/validation/compare/service-transits.ts +205 -0
  62. package/tests/validation/fixtures/electional/core.ts +88 -0
  63. package/tests/validation/fixtures/rising-sign-windows/core.ts +57 -0
  64. package/tests/validation/fixtures/service-transits/core.ts +89 -0
  65. package/tests/validation/utils/fixtureTypes.ts +139 -1
  66. package/tests/validation/validation.spec.ts +82 -0
@@ -0,0 +1,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
+ ];