View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.commons.lang3.time;
18  
19  import static org.junit.jupiter.api.Assertions.assertEquals;
20  import static org.junit.jupiter.api.Assertions.assertNotEquals;
21  import static org.junit.jupiter.api.Assertions.assertNotNull;
22  import static org.junit.jupiter.api.Assertions.fail;
23  import static org.junit.jupiter.api.Assumptions.assumeFalse;
24  import static org.junit.jupiter.api.Assumptions.assumeTrue;
25  
26  import java.text.DateFormatSymbols;
27  import java.text.ParseException;
28  import java.time.ZoneId;
29  import java.util.ArrayList;
30  import java.util.Comparator;
31  import java.util.Date;
32  import java.util.List;
33  import java.util.Locale;
34  import java.util.Map;
35  import java.util.Map.Entry;
36  import java.util.Objects;
37  import java.util.Set;
38  import java.util.TimeZone;
39  import java.util.concurrent.atomic.AtomicInteger;
40  
41  import org.apache.commons.lang3.AbstractLangTest;
42  import org.apache.commons.lang3.ArraySorter;
43  import org.apache.commons.lang3.JavaVersion;
44  import org.apache.commons.lang3.LocaleUtils;
45  import org.apache.commons.lang3.SystemUtils;
46  import org.junit.jupiter.api.AfterAll;
47  import org.junit.jupiter.api.Disabled;
48  import org.junit.jupiter.api.Test;
49  import org.junit.jupiter.params.ParameterizedTest;
50  import org.junit.jupiter.params.provider.MethodSource;
51  import org.junit.jupiter.params.provider.ValueSource;
52  import org.junitpioneer.jupiter.DefaultLocale;
53  import org.junitpioneer.jupiter.DefaultTimeZone;
54  import org.junitpioneer.jupiter.ReadsDefaultLocale;
55  import org.junitpioneer.jupiter.ReadsDefaultTimeZone;
56  
57  /**
58   * Tests {@link FastDateParser}.
59   */
60  /* Make test reproducible */ @DefaultLocale(language = "en")
61  /* Make test reproducible */ @DefaultTimeZone(TimeZones.GMT_ID)
62  /* Make test reproducible */ @ReadsDefaultLocale
63  /* Make test reproducible */ @ReadsDefaultTimeZone
64  class FastDateParser_TimeZoneStrategyTest extends AbstractLangTest {
65  
66      private static final String UTC_FULL_NAME = "Coordinated Universal Time";
67      private static final List<Locale> JAVA_11_FAILURES = new ArrayList<>();
68      private static final List<Locale> JAVA_17_FAILURES = new ArrayList<>();
69      private static final AtomicInteger FAILS = new AtomicInteger();
70  
71      @AfterAll
72      public static void afterAll() {
73          if (!JAVA_17_FAILURES.isEmpty()) {
74              System.err.printf("Actual failures on Java 17+: %,d%n%s%n", JAVA_17_FAILURES.size(), JAVA_17_FAILURES);
75          }
76          if (!JAVA_11_FAILURES.isEmpty()) {
77              System.err.printf("Actual failures on Java 11: %,d%n%s%n", JAVA_11_FAILURES.size(), JAVA_11_FAILURES);
78          }
79      }
80  
81      static Set<Entry<String, String>> getZoneIdStream() {
82          return ZoneId.SHORT_IDS.entrySet();
83      }
84  
85      private String[][] getZoneStringsSorted(final Locale locale) {
86          return ArraySorter.sort(DateFormatSymbols.getInstance(locale).getZoneStrings(), Comparator.comparing(array -> array[0]));
87      }
88  
89      /**
90       * Tests that known short {@link ZoneId}s still parse since all short IDs are deprecated starting in Java 25, but are not removed.
91       *
92       * TODO: Why don't all short IDs parse, even on Java 8?
93       *
94       * @throws ParseException Thrown on test failure.
95       */
96      @ParameterizedTest
97      @ValueSource(strings = { "ACT", "CST" })
98      @ReadsDefaultLocale
99      void testJava25DeprecatedZoneId(final String shortId) throws ParseException {
100         final FastDateParser parser = new FastDateParser("dd.MM.yyyy HH:mm:ss z", TimeZone.getTimeZone(shortId), Locale.getDefault());
101         final Date date1 = parser.parse("26.10.2014 02:00:00 " + shortId);
102         // 1) parsing returns a value and doesn't throw.
103         assertNotNull(date1);
104         // 2) Something reasonable, note that getYear() subtracts 1900.
105         assertEquals(2014, date1.getYear() + 1900);
106     }
107 
108     /**
109      * Tests that {@link ZoneId#SHORT_IDS} keys and values still works as they are deprecated starting in Java 25, but not removed yet.
110      *
111      * TODO: Why don't all short IDs parse, even on Java 8?
112      *
113      * @throws ParseException Thrown on test failure.
114      */
115     @Disabled
116     @ParameterizedTest
117     @MethodSource("getZoneIdStream")
118     void testJava25DeprecatedZoneIds(final Map.Entry<String, String> entry) throws ParseException {
119         final FastDateParser parser = new FastDateParser("dd.MM.yyyy HH:mm:ss z", TimeZone.getDefault(), Locale.GERMAN);
120         final Date date1 = parser.parse("26.10.2014 02:00:00 " + entry.getKey());
121         final Date date2 = parser.parse("26.10.2014 02:00:00 " + entry.getValue());
122         assertNotEquals(date1.getTime(), date2.getTime());
123     }
124 
125     @Test
126     void testLang1219() throws ParseException {
127         final FastDateParser parser = new FastDateParser("dd.MM.yyyy HH:mm:ss z", TimeZone.getDefault(), Locale.GERMAN);
128         final Date summer = parser.parse("26.10.2014 02:00:00 MESZ");
129         final Date standard = parser.parse("26.10.2014 02:00:00 MEZ");
130         assertNotEquals(summer.getTime(), standard.getTime());
131     }
132 
133     @ParameterizedTest
134     @MethodSource("org.apache.commons.lang3.LocaleUtils#availableLocaleList()")
135     void testTimeZoneStrategy_DateFormatSymbols(final Locale locale) {
136         testTimeZoneStrategyPattern_DateFormatSymbols_getZoneStrings(locale);
137     }
138 
139     @ParameterizedTest
140     @MethodSource("org.apache.commons.lang3.LocaleUtils#availableLocaleList()")
141     void testTimeZoneStrategy_TimeZone(final Locale locale) {
142         testTimeZoneStrategyPattern_TimeZone_getAvailableIDs(locale);
143     }
144 
145     private void testTimeZoneStrategyPattern(final Locale locale, final String timeZoneId) {
146         final TimeZone timeZone = TimeZones.getTimeZone(timeZoneId);
147         final String displayName = timeZone.getDisplayName(locale);
148         final FastDateParser parser = new FastDateParser("z", timeZone, locale);
149         try {
150             parser.parse(displayName);
151         } catch (final ParseException e) {
152             final StringBuilder sb = new StringBuilder("chars: [");
153             displayName.chars().forEach(c -> sb.append(c).append(", "));
154             sb.append("], code points: [");
155             displayName.codePoints().forEach(c -> sb.append(c).append(", "));
156             sb.append("]");
157             if (displayName.contains("\uFFFD")) {
158                 System.err.printf("TimeZone ID %s displayName contains Unicode character 'REPLACEMENT CHARACTER' (U+FFFD) in '%s'%n", timeZoneId, displayName);
159             } else {
160                 // Missing "Zulu" or something else in broken JDK's GH builds?
161                 fail(String.format("displayName: '%s' for id: '%s', %s, exception: %s, %s, parser = %s", displayName, timeZoneId, sb.toString(), e,
162                         toFailureMessage(locale, null, timeZone), parser.toStringAll()));
163             }
164         }
165     }
166 
167     private void testTimeZoneStrategyPattern(final String languageTag, final String source) throws ParseException {
168         final Locale locale = Locale.forLanguageTag(languageTag);
169         final TimeZone timeZone = TimeZones.getTimeZone("Etc/UTC");
170         assumeFalse(LocaleUtils.isLanguageUndetermined(locale), () -> toFailureMessage(locale, languageTag, timeZone));
171         assumeTrue(LocaleUtils.isAvailableLocale(locale), () -> toFailureMessage(locale, languageTag, timeZone));
172         final FastDateParser parser = new FastDateParser("z", timeZone, locale);
173         parser.parse(source);
174         testTimeZoneStrategyPattern_TimeZone_getAvailableIDs(locale);
175     }
176 
177     private void testTimeZoneStrategyPattern_DateFormatSymbols_getZoneStrings(final Locale locale) {
178         Objects.requireNonNull(locale, "locale");
179         assumeFalse(LocaleUtils.isLanguageUndetermined(locale), () -> toFailureMessage(locale, null, null));
180         assumeTrue(LocaleUtils.isAvailableLocale(locale), () -> toFailureMessage(locale, null, null));
181         final String[][] zones = getZoneStringsSorted(locale);
182         for (final String[] zone : zones) {
183             for (int zIndex = 1; zIndex < zone.length; ++zIndex) {
184                 final String tzDisplay = zone[zIndex];
185                 if (tzDisplay == null) {
186                     break;
187                 }
188                 final TimeZone timeZone = TimeZone.getDefault();
189                 final FastDateParser parser = new FastDateParser("z", timeZone, locale);
190                 // An exception will be thrown and the test will fail if parsing isn't successful
191                 try {
192                     parser.parse(tzDisplay);
193                 } catch (final ParseException e) {
194                     // Hack Start
195                     // See failures on GitHub Actions builds for Java 17.
196                     final String localeStr = locale.toString();
197                     if (SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_17)
198                             && (localeStr.contains("_") || UTC_FULL_NAME.equals(tzDisplay) || "sommartid – Atyrau".equals(tzDisplay))) {
199                         JAVA_17_FAILURES.add(locale);
200                         // Mark as an assumption failure instead of a hard fail
201                         // @formatter:off
202                         System.err.printf(
203                                 "[%,d][%s] Java %s %s - Mark as an assumption failure instead of a hard fail: locale = '%s', parse = '%s'%n",
204                                 FAILS.incrementAndGet(),
205                                 Thread.currentThread().getName(),
206                                 SystemUtils.JAVA_VENDOR,
207                                 SystemUtils.JAVA_VM_VERSION,
208                                 localeStr, tzDisplay);
209                         // @formatter:on
210                         assumeTrue(false, localeStr);
211                         continue;
212                     }
213                     if (SystemUtils.IS_JAVA_11 && (localeStr.contains("_") || UTC_FULL_NAME.equals(tzDisplay))) {
214                         JAVA_11_FAILURES.add(locale);
215                         // Mark as an assumption failure instead of a hard fail
216                         // @formatter:off
217                         System.err.printf(
218                                 "[%,d][%s] Java %s %s - Mark as an assumption failure instead of a hard fail: locale = '%s', parse = '%s'%n",
219                                 FAILS.incrementAndGet(),
220                                 Thread.currentThread().getName(),
221                                 SystemUtils.JAVA_VENDOR,
222                                 SystemUtils.JAVA_VM_VERSION,
223                                 localeStr, tzDisplay);
224                         // @formatter:on
225                         assumeTrue(false, localeStr);
226                         continue;
227                     }
228                     // Hack End
229                     fail(String.format("%s: with locale = %s, zIndex = %,d, tzDisplay = '%s', parser = '%s'", e, localeStr, zIndex, tzDisplay, parser), e);
230                 }
231             }
232         }
233     }
234 
235     /**
236      * Breaks randomly on GitHub for Locale "pt_PT", TimeZone "Etc/UTC" if we do not check if the Locale's language is "undetermined".
237      *
238      * @throws ParseException Thrown if an operation in the test fails.
239      */
240     private void testTimeZoneStrategyPattern_TimeZone_getAvailableIDs(final Locale locale) {
241         Objects.requireNonNull(locale, "locale");
242         assumeFalse(LocaleUtils.isLanguageUndetermined(locale), () -> toFailureMessage(locale, null, null));
243         assumeTrue(LocaleUtils.isAvailableLocale(locale), () -> toFailureMessage(locale, null, null));
244         for (final String timeZoneId : TimeZones.SORTED_AVAILABLE_IDS) {
245             testTimeZoneStrategyPattern(locale, timeZoneId);
246         }
247     }
248 
249     @Test
250     void testTimeZoneStrategyPattern_zh_HK_Hans() throws ParseException {
251         testTimeZoneStrategyPattern("zh_HK_#Hans", "?????????");
252     }
253 
254     /**
255      * Breaks randomly on GitHub CI for Locale "pt_PT", TimeZone "Etc/UTC" if we do not check if the Locale's language is "undetermined".
256      *
257      * <pre>{@code
258      * java.text.ParseException: Unparseable date: Horário do Meridiano de Greenwich: with tzDefault =
259      * sun.util.calendar.ZoneInfo[id="Etc/UTC",offset=0,dstSavings=0,useDaylight=false,transitions=0,lastRule=null], locale = pt_LU, zones[][] size = '601',
260      * zone[] size = '7', zIndex = 3, tzDisplay = 'Horário do Meridiano de Greenwich'
261      * }</pre>
262      *
263      * @throws ParseException Thrown if an operation in the test fails.
264      */
265     @Test
266     void testTimeZoneStrategyPatternPortugal_PT() throws ParseException {
267         testTimeZoneStrategyPattern("pt_PT", "Horário do Meridiano de Greenwich");
268     }
269 
270     /**
271      * Breaks randomly on GitHub CI for Java 25 and Locale "pt_ST", TimeZone "Hora padrão de Damasco".
272      *
273      * @throws ParseException Thrown if an operation in the test fails.
274      */
275     @Test
276     void testTimeZoneStrategyPatternPortugal_ST() throws ParseException {
277         testTimeZoneStrategyPattern("pt_ST", "Hora padrão de Damasco");
278     }
279 
280     /**
281      * Breaks randomly on GitHub CI for Java 25 and Locale "pt_TL", TimeZone "Hora padrão de Damasco".
282      *
283      * @throws ParseException Thrown if an operation in the test fails.
284      */
285     @Test
286     void testTimeZoneStrategyPatternPortugal_TL() throws ParseException {
287         testTimeZoneStrategyPattern("pt_TL", "Hora padrão de Damasco");
288     }
289 
290     /**
291      * Breaks randomly on GitHub for Locale "sr_ME_#Cyrl", TimeZone "Etc/UTC" if we do not check if the Locale's language is "undetermined".
292      *
293      * <pre>{@code
294      * java.text.ParseException: Unparseable date: Srednje vreme po Griniču: with tzDefault = sun.util.calendar.ZoneInfo[id="Etc/UTC",
295      * offset=0,dstSavings=0,useDaylight=false,transitions=0,lastRule=null], locale = sr_ME_#Cyrl, zones[][] size = '601',
296      * zone[] size = '7', zIndex = 3, tzDisplay = 'Srednje vreme po Griniču'
297      * }</pre>
298      *
299      * @throws ParseException Thrown if an operation in the test fails.
300      */
301     @Test
302     void testTimeZoneStrategyPatternSuriname() throws ParseException {
303         testTimeZoneStrategyPattern("sr_ME_#Cyrl", "Srednje vreme po Griniču");
304     }
305 
306     private String toFailureMessage(final Locale locale, final String languageTag, final TimeZone timeZone) {
307         return String.format("locale = %s, languageTag = '%s', isAvailableLocale = %s, isLanguageUndetermined = %s, timeZone = %s", languageTag, locale,
308                 LocaleUtils.isAvailableLocale(locale), LocaleUtils.isLanguageUndetermined(locale), TimeZones.toTimeZone(timeZone));
309     }
310 }