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.
Files changed (138) hide show
  1. package/.env.example +13 -0
  2. package/.github/pull_request_template.md +16 -0
  3. package/.github/workflows/release.yml +35 -0
  4. package/.github/workflows/test.yml +32 -0
  5. package/AGENTS.md +99 -0
  6. package/LICENSE +18 -0
  7. package/NOTICE.md +45 -0
  8. package/README.md +301 -0
  9. package/SETUP.md +70 -0
  10. package/TESTING_SUMMARY.md +238 -0
  11. package/TEST_SUITE_STATUS.md +218 -0
  12. package/biome.json +48 -0
  13. package/dist/astro-service.d.ts +98 -0
  14. package/dist/astro-service.js +496 -0
  15. package/dist/chart-types.d.ts +52 -0
  16. package/dist/chart-types.js +51 -0
  17. package/dist/charts.d.ts +125 -0
  18. package/dist/charts.js +324 -0
  19. package/dist/cli.d.ts +7 -0
  20. package/dist/cli.js +472 -0
  21. package/dist/constants.d.ts +81 -0
  22. package/dist/constants.js +76 -0
  23. package/dist/eclipses.d.ts +85 -0
  24. package/dist/eclipses.js +184 -0
  25. package/dist/ephemeris.d.ts +120 -0
  26. package/dist/ephemeris.js +379 -0
  27. package/dist/formatter.d.ts +2 -0
  28. package/dist/formatter.js +22 -0
  29. package/dist/houses.d.ts +82 -0
  30. package/dist/houses.js +169 -0
  31. package/dist/index.d.ts +14 -0
  32. package/dist/index.js +150 -0
  33. package/dist/loader.d.ts +2 -0
  34. package/dist/loader.js +31 -0
  35. package/dist/logger.d.ts +25 -0
  36. package/dist/logger.js +73 -0
  37. package/dist/profile-store.d.ts +48 -0
  38. package/dist/profile-store.js +156 -0
  39. package/dist/riseset.d.ts +82 -0
  40. package/dist/riseset.js +185 -0
  41. package/dist/storage.d.ts +10 -0
  42. package/dist/storage.js +40 -0
  43. package/dist/time-utils.d.ts +68 -0
  44. package/dist/time-utils.js +136 -0
  45. package/dist/tool-registry.d.ts +35 -0
  46. package/dist/tool-registry.js +307 -0
  47. package/dist/tool-result.d.ts +175 -0
  48. package/dist/tool-result.js +188 -0
  49. package/dist/transits.d.ts +108 -0
  50. package/dist/transits.js +263 -0
  51. package/dist/types.d.ts +450 -0
  52. package/dist/types.js +161 -0
  53. package/example-usage.md +131 -0
  54. package/natal-chart.json +187 -0
  55. package/package.json +61 -0
  56. package/scripts/download-ephemeris.js +115 -0
  57. package/setup.sh +21 -0
  58. package/src/astro-service.ts +710 -0
  59. package/src/chart-types.ts +125 -0
  60. package/src/charts.ts +399 -0
  61. package/src/cli.ts +694 -0
  62. package/src/constants.ts +89 -0
  63. package/src/eclipses.ts +226 -0
  64. package/src/ephemeris.ts +437 -0
  65. package/src/formatter.ts +25 -0
  66. package/src/houses.ts +202 -0
  67. package/src/index.ts +170 -0
  68. package/src/loader.ts +36 -0
  69. package/src/logger.ts +104 -0
  70. package/src/profile-store.ts +285 -0
  71. package/src/riseset.ts +229 -0
  72. package/src/time-utils.ts +167 -0
  73. package/src/tool-registry.ts +357 -0
  74. package/src/tool-result.ts +283 -0
  75. package/src/transits.ts +352 -0
  76. package/src/types.ts +547 -0
  77. package/tests/README.md +173 -0
  78. package/tests/TESTING_STRATEGY.md +178 -0
  79. package/tests/fixtures/bowen-yang-chart.ts +69 -0
  80. package/tests/fixtures/calculate-expected.ts +81 -0
  81. package/tests/fixtures/expected-results.ts +117 -0
  82. package/tests/fixtures/generate-expected-simple.ts +94 -0
  83. package/tests/helpers/date-fixtures.ts +15 -0
  84. package/tests/helpers/ephem.ts +11 -0
  85. package/tests/helpers/temp.ts +9 -0
  86. package/tests/setup.ts +11 -0
  87. package/tests/unit/astro-service.test.ts +323 -0
  88. package/tests/unit/chart-types.test.ts +18 -0
  89. package/tests/unit/charts-errors.test.ts +42 -0
  90. package/tests/unit/charts.test.ts +157 -0
  91. package/tests/unit/cli-commands.test.ts +82 -0
  92. package/tests/unit/cli-profiles.test.ts +128 -0
  93. package/tests/unit/cli.test.ts +191 -0
  94. package/tests/unit/constants.test.ts +26 -0
  95. package/tests/unit/correctness-critical.test.ts +408 -0
  96. package/tests/unit/eclipses.test.ts +108 -0
  97. package/tests/unit/ephemeris.test.ts +213 -0
  98. package/tests/unit/error-handling.test.ts +116 -0
  99. package/tests/unit/formatter.test.ts +29 -0
  100. package/tests/unit/houses-errors.test.ts +27 -0
  101. package/tests/unit/houses-validation.test.ts +164 -0
  102. package/tests/unit/houses.test.ts +205 -0
  103. package/tests/unit/profile-store.test.ts +163 -0
  104. package/tests/unit/real-user-charts.test.ts +148 -0
  105. package/tests/unit/riseset.test.ts +106 -0
  106. package/tests/unit/solver-edges.test.ts +197 -0
  107. package/tests/unit/time-utils-temporal.test.ts +303 -0
  108. package/tests/unit/time-utils.test.ts +173 -0
  109. package/tests/unit/tool-registry.test.ts +222 -0
  110. package/tests/unit/tool-result.test.ts +45 -0
  111. package/tests/unit/transit-correctness.test.ts +78 -0
  112. package/tests/unit/transits.test.ts +238 -0
  113. package/tests/validation/README.md +32 -0
  114. package/tests/validation/adapters/astrolog.ts +306 -0
  115. package/tests/validation/adapters/internal.ts +184 -0
  116. package/tests/validation/compare/eclipses.ts +47 -0
  117. package/tests/validation/compare/houses.ts +76 -0
  118. package/tests/validation/compare/positions.ts +104 -0
  119. package/tests/validation/compare/riseSet.ts +48 -0
  120. package/tests/validation/compare/roots.ts +90 -0
  121. package/tests/validation/compare/transits.ts +69 -0
  122. package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
  123. package/tests/validation/fixtures/eclipses/core.ts +14 -0
  124. package/tests/validation/fixtures/houses/core.ts +47 -0
  125. package/tests/validation/fixtures/positions/core.ts +159 -0
  126. package/tests/validation/fixtures/rise-set/core.ts +20 -0
  127. package/tests/validation/fixtures/roots/core.ts +47 -0
  128. package/tests/validation/fixtures/transits/core.ts +61 -0
  129. package/tests/validation/fixtures/transits/dst.ts +21 -0
  130. package/tests/validation/oracle.spec.ts +129 -0
  131. package/tests/validation/utils/denseRootOracle.ts +269 -0
  132. package/tests/validation/utils/fixtureTypes.ts +146 -0
  133. package/tests/validation/utils/report.ts +60 -0
  134. package/tests/validation/utils/tolerances.ts +23 -0
  135. package/tests/validation/validation.spec.ts +836 -0
  136. package/tools/color-picker.html +388 -0
  137. package/tsconfig.json +17 -0
  138. 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
+ });