@@ -12,6 +12,7 @@ import kotlinx.datetime.*
12
12
import kotlinx.datetime.internal.*
13
13
import platform.windows.*
14
14
import kotlin.test.*
15
+ import kotlin.time.Duration.Companion.hours
15
16
import kotlin.time.Duration.Companion.milliseconds
16
17
17
18
class TimeZoneRulesCompleteTest {
@@ -25,7 +26,34 @@ class TimeZoneRulesCompleteTest {
25
26
val inputSystemtime = alloc<SYSTEMTIME >()
26
27
val outputSystemtime = alloc<SYSTEMTIME >()
27
28
val dtzi = alloc<DYNAMIC_TIME_ZONE_INFORMATION >()
29
+ fun offsetAtAccordingToWindows (instant : Instant ): Int {
30
+ val ldtAccordingToWindows =
31
+ instant.toLocalDateTime(dtzi, inputSystemtime.ptr, outputSystemtime.ptr)
32
+ return (ldtAccordingToWindows.toInstant(UtcOffset .ZERO ) - instant).inWholeSeconds.toInt()
33
+ }
34
+ fun transitionsAccordingToWindows (year : Int ): List <OffsetInfo > = buildList {
35
+ var lastInstant = LocalDate (year, Month .JANUARY , 1 )
36
+ .atTime(0 , 0 ).toInstant(UtcOffset .ZERO )
37
+ var lastOffsetAccordingToWindows = offsetAtAccordingToWindows(lastInstant)
38
+ repeat(LocalDate (year, Month .DECEMBER , 31 ).dayOfYear - 1 ) {
39
+ val instant = lastInstant + 24 .hours
40
+ val offset = offsetAtAccordingToWindows(instant)
41
+ if (lastOffsetAccordingToWindows != offset) {
42
+ add(OffsetInfo (
43
+ binarySearchInstant(lastInstant, instant) {
44
+ offset == offsetAtAccordingToWindows(it)
45
+ },
46
+ UtcOffset (seconds = lastOffsetAccordingToWindows),
47
+ UtcOffset (seconds = offset)
48
+ ))
49
+ lastOffsetAccordingToWindows = offset
50
+ }
51
+ lastInstant = instant
52
+ }
53
+ }
54
+ val issues = mutableListOf<IncompatibilityWithWindowsRegistry >()
28
55
var i: DWORD = 0u
56
+ val currentYear = Clock .System .todayIn(TimeZone .UTC ).year
29
57
while (true ) {
30
58
when (val dwResult: Int = EnumDynamicTimeZoneInformation (i++ , dtzi.ptr).toInt()) {
31
59
ERROR_NO_MORE_ITEMS -> break
@@ -40,79 +68,103 @@ class TimeZoneRulesCompleteTest {
40
68
continue
41
69
}
42
70
val rules = tzdb.rulesForId(id)
43
- fun checkAtInstant (instant : Instant ) {
71
+ fun MutableList<Mismatch>. checkAtInstant (instant : Instant ) {
44
72
val ldt = instant.toLocalDateTime(dtzi, inputSystemtime.ptr, outputSystemtime.ptr)
45
73
val offset = rules.infoAtInstant(instant)
46
74
val ourLdt = instant.toLocalDateTime(offset)
47
- if (ldt != ourLdt) {
48
- val offsetsAccordingToWindows = buildList {
49
- var date = LocalDate (ldt.year, Month .JANUARY , 1 )
50
- while (date.year == ldt.year) {
51
- val instant = date.atTime(0 , 0 ).toInstant(UtcOffset .ZERO )
52
- val ldtAccordingToWindows =
53
- instant.toLocalDateTime(dtzi, inputSystemtime.ptr, outputSystemtime.ptr)
54
- val offsetAccordingToWindows =
55
- (ldtAccordingToWindows.toInstant(UtcOffset .ZERO ) - instant).inWholeSeconds
56
- add(date to offsetAccordingToWindows)
57
- date = date.plus(1 , DateTimeUnit .DAY )
75
+ if (ldt != ourLdt) add(Mismatch (ourLdt, ldt, instant))
76
+ }
77
+ fun MutableList<Mismatch>.checkTransition (instant : Instant ) {
78
+ checkAtInstant(instant - 2 .milliseconds)
79
+ checkAtInstant(instant)
80
+ }
81
+ val mismatches = buildList {
82
+ // check historical data
83
+ if (windowsName == " Central Brazilian Standard Time" ) {
84
+ // This one reports transitions on Jan 1st for years 1970..2003, but the registry contains transitions
85
+ // on the first Thursday of January.
86
+ // Neither of these is correct: https://en.wikipedia.org/wiki/Daylight_saving_time_in_Brazil
87
+ for (transition in rules.transitionEpochSeconds) {
88
+ val instant = Instant .fromEpochSeconds(transition)
89
+ if (instant.toLocalDateTime(TimeZone .UTC ).year >= 2004 ) {
90
+ checkTransition(instant)
58
91
}
59
92
}
60
- val rawData = memScoped {
61
- val hKey = alloc<HKEYVar >()
62
- RegOpenKeyExW (HKEY_LOCAL_MACHINE !! , " SOFTWARE\\ Microsoft\\ Windows NT\\ CurrentVersion\\ Time Zones\\ $windowsName " , 0u , KEY_READ .toUInt(), hKey.ptr)
63
- try {
64
- val cbDataBuffer = alloc<DWORDVar >()
65
- val SIZE_BYTES = 44
66
- val zoneInfoBuffer = allocArray<BYTEVar >(SIZE_BYTES )
67
- cbDataBuffer.value = SIZE_BYTES .convert()
68
- RegQueryValueExW (hKey.value, " TZI" , null , null , zoneInfoBuffer, cbDataBuffer.ptr)
69
- zoneInfoBuffer.readBytes(SIZE_BYTES ).toHexString()
70
- } finally {
71
- RegCloseKey (hKey.value)
93
+ } else {
94
+ for (transition in rules.transitionEpochSeconds) {
95
+ checkTransition(Instant .fromEpochSeconds(transition))
96
+ }
97
+ }
98
+ // check recurring rules
99
+ if (windowsName !in timeZonesWithBrokenRecurringRules) {
100
+ for (year in 1970 .. currentYear + 1 ) {
101
+ val rulesForYear = rules.recurringZoneRules!! .rulesForYear(year)
102
+ if (rulesForYear.isEmpty()) {
103
+ checkAtInstant(
104
+ LocalDate (year, 6 , 1 ).atStartOfDayIn(TimeZone .UTC )
105
+ )
106
+ } else {
107
+ for (rule in rulesForYear) {
108
+ checkTransition(rule.transitionDateTime)
109
+ }
72
110
}
73
111
}
74
- throw AssertionError (
75
- " Expected $ldt , got $ourLdt in zone $windowsName at $instant (our guess at the offset is $offset )." +
76
- " The rules are $rules , and the offsets throughout the year according to Windows are: $offsetsAccordingToWindows ; the raw data for the recurring rules is $rawData "
77
- )
78
112
}
79
113
}
80
- fun checkTransition (instant : Instant ) {
81
- checkAtInstant(instant - 2 .milliseconds)
82
- checkAtInstant(instant)
83
- }
84
- // check historical data
85
- for (transition in rules.transitionEpochSeconds) {
86
- checkTransition(Instant .fromEpochSeconds(transition))
87
- }
88
- // check recurring rules
89
- if (windowsName !in strangeTimeZones) {
90
- // we skip checking these time zones because Windows does something arbitrary with them
91
- // after 2030. For example, Morocco DST transitions are linked to the month of Ramadan,
92
- // and after 2030, Windows doesn't seem to calculate Ramadan properly, but also, it doesn't
93
- // follow the rules stored in the registry. Odd, but it doesn't seem worth it trying to
94
- // reverse engineer results that aren't even correct.
95
- val lastTransition = Instant .fromEpochSeconds(
96
- rules.transitionEpochSeconds.lastOrNull() ? : 1715000000 // arbitrary time
97
- )
98
- val lastTransitionYear = lastTransition.toLocalDateTime(TimeZone .UTC ).year
99
- for (year in lastTransitionYear + 1 .. lastTransitionYear + 15 ) {
100
- val rulesForYear = rules.recurringZoneRules!! .rulesForYear(year)
101
- if (rulesForYear.isEmpty()) {
102
- checkAtInstant(
103
- LocalDate (year, 6 , 1 ).atStartOfDayIn(TimeZone .UTC )
104
- )
105
- } else {
106
- for (rule in rulesForYear) {
107
- checkTransition(rule.transitionDateTime)
114
+ if (mismatches.isNotEmpty()) {
115
+ val mismatchYears =
116
+ mismatches.map { it.instant.toLocalDateTime(TimeZone .UTC ).year }.distinct()
117
+ val rawData = memScoped {
118
+ val hKey = alloc<HKEYVar >()
119
+ RegOpenKeyExW (HKEY_LOCAL_MACHINE !! , " SOFTWARE\\ Microsoft\\ Windows NT\\ CurrentVersion\\ Time Zones\\ $windowsName " , 0u , KEY_READ .toUInt(), hKey.ptr)
120
+ try {
121
+ val cbDataBuffer = alloc<DWORDVar >()
122
+ val SIZE_BYTES = 44
123
+ val zoneInfoBuffer = allocArray<BYTEVar >(SIZE_BYTES )
124
+ cbDataBuffer.value = SIZE_BYTES .convert()
125
+ RegQueryValueExW (hKey.value, " TZI" , null , null , zoneInfoBuffer, cbDataBuffer.ptr)
126
+ zoneInfoBuffer.readBytes(SIZE_BYTES ).toHexString()
127
+ } finally {
128
+ RegCloseKey (hKey.value)
129
+ }
130
+ }
131
+ val historicData = memScoped {
132
+ val hKey = alloc<HKEYVar >()
133
+ RegOpenKeyExW (HKEY_LOCAL_MACHINE !! , " SOFTWARE\\ Microsoft\\ Windows NT\\ CurrentVersion\\ Time Zones\\ $windowsName \\ Dynamic DST" , 0u , KEY_READ .toUInt(), hKey.ptr)
134
+ try {
135
+ val dwordBuffer = alloc<DWORDVar >()
136
+ val cbDataBuffer = alloc<DWORDVar >().apply { value = sizeOf<DWORDVar >().convert() }
137
+ RegQueryValueExW (hKey.value!! , " FirstEntry" , null , null , dwordBuffer.ptr.reinterpret(), cbDataBuffer.ptr)
138
+ val firstEntry = dwordBuffer.value.toInt()
139
+ RegQueryValueExW (hKey.value!! , " LastEntry" , null , null , dwordBuffer.ptr.reinterpret(), cbDataBuffer.ptr)
140
+ val lastEntry = dwordBuffer.value.toInt()
141
+ val SIZE_BYTES = 44
142
+ val zoneInfoBuffer = allocArray<BYTEVar >(SIZE_BYTES )
143
+ cbDataBuffer.value = SIZE_BYTES .convert()
144
+ (firstEntry.. lastEntry).map { year ->
145
+ RegQueryValueExW (hKey.value!! , year.toString(), null , null , zoneInfoBuffer, cbDataBuffer.ptr)
146
+ year to zoneInfoBuffer.readBytes(SIZE_BYTES ).toHexString()
108
147
}
148
+ } finally {
149
+ RegCloseKey (hKey.value)
109
150
}
110
151
}
152
+ issues.add(
153
+ IncompatibilityWithWindowsRegistry (
154
+ timeZoneName = windowsName,
155
+ dataOnAffectedYears = mismatchYears.flatMap {
156
+ transitionsAccordingToWindows(it)
157
+ },
158
+ recurringRules = rawData,
159
+ historicData = historicData,
160
+ mismatches = mismatches,
161
+ ))
111
162
}
112
163
}
113
164
else -> error(" Unexpected error code $dwResult " )
114
165
}
115
166
}
167
+ if (issues.isNotEmpty()) throw AssertionError (issues.toString())
116
168
}
117
169
}
118
170
}
@@ -123,7 +175,8 @@ private fun Instant.toLocalDateTime(
123
175
outputBuffer : CPointer <SYSTEMTIME >
124
176
): LocalDateTime {
125
177
toLocalDateTime(TimeZone .UTC ).toSystemTime(inputBuffer)
126
- SystemTimeToTzSpecificLocalTimeEx (tzinfo.ptr, inputBuffer, outputBuffer)
178
+ val result = SystemTimeToTzSpecificLocalTimeEx (tzinfo.ptr, inputBuffer, outputBuffer)
179
+ check(result != 0 ) { " SystemTimeToTzSpecificLocalTimeEx failed: ${getLastWindowsError()} " }
127
180
return outputBuffer.pointed.toLocalDateTime()
128
181
}
129
182
@@ -152,7 +205,34 @@ private fun SYSTEMTIME.toLocalDateTime(): LocalDateTime =
152
205
nanosecond = wMilliseconds.convert<Int >() * (NANOS_PER_ONE / MILLIS_PER_ONE )
153
206
)
154
207
155
- private val strangeTimeZones = listOf (
156
- " Morocco Standard Time" , " West Bank Standard Time" , " Iran Standard Time" , " Syria Standard Time" ,
208
+ private val timeZonesWithBrokenRecurringRules = listOf (
157
209
" Paraguay Standard Time"
158
210
)
211
+
212
+ private fun binarySearchInstant (instant1 : Instant , instant2 : Instant , predicate : (Instant ) -> Boolean ): Instant {
213
+ var low = instant1
214
+ var high = instant2
215
+ while (low < high) {
216
+ val mid = low + (high - low) / 2
217
+ if (predicate(mid)) {
218
+ high = mid
219
+ } else {
220
+ low = mid + 1 .milliseconds
221
+ }
222
+ }
223
+ return low
224
+ }
225
+
226
+ private data class IncompatibilityWithWindowsRegistry (
227
+ val timeZoneName : String ,
228
+ val dataOnAffectedYears : List <OffsetInfo >,
229
+ val recurringRules : String ,
230
+ val historicData : List <Pair <Int , String >>,
231
+ val mismatches : List <Mismatch >,
232
+ )
233
+
234
+ private data class Mismatch (
235
+ val ourGuess : LocalDateTime ,
236
+ val windowsGuess : LocalDateTime ,
237
+ val instant : Instant ,
238
+ )
0 commit comments