1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package android.tzdata.mts; 17 18 import static org.junit.Assert.assertEquals; 19 import static org.junit.Assert.assertFalse; 20 import static org.junit.Assert.assertTrue; 21 22 import android.icu.text.TimeZoneNames; 23 24 import org.junit.Test; 25 26 import java.time.ZoneId; 27 import java.util.ArrayList; 28 import java.util.Arrays; 29 import java.util.Collection; 30 import java.util.Date; 31 import java.util.List; 32 import java.util.Locale; 33 import java.util.Set; 34 import java.util.TimeZone; 35 import java.util.concurrent.TimeUnit; 36 37 /** 38 * Tests relating to time zone rules that could be changed by the time zone data module. These are 39 * intended to prove that a time zone data module update hasn't broken behavior. Since time zone 40 * rule mutate over time this test could be quite brittle, so it is suggested that only a few 41 * examples are tested. 42 */ 43 public class TimeZoneRulesTest { 44 45 @Test preHistoricInDaylightTime()46 public void preHistoricInDaylightTime() { 47 // A zone that lacks an explicit transition at Integer.MIN_VALUE with zic 2023a and 2024b 48 // data. 49 TimeZone tz = TimeZone.getTimeZone("CET"); 50 51 long firstTransitionTimeMillis = -1693702800000L; // Apr 30, 1916 23:00:00 GMT 52 assertEquals(7200000L, tz.getOffset(firstTransitionTimeMillis)); 53 assertTrue(tz.inDaylightTime(new Date(firstTransitionTimeMillis))); 54 55 long beforeFirstTransitionTimeMillis = firstTransitionTimeMillis - 1; 56 assertEquals(3600000L, tz.getOffset(beforeFirstTransitionTimeMillis)); 57 assertFalse(tz.inDaylightTime(new Date(beforeFirstTransitionTimeMillis))); 58 } 59 60 @Test getDisplayNameShort_nonHourOffsets()61 public void getDisplayNameShort_nonHourOffsets() { 62 TimeZone iranTz = TimeZone.getTimeZone("Asia/Tehran"); 63 assertEquals("GMT+03:30", iranTz.getDisplayName(false, TimeZone.SHORT, Locale.UK)); 64 65 TimeZone chathamTz = TimeZone.getTimeZone("Pacific/Chatham"); 66 assertEquals("GMT+12:45", chathamTz.getDisplayName(false, TimeZone.SHORT, Locale.UK)); 67 assertEquals("GMT+13:45", chathamTz.getDisplayName(true, TimeZone.SHORT, Locale.UK)); 68 } 69 70 @Test minimalTransitionZones()71 public void minimalTransitionZones() throws Exception { 72 // Zones with minimal transitions, historical or future, seem ideal for testing. 73 // UTC is also included, although it may be implemented differently from the others. 74 String[] ids = new String[] { "Africa/Bujumbura", "Indian/Cocos", "Pacific/Wake", "UTC" }; 75 for (String id : ids) { 76 TimeZone tz = TimeZone.getTimeZone(id); 77 assertFalse(tz.useDaylightTime()); 78 assertFalse(tz.inDaylightTime(new Date(Integer.MIN_VALUE))); 79 assertFalse(tz.inDaylightTime(new Date(0))); 80 assertFalse(tz.inDaylightTime(new Date(Integer.MAX_VALUE))); 81 int currentOffset = tz.getOffset(new Date(0).getTime()); 82 assertEquals(currentOffset, tz.getOffset(new Date(Integer.MIN_VALUE).getTime())); 83 assertEquals(currentOffset, tz.getOffset(new Date(Integer.MAX_VALUE).getTime())); 84 } 85 } 86 87 @Test getDSTSavings()88 public void getDSTSavings() throws Exception { 89 assertEquals(0, TimeZone.getTimeZone("UTC").getDSTSavings()); 90 assertEquals(3600000, TimeZone.getTimeZone("America/Los_Angeles").getDSTSavings()); 91 assertEquals(1800000, TimeZone.getTimeZone("Australia/Lord_Howe").getDSTSavings()); 92 } 93 94 // http://b/7955614 and http://b/8026776. 95 @Test displayNames()96 public void displayNames() throws Exception { 97 checkDisplayNames(Locale.US); 98 } 99 100 @Test displayNames_nonUS()101 public void displayNames_nonUS() throws Exception { 102 // run checkDisplayNames with an arbitrary set of Locales. 103 checkDisplayNames(Locale.CHINESE); 104 checkDisplayNames(Locale.FRENCH); 105 checkDisplayNames(Locale.forLanguageTag("bn-BD")); 106 } 107 checkDisplayNames(Locale locale)108 private static void checkDisplayNames(Locale locale) throws Exception { 109 // Check that there are no time zones that use DST but have the same display name for 110 // both standard and daylight time. 111 StringBuilder failures = new StringBuilder(); 112 for (String id : TimeZone.getAvailableIDs()) { 113 TimeZone tz = TimeZone.getTimeZone(id); 114 String longDst = tz.getDisplayName(true, TimeZone.LONG, locale); 115 String longStd = tz.getDisplayName(false, TimeZone.LONG, locale); 116 String shortDst = tz.getDisplayName(true, TimeZone.SHORT, locale); 117 String shortStd = tz.getDisplayName(false, TimeZone.SHORT, locale); 118 119 if (tz.useDaylightTime()) { 120 // The long std and dst strings must differ! 121 if (longDst.equals(longStd)) { 122 failures.append(String.format("\n%20s: LD='%s' LS='%s'!", 123 id, longDst, longStd)); 124 } 125 // The short std and dst strings must differ! 126 if (shortDst.equals(shortStd)) { 127 failures.append(String.format("\n%20s: SD='%s' SS='%s'!", 128 id, shortDst, shortStd)); 129 } 130 131 // If the short std matches the long dst, or the long std matches the short dst, 132 // it probably means we have a time zone that icu4c doesn't believe has ever 133 // observed dst. 134 if (shortStd.equals(longDst)) { 135 failures.append(String.format("\n%20s: SS='%s' LD='%s'!", 136 id, shortStd, longDst)); 137 } 138 if (longStd.equals(shortDst)) { 139 failures.append(String.format("\n%20s: LS='%s' SD='%s'!", 140 id, longStd, shortDst)); 141 } 142 143 // The long and short dst strings must differ! 144 if (longDst.equals(shortDst) && !longDst.startsWith("GMT")) { 145 failures.append(String.format("\n%20s: LD='%s' SD='%s'!", 146 id, longDst, shortDst)); 147 } 148 } 149 150 // Confidence check that whenever a display name is just a GMT string that it's the 151 // right GMT string. 152 String gmtDst = formatGmtString(tz, true); 153 String gmtStd = formatGmtString(tz, false); 154 if (isGmtString(longDst) && !longDst.equals(gmtDst)) { 155 failures.append(String.format("\n%s: LD %s", id, longDst)); 156 } 157 if (isGmtString(longStd) && !longStd.equals(gmtStd)) { 158 failures.append(String.format("\n%s: LS %s", id, longStd)); 159 } 160 if (isGmtString(shortDst) && !shortDst.equals(gmtDst)) { 161 failures.append(String.format("\n%s: SD %s", id, shortDst)); 162 } 163 if (isGmtString(shortStd) && !shortStd.equals(gmtStd)) { 164 failures.append(String.format("\n%s: SS %s", id, shortStd)); 165 } 166 } 167 assertEquals("", failures.toString()); 168 } 169 isGmtString(String s)170 private static boolean isGmtString(String s) { 171 return s.startsWith("GMT+") || s.startsWith("GMT-"); 172 } 173 formatGmtString(TimeZone tz, boolean daylight)174 private static String formatGmtString(TimeZone tz, boolean daylight) { 175 int offset = tz.getRawOffset(); 176 if (daylight) { 177 offset += tz.getDSTSavings(); 178 } 179 offset /= 60000; 180 char sign = '+'; 181 if (offset < 0) { 182 sign = '-'; 183 offset = -offset; 184 } 185 return String.format("GMT%c%02d:%02d", sign, offset / 60, offset % 60); 186 } 187 188 /** 189 * This test is to catch issues with the rules update process that could let the 190 * "negative DST" scheme enter the Android data set for either java.util.TimeZone or 191 * android.icu.util.TimeZone. 192 */ 193 @Test dstMeansSummer()194 public void dstMeansSummer() { 195 // Ireland was the original example that caused the default IANA upstream tzdata to contain 196 // a zone where DST is in the Winter (since tzdata 2018e, though it was tried in 2018a 197 // first). This change was made to historical and future transitions. 198 // 199 // The upstream reasoning went like this: "Irish *Standard* Time" is summer, so the other 200 // time must be the DST. So, DST is considered to be in the winter and the associated DST 201 // adjustment is negative from the standard time. In the old scheme "Irish Standard Time" / 202 // summer was just modeled as the DST in common with all other global time zones. 203 // 204 // Unfortunately, various users of formatting APIs assume standard and DST times are 205 // consistent and (effectively) that "DST" means "summer". We likely cannot adopt the 206 // concept of a winter DST without risking app compat issues. 207 // 208 // For example, getDisplayName(boolean daylight) has always returned the winter time for 209 // false, and the summer time for true. If we change this then it should be changed on a 210 // major release boundary, with improved APIs (e.g. a version of getDisplayName() that takes 211 // a millis), existing API behavior made dependent on target API version, and after fixing 212 // any platform code that makes incorrect assumptions about DST meaning "1 hour forward". 213 214 final String timeZoneId = "Europe/Dublin"; 215 final Locale locale = Locale.UK; 216 // 26 Oct 2015 01:00:00 GMT - one day after the start of "Greenwich Mean Time" in 217 // Europe/Dublin in 2015. An arbitrary historical example of winter in Ireland. 218 final long winterTimeMillis = 1445821200000L; 219 final String winterTimeName = "Greenwich Mean Time"; 220 final int winterOffsetRawMillis = 0; 221 final int winterOffsetDstMillis = 0; 222 223 // 30 Mar 2015 01:00:00 GMT - one day after the start of "Irish Standard Time" in 224 // Europe/Dublin in 2015. An arbitrary historical example of summer in Ireland. 225 final long summerTimeMillis = 1427677200000L; 226 final String summerTimeName = "Irish Standard Time"; 227 final int summerOffsetRawMillis = 0; 228 final int summerOffsetDstMillis = (int) TimeUnit.HOURS.toMillis(1); 229 230 // There is no common interface between java.util.TimeZone and android.icu.util.TimeZone 231 // so the tests are for each are effectively duplicated. 232 233 // java.util.TimeZone 234 { 235 java.util.TimeZone timeZone = java.util.TimeZone.getTimeZone(timeZoneId); 236 assertTrue(timeZone.useDaylightTime()); 237 238 assertFalse(timeZone.inDaylightTime(new Date(winterTimeMillis))); 239 assertTrue(timeZone.inDaylightTime(new Date(summerTimeMillis))); 240 241 assertEquals(winterOffsetRawMillis + winterOffsetDstMillis, 242 timeZone.getOffset(winterTimeMillis)); 243 assertEquals(summerOffsetRawMillis + summerOffsetDstMillis, 244 timeZone.getOffset(summerTimeMillis)); 245 assertEquals(winterTimeName, 246 timeZone.getDisplayName(false /* daylight */, java.util.TimeZone.LONG, 247 locale)); 248 assertEquals(summerTimeName, 249 timeZone.getDisplayName(true /* daylight */, java.util.TimeZone.LONG, 250 locale)); 251 } 252 253 // android.icu.util.TimeZone 254 { 255 android.icu.util.TimeZone timeZone = android.icu.util.TimeZone.getTimeZone(timeZoneId); 256 assertTrue(timeZone.useDaylightTime()); 257 258 assertFalse(timeZone.inDaylightTime(new Date(winterTimeMillis))); 259 assertTrue(timeZone.inDaylightTime(new Date(summerTimeMillis))); 260 261 assertEquals(winterOffsetRawMillis + winterOffsetDstMillis, 262 timeZone.getOffset(winterTimeMillis)); 263 assertEquals(summerOffsetRawMillis + summerOffsetDstMillis, 264 timeZone.getOffset(summerTimeMillis)); 265 266 // These methods show the trouble we'd have if callers were to take the output from 267 // inDaylightTime() and pass it to getDisplayName(). 268 assertEquals(winterTimeName, 269 timeZone.getDisplayName(false /* daylight */, android.icu.util.TimeZone.LONG, 270 locale)); 271 assertEquals(summerTimeName, 272 timeZone.getDisplayName(true /* daylight */, android.icu.util.TimeZone.LONG, 273 locale)); 274 275 // APIs not identical to java.util.TimeZone tested below. 276 int[] offsets = new int[2]; 277 timeZone.getOffset(winterTimeMillis, false /* local */, offsets); 278 assertEquals(winterOffsetRawMillis, offsets[0]); 279 assertEquals(winterOffsetDstMillis, offsets[1]); 280 281 timeZone.getOffset(summerTimeMillis, false /* local */, offsets); 282 assertEquals(summerOffsetRawMillis, offsets[0]); 283 assertEquals(summerOffsetDstMillis, offsets[1]); 284 } 285 286 // icu TimeZoneNames 287 TimeZoneNames timeZoneNames = TimeZoneNames.getInstance(locale); 288 // getDisplayName: date = winterTimeMillis 289 assertEquals(winterTimeName, timeZoneNames.getDisplayName( 290 timeZoneId, TimeZoneNames.NameType.LONG_STANDARD, winterTimeMillis)); 291 assertEquals(summerTimeName, timeZoneNames.getDisplayName( 292 timeZoneId, TimeZoneNames.NameType.LONG_DAYLIGHT, winterTimeMillis)); 293 // getDisplayName: date = summerTimeMillis 294 assertEquals(winterTimeName, timeZoneNames.getDisplayName( 295 timeZoneId, TimeZoneNames.NameType.LONG_STANDARD, summerTimeMillis)); 296 assertEquals(summerTimeName, timeZoneNames.getDisplayName( 297 timeZoneId, TimeZoneNames.NameType.LONG_DAYLIGHT, summerTimeMillis)); 298 } 299 300 /** 301 * ICU's time zone IDs may be a superset of IDs available via other APIs. 302 */ 303 @Test timeZoneIdsKnown()304 public void timeZoneIdsKnown() { 305 // java.util 306 List<String> zoneInfoDbAvailableIds = Arrays.asList(java.util.TimeZone.getAvailableIDs()); 307 checkZoneIdsAreKnownToIcu(zoneInfoDbAvailableIds); 308 309 // java.time 310 checkZoneIdsAreKnownToIcu(ZoneId.getAvailableZoneIds()); 311 } 312 checkZoneIdsAreKnownToIcu(Collection<String> zoneInfoDbAvailableIds)313 private static void checkZoneIdsAreKnownToIcu(Collection<String> zoneInfoDbAvailableIds) { 314 // ICU has a known set of IDs. We want ANY because we don't want to filter to ICU's 315 // canonical IDs only. 316 Set<String> icuAvailableIds = android.icu.util.TimeZone.getAvailableIDs( 317 android.icu.util.TimeZone.SystemTimeZoneType.ANY, null /* region */, 318 null /* rawOffset */); 319 320 List<String> nonIcuAvailableIds = new ArrayList<>(); 321 List<String> creationFailureIds = new ArrayList<>(); 322 List<String> noCanonicalLookupIds = new ArrayList<>(); 323 List<String> nonSystemIds = new ArrayList<>(); 324 for (String zoneInfoDbId : zoneInfoDbAvailableIds) { 325 if (!icuAvailableIds.contains(zoneInfoDbId)) { 326 nonIcuAvailableIds.add(zoneInfoDbId); 327 } 328 329 boolean[] isSystemId = new boolean[1]; 330 String canonicalId = android.icu.util.TimeZone.getCanonicalID(zoneInfoDbId, isSystemId); 331 if (canonicalId == null) { 332 noCanonicalLookupIds.add(zoneInfoDbId); 333 } 334 if (!isSystemId[0]) { 335 nonSystemIds.add(zoneInfoDbId); 336 } 337 338 android.icu.util.TimeZone icuTimeZone = 339 android.icu.util.TimeZone.getTimeZone(zoneInfoDbId); 340 if (icuTimeZone.getID().equals(android.icu.util.TimeZone.UNKNOWN_ZONE_ID)) { 341 creationFailureIds.add(zoneInfoDbId); 342 } 343 } 344 assertTrue("Non-ICU available IDs: " + nonIcuAvailableIds 345 + ", creation failed IDs: " + creationFailureIds 346 + ", non-system IDs: " + nonSystemIds 347 + ", ids without canonical IDs: " + noCanonicalLookupIds, 348 nonIcuAvailableIds.isEmpty() 349 && creationFailureIds.isEmpty() 350 && nonSystemIds.isEmpty() 351 && noCanonicalLookupIds.isEmpty()); 352 } 353 } 354