ether-to-astro 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -0
- package/.github/pull_request_template.md +16 -0
- package/.github/workflows/release.yml +35 -0
- package/.github/workflows/test.yml +32 -0
- package/AGENTS.md +99 -0
- package/LICENSE +18 -0
- package/NOTICE.md +45 -0
- package/README.md +301 -0
- package/SETUP.md +70 -0
- package/TESTING_SUMMARY.md +238 -0
- package/TEST_SUITE_STATUS.md +218 -0
- package/biome.json +48 -0
- package/dist/astro-service.d.ts +98 -0
- package/dist/astro-service.js +496 -0
- package/dist/chart-types.d.ts +52 -0
- package/dist/chart-types.js +51 -0
- package/dist/charts.d.ts +125 -0
- package/dist/charts.js +324 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +472 -0
- package/dist/constants.d.ts +81 -0
- package/dist/constants.js +76 -0
- package/dist/eclipses.d.ts +85 -0
- package/dist/eclipses.js +184 -0
- package/dist/ephemeris.d.ts +120 -0
- package/dist/ephemeris.js +379 -0
- package/dist/formatter.d.ts +2 -0
- package/dist/formatter.js +22 -0
- package/dist/houses.d.ts +82 -0
- package/dist/houses.js +169 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +150 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +31 -0
- package/dist/logger.d.ts +25 -0
- package/dist/logger.js +73 -0
- package/dist/profile-store.d.ts +48 -0
- package/dist/profile-store.js +156 -0
- package/dist/riseset.d.ts +82 -0
- package/dist/riseset.js +185 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +40 -0
- package/dist/time-utils.d.ts +68 -0
- package/dist/time-utils.js +136 -0
- package/dist/tool-registry.d.ts +35 -0
- package/dist/tool-registry.js +307 -0
- package/dist/tool-result.d.ts +175 -0
- package/dist/tool-result.js +188 -0
- package/dist/transits.d.ts +108 -0
- package/dist/transits.js +263 -0
- package/dist/types.d.ts +450 -0
- package/dist/types.js +161 -0
- package/example-usage.md +131 -0
- package/natal-chart.json +187 -0
- package/package.json +61 -0
- package/scripts/download-ephemeris.js +115 -0
- package/setup.sh +21 -0
- package/src/astro-service.ts +710 -0
- package/src/chart-types.ts +125 -0
- package/src/charts.ts +399 -0
- package/src/cli.ts +694 -0
- package/src/constants.ts +89 -0
- package/src/eclipses.ts +226 -0
- package/src/ephemeris.ts +437 -0
- package/src/formatter.ts +25 -0
- package/src/houses.ts +202 -0
- package/src/index.ts +170 -0
- package/src/loader.ts +36 -0
- package/src/logger.ts +104 -0
- package/src/profile-store.ts +285 -0
- package/src/riseset.ts +229 -0
- package/src/time-utils.ts +167 -0
- package/src/tool-registry.ts +357 -0
- package/src/tool-result.ts +283 -0
- package/src/transits.ts +352 -0
- package/src/types.ts +547 -0
- package/tests/README.md +173 -0
- package/tests/TESTING_STRATEGY.md +178 -0
- package/tests/fixtures/bowen-yang-chart.ts +69 -0
- package/tests/fixtures/calculate-expected.ts +81 -0
- package/tests/fixtures/expected-results.ts +117 -0
- package/tests/fixtures/generate-expected-simple.ts +94 -0
- package/tests/helpers/date-fixtures.ts +15 -0
- package/tests/helpers/ephem.ts +11 -0
- package/tests/helpers/temp.ts +9 -0
- package/tests/setup.ts +11 -0
- package/tests/unit/astro-service.test.ts +323 -0
- package/tests/unit/chart-types.test.ts +18 -0
- package/tests/unit/charts-errors.test.ts +42 -0
- package/tests/unit/charts.test.ts +157 -0
- package/tests/unit/cli-commands.test.ts +82 -0
- package/tests/unit/cli-profiles.test.ts +128 -0
- package/tests/unit/cli.test.ts +191 -0
- package/tests/unit/constants.test.ts +26 -0
- package/tests/unit/correctness-critical.test.ts +408 -0
- package/tests/unit/eclipses.test.ts +108 -0
- package/tests/unit/ephemeris.test.ts +213 -0
- package/tests/unit/error-handling.test.ts +116 -0
- package/tests/unit/formatter.test.ts +29 -0
- package/tests/unit/houses-errors.test.ts +27 -0
- package/tests/unit/houses-validation.test.ts +164 -0
- package/tests/unit/houses.test.ts +205 -0
- package/tests/unit/profile-store.test.ts +163 -0
- package/tests/unit/real-user-charts.test.ts +148 -0
- package/tests/unit/riseset.test.ts +106 -0
- package/tests/unit/solver-edges.test.ts +197 -0
- package/tests/unit/time-utils-temporal.test.ts +303 -0
- package/tests/unit/time-utils.test.ts +173 -0
- package/tests/unit/tool-registry.test.ts +222 -0
- package/tests/unit/tool-result.test.ts +45 -0
- package/tests/unit/transit-correctness.test.ts +78 -0
- package/tests/unit/transits.test.ts +238 -0
- package/tests/validation/README.md +32 -0
- package/tests/validation/adapters/astrolog.ts +306 -0
- package/tests/validation/adapters/internal.ts +184 -0
- package/tests/validation/compare/eclipses.ts +47 -0
- package/tests/validation/compare/houses.ts +76 -0
- package/tests/validation/compare/positions.ts +104 -0
- package/tests/validation/compare/riseSet.ts +48 -0
- package/tests/validation/compare/roots.ts +90 -0
- package/tests/validation/compare/transits.ts +69 -0
- package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
- package/tests/validation/fixtures/eclipses/core.ts +14 -0
- package/tests/validation/fixtures/houses/core.ts +47 -0
- package/tests/validation/fixtures/positions/core.ts +159 -0
- package/tests/validation/fixtures/rise-set/core.ts +20 -0
- package/tests/validation/fixtures/roots/core.ts +47 -0
- package/tests/validation/fixtures/transits/core.ts +61 -0
- package/tests/validation/fixtures/transits/dst.ts +21 -0
- package/tests/validation/oracle.spec.ts +129 -0
- package/tests/validation/utils/denseRootOracle.ts +269 -0
- package/tests/validation/utils/fixtureTypes.ts +146 -0
- package/tests/validation/utils/report.ts +60 -0
- package/tests/validation/utils/tolerances.ts +23 -0
- package/tests/validation/validation.spec.ts +836 -0
- package/tools/color-picker.html +388 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +31 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { EphemerisCalculator } from '../../src/ephemeris.js';
|
|
3
|
+
import { TransitCalculator, deduplicateTransits } from '../../src/transits.js';
|
|
4
|
+
import { PLANETS, type PlanetPosition, type Transit } from '../../src/types.js';
|
|
5
|
+
|
|
6
|
+
describe('Solver and transit edge policies', () => {
|
|
7
|
+
let ephem: EphemerisCalculator;
|
|
8
|
+
let transitCalc: TransitCalculator;
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
ephem = new EphemerisCalculator();
|
|
12
|
+
await ephem.init();
|
|
13
|
+
transitCalc = new TransitCalculator(ephem);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('captures root at coarse-scan endpoint', () => {
|
|
17
|
+
const startDate = new Date('2024-03-20T00:00:00Z');
|
|
18
|
+
const startJD = ephem.dateToJulianDay(startDate);
|
|
19
|
+
const endJD = startJD + 30;
|
|
20
|
+
|
|
21
|
+
const sun = ephem.getAllPlanets(startJD, [PLANETS.SUN])[0];
|
|
22
|
+
const roots = ephem.findExactTransitTimes(PLANETS.SUN, sun.longitude, startJD, endJD);
|
|
23
|
+
|
|
24
|
+
expect(roots.length).toBeGreaterThan(0);
|
|
25
|
+
expect(Math.abs(roots[0] - startJD)).toBeLessThan(1 / 1440);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns multiple roots in a large interval', () => {
|
|
29
|
+
const startDate = new Date('2024-01-01T00:00:00Z');
|
|
30
|
+
const startJD = ephem.dateToJulianDay(startDate);
|
|
31
|
+
const endJD = startJD + 800;
|
|
32
|
+
|
|
33
|
+
const targetLongitude = 0;
|
|
34
|
+
const roots = ephem.findExactTransitTimes(PLANETS.SUN, targetLongitude, startJD, endJD);
|
|
35
|
+
|
|
36
|
+
expect(roots.length).toBeGreaterThanOrEqual(2);
|
|
37
|
+
for (let i = 1; i < roots.length; i++) {
|
|
38
|
+
expect(roots[i]).toBeGreaterThan(roots[i - 1]);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('finds tangential root near station without sign-change bracketing', () => {
|
|
43
|
+
// Mercury station region creates local minima/maxima in longitude.
|
|
44
|
+
// Targeting a sampled local minimum should produce a tangential root.
|
|
45
|
+
const centerDate = new Date('2023-12-13T00:00:00Z');
|
|
46
|
+
const centerJD = ephem.dateToJulianDay(centerDate);
|
|
47
|
+
const startJD = centerJD - 2;
|
|
48
|
+
const endJD = centerJD + 2;
|
|
49
|
+
|
|
50
|
+
// Find a local minimum longitude sample in this station window.
|
|
51
|
+
let minSampleJD = startJD;
|
|
52
|
+
let minSampleLon = Infinity;
|
|
53
|
+
for (let i = 0; i <= 96; i++) {
|
|
54
|
+
const jd = startJD + (i * (endJD - startJD)) / 96;
|
|
55
|
+
const lon = ephem.getAllPlanets(jd, [PLANETS.MERCURY])[0].longitude;
|
|
56
|
+
if (lon < minSampleLon) {
|
|
57
|
+
minSampleLon = lon;
|
|
58
|
+
minSampleJD = jd;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const signedDiff = (lon: number, target: number): number => {
|
|
63
|
+
let d = lon - target;
|
|
64
|
+
if (d > 180) d -= 360;
|
|
65
|
+
if (d < -180) d += 360;
|
|
66
|
+
return d;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const startLon = ephem.getAllPlanets(startJD, [PLANETS.MERCURY])[0].longitude;
|
|
70
|
+
const endLon = ephem.getAllPlanets(endJD, [PLANETS.MERCURY])[0].longitude;
|
|
71
|
+
const startDiff = signedDiff(startLon, minSampleLon);
|
|
72
|
+
const endDiff = signedDiff(endLon, minSampleLon);
|
|
73
|
+
|
|
74
|
+
// Tangential case: endpoints are on the same side (or very near) of target.
|
|
75
|
+
expect(startDiff).toBeGreaterThanOrEqual(-0.05);
|
|
76
|
+
expect(endDiff).toBeGreaterThanOrEqual(-0.05);
|
|
77
|
+
|
|
78
|
+
const roots = ephem.findExactTransitTimes(
|
|
79
|
+
PLANETS.MERCURY,
|
|
80
|
+
minSampleLon,
|
|
81
|
+
startJD,
|
|
82
|
+
endJD,
|
|
83
|
+
0.01
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(roots.length).toBeGreaterThan(0);
|
|
87
|
+
|
|
88
|
+
const nearest = roots.reduce((best, jd) =>
|
|
89
|
+
Math.abs(jd - minSampleJD) < Math.abs(best - minSampleJD) ? jd : best
|
|
90
|
+
, roots[0]);
|
|
91
|
+
|
|
92
|
+
expect(Math.abs(nearest - minSampleJD)).toBeLessThan(0.5);
|
|
93
|
+
const nearestLon = ephem.getAllPlanets(nearest, [PLANETS.MERCURY])[0].longitude;
|
|
94
|
+
expect(ephem.calculateAspectAngle(nearestLon, minSampleLon)).toBeLessThan(0.1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('uses root-based applying/separating when exact root is selected', () => {
|
|
98
|
+
const now = new Date('2024-03-15T00:00:00Z');
|
|
99
|
+
const currentJD = ephem.dateToJulianDay(now);
|
|
100
|
+
const mars = ephem.getAllPlanets(currentJD, [PLANETS.MARS])[0];
|
|
101
|
+
|
|
102
|
+
const applyingNatal: PlanetPosition = {
|
|
103
|
+
planetId: PLANETS.VENUS,
|
|
104
|
+
planet: 'Venus',
|
|
105
|
+
longitude: (mars.longitude + 92) % 360,
|
|
106
|
+
latitude: 0,
|
|
107
|
+
distance: 1,
|
|
108
|
+
speed: 1,
|
|
109
|
+
sign: 'Cancer',
|
|
110
|
+
degree: 2,
|
|
111
|
+
isRetrograde: false,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const separatingNatal: PlanetPosition = {
|
|
115
|
+
...applyingNatal,
|
|
116
|
+
longitude: (mars.longitude + 88 + 360) % 360,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const applying = transitCalc.findTransits([mars], [applyingNatal], currentJD)
|
|
120
|
+
.find((t) => t.aspect === 'square');
|
|
121
|
+
const separating = transitCalc.findTransits([mars], [separatingNatal], currentJD)
|
|
122
|
+
.find((t) => t.aspect === 'square');
|
|
123
|
+
|
|
124
|
+
expect(applying).toBeDefined();
|
|
125
|
+
expect(separating).toBeDefined();
|
|
126
|
+
|
|
127
|
+
if (applying?.exactTimeStatus === 'within_preview' && applying.exactTime) {
|
|
128
|
+
expect(applying.isApplying).toBe(true);
|
|
129
|
+
expect(applying.exactTime.getTime()).toBeGreaterThanOrEqual(now.getTime());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (separating?.exactTimeStatus === 'within_preview' && separating.exactTime) {
|
|
133
|
+
expect(separating.isApplying).toBe(false);
|
|
134
|
+
expect(separating.exactTime.getTime()).toBeLessThanOrEqual(now.getTime());
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('marks unsupported bodies honestly', () => {
|
|
139
|
+
const now = new Date('2024-03-15T00:00:00Z');
|
|
140
|
+
const currentJD = ephem.dateToJulianDay(now);
|
|
141
|
+
|
|
142
|
+
const unsupportedTransit: PlanetPosition = {
|
|
143
|
+
planetId: 9999,
|
|
144
|
+
planet: 'Sun',
|
|
145
|
+
longitude: 120,
|
|
146
|
+
latitude: 0,
|
|
147
|
+
distance: 1,
|
|
148
|
+
speed: 1,
|
|
149
|
+
sign: 'Cancer',
|
|
150
|
+
degree: 0,
|
|
151
|
+
isRetrograde: false,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const natal: PlanetPosition = {
|
|
155
|
+
planetId: PLANETS.MARS,
|
|
156
|
+
planet: 'Mars',
|
|
157
|
+
longitude: 120,
|
|
158
|
+
latitude: 0,
|
|
159
|
+
distance: 1,
|
|
160
|
+
speed: 1,
|
|
161
|
+
sign: 'Cancer',
|
|
162
|
+
degree: 0,
|
|
163
|
+
isRetrograde: false,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const transit = transitCalc.findTransits([unsupportedTransit], [natal], currentJD)
|
|
167
|
+
.find((t) => t.aspect === 'conjunction');
|
|
168
|
+
|
|
169
|
+
expect(transit).toBeDefined();
|
|
170
|
+
expect(transit?.exactTimeStatus).toBe('unsupported_body');
|
|
171
|
+
expect(transit?.exactTime).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('deduplicates deterministically under total ties', () => {
|
|
175
|
+
const a: Transit = {
|
|
176
|
+
transitingPlanet: 'Mars',
|
|
177
|
+
natalPlanet: 'Venus',
|
|
178
|
+
aspect: 'square',
|
|
179
|
+
orb: 0.5,
|
|
180
|
+
isApplying: true,
|
|
181
|
+
transitLongitude: 100,
|
|
182
|
+
natalLongitude: 81,
|
|
183
|
+
exactTimeStatus: 'not_found',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const b: Transit = {
|
|
187
|
+
...a,
|
|
188
|
+
natalLongitude: 80,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const first = deduplicateTransits([a, b])[0];
|
|
192
|
+
const second = deduplicateTransits([b, a])[0];
|
|
193
|
+
|
|
194
|
+
expect(first.natalLongitude).toBe(second.natalLongitude);
|
|
195
|
+
expect(first.natalLongitude).toBe(80);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comprehensive tests for Temporal-based time-utils
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Timezone validity
|
|
6
|
+
* - Normal UTC <-> local round-trip
|
|
7
|
+
* - DST spring-forward gap (nonexistent times)
|
|
8
|
+
* - DST fall-back overlap (ambiguous times)
|
|
9
|
+
* - Non-hour offsets (Asia/Kolkata, Asia/Kathmandu)
|
|
10
|
+
* - Offset sign convention and correctness
|
|
11
|
+
* - Midnight handling
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
localToUTC,
|
|
17
|
+
utcToLocal,
|
|
18
|
+
isValidTimezone,
|
|
19
|
+
getTimezoneOffset,
|
|
20
|
+
type LocalDateTime,
|
|
21
|
+
} from '../../src/time-utils.js';
|
|
22
|
+
|
|
23
|
+
describe('Time utils with Temporal', () => {
|
|
24
|
+
describe('Timezone validity', () => {
|
|
25
|
+
it('should accept valid IANA timezone', () => {
|
|
26
|
+
expect(isValidTimezone('America/New_York')).toBe(true);
|
|
27
|
+
expect(isValidTimezone('Europe/London')).toBe(true);
|
|
28
|
+
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
|
|
29
|
+
expect(isValidTimezone('UTC')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should reject invalid timezone', () => {
|
|
33
|
+
expect(isValidTimezone('Invalid/Garbage')).toBe(false);
|
|
34
|
+
expect(isValidTimezone('')).toBe(false);
|
|
35
|
+
expect(isValidTimezone('NotAZone')).toBe(false);
|
|
36
|
+
expect(isValidTimezone('123456')).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Normal conversion round-trip', () => {
|
|
41
|
+
it('should round-trip UTC <-> local for normal date', () => {
|
|
42
|
+
const local: LocalDateTime = {
|
|
43
|
+
year: 2024,
|
|
44
|
+
month: 6,
|
|
45
|
+
day: 15,
|
|
46
|
+
hour: 14,
|
|
47
|
+
minute: 30,
|
|
48
|
+
second: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const utc = localToUTC(local, 'America/Los_Angeles');
|
|
52
|
+
const roundTrip = utcToLocal(utc, 'America/Los_Angeles');
|
|
53
|
+
|
|
54
|
+
expect(roundTrip.year).toBe(local.year);
|
|
55
|
+
expect(roundTrip.month).toBe(local.month);
|
|
56
|
+
expect(roundTrip.day).toBe(local.day);
|
|
57
|
+
expect(roundTrip.hour).toBe(local.hour);
|
|
58
|
+
expect(roundTrip.minute).toBe(local.minute);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should convert UTC to local correctly', () => {
|
|
62
|
+
// 2024-06-15 21:30 UTC = 2024-06-15 14:30 PDT (UTC-7)
|
|
63
|
+
const utc = new Date('2024-06-15T21:30:00Z');
|
|
64
|
+
const local = utcToLocal(utc, 'America/Los_Angeles');
|
|
65
|
+
|
|
66
|
+
expect(local.year).toBe(2024);
|
|
67
|
+
expect(local.month).toBe(6);
|
|
68
|
+
expect(local.day).toBe(15);
|
|
69
|
+
expect(local.hour).toBe(14);
|
|
70
|
+
expect(local.minute).toBe(30);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('DST spring-forward gap (nonexistent times)', () => {
|
|
75
|
+
it('should handle nonexistent time in America/New_York with compatible', () => {
|
|
76
|
+
// March 10, 2024, 2:30 AM doesn't exist (clock jumps 2:00 -> 3:00)
|
|
77
|
+
const nonexistent: LocalDateTime = {
|
|
78
|
+
year: 2024,
|
|
79
|
+
month: 3,
|
|
80
|
+
day: 10,
|
|
81
|
+
hour: 2,
|
|
82
|
+
minute: 30,
|
|
83
|
+
second: 0,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// 'compatible' should shift forward
|
|
87
|
+
const utc = localToUTC(nonexistent, 'America/New_York', 'compatible');
|
|
88
|
+
expect(utc).toBeDefined();
|
|
89
|
+
|
|
90
|
+
// Verify it shifted to 3:30 AM EDT (which is 7:30 UTC)
|
|
91
|
+
const local = utcToLocal(utc, 'America/New_York');
|
|
92
|
+
expect(local.hour).toBe(3); // Shifted forward
|
|
93
|
+
expect(local.minute).toBe(30);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should reject nonexistent time with reject disambiguation', () => {
|
|
97
|
+
const nonexistent: LocalDateTime = {
|
|
98
|
+
year: 2024,
|
|
99
|
+
month: 3,
|
|
100
|
+
day: 10,
|
|
101
|
+
hour: 2,
|
|
102
|
+
minute: 30,
|
|
103
|
+
second: 0,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
expect(() => {
|
|
107
|
+
localToUTC(nonexistent, 'America/New_York', 'reject');
|
|
108
|
+
}).toThrow();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('DST fall-back overlap (ambiguous times)', () => {
|
|
113
|
+
it('should handle ambiguous time in America/New_York with compatible', () => {
|
|
114
|
+
// November 3, 2024, 1:30 AM happens twice (clock falls back 2:00 -> 1:00)
|
|
115
|
+
const ambiguous: LocalDateTime = {
|
|
116
|
+
year: 2024,
|
|
117
|
+
month: 11,
|
|
118
|
+
day: 3,
|
|
119
|
+
hour: 1,
|
|
120
|
+
minute: 30,
|
|
121
|
+
second: 0,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// 'compatible' should pick the earlier occurrence (EDT, before fall-back)
|
|
125
|
+
const utc = localToUTC(ambiguous, 'America/New_York', 'compatible');
|
|
126
|
+
expect(utc).toBeDefined();
|
|
127
|
+
|
|
128
|
+
// The earlier 1:30 AM EDT is 5:30 UTC
|
|
129
|
+
// The later 1:30 AM EST is 6:30 UTC
|
|
130
|
+
// 'compatible' picks earlier, so should be 5:30 UTC
|
|
131
|
+
expect(utc.getUTCHours()).toBe(5);
|
|
132
|
+
expect(utc.getUTCMinutes()).toBe(30);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle ambiguous time with earlier disambiguation', () => {
|
|
136
|
+
const ambiguous: LocalDateTime = {
|
|
137
|
+
year: 2024,
|
|
138
|
+
month: 11,
|
|
139
|
+
day: 3,
|
|
140
|
+
hour: 1,
|
|
141
|
+
minute: 30,
|
|
142
|
+
second: 0,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const utc = localToUTC(ambiguous, 'America/New_York', 'earlier');
|
|
146
|
+
expect(utc.getUTCHours()).toBe(5); // Earlier occurrence
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should handle ambiguous time with later disambiguation', () => {
|
|
150
|
+
const ambiguous: LocalDateTime = {
|
|
151
|
+
year: 2024,
|
|
152
|
+
month: 11,
|
|
153
|
+
day: 3,
|
|
154
|
+
hour: 1,
|
|
155
|
+
minute: 30,
|
|
156
|
+
second: 0,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const utc = localToUTC(ambiguous, 'America/New_York', 'later');
|
|
160
|
+
expect(utc.getUTCHours()).toBe(6); // Later occurrence
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should reject ambiguous time with reject disambiguation', () => {
|
|
164
|
+
const ambiguous: LocalDateTime = {
|
|
165
|
+
year: 2024,
|
|
166
|
+
month: 11,
|
|
167
|
+
day: 3,
|
|
168
|
+
hour: 1,
|
|
169
|
+
minute: 30,
|
|
170
|
+
second: 0,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
expect(() => {
|
|
174
|
+
localToUTC(ambiguous, 'America/New_York', 'reject');
|
|
175
|
+
}).toThrow();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('Non-hour offsets', () => {
|
|
180
|
+
it('should handle Asia/Kolkata (UTC+5:30)', () => {
|
|
181
|
+
const local: LocalDateTime = {
|
|
182
|
+
year: 2024,
|
|
183
|
+
month: 6,
|
|
184
|
+
day: 15,
|
|
185
|
+
hour: 14,
|
|
186
|
+
minute: 30,
|
|
187
|
+
second: 0,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const utc = localToUTC(local, 'Asia/Kolkata');
|
|
191
|
+
const roundTrip = utcToLocal(utc, 'Asia/Kolkata');
|
|
192
|
+
|
|
193
|
+
expect(roundTrip.hour).toBe(local.hour);
|
|
194
|
+
expect(roundTrip.minute).toBe(local.minute);
|
|
195
|
+
|
|
196
|
+
// Verify offset is +330 minutes (5.5 hours)
|
|
197
|
+
const offset = getTimezoneOffset(utc, 'Asia/Kolkata');
|
|
198
|
+
expect(offset).toBe(330);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should handle Asia/Kathmandu (UTC+5:45)', () => {
|
|
202
|
+
const local: LocalDateTime = {
|
|
203
|
+
year: 2024,
|
|
204
|
+
month: 6,
|
|
205
|
+
day: 15,
|
|
206
|
+
hour: 14,
|
|
207
|
+
minute: 30,
|
|
208
|
+
second: 0,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const utc = localToUTC(local, 'Asia/Kathmandu');
|
|
212
|
+
const roundTrip = utcToLocal(utc, 'Asia/Kathmandu');
|
|
213
|
+
|
|
214
|
+
expect(roundTrip.hour).toBe(local.hour);
|
|
215
|
+
expect(roundTrip.minute).toBe(local.minute);
|
|
216
|
+
|
|
217
|
+
// Verify offset is +345 minutes (5.75 hours)
|
|
218
|
+
const offset = getTimezoneOffset(utc, 'Asia/Kathmandu');
|
|
219
|
+
expect(offset).toBe(345);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('Offset sign convention and correctness', () => {
|
|
224
|
+
it('should return negative offset for America/Los_Angeles (west of UTC)', () => {
|
|
225
|
+
// Winter (PST, UTC-8)
|
|
226
|
+
const winterDate = new Date('2024-01-15T12:00:00Z');
|
|
227
|
+
const winterOffset = getTimezoneOffset(winterDate, 'America/Los_Angeles');
|
|
228
|
+
expect(winterOffset).toBe(-480); // -8 hours
|
|
229
|
+
|
|
230
|
+
// Summer (PDT, UTC-7)
|
|
231
|
+
const summerDate = new Date('2024-07-15T12:00:00Z');
|
|
232
|
+
const summerOffset = getTimezoneOffset(summerDate, 'America/Los_Angeles');
|
|
233
|
+
expect(summerOffset).toBe(-420); // -7 hours
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should return positive offset for Asia/Tokyo (east of UTC)', () => {
|
|
237
|
+
const date = new Date('2024-06-15T12:00:00Z');
|
|
238
|
+
const offset = getTimezoneOffset(date, 'Asia/Tokyo');
|
|
239
|
+
expect(offset).toBe(540); // +9 hours
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should return zero offset for UTC', () => {
|
|
243
|
+
const date = new Date('2024-06-15T12:00:00Z');
|
|
244
|
+
const offset = getTimezoneOffset(date, 'UTC');
|
|
245
|
+
expect(offset).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('Midnight handling', () => {
|
|
250
|
+
it('should handle midnight without 24:00 weirdness', () => {
|
|
251
|
+
const midnight: LocalDateTime = {
|
|
252
|
+
year: 2024,
|
|
253
|
+
month: 6,
|
|
254
|
+
day: 15,
|
|
255
|
+
hour: 0,
|
|
256
|
+
minute: 0,
|
|
257
|
+
second: 0,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const utc = localToUTC(midnight, 'America/New_York');
|
|
261
|
+
const roundTrip = utcToLocal(utc, 'America/New_York');
|
|
262
|
+
|
|
263
|
+
expect(roundTrip.hour).toBe(0);
|
|
264
|
+
expect(roundTrip.minute).toBe(0);
|
|
265
|
+
expect(roundTrip.day).toBe(15);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should handle 23:59:59 without overflow', () => {
|
|
269
|
+
const almostMidnight: LocalDateTime = {
|
|
270
|
+
year: 2024,
|
|
271
|
+
month: 6,
|
|
272
|
+
day: 15,
|
|
273
|
+
hour: 23,
|
|
274
|
+
minute: 59,
|
|
275
|
+
second: 59,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const utc = localToUTC(almostMidnight, 'America/New_York');
|
|
279
|
+
const roundTrip = utcToLocal(utc, 'America/New_York');
|
|
280
|
+
|
|
281
|
+
expect(roundTrip.hour).toBe(23);
|
|
282
|
+
expect(roundTrip.minute).toBe(59);
|
|
283
|
+
expect(roundTrip.second).toBe(59);
|
|
284
|
+
expect(roundTrip.day).toBe(15);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('Error handling', () => {
|
|
289
|
+
it('should throw on invalid timezone in localToUTC', () => {
|
|
290
|
+
const local: LocalDateTime = {
|
|
291
|
+
year: 2024,
|
|
292
|
+
month: 6,
|
|
293
|
+
day: 15,
|
|
294
|
+
hour: 12,
|
|
295
|
+
minute: 0,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
expect(() => {
|
|
299
|
+
localToUTC(local, 'Invalid/Timezone');
|
|
300
|
+
}).toThrow(/Invalid timezone/);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { localToUTC, utcToLocal, isValidTimezone, getTimezoneOffset } from '../../src/time-utils.js';
|
|
3
|
+
import type { LocalDateTime } from '../../src/time-utils.js';
|
|
4
|
+
|
|
5
|
+
describe('Time conversion utility', () => {
|
|
6
|
+
describe('localToUTC', () => {
|
|
7
|
+
it('should convert EDT to UTC correctly', () => {
|
|
8
|
+
// Oct 17 1977, 1:06 PM EDT = Oct 17 1977, 5:06 PM UTC
|
|
9
|
+
// EDT is UTC-4
|
|
10
|
+
const local: LocalDateTime = {
|
|
11
|
+
year: 1977,
|
|
12
|
+
month: 10,
|
|
13
|
+
day: 17,
|
|
14
|
+
hour: 13,
|
|
15
|
+
minute: 6,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const utc = localToUTC(local, 'America/New_York');
|
|
19
|
+
|
|
20
|
+
expect(utc.getUTCFullYear()).toBe(1977);
|
|
21
|
+
expect(utc.getUTCMonth()).toBe(9); // October = 9 (0-indexed)
|
|
22
|
+
expect(utc.getUTCDate()).toBe(17);
|
|
23
|
+
expect(utc.getUTCHours()).toBe(17); // 1:06 PM + 4 hours = 5:06 PM
|
|
24
|
+
expect(utc.getUTCMinutes()).toBe(6);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should handle DST transitions correctly', () => {
|
|
28
|
+
// Test date in EDT (summer) - UTC-4
|
|
29
|
+
const summer: LocalDateTime = {
|
|
30
|
+
year: 2024,
|
|
31
|
+
month: 7,
|
|
32
|
+
day: 15,
|
|
33
|
+
hour: 12,
|
|
34
|
+
minute: 0,
|
|
35
|
+
};
|
|
36
|
+
const summerUTC = localToUTC(summer, 'America/New_York');
|
|
37
|
+
expect(summerUTC.getUTCHours()).toBe(16); // EDT = UTC-4
|
|
38
|
+
|
|
39
|
+
// Test date in EST (winter) - UTC-5
|
|
40
|
+
const winter: LocalDateTime = {
|
|
41
|
+
year: 2024,
|
|
42
|
+
month: 1,
|
|
43
|
+
day: 15,
|
|
44
|
+
hour: 12,
|
|
45
|
+
minute: 0,
|
|
46
|
+
};
|
|
47
|
+
const winterUTC = localToUTC(winter, 'America/New_York');
|
|
48
|
+
expect(winterUTC.getUTCHours()).toBe(17); // EST = UTC-5
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should handle multiple timezones', () => {
|
|
52
|
+
const local: LocalDateTime = {
|
|
53
|
+
year: 2024,
|
|
54
|
+
month: 3,
|
|
55
|
+
day: 15,
|
|
56
|
+
hour: 14,
|
|
57
|
+
minute: 30,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const utcNY = localToUTC(local, 'America/New_York');
|
|
61
|
+
const utcLA = localToUTC(local, 'America/Los_Angeles');
|
|
62
|
+
const utcSydney = localToUTC(local, 'Australia/Sydney');
|
|
63
|
+
|
|
64
|
+
// LA is 3 hours behind NY
|
|
65
|
+
expect(utcLA.getTime() - utcNY.getTime()).toBe(3 * 60 * 60 * 1000);
|
|
66
|
+
|
|
67
|
+
// Sydney is ahead of UTC
|
|
68
|
+
expect(utcSydney.getUTCHours()).toBeLessThan(local.hour);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should handle midnight correctly', () => {
|
|
72
|
+
const local: LocalDateTime = {
|
|
73
|
+
year: 2024,
|
|
74
|
+
month: 3,
|
|
75
|
+
day: 15,
|
|
76
|
+
hour: 0,
|
|
77
|
+
minute: 0,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const utc = localToUTC(local, 'America/New_York');
|
|
81
|
+
|
|
82
|
+
// Midnight EDT (March 15 is after DST starts) = 4 AM UTC
|
|
83
|
+
expect(utc.getUTCHours()).toBe(4);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should handle date rollover when converting to UTC', () => {
|
|
87
|
+
// 11 PM PDT should become next day in UTC
|
|
88
|
+
const local: LocalDateTime = {
|
|
89
|
+
year: 2024,
|
|
90
|
+
month: 3,
|
|
91
|
+
day: 15,
|
|
92
|
+
hour: 23,
|
|
93
|
+
minute: 0,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const utc = localToUTC(local, 'America/Los_Angeles');
|
|
97
|
+
|
|
98
|
+
// 11 PM PDT (March 15 is after DST) = 6 AM UTC next day
|
|
99
|
+
expect(utc.getUTCDate()).toBe(16);
|
|
100
|
+
expect(utc.getUTCHours()).toBe(6);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('utcToLocal', () => {
|
|
105
|
+
it('should convert UTC to local time correctly', () => {
|
|
106
|
+
const utc = new Date(Date.UTC(1977, 9, 17, 17, 6));
|
|
107
|
+
|
|
108
|
+
const local = utcToLocal(utc, 'America/New_York');
|
|
109
|
+
|
|
110
|
+
expect(local.year).toBe(1977);
|
|
111
|
+
expect(local.month).toBe(10);
|
|
112
|
+
expect(local.day).toBe(17);
|
|
113
|
+
expect(local.hour).toBe(13); // 5:06 PM UTC - 4 hours = 1:06 PM EDT
|
|
114
|
+
expect(local.minute).toBe(6);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should handle DST in reverse', () => {
|
|
118
|
+
// Summer: UTC to EDT
|
|
119
|
+
const summerUTC = new Date(Date.UTC(2024, 6, 15, 16, 0));
|
|
120
|
+
const summerLocal = utcToLocal(summerUTC, 'America/New_York');
|
|
121
|
+
expect(summerLocal.hour).toBe(12); // 4 PM UTC - 4 hours = 12 PM EDT
|
|
122
|
+
|
|
123
|
+
// Winter: UTC to EST
|
|
124
|
+
const winterUTC = new Date(Date.UTC(2024, 0, 15, 17, 0));
|
|
125
|
+
const winterLocal = utcToLocal(winterUTC, 'America/New_York');
|
|
126
|
+
expect(winterLocal.hour).toBe(12); // 5 PM UTC - 5 hours = 12 PM EST
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('isValidTimezone', () => {
|
|
131
|
+
it('should validate correct timezone strings', () => {
|
|
132
|
+
expect(isValidTimezone('America/New_York')).toBe(true);
|
|
133
|
+
expect(isValidTimezone('America/Los_Angeles')).toBe(true);
|
|
134
|
+
expect(isValidTimezone('Europe/London')).toBe(true);
|
|
135
|
+
expect(isValidTimezone('Australia/Sydney')).toBe(true);
|
|
136
|
+
expect(isValidTimezone('UTC')).toBe(true);
|
|
137
|
+
expect(isValidTimezone('EST')).toBe(true);
|
|
138
|
+
expect(isValidTimezone('GMT')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should reject invalid timezone strings', () => {
|
|
142
|
+
expect(isValidTimezone('Invalid/Timezone')).toBe(false);
|
|
143
|
+
expect(isValidTimezone('NotAZone')).toBe(false);
|
|
144
|
+
expect(isValidTimezone('')).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('getTimezoneOffset', () => {
|
|
149
|
+
it('should return correct offset for EDT', () => {
|
|
150
|
+
const date = new Date(Date.UTC(1977, 9, 17, 17, 6));
|
|
151
|
+
const offset = getTimezoneOffset(date, 'America/New_York');
|
|
152
|
+
|
|
153
|
+
// EDT is UTC-4, so offset should be -240 minutes
|
|
154
|
+
expect(offset).toBe(-240);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should return correct offset for EST', () => {
|
|
158
|
+
const date = new Date(Date.UTC(2024, 0, 15, 12, 0));
|
|
159
|
+
const offset = getTimezoneOffset(date, 'America/New_York');
|
|
160
|
+
|
|
161
|
+
// EST is UTC-5, so offset should be -300 minutes
|
|
162
|
+
expect(offset).toBe(-300);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should handle positive offsets', () => {
|
|
166
|
+
const date = new Date(Date.UTC(2024, 6, 15, 12, 0));
|
|
167
|
+
const offset = getTimezoneOffset(date, 'Australia/Sydney');
|
|
168
|
+
|
|
169
|
+
// AEST is UTC+10, so offset should be 600 minutes
|
|
170
|
+
expect(offset).toBe(600);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|