blob: 0fe9f1da31558954cfabcf7d1f5eda30a2ee98da [file] [log] [blame]
Neil Fuller808184e2020-01-16 18:35:17 +00001/*
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 */
16package android.tzdata.mts;
17
18import static org.junit.Assert.assertEquals;
19import static org.junit.Assert.assertFalse;
20import static org.junit.Assert.assertTrue;
21
22import android.icu.text.TimeZoneNames;
23
24import org.junit.Test;
25
26import java.time.ZoneId;
27import java.util.ArrayList;
28import java.util.Arrays;
29import java.util.Collection;
30import java.util.Date;
31import java.util.List;
32import java.util.Locale;
33import java.util.Set;
34import java.util.TimeZone;
35import 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
Neil Fullerc66165e2020-01-29 17:26:27 +000040 * rule mutate over time this test could be quite brittle, so it is suggested that only a few
41 * examples are tested.
Neil Fuller808184e2020-01-16 18:35:17 +000042 */
43public class TimeZoneRulesTest {
44
45 @Test
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
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
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
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
93 public void displayNames() throws Exception {
94 checkDisplayNames(Locale.US);
95 }
96
97 @Test
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
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 // Sanity 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
167 private static boolean isGmtString(String s) {
168 return s.startsWith("GMT+") || s.startsWith("GMT-");
169 }
170
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
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
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
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}