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