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