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
@@ -218,7 +218,7 @@ export const MCP_TOOL_SPECS: ToolSpec[] = [
218
218
  include_mundane: {
219
219
  type: 'boolean',
220
220
  description:
221
- 'Include current planetary positions (not transits to natal chart). Defaults to false.',
221
+ 'Include deterministic mundane baseline data for the requested window. Output includes planetary positions, transit-to-transit mundane aspects, and non-narrative weather grouping metadata; forecast windows also include per-day mundane.days entries. Defaults to false.',
222
222
  },
223
223
  days_ahead: {
224
224
  type: 'number',
package/tests/README.md CHANGED
@@ -13,6 +13,7 @@ tests/
13
13
  │ ├── ephemeris/transits/* # Core astrology math and solver behavior
14
14
  │ ├── riseset/eclipses/* # Event calculators and edge flags
15
15
  │ └── charts/houses/* # Rendering and house-system behavior
16
+ ├── property/ # `fast-check` invariant coverage and shrinkable counterexamples
16
17
  ├── helpers/ # Reusable test helpers/builders
17
18
  ├── fixtures/ # Test data and fixtures
18
19
  │ ├── bowen-yang-chart.ts # Bowen Yang's birth chart
@@ -32,6 +33,9 @@ npm test
32
33
  # Run tests with coverage
33
34
  npm run test:coverage
34
35
 
36
+ # Run property tests
37
+ npm run test:property
38
+
35
39
  # Run tests with UI
36
40
  npm run test:ui
37
41
 
@@ -74,12 +78,23 @@ describe('When an AI asks "What transits is Bowen experiencing today?"', () => {
74
78
  2. **Orchestration lane:** Fast deterministic tests with injected mocks/fakes
75
79
  3. **Filesystem/profile lane:** Temp-fs integration style for precedence and parsing
76
80
  4. **Validation lane:** Cross-check production outputs with oracle comparators
81
+ 5. **Property lane:** Generated invariant checks with `fast-check`
82
+
83
+ ### Property Lane
84
+ - Property tests live under `tests/property/`.
85
+ - Seeded reruns are supported via:
86
+ - `ASTRO_PROPERTY_SEED`
87
+ - `ASTRO_PROPERTY_RUNS`
88
+ - `ASTRO_PROPERTY_HEAVY_RUNS`
89
+ - This lane is additive and intentionally separate from `quality:gate` for now.
90
+ - Use it for invariants, determinism, and shrinkable counterexamples rather than external parity.
77
91
 
78
92
  ## Current Status
79
93
 
80
94
  ✅ **Completed:**
81
95
  - Unit suites for service, CLI, registry, domain calculators, and profile store
82
96
  - Validation harness with subsystem comparators and dense root oracle
97
+ - Property-test lane for generated invariants across time utils, services, houses, and transits
83
98
  - Deterministic time setup and fixture-driven real-world chart checks
84
99
 
85
100
  ## Known Issues
@@ -0,0 +1,67 @@
1
+ import fc from 'fast-check';
2
+ import { beforeAll, describe, expect, it } from 'vitest';
3
+ import type { GetElectionalContextInput } from '../../src/astro-service/service-types.js';
4
+ import type { InternalValidationAdapter } from '../validation/adapters/internal.js';
5
+ import {
6
+ dateOnlyArb,
7
+ electionalHouseSystemArb,
8
+ longitudeArb,
9
+ nonPolarLatitudeArb,
10
+ polarLatitudeArb,
11
+ timezoneArb,
12
+ } from './helpers/arbitraries.js';
13
+ import { propertyConfig } from './helpers/config.js';
14
+ import { getInternalValidationAdapter } from './helpers/runtime.js';
15
+
16
+ const SUPPORTED_ELECTIONAL_SYSTEMS = ['P', 'K', 'W', 'R'] as const;
17
+
18
+ describe('Property: electional service', () => {
19
+ let adapter: InternalValidationAdapter;
20
+
21
+ beforeAll(async () => {
22
+ adapter = await getInternalValidationAdapter();
23
+ });
24
+
25
+ it('keeps sect classification, warnings, and optional fields aligned with the contract', async () => {
26
+ const electionalInputArb = fc.record({
27
+ date: dateOnlyArb,
28
+ time: fc.constantFrom('00:00', '06:00', '12:00', '18:00', '23:30'),
29
+ timezone: timezoneArb,
30
+ latitude: fc.oneof(nonPolarLatitudeArb, polarLatitudeArb),
31
+ longitude: longitudeArb,
32
+ house_system: electionalHouseSystemArb,
33
+ include_ruler_basics: fc.boolean(),
34
+ include_planetary_applications: fc.boolean(),
35
+ orb_degrees: fc.double({
36
+ min: 0.5,
37
+ max: 5,
38
+ noNaN: true,
39
+ noDefaultInfinity: true,
40
+ }),
41
+ });
42
+
43
+ await fc.assert(
44
+ fc.property(electionalInputArb, (input) => {
45
+ const result = adapter.getElectionalContext(input satisfies GetElectionalContextInput);
46
+
47
+ expect(result.classification).toBe(result.rawSunAltitudeDegrees >= 0 ? 'day' : 'night');
48
+ expect(result.isDayChart).toBe(result.rawSunAltitudeDegrees >= 0);
49
+ expect(SUPPORTED_ELECTIONAL_SYSTEMS).toContain(result.houseSystem);
50
+
51
+ const horizonWarning = result.warnings.some((warning) => warning.includes('near the horizon'));
52
+ expect(horizonWarning).toBe(Math.abs(result.rawSunAltitudeDegrees) < 0.5);
53
+
54
+ const fallbackWarning = result.warnings.some((warning) =>
55
+ warning.includes('House calculation fell back')
56
+ );
57
+ expect(fallbackWarning).toBe(result.houseSystem !== input.house_system);
58
+
59
+ expect(result.hasApplyingAspects).toBe(input.include_planetary_applications);
60
+ expect(result.hasMoonApplyingAspects).toBe(input.include_planetary_applications);
61
+ expect(result.hasRulerBasics).toBe(input.include_ruler_basics);
62
+ }),
63
+ propertyConfig({ heavy: true })
64
+ );
65
+ });
66
+ });
67
+
@@ -0,0 +1,126 @@
1
+ import fc from 'fast-check';
2
+ import { utcToLocal } from '../../../src/time-utils.js';
3
+ import type { ElectionalHouseSystem, HouseSystem } from '../../../src/types.js';
4
+
5
+ export const REPRESENTATIVE_TIMEZONES = [
6
+ 'UTC',
7
+ 'America/Los_Angeles',
8
+ 'America/New_York',
9
+ 'Europe/London',
10
+ 'Europe/Berlin',
11
+ 'Asia/Tokyo',
12
+ 'Asia/Kolkata',
13
+ 'Asia/Kathmandu',
14
+ 'Australia/Sydney',
15
+ 'Pacific/Auckland',
16
+ 'Pacific/Chatham',
17
+ ] as const;
18
+
19
+ export const NON_HOUR_OFFSET_TIMEZONES = [
20
+ 'Asia/Kolkata',
21
+ 'Asia/Kathmandu',
22
+ 'Pacific/Chatham',
23
+ ] as const;
24
+
25
+ export const SUPPORTED_HOUSE_SYSTEMS = [
26
+ 'P',
27
+ 'K',
28
+ 'W',
29
+ 'E',
30
+ 'O',
31
+ 'R',
32
+ 'C',
33
+ 'A',
34
+ 'V',
35
+ 'X',
36
+ 'H',
37
+ 'T',
38
+ 'B',
39
+ ] as const satisfies readonly HouseSystem[];
40
+
41
+ export const ELECTIONAL_HOUSE_SYSTEMS = [
42
+ 'P',
43
+ 'K',
44
+ 'W',
45
+ 'R',
46
+ ] as const satisfies readonly ElectionalHouseSystem[];
47
+
48
+ const MIN_UTC_DATE = new Date('2000-01-01T00:00:00Z');
49
+ const MAX_UTC_DATE = new Date('2035-12-31T23:59:59Z');
50
+
51
+ export const timezoneArb = fc.constantFrom(...REPRESENTATIVE_TIMEZONES);
52
+ export const nonHourTimezoneArb = fc.constantFrom(...NON_HOUR_OFFSET_TIMEZONES);
53
+ export const utcDateArb = fc
54
+ .date({ min: MIN_UTC_DATE, max: MAX_UTC_DATE })
55
+ .filter((date) => Number.isFinite(date.getTime()));
56
+ export const dateOnlyArb = utcDateArb.map(formatDateOnly);
57
+ export const minuteArb = fc.integer({ min: 0, max: 59 });
58
+ export const secondArb = fc.integer({ min: 0, max: 59 });
59
+ export const nonPolarLatitudeArb = fc.double({
60
+ min: -65,
61
+ max: 65,
62
+ noNaN: true,
63
+ noDefaultInfinity: true,
64
+ });
65
+ export const polarLatitudeArb = fc.oneof(
66
+ fc.double({ min: 66.1, max: 80, noNaN: true, noDefaultInfinity: true }),
67
+ fc.double({ min: -80, max: -66.1, noNaN: true, noDefaultInfinity: true })
68
+ );
69
+ export const longitudeArb = fc.double({
70
+ min: -180,
71
+ max: 180,
72
+ noNaN: true,
73
+ noDefaultInfinity: true,
74
+ });
75
+ export const houseSystemArb = fc.constantFrom(...SUPPORTED_HOUSE_SYSTEMS);
76
+ export const electionalHouseSystemArb = fc.constantFrom(...ELECTIONAL_HOUSE_SYSTEMS);
77
+
78
+ export const validLocalDateTimeArb = timezoneArb.chain((timezone) =>
79
+ fc
80
+ .record({
81
+ date: utcDateArb,
82
+ minute: minuteArb,
83
+ second: secondArb,
84
+ })
85
+ .map(({ date, minute, second }) => {
86
+ const localDate = utcToLocal(date, timezone);
87
+ return {
88
+ timezone,
89
+ local: {
90
+ year: localDate.year,
91
+ month: localDate.month,
92
+ day: localDate.day,
93
+ hour: 12,
94
+ minute,
95
+ second,
96
+ },
97
+ };
98
+ })
99
+ );
100
+
101
+ export const nonHourLocalDateTimeArb = nonHourTimezoneArb.chain((timezone) =>
102
+ fc
103
+ .record({
104
+ date: utcDateArb,
105
+ minute: minuteArb,
106
+ second: secondArb,
107
+ })
108
+ .map(({ date, minute, second }) => {
109
+ const localDate = utcToLocal(date, timezone);
110
+ return {
111
+ timezone,
112
+ local: {
113
+ year: localDate.year,
114
+ month: localDate.month,
115
+ day: localDate.day,
116
+ hour: 12,
117
+ minute,
118
+ second,
119
+ },
120
+ };
121
+ })
122
+ );
123
+
124
+ export function formatDateOnly(date: Date): string {
125
+ return date.toISOString().slice(0, 10);
126
+ }
@@ -0,0 +1,52 @@
1
+ const DEFAULT_PROPERTY_RUNS = 100;
2
+ const DEFAULT_HEAVY_PROPERTY_RUNS = 25;
3
+
4
+ function readPositiveIntegerEnv(name: string, fallback: number): number {
5
+ const raw = process.env[name];
6
+ if (!raw) {
7
+ return fallback;
8
+ }
9
+
10
+ const parsed = Number.parseInt(raw, 10);
11
+ if (!Number.isFinite(parsed) || parsed <= 0) {
12
+ throw new Error(`${name} must be a positive integer when provided.`);
13
+ }
14
+
15
+ return parsed;
16
+ }
17
+
18
+ function readOptionalIntegerEnv(name: string): number | undefined {
19
+ const raw = process.env[name];
20
+ if (!raw) {
21
+ return undefined;
22
+ }
23
+
24
+ const parsed = Number.parseInt(raw, 10);
25
+ if (!Number.isFinite(parsed) || parsed < 0) {
26
+ throw new Error(`${name} must be a non-negative integer when provided.`);
27
+ }
28
+
29
+ return parsed;
30
+ }
31
+
32
+ const sharedSeed = readOptionalIntegerEnv('ASTRO_PROPERTY_SEED');
33
+ const defaultRuns = readPositiveIntegerEnv('ASTRO_PROPERTY_RUNS', DEFAULT_PROPERTY_RUNS);
34
+ const defaultHeavyRuns = readPositiveIntegerEnv(
35
+ 'ASTRO_PROPERTY_HEAVY_RUNS',
36
+ DEFAULT_HEAVY_PROPERTY_RUNS
37
+ );
38
+
39
+ /**
40
+ * Standard `fast-check` execution parameters for this repo's property lane.
41
+ *
42
+ * @remarks
43
+ * All failures should remain reproducible by rerunning with the reported seed,
44
+ * or by overriding `ASTRO_PROPERTY_SEED` and the run-count env vars locally.
45
+ */
46
+ export function propertyConfig(options: { heavy?: boolean } = {}) {
47
+ return {
48
+ numRuns: options.heavy ? defaultHeavyRuns : defaultRuns,
49
+ ...(sharedSeed !== undefined ? { seed: sharedSeed } : {}),
50
+ verbose: 1 as const,
51
+ };
52
+ }
@@ -0,0 +1,12 @@
1
+ import { InternalValidationAdapter } from '../../validation/adapters/internal.js';
2
+
3
+ let sharedAdapterPromise: Promise<InternalValidationAdapter> | undefined;
4
+
5
+ /**
6
+ * Reuse a single initialized adapter per property-test worker so ephemeris
7
+ * startup cost does not scale with generated cases.
8
+ */
9
+ export function getInternalValidationAdapter(): Promise<InternalValidationAdapter> {
10
+ sharedAdapterPromise ??= InternalValidationAdapter.create();
11
+ return sharedAdapterPromise;
12
+ }
@@ -0,0 +1,74 @@
1
+ import fc from 'fast-check';
2
+ import { beforeAll, describe, expect, it } from 'vitest';
3
+ import type { InternalValidationAdapter } from '../validation/adapters/internal.js';
4
+ import {
5
+ dateOnlyArb,
6
+ houseSystemArb,
7
+ longitudeArb,
8
+ nonPolarLatitudeArb,
9
+ polarLatitudeArb,
10
+ } from './helpers/arbitraries.js';
11
+ import { propertyConfig } from './helpers/config.js';
12
+ import { getInternalValidationAdapter } from './helpers/runtime.js';
13
+
14
+ function toIsoAtNoon(date: string): string {
15
+ return `${date}T12:00:00Z`;
16
+ }
17
+
18
+ function angleDeltaDegrees(left: number, right: number): number {
19
+ return (right - left + 360) % 360;
20
+ }
21
+
22
+ describe('Property: houses', () => {
23
+ let adapter: InternalValidationAdapter;
24
+
25
+ beforeAll(async () => {
26
+ adapter = await getInternalValidationAdapter();
27
+ });
28
+
29
+ it('keeps house outputs inside valid longitude ranges', async () => {
30
+ await fc.assert(
31
+ fc.property(dateOnlyArb, nonPolarLatitudeArb, longitudeArb, houseSystemArb, (date, latitude, longitude, houseSystem) => {
32
+ const result = adapter.getHouseResult(toIsoAtNoon(date), latitude, longitude, houseSystem);
33
+
34
+ expect(result.cusps).toHaveLength(12);
35
+ for (const value of [...result.cusps, result.ascendant, result.mc]) {
36
+ expect(value).toBeGreaterThanOrEqual(0);
37
+ expect(value).toBeLessThan(360);
38
+ }
39
+ }),
40
+ propertyConfig({ heavy: true })
41
+ );
42
+ });
43
+
44
+ it('keeps Whole Sign and Equal cusps spaced by 30 degrees', async () => {
45
+ await fc.assert(
46
+ fc.property(dateOnlyArb, nonPolarLatitudeArb, longitudeArb, fc.constantFrom('W' as const, 'E' as const), (date, latitude, longitude, houseSystem) => {
47
+ const result = adapter.getHouseResult(toIsoAtNoon(date), latitude, longitude, houseSystem);
48
+
49
+ for (let index = 0; index < result.cusps.length; index += 1) {
50
+ const current = result.cusps[index];
51
+ const next = result.cusps[(index + 1) % result.cusps.length];
52
+ expect(Math.abs(angleDeltaDegrees(current, next) - 30)).toBeLessThan(0.05);
53
+ }
54
+ }),
55
+ propertyConfig({ heavy: true })
56
+ );
57
+ });
58
+
59
+ it('only falls back to Whole Sign in polar edge cases', async () => {
60
+ await fc.assert(
61
+ fc.property(dateOnlyArb, polarLatitudeArb, longitudeArb, houseSystemArb, (date, latitude, longitude, houseSystem) => {
62
+ const result = adapter.getHouseResult(toIsoAtNoon(date), latitude, longitude, houseSystem);
63
+
64
+ if (houseSystem === 'W') {
65
+ expect(result.system).toBe('W');
66
+ } else if (result.system !== houseSystem) {
67
+ expect(result.system).toBe('W');
68
+ }
69
+ }),
70
+ propertyConfig({ heavy: true })
71
+ );
72
+ });
73
+ });
74
+
@@ -0,0 +1,255 @@
1
+ import fc from 'fast-check';
2
+ import { beforeAll, describe, expect, it } from 'vitest';
3
+ import {
4
+ addLocalDays,
5
+ formatLocalTimestampWithOffset,
6
+ localToUTC,
7
+ } from '../../src/time-utils.js';
8
+ import type { GetRisingSignWindowsInput } from '../../src/astro-service/service-types.js';
9
+ import type { InternalValidationAdapter } from '../validation/adapters/internal.js';
10
+ import type { NormalizedRisingSignWindowResult } from '../validation/utils/fixtureTypes.js';
11
+ import {
12
+ dateOnlyArb,
13
+ longitudeArb,
14
+ nonPolarLatitudeArb,
15
+ timezoneArb,
16
+ } from './helpers/arbitraries.js';
17
+ import { propertyConfig } from './helpers/config.js';
18
+ import { getInternalValidationAdapter } from './helpers/runtime.js';
19
+
20
+ const DST_FIXTURES: Array<Omit<GetRisingSignWindowsInput, 'mode'>> = [
21
+ {
22
+ date: '2024-03-10',
23
+ latitude: 34.0522,
24
+ longitude: -118.2437,
25
+ timezone: 'America/Los_Angeles',
26
+ },
27
+ {
28
+ date: '2024-11-03',
29
+ latitude: 34.0522,
30
+ longitude: -118.2437,
31
+ timezone: 'America/Los_Angeles',
32
+ },
33
+ {
34
+ date: '2024-03-31',
35
+ latitude: 51.5074,
36
+ longitude: -0.1278,
37
+ timezone: 'Europe/London',
38
+ },
39
+ ] as const;
40
+
41
+ function assertFullDayCoverage(
42
+ result: NormalizedRisingSignWindowResult,
43
+ input: Pick<GetRisingSignWindowsInput, 'date' | 'timezone'>
44
+ ): void {
45
+ expect(result.windows.length).toBeGreaterThan(0);
46
+
47
+ const [firstWindow] = result.windows;
48
+ const lastWindow = result.windows[result.windows.length - 1];
49
+ const expectedStart = formatLocalTimestampWithOffset(
50
+ localToUTC(
51
+ {
52
+ year: Number.parseInt(input.date.slice(0, 4), 10),
53
+ month: Number.parseInt(input.date.slice(5, 7), 10),
54
+ day: Number.parseInt(input.date.slice(8, 10), 10),
55
+ hour: 0,
56
+ minute: 0,
57
+ second: 0,
58
+ },
59
+ input.timezone
60
+ ),
61
+ input.timezone
62
+ );
63
+ const expectedEnd = formatLocalTimestampWithOffset(
64
+ addLocalDays(
65
+ {
66
+ year: Number.parseInt(input.date.slice(0, 4), 10),
67
+ month: Number.parseInt(input.date.slice(5, 7), 10),
68
+ day: Number.parseInt(input.date.slice(8, 10), 10),
69
+ hour: 0,
70
+ minute: 0,
71
+ second: 0,
72
+ },
73
+ input.timezone,
74
+ 1
75
+ ),
76
+ input.timezone
77
+ );
78
+
79
+ expect(firstWindow.start).toBe(expectedStart);
80
+ expect(lastWindow.end).toBe(expectedEnd);
81
+
82
+ for (const [index, window] of result.windows.entries()) {
83
+ const startMs = new Date(window.start).getTime();
84
+ const endMs = new Date(window.end).getTime();
85
+
86
+ expect(window.start).toMatch(/[+-]\d{2}:\d{2}$/);
87
+ expect(window.end).toMatch(/[+-]\d{2}:\d{2}$/);
88
+ expect(endMs).toBeGreaterThan(startMs);
89
+ expect(window.durationMs).toBe(endMs - startMs);
90
+
91
+ if (index > 0) {
92
+ expect(window.start).toBe(result.windows[index - 1].end);
93
+ }
94
+ }
95
+ }
96
+
97
+ function estimateBoundaryErrorMs(
98
+ adapter: InternalValidationAdapter,
99
+ boundaryUtc: Date,
100
+ latitude: number,
101
+ longitude: number,
102
+ fromSign: string
103
+ ): number {
104
+ const searchRadiusMs = 2 * 60 * 60 * 1000;
105
+ const stepMs = 60 * 1000;
106
+ let previousTime = new Date(boundaryUtc.getTime() - searchRadiusMs);
107
+ let previousSign = adapter.getAscendantSignAt(previousTime.toISOString(), latitude, longitude);
108
+
109
+ for (
110
+ let currentMs = previousTime.getTime() + stepMs;
111
+ currentMs <= boundaryUtc.getTime() + searchRadiusMs;
112
+ currentMs += stepMs
113
+ ) {
114
+ const currentTime = new Date(currentMs);
115
+ const currentSign = adapter.getAscendantSignAt(currentTime.toISOString(), latitude, longitude);
116
+
117
+ if (previousSign === fromSign && currentSign !== fromSign) {
118
+ return Math.abs(boundaryUtc.getTime() - currentMs);
119
+ }
120
+
121
+ previousTime = currentTime;
122
+ previousSign = currentSign;
123
+ }
124
+
125
+ throw new Error(`Unable to bracket rising-sign transition near ${boundaryUtc.toISOString()}.`);
126
+ }
127
+
128
+ function collapseConsecutiveWindows(result: NormalizedRisingSignWindowResult) {
129
+ const collapsed: NormalizedRisingSignWindowResult['windows'] = [];
130
+
131
+ for (const window of result.windows) {
132
+ const previous = collapsed[collapsed.length - 1];
133
+ if (!previous || previous.sign !== window.sign) {
134
+ collapsed.push({ ...window });
135
+ continue;
136
+ }
137
+
138
+ previous.end = window.end;
139
+ previous.durationMs += window.durationMs;
140
+ }
141
+
142
+ return collapsed;
143
+ }
144
+
145
+ function findSubsequenceIndices(sequence: string[], candidateSubsequence: string[]): number[] | null {
146
+ const indices: number[] = [];
147
+ let cursor = 0;
148
+
149
+ for (const sign of candidateSubsequence) {
150
+ while (cursor < sequence.length && sequence[cursor] !== sign) {
151
+ cursor += 1;
152
+ }
153
+
154
+ if (cursor >= sequence.length) {
155
+ return null;
156
+ }
157
+
158
+ indices.push(cursor);
159
+ cursor += 1;
160
+ }
161
+
162
+ return indices;
163
+ }
164
+
165
+ describe('Property: rising-sign service', () => {
166
+ let adapter: InternalValidationAdapter;
167
+
168
+ beforeAll(async () => {
169
+ adapter = await getInternalValidationAdapter();
170
+ });
171
+
172
+ it('covers a full local day with contiguous exact-mode windows', async () => {
173
+ const exactInputArb = fc.record({
174
+ date: dateOnlyArb,
175
+ latitude: nonPolarLatitudeArb,
176
+ longitude: longitudeArb,
177
+ timezone: timezoneArb,
178
+ });
179
+
180
+ await fc.assert(
181
+ fc.property(exactInputArb, (baseInput) => {
182
+ const input: GetRisingSignWindowsInput = { ...baseInput, mode: 'exact' };
183
+ const first = adapter.getRisingSignWindows(input);
184
+ const second = adapter.getRisingSignWindows(input);
185
+
186
+ expect(second).toEqual(first);
187
+ assertFullDayCoverage(first, input);
188
+ }),
189
+ propertyConfig({ heavy: true })
190
+ );
191
+ });
192
+
193
+ it('keeps exact-mode boundaries at least as precise as approximate mode', async () => {
194
+ const comparisonInputArb = fc.record({
195
+ date: dateOnlyArb,
196
+ latitude: nonPolarLatitudeArb,
197
+ longitude: longitudeArb,
198
+ timezone: timezoneArb,
199
+ });
200
+
201
+ await fc.assert(
202
+ fc.property(comparisonInputArb, (baseInput) => {
203
+ const approximate = adapter.getRisingSignWindows({ ...baseInput, mode: 'approximate' });
204
+ const exact = adapter.getRisingSignWindows({ ...baseInput, mode: 'exact' });
205
+ const collapsedApproximate = collapseConsecutiveWindows(approximate);
206
+ const collapsedExact = collapseConsecutiveWindows(exact);
207
+ const exactSigns = collapsedExact.map((window) => window.sign);
208
+ const approximateSigns = collapsedApproximate.map((window) => window.sign);
209
+ const matchingIndices = findSubsequenceIndices(exactSigns, approximateSigns);
210
+
211
+ expect(matchingIndices).not.toBeNull();
212
+
213
+ for (let index = 0; index < collapsedApproximate.length - 1; index += 1) {
214
+ const exactFromIndex = matchingIndices![index];
215
+ const fromSign = collapsedApproximate[index].sign;
216
+ const exactBoundary = new Date(collapsedExact[exactFromIndex].end);
217
+ const approximateBoundary = new Date(collapsedApproximate[index].end);
218
+
219
+ const exactError = estimateBoundaryErrorMs(
220
+ adapter,
221
+ exactBoundary,
222
+ baseInput.latitude,
223
+ baseInput.longitude,
224
+ fromSign
225
+ );
226
+ const approximateError = estimateBoundaryErrorMs(
227
+ adapter,
228
+ approximateBoundary,
229
+ baseInput.latitude,
230
+ baseInput.longitude,
231
+ fromSign
232
+ );
233
+
234
+ expect(exactError).toBeLessThanOrEqual(approximateError + 60_000);
235
+ }
236
+ }),
237
+ propertyConfig({ heavy: true })
238
+ );
239
+ });
240
+
241
+ it('keeps DST-transition days contiguous and fully covered', async () => {
242
+ await fc.assert(
243
+ fc.property(fc.constantFrom(...DST_FIXTURES), (baseInput) => {
244
+ const result = adapter.getRisingSignWindows({ ...baseInput, mode: 'exact' });
245
+ assertFullDayCoverage(result, baseInput);
246
+
247
+ const offsets = new Set(
248
+ result.windows.flatMap((window) => [window.start.slice(-6), window.end.slice(-6)])
249
+ );
250
+ expect(offsets.size).toBeGreaterThanOrEqual(2);
251
+ }),
252
+ propertyConfig({ heavy: true })
253
+ );
254
+ });
255
+ });