@@ -12,6 +12,7 @@ import kotlinx.datetime.*
1212import kotlinx.datetime.internal.*
1313import platform.windows.*
1414import kotlin.test.*
15+ import kotlin.time.Duration.Companion.hours
1516import kotlin.time.Duration.Companion.milliseconds
1617
1718class TimeZoneRulesCompleteTest {
@@ -25,7 +26,34 @@ class TimeZoneRulesCompleteTest {
2526 val inputSystemtime = alloc<SYSTEMTIME >()
2627 val outputSystemtime = alloc<SYSTEMTIME >()
2728 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 >()
2855 var i: DWORD = 0u
56+ val currentYear = Clock .System .todayIn(TimeZone .UTC ).year
2957 while (true ) {
3058 when (val dwResult: Int = EnumDynamicTimeZoneInformation (i++ , dtzi.ptr).toInt()) {
3159 ERROR_NO_MORE_ITEMS -> break
@@ -40,79 +68,103 @@ class TimeZoneRulesCompleteTest {
4068 continue
4169 }
4270 val rules = tzdb.rulesForId(id)
43- fun checkAtInstant (instant : Instant ) {
71+ fun MutableList<Mismatch>. checkAtInstant (instant : Instant ) {
4472 val ldt = instant.toLocalDateTime(dtzi, inputSystemtime.ptr, outputSystemtime.ptr)
4573 val offset = rules.infoAtInstant(instant)
4674 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)
5891 }
5992 }
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+ }
72110 }
73111 }
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- )
78112 }
79113 }
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()
108147 }
148+ } finally {
149+ RegCloseKey (hKey.value)
109150 }
110151 }
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+ ))
111162 }
112163 }
113164 else -> error(" Unexpected error code $dwResult " )
114165 }
115166 }
167+ if (issues.isNotEmpty()) throw AssertionError (issues.toString())
116168 }
117169 }
118170}
@@ -123,7 +175,8 @@ private fun Instant.toLocalDateTime(
123175 outputBuffer : CPointer <SYSTEMTIME >
124176): LocalDateTime {
125177 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()} " }
127180 return outputBuffer.pointed.toLocalDateTime()
128181}
129182
@@ -152,7 +205,34 @@ private fun SYSTEMTIME.toLocalDateTime(): LocalDateTime =
152205 nanosecond = wMilliseconds.convert<Int >() * (NANOS_PER_ONE / MILLIS_PER_ONE )
153206 )
154207
155- private val strangeTimeZones = listOf (
156- " Morocco Standard Time" , " West Bank Standard Time" , " Iran Standard Time" , " Syria Standard Time" ,
208+ private val timeZonesWithBrokenRecurringRules = listOf (
157209 " Paraguay Standard Time"
158210)
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