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
package/src/tool-registry.ts
CHANGED
|
@@ -218,7 +218,7 @@ export const MCP_TOOL_SPECS: ToolSpec[] = [
|
|
|
218
218
|
include_mundane: {
|
|
219
219
|
type: 'boolean',
|
|
220
220
|
description:
|
|
221
|
-
'Include
|
|
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
|
+
});
|