/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.tzdata.mts; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import android.icu.text.TimeZoneNames; import org.junit.Test; import java.time.ZoneId; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.TimeZone; import java.util.concurrent.TimeUnit; /** * Tests relating to time zone rules that could be changed by the time zone data module. These are * intended to prove that a time zone data module update hasn't broken behavior. Since time zone * rule mutate over time this test could be quite brittle, so it is suggested that only a few * examples are tested. */ public class TimeZoneRulesTest { @Test public void preHistoricInDaylightTime() { // A zone that lacks an explicit transition at Integer.MIN_VALUE with zic 2019a and 2019a // data. TimeZone tz = TimeZone.getTimeZone("CET"); long firstTransitionTimeMillis = -1693706400000L; // Apr 30, 1916 22:00:00 GMT assertEquals(7200000L, tz.getOffset(firstTransitionTimeMillis)); assertTrue(tz.inDaylightTime(new Date(firstTransitionTimeMillis))); long beforeFirstTransitionTimeMillis = firstTransitionTimeMillis - 1; assertEquals(3600000L, tz.getOffset(beforeFirstTransitionTimeMillis)); assertFalse(tz.inDaylightTime(new Date(beforeFirstTransitionTimeMillis))); } @Test public void getDisplayNameShort_nonHourOffsets() { TimeZone iranTz = TimeZone.getTimeZone("Asia/Tehran"); assertEquals("GMT+03:30", iranTz.getDisplayName(false, TimeZone.SHORT, Locale.UK)); assertEquals("GMT+04:30", iranTz.getDisplayName(true, TimeZone.SHORT, Locale.UK)); } @Test public void minimalTransitionZones() throws Exception { // Zones with minimal transitions, historical or future, seem ideal for testing. // UTC is also included, although it may be implemented differently from the others. String[] ids = new String[] { "Africa/Bujumbura", "Indian/Cocos", "Pacific/Wake", "UTC" }; for (String id : ids) { TimeZone tz = TimeZone.getTimeZone(id); assertFalse(tz.useDaylightTime()); assertFalse(tz.inDaylightTime(new Date(Integer.MIN_VALUE))); assertFalse(tz.inDaylightTime(new Date(0))); assertFalse(tz.inDaylightTime(new Date(Integer.MAX_VALUE))); int currentOffset = tz.getOffset(new Date(0).getTime()); assertEquals(currentOffset, tz.getOffset(new Date(Integer.MIN_VALUE).getTime())); assertEquals(currentOffset, tz.getOffset(new Date(Integer.MAX_VALUE).getTime())); } } @Test public void getDSTSavings() throws Exception { assertEquals(0, TimeZone.getTimeZone("UTC").getDSTSavings()); assertEquals(3600000, TimeZone.getTimeZone("America/Los_Angeles").getDSTSavings()); assertEquals(1800000, TimeZone.getTimeZone("Australia/Lord_Howe").getDSTSavings()); } // http://b/7955614 and http://b/8026776. @Test public void displayNames() throws Exception { checkDisplayNames(Locale.US); } @Test public void displayNames_nonUS() throws Exception { // run checkDisplayNames with an arbitrary set of Locales. checkDisplayNames(Locale.CHINESE); checkDisplayNames(Locale.FRENCH); checkDisplayNames(Locale.forLanguageTag("bn-BD")); } private static void checkDisplayNames(Locale locale) throws Exception { // Check that there are no time zones that use DST but have the same display name for // both standard and daylight time. StringBuilder failures = new StringBuilder(); for (String id : TimeZone.getAvailableIDs()) { TimeZone tz = TimeZone.getTimeZone(id); String longDst = tz.getDisplayName(true, TimeZone.LONG, locale); String longStd = tz.getDisplayName(false, TimeZone.LONG, locale); String shortDst = tz.getDisplayName(true, TimeZone.SHORT, locale); String shortStd = tz.getDisplayName(false, TimeZone.SHORT, locale); if (tz.useDaylightTime()) { // The long std and dst strings must differ! if (longDst.equals(longStd)) { failures.append(String.format("\n%20s: LD='%s' LS='%s'!", id, longDst, longStd)); } // The short std and dst strings must differ! if (shortDst.equals(shortStd)) { failures.append(String.format("\n%20s: SD='%s' SS='%s'!", id, shortDst, shortStd)); } // If the short std matches the long dst, or the long std matches the short dst, // it probably means we have a time zone that icu4c doesn't believe has ever // observed dst. if (shortStd.equals(longDst)) { failures.append(String.format("\n%20s: SS='%s' LD='%s'!", id, shortStd, longDst)); } if (longStd.equals(shortDst)) { failures.append(String.format("\n%20s: LS='%s' SD='%s'!", id, longStd, shortDst)); } // The long and short dst strings must differ! if (longDst.equals(shortDst) && !longDst.startsWith("GMT")) { failures.append(String.format("\n%20s: LD='%s' SD='%s'!", id, longDst, shortDst)); } } // Confidence check that whenever a display name is just a GMT string that it's the // right GMT string. String gmtDst = formatGmtString(tz, true); String gmtStd = formatGmtString(tz, false); if (isGmtString(longDst) && !longDst.equals(gmtDst)) { failures.append(String.format("\n%s: LD %s", id, longDst)); } if (isGmtString(longStd) && !longStd.equals(gmtStd)) { failures.append(String.format("\n%s: LS %s", id, longStd)); } if (isGmtString(shortDst) && !shortDst.equals(gmtDst)) { failures.append(String.format("\n%s: SD %s", id, shortDst)); } if (isGmtString(shortStd) && !shortStd.equals(gmtStd)) { failures.append(String.format("\n%s: SS %s", id, shortStd)); } } assertEquals("", failures.toString()); } private static boolean isGmtString(String s) { return s.startsWith("GMT+") || s.startsWith("GMT-"); } private static String formatGmtString(TimeZone tz, boolean daylight) { int offset = tz.getRawOffset(); if (daylight) { offset += tz.getDSTSavings(); } offset /= 60000; char sign = '+'; if (offset < 0) { sign = '-'; offset = -offset; } return String.format("GMT%c%02d:%02d", sign, offset / 60, offset % 60); } /** * This test is to catch issues with the rules update process that could let the * "negative DST" scheme enter the Android data set for either java.util.TimeZone or * android.icu.util.TimeZone. */ @Test public void dstMeansSummer() { // Ireland was the original example that caused the default IANA upstream tzdata to contain // a zone where DST is in the Winter (since tzdata 2018e, though it was tried in 2018a // first). This change was made to historical and future transitions. // // The upstream reasoning went like this: "Irish *Standard* Time" is summer, so the other // time must be the DST. So, DST is considered to be in the winter and the associated DST // adjustment is negative from the standard time. In the old scheme "Irish Standard Time" / // summer was just modeled as the DST in common with all other global time zones. // // Unfortunately, various users of formatting APIs assume standard and DST times are // consistent and (effectively) that "DST" means "summer". We likely cannot adopt the // concept of a winter DST without risking app compat issues. // // For example, getDisplayName(boolean daylight) has always returned the winter time for // false, and the summer time for true. If we change this then it should be changed on a // major release boundary, with improved APIs (e.g. a version of getDisplayName() that takes // a millis), existing API behavior made dependent on target API version, and after fixing // any platform code that makes incorrect assumptions about DST meaning "1 hour forward". final String timeZoneId = "Europe/Dublin"; final Locale locale = Locale.UK; // 26 Oct 2015 01:00:00 GMT - one day after the start of "Greenwich Mean Time" in // Europe/Dublin in 2015. An arbitrary historical example of winter in Ireland. final long winterTimeMillis = 1445821200000L; final String winterTimeName = "Greenwich Mean Time"; final int winterOffsetRawMillis = 0; final int winterOffsetDstMillis = 0; // 30 Mar 2015 01:00:00 GMT - one day after the start of "Irish Standard Time" in // Europe/Dublin in 2015. An arbitrary historical example of summer in Ireland. final long summerTimeMillis = 1427677200000L; final String summerTimeName = "Irish Standard Time"; final int summerOffsetRawMillis = 0; final int summerOffsetDstMillis = (int) TimeUnit.HOURS.toMillis(1); // There is no common interface between java.util.TimeZone and android.icu.util.TimeZone // so the tests are for each are effectively duplicated. // java.util.TimeZone { java.util.TimeZone timeZone = java.util.TimeZone.getTimeZone(timeZoneId); assertTrue(timeZone.useDaylightTime()); assertFalse(timeZone.inDaylightTime(new Date(winterTimeMillis))); assertTrue(timeZone.inDaylightTime(new Date(summerTimeMillis))); assertEquals(winterOffsetRawMillis + winterOffsetDstMillis, timeZone.getOffset(winterTimeMillis)); assertEquals(summerOffsetRawMillis + summerOffsetDstMillis, timeZone.getOffset(summerTimeMillis)); assertEquals(winterTimeName, timeZone.getDisplayName(false /* daylight */, java.util.TimeZone.LONG, locale)); assertEquals(summerTimeName, timeZone.getDisplayName(true /* daylight */, java.util.TimeZone.LONG, locale)); } // android.icu.util.TimeZone { android.icu.util.TimeZone timeZone = android.icu.util.TimeZone.getTimeZone(timeZoneId); assertTrue(timeZone.useDaylightTime()); assertFalse(timeZone.inDaylightTime(new Date(winterTimeMillis))); assertTrue(timeZone.inDaylightTime(new Date(summerTimeMillis))); assertEquals(winterOffsetRawMillis + winterOffsetDstMillis, timeZone.getOffset(winterTimeMillis)); assertEquals(summerOffsetRawMillis + summerOffsetDstMillis, timeZone.getOffset(summerTimeMillis)); // These methods show the trouble we'd have if callers were to take the output from // inDaylightTime() and pass it to getDisplayName(). assertEquals(winterTimeName, timeZone.getDisplayName(false /* daylight */, android.icu.util.TimeZone.LONG, locale)); assertEquals(summerTimeName, timeZone.getDisplayName(true /* daylight */, android.icu.util.TimeZone.LONG, locale)); // APIs not identical to java.util.TimeZone tested below. int[] offsets = new int[2]; timeZone.getOffset(winterTimeMillis, false /* local */, offsets); assertEquals(winterOffsetRawMillis, offsets[0]); assertEquals(winterOffsetDstMillis, offsets[1]); timeZone.getOffset(summerTimeMillis, false /* local */, offsets); assertEquals(summerOffsetRawMillis, offsets[0]); assertEquals(summerOffsetDstMillis, offsets[1]); } // icu TimeZoneNames TimeZoneNames timeZoneNames = TimeZoneNames.getInstance(locale); // getDisplayName: date = winterTimeMillis assertEquals(winterTimeName, timeZoneNames.getDisplayName( timeZoneId, TimeZoneNames.NameType.LONG_STANDARD, winterTimeMillis)); assertEquals(summerTimeName, timeZoneNames.getDisplayName( timeZoneId, TimeZoneNames.NameType.LONG_DAYLIGHT, winterTimeMillis)); // getDisplayName: date = summerTimeMillis assertEquals(winterTimeName, timeZoneNames.getDisplayName( timeZoneId, TimeZoneNames.NameType.LONG_STANDARD, summerTimeMillis)); assertEquals(summerTimeName, timeZoneNames.getDisplayName( timeZoneId, TimeZoneNames.NameType.LONG_DAYLIGHT, summerTimeMillis)); } /** * ICU's time zone IDs may be a superset of IDs available via other APIs. */ @Test public void timeZoneIdsKnown() { // java.util List zoneInfoDbAvailableIds = Arrays.asList(java.util.TimeZone.getAvailableIDs()); checkZoneIdsAreKnownToIcu(zoneInfoDbAvailableIds); // java.time checkZoneIdsAreKnownToIcu(ZoneId.getAvailableZoneIds()); } private static void checkZoneIdsAreKnownToIcu(Collection zoneInfoDbAvailableIds) { // ICU has a known set of IDs. We want ANY because we don't want to filter to ICU's // canonical IDs only. Set icuAvailableIds = android.icu.util.TimeZone.getAvailableIDs( android.icu.util.TimeZone.SystemTimeZoneType.ANY, null /* region */, null /* rawOffset */); List nonIcuAvailableIds = new ArrayList<>(); List creationFailureIds = new ArrayList<>(); List noCanonicalLookupIds = new ArrayList<>(); List nonSystemIds = new ArrayList<>(); for (String zoneInfoDbId : zoneInfoDbAvailableIds) { if (!icuAvailableIds.contains(zoneInfoDbId)) { nonIcuAvailableIds.add(zoneInfoDbId); } boolean[] isSystemId = new boolean[1]; String canonicalId = android.icu.util.TimeZone.getCanonicalID(zoneInfoDbId, isSystemId); if (canonicalId == null) { noCanonicalLookupIds.add(zoneInfoDbId); } if (!isSystemId[0]) { nonSystemIds.add(zoneInfoDbId); } android.icu.util.TimeZone icuTimeZone = android.icu.util.TimeZone.getTimeZone(zoneInfoDbId); if (icuTimeZone.getID().equals(android.icu.util.TimeZone.UNKNOWN_ZONE_ID)) { creationFailureIds.add(zoneInfoDbId); } } assertTrue("Non-ICU available IDs: " + nonIcuAvailableIds + ", creation failed IDs: " + creationFailureIds + ", non-system IDs: " + nonSystemIds + ", ids without canonical IDs: " + noCanonicalLookupIds, nonIcuAvailableIds.isEmpty() && creationFailureIds.isEmpty() && nonSystemIds.isEmpty() && noCanonicalLookupIds.isEmpty()); } }