From d7a0a45c1737a056aff89ad4d7f5ec74d3aac224 Mon Sep 17 00:00:00 2001 From: John DeRegnaucourt Date: Sat, 6 Jan 2024 22:50:40 -0500 Subject: [PATCH] DateUtilities - improved TimeZone handling, consolidated and strengthened regex's --- .../com/cedarsoftware/util/CompactMap.java | 4 +- .../com/cedarsoftware/util/Converter.java | 64 +++- .../com/cedarsoftware/util/DateUtilities.java | 349 +++++++++--------- .../com/cedarsoftware/util/TestConverter.java | 125 ++++++- .../cedarsoftware/util/TestDateUtilities.java | 273 +++++++++----- .../util/TestExceptionUtilities.java | 2 +- 6 files changed, 546 insertions(+), 271 deletions(-) diff --git a/src/main/java/com/cedarsoftware/util/CompactMap.java b/src/main/java/com/cedarsoftware/util/CompactMap.java index d9b7cd5e6..fcb8cff5b 100644 --- a/src/main/java/com/cedarsoftware/util/CompactMap.java +++ b/src/main/java/com/cedarsoftware/util/CompactMap.java @@ -18,7 +18,7 @@ * Many developers do not realize than they may have thousands or hundreds of thousands of Maps in memory, often * representing small JSON objects. These maps (often HashMaps) usually have a table of 16/32/64... elements in them, * with many empty elements. HashMap doubles it's internal storage each time it expands, so often these Maps have - * fewer than 50% of these arrays filled.

+ * barely 50% of these arrays filled.

* * CompactMap is a Map that strives to reduce memory at all costs while retaining speed that is close to HashMap's speed. * It does this by using only one (1) member variable (of type Object) and changing it as the Map grows. It goes from @@ -35,7 +35,7 @@ * // Map you would like it to use when size() {@literal >} compactSize(). HashMap is default * protected abstract Map{@literal <}K, V{@literal >} getNewMap(); * - * // If you want case insensitivity, return true and return new CaseInsensitiveMap or TreeMap(String.CASE_INSENSITIVE_PRDER) from getNewMap() + * // If you want case insensitivity, return true and return new CaseInsensitiveMap or TreeMap(String.CASE_INSENSITIVE_ORDER) from getNewMap() * protected boolean isCaseInsensitive() { return false; } * * // When size() {@literal >} than this amount, the Map returned from getNewMap() is used to store elements. diff --git a/src/main/java/com/cedarsoftware/util/Converter.java b/src/main/java/com/cedarsoftware/util/Converter.java index 46a3593cc..ff096c504 100644 --- a/src/main/java/com/cedarsoftware/util/Converter.java +++ b/src/main/java/com/cedarsoftware/util/Converter.java @@ -13,6 +13,7 @@ import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -101,6 +102,8 @@ protected interface Work conversion.put(AtomicInteger.class, Converter::convertToAtomicInteger); conversion.put(AtomicLong.class, Converter::convertToAtomicLong); conversion.put(AtomicBoolean.class, Converter::convertToAtomicBoolean); + conversion.put(Class.class, Converter::convertToClass); + conversion.put(UUID.class, Converter::convertToUUID); conversionToString.put(String.class, fromInstance -> fromInstance); conversionToString.put(BigDecimal.class, fromInstance -> { @@ -125,7 +128,11 @@ protected interface Work Work toNoExpString = Object::toString; conversionToString.put(Double.class, toNoExpString); conversionToString.put(Float.class, toNoExpString); - + conversionToString.put(Class.class, fromInstance -> { + Class clazz = (Class) fromInstance; + return clazz.getName(); + }); + conversionToString.put(UUID.class, Object::toString); conversionToString.put(Date.class, fromInstance -> SafeSimpleDateFormat.getDateFormat("yyyy-MM-dd'T'HH:mm:ss").format(fromInstance)); conversionToString.put(Character.class, fromInstance -> "" + fromInstance); conversionToString.put(LocalDate.class, fromInstance -> { @@ -232,6 +239,50 @@ else if (fromInstance instanceof Enum) return nope(fromInstance, "String"); } + public static Class convertToClass(Object fromInstance) { + if (fromInstance instanceof Class) { + return (Class)fromInstance; + } else if (fromInstance instanceof String) { + try { + Class clazz = Class.forName((String)fromInstance); + return clazz; + } + catch (ClassNotFoundException ignore) { + } + } + throw new IllegalArgumentException("value [" + name(fromInstance) + "] could not be converted to a 'Class'"); + } + + public static UUID convertToUUID(Object fromInstance) { + try { + if (fromInstance instanceof UUID) { + return (UUID)fromInstance; + } else if (fromInstance instanceof String) { + return UUID.fromString((String)fromInstance); + } else if (fromInstance instanceof BigInteger) { + BigInteger bigInteger = (BigInteger) fromInstance; + BigInteger mask = BigInteger.valueOf(Long.MAX_VALUE); + long mostSignificantBits = bigInteger.shiftRight(64).and(mask).longValue(); + long leastSignificantBits = bigInteger.and(mask).longValue(); + return new UUID(mostSignificantBits, leastSignificantBits); + } + else if (fromInstance instanceof Map) { + Map map = (Map) fromInstance; + if (map.containsKey("mostSigBits") && map.containsKey("leastSigBits")) { + long mostSigBits = convert2long(map.get("mostSigBits")); + long leastSigBits = convert2long(map.get("leastSigBits")); + return new UUID(mostSigBits, leastSigBits); + } else { + throw new IllegalArgumentException("To convert Map to UUID, the Map must contain both a 'mostSigBits' and 'leastSigBits' key."); + } + } + } catch (Exception e) { + throw new IllegalArgumentException("value [" + name(fromInstance) + "] could not be converted to a 'UUID'", e); + } + nope(fromInstance, "UUID"); + return null; + } + /** * Convert from the passed in instance to a BigDecimal. If null or "" is passed in, this method will return a * BigDecimal with the value of 0. Possible inputs are String (base10 numeric values in string), BigInteger, @@ -374,6 +425,12 @@ else if (fromInstance instanceof BigDecimal) else if (fromInstance instanceof Number) { return new BigInteger(Long.toString(((Number) fromInstance).longValue())); + } else if (fromInstance instanceof UUID) { + UUID uuid = (UUID) fromInstance; + BigInteger mostSignificant = BigInteger.valueOf(uuid.getMostSignificantBits()); + BigInteger leastSignificant = BigInteger.valueOf(uuid.getLeastSignificantBits()); + // Shift the most significant bits to the left and add the least significant bits + return mostSignificant.shiftLeft(64).add(leastSignificant); } else if (fromInstance instanceof Boolean) { @@ -823,7 +880,7 @@ else if (fromInstance instanceof AtomicLong) } catch (Exception e) { - throw new IllegalArgumentException("value [" + name(fromInstance) + "] could not be converted to a 'LocalDateTime'", e); + throw new IllegalArgumentException("value [" + name(fromInstance) + "] could not be converted to a 'ZonedDateTime'", e); } nope(fromInstance, "LocalDateTime"); return null; @@ -1541,6 +1598,9 @@ private static String nope(Object fromInstance, String targetType) private static String name(Object fromInstance) { + if (fromInstance == null) { + return "null"; + } return fromInstance.getClass().getName() + " (" + fromInstance.toString() + ")"; } diff --git a/src/main/java/com/cedarsoftware/util/DateUtilities.java b/src/main/java/com/cedarsoftware/util/DateUtilities.java index 691afa28c..0e6c2a442 100644 --- a/src/main/java/com/cedarsoftware/util/DateUtilities.java +++ b/src/main/java/com/cedarsoftware/util/DateUtilities.java @@ -1,5 +1,8 @@ package com.cedarsoftware.util; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.Calendar; import java.util.Date; import java.util.Map; @@ -9,7 +12,62 @@ import java.util.regex.Pattern; /** - * Handy utilities for working with Java Dates. + * Utility for parsing String dates with optional times, especially when the input String formats + * may be inconsistent. This will parse the following formats (constrained only by java.util.Date limitations...best + * time resolution is milliseconds):
+ *
+ * 12-31-2023  -or-  12/31/2023     mm is 1-12 or 01-12, dd is 1-31 or 01-31, and yyyy can be 0000 to 9999.
+ *                                  
+ * 2023-12-31  -or-  2023/12/31     mm is 1-12 or 01-12, dd is 1-31 or 01-31, and yyyy can be 0000 to 9999.
+ *                                  
+ * January 6th, 2024                Month (3-4 digit abbreviation or full English name), white-space and optional comma,
+ *                                  day of month (1-31 or 0-31) with optional suffixes 1st, 3rd, 22nd, whitespace and
+ *                                  optional comma, and yyyy (0000-9999)
+ *
+ * 17th January 2024                day of month (1-31 or 0-31) with optional suffixes (e.g. 1st, 3rd, 22nd),
+ *                                  Month (3-4 digit abbreviation or full English name), whites space and optional comma,
+ *                                  and yyyy (0000-9999)
+ *
+ * 2024 January 31st                4 digit year, white space and optional comma, Month (3-4 digit abbreviation or full
+ *                                  English name), white space and optional command, and day of month with optional
+ *                                  suffixes (1st, 3rd, 22nd)
+ *
+ * Sat Jan 6 11:06:10 EST 2024      Unix/Linux style.  Day of week (3-letter or full name), Month (3-4 digit or full
+ *                                  English name), time hh:mm:ss, TimeZone (Java supported Timezone names), Year
+ * 
+ * All dates can be followed by a Time, or the time can precede the Date. Whitespace or a single letter T must separate the + * date and the time for the non-Unix time formats. The Time formats supported:
+ *
+ * hh:mm                            hours (00-23), minutes (00-59).  24 hour format.
+ * 
+ * hh:mm:ss                         hours (00-23), minutes (00-59), seconds (00-59).  24 hour format.
+ *
+ * hh:mm:ss.sssss                   hh:mm:ss and fractional seconds. Variable fractional seconds supported. Date only
+ *                                  supports up to millisecond precision, so anything after 3 decimal places is
+ *                                  effectively ignored.
+ *
+ * hh:mm:offset -or-                offset can be specified as +HH:mm, +HHmm, +HH, -HH:mm, -HHmm, -HH, or Z (GMT)
+ * hh:mm:ss.sss:offset              which will match: "12:34", "12:34:56", "12:34.789", "12:34:56.789", "12:34+01:00",
+ *                                  "12:34:56+1:00", "12:34-01", "12:34:56-1", "12:34Z", "12:34:56Z"
+ *
+ * hh:mm:zone -or-                  Zone can be specified as Z (Zooloo = UTC), older short forms: GMT, EST, CST, MST,
+ * hh:mm:ss.sss:zone                PST, IST, JST, BST etc. as well as the long forms: "America/New York", "Asia/Saigon",
+ *                                  etc. See ZoneId.getAvailableZoneIds().
+ * 
+ * DateUtilities will parse Epoch-based integer-based values. It supports the following 3 types: + *
+ * "0" through "999999"              A string of digits in this range will be parsed and returned as the number of days
+ *                                   since the Unix Epoch, January 1st, 1970 00:00:00 UTC.
+ *
+ * "1000000" through "999999999999"  A string of digits in this range will be parsed and returned as the number of seconds
+ *                                   since the Unix Epoch, January 1st, 1970 00:00:00 UTC.
+ *
+ * "1000000000000" or larger         A string of digits in this range will be parsed and returned as the number of milli-
+ *                                   seconds since the Unix Epoch, January 1st, 1970 00:00:00 UTC.
+ * 
+ * On all patterns above, if a day-of-week (e.g. Thu, Sunday, etc.) is included (front, back, or between date and time), + * it will be ignored, allowing for even more formats than what is listed here. The day-of-week is not be used to + * influence the Date calculation. * * @author John DeRegnaucourt (jdereg@gmail.com) *
@@ -27,24 +85,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -public final class DateUtilities -{ +public final class DateUtilities { + private static final Pattern allDigits = Pattern.compile("^\\d+$"); private static final String days = "(monday|mon|tuesday|tues|tue|wednesday|wed|thursday|thur|thu|friday|fri|saturday|sat|sunday|sun)"; // longer before shorter matters - private static final String mos = "(Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)"; - private static final Pattern datePattern1 = Pattern.compile("(\\d{4})[./-](\\d{1,2})[./-](\\d{1,2})"); - private static final Pattern datePattern2 = Pattern.compile("(\\d{1,2})[./-](\\d{1,2})[./-](\\d{4})"); - private static final Pattern datePattern3 = Pattern.compile(mos + "[ ]*+[,]?+[ ]*+(\\d{1,2}+)(st|nd|rd|th|)[ ]*+[,]?+[ ]*+(\\d{4})", Pattern.CASE_INSENSITIVE); - private static final Pattern datePattern4 = Pattern.compile("(\\d{1,2}+)(st|nd|rd|th|)[ ]*+[,]?+[ ]*+" + mos + "[ ]*+[,]?+[ ]*+(\\d{4})", Pattern.CASE_INSENSITIVE); - private static final Pattern datePattern5 = Pattern.compile("(\\d{4})[ ]*+[,]?+[ ]*+" + mos + "[ ]*+[,]?+[ ]*(\\d{1,2}+)(st|nd|rd|th|)", Pattern.CASE_INSENSITIVE); - private static final Pattern datePattern6 = Pattern.compile(days+"[ ]++" + mos + "[ ]++(\\d{1,2}+)[ ]++(\\d{2}:\\d{2}:\\d{2})[ ]++[A-Z]{1,3}+\\s++(\\d{4})", Pattern.CASE_INSENSITIVE); - private static final Pattern timePattern1 = Pattern.compile("(\\d{2})[:.](\\d{2})[:.](\\d{2})[.](\\d{1,10}+)([+-]\\d{2}[:]?+\\d{2}|Z)?"); - private static final Pattern timePattern2 = Pattern.compile("(\\d{2})[:.](\\d{2})[:.](\\d{2})([+-]\\d{2}[:]?\\d{2}|Z)?"); - private static final Pattern timePattern3 = Pattern.compile("(\\d{2})[:.](\\d{2})([+-]\\d{2}[:]?+\\d{2}|Z)?"); + private static final String mos = "(January|Jan|February|Feb|March|Mar|April|Apr|May|June|Jun|July|Jul|August|Aug|September|Sept|Sep|October|Oct|November|Nov|December|Dec)"; + private static final Pattern datePattern1 = Pattern.compile("(\\d{4})([./-])(\\d{1,2})\\2(\\d{1,2})|(\\d{1,2})([./-])(\\d{1,2})\\6(\\d{4})"); // \2 and \6 references the separator, enforcing same + private static final Pattern datePattern2 = Pattern.compile(mos + "[ ,]*(\\d{1,2})(st|nd|rd|th)?[ ,]*(\\d{4})", Pattern.CASE_INSENSITIVE); + private static final Pattern datePattern3 = Pattern.compile("(\\d{1,2})(st|nd|rd|th)?[ ,]*" + mos + "[ ,]*(\\d{4})", Pattern.CASE_INSENSITIVE); + private static final Pattern datePattern4 = Pattern.compile("(\\d{4})[ ,]*" + mos + "[ ,]*(\\d{1,2})(st|nd|rd|th)?", Pattern.CASE_INSENSITIVE); + private static final Pattern unixDatePattern = Pattern.compile(days + "\\s+" + mos + "\\s+(\\d{1,2})\\s+(\\d{2}:\\d{2}:\\d{2})\\s*([A-Z]{1,3})?\\s*(\\d{4})", Pattern.CASE_INSENSITIVE); + private static final Pattern timePattern = Pattern.compile("(\\d{2}):(\\d{2})(?::(\\d{2}))?(\\.\\d+)?([+-]\\d{1,2}:\\d{2}|[+-]\\d{4}|[+-]\\d{1,2}|Z|\\s+[A-Za-z][A-Za-z0-9~/._+-]+)?", Pattern.CASE_INSENSITIVE); private static final Pattern dayPattern = Pattern.compile(days, Pattern.CASE_INSENSITIVE); private static final Map months = new ConcurrentHashMap<>(); - - static - { + // Sat Jan 6 22:06:58 EST 2024 + static { // Month name to number map months.put("jan", "1"); months.put("january", "1"); @@ -73,216 +127,169 @@ public final class DateUtilities } private DateUtilities() { - super(); } - public static Date parseDate(String dateStr) - { - if (dateStr == null) - { + public static Date parseDate(String dateStr) { + if (dateStr == null) { return null; } + dateStr = dateStr.trim(); - if ("".equals(dateStr)) - { + if (dateStr.isEmpty()) { return null; } + if (allDigits.matcher(dateStr).matches()) { + return parseEpochString(dateStr); + } + // Determine which date pattern (Matcher) to use Matcher matcher = datePattern1.matcher(dateStr); - String year, month = null, day, mon = null, remains; + String year = null, month = null, day = null, mon = null, remains = "", sep, tz = null; - if (matcher.find()) - { - year = matcher.group(1); - month = matcher.group(2); - day = matcher.group(3); + if (matcher.find()) { + if (matcher.group(1) != null) { + year = matcher.group(1); + month = matcher.group(3); + day = matcher.group(4); + } else { + year = matcher.group(8); + month = matcher.group(5); + day = matcher.group(7); + } remains = matcher.replaceFirst(""); - } - else - { + } else { matcher = datePattern2.matcher(dateStr); - if (matcher.find()) - { - month = matcher.group(1); + if (matcher.find()) { + mon = matcher.group(1); day = matcher.group(2); - year = matcher.group(3); + year = matcher.group(4); remains = matcher.replaceFirst(""); - } - else - { + } else { matcher = datePattern3.matcher(dateStr); - if (matcher.find()) - { - mon = matcher.group(1); - day = matcher.group(2); + if (matcher.find()) { + day = matcher.group(1); + mon = matcher.group(3); year = matcher.group(4); remains = matcher.replaceFirst(""); - } - else - { + } else { matcher = datePattern4.matcher(dateStr); - if (matcher.find()) - { - day = matcher.group(1); - mon = matcher.group(3); - year = matcher.group(4); + if (matcher.find()) { + year = matcher.group(1); + mon = matcher.group(2); + day = matcher.group(3); remains = matcher.replaceFirst(""); - } - else - { - matcher = datePattern5.matcher(dateStr); - if (matcher.find()) - { - year = matcher.group(1); - mon = matcher.group(2); - day = matcher.group(3); - remains = matcher.replaceFirst(""); - } - else - { - matcher = datePattern6.matcher(dateStr); - if (!matcher.find()) - { - error("Unable to parse: " + dateStr); - } - year = matcher.group(5); - mon = matcher.group(2); - day = matcher.group(3); - remains = matcher.group(4); + } else { + matcher = unixDatePattern.matcher(dateStr); + if (!matcher.find()) { + throw new IllegalArgumentException("Unable to parse: " + dateStr + " as a date"); } + year = matcher.group(6); + mon = matcher.group(2); + day = matcher.group(3); + tz = matcher.group(5); + remains = matcher.group(4); } } } } - if (mon != null) - { // Month will always be in Map, because regex forces this. + if (mon != null) { // Month will always be in Map, because regex forces this. month = months.get(mon.trim().toLowerCase()); } // Determine which date pattern (Matcher) to use - String hour = null, min = null, sec = "00", milli = "0", tz = null; + String hour = null, min = null, sec = "00", milli = "0"; remains = remains.trim(); - matcher = timePattern1.matcher(remains); - if (matcher.find()) - { + matcher = timePattern.matcher(remains); + if (matcher.find()) { hour = matcher.group(1); min = matcher.group(2); - sec = matcher.group(3); - milli = matcher.group(4); - if (matcher.groupCount() > 4) - { - tz = matcher.group(5); - } - } - else - { - matcher = timePattern2.matcher(remains); - if (matcher.find()) - { - hour = matcher.group(1); - min = matcher.group(2); + if (matcher.group(3) != null) { sec = matcher.group(3); - if (matcher.groupCount() > 3) - { - tz = matcher.group(4); - } } - else - { - matcher = timePattern3.matcher(remains); - if (matcher.find()) - { - hour = matcher.group(1); - min = matcher.group(2); - if (matcher.groupCount() > 2) - { - tz = matcher.group(3); - } - } - else - { - matcher = null; - } + if (matcher.group(4) != null) { + milli = matcher.group(4).substring(1); } + if (matcher.group(5) != null) { + tz = matcher.group(5).trim(); + } + } else { + matcher = null; } - if (matcher != null) - { + if (matcher != null) { remains = matcher.replaceFirst(""); } // Clear out day of week (mon, tue, wed, ...) - if (StringUtilities.length(remains) > 0) - { + if (StringUtilities.length(remains) > 0) { Matcher dayMatcher = dayPattern.matcher(remains); - if (dayMatcher.find()) - { + if (dayMatcher.find()) { remains = dayMatcher.replaceFirst("").trim(); } } - if (StringUtilities.length(remains) > 0) - { + if (StringUtilities.length(remains) > 0) { remains = remains.trim(); - if (!remains.equals(",") && (!remains.equals("T"))) - { - error("Issue parsing data/time, other characters present: " + remains); + if (!remains.equals(",") && (!remains.equals("T"))) { + throw new IllegalArgumentException("Issue parsing data/time, other characters present: " + remains); } } Calendar c = Calendar.getInstance(); - c.clear(); - if (tz != null) - { - if ("z".equalsIgnoreCase(tz)) - { - c.setTimeZone(TimeZone.getTimeZone("GMT")); - } - else - { - c.setTimeZone(TimeZone.getTimeZone("GMT" + tz)); + if (tz != null) { + if (tz.startsWith("-") || tz.startsWith("+")) { + ZoneOffset offset = ZoneOffset.of(tz); + ZoneId zoneId = ZoneId.ofOffset("UTC", offset); + TimeZone timeZone = TimeZone.getTimeZone(zoneId); + c.setTimeZone(timeZone); + } else { + try { + ZoneId zoneId = ZoneId.of(tz); + TimeZone timeZone = TimeZone.getTimeZone(zoneId); + c.setTimeZone(timeZone); + } catch (Exception e) { + TimeZone timeZone = TimeZone.getTimeZone(tz); + if (timeZone.getRawOffset() != 0) { + c.setTimeZone(timeZone); + } else { + throw e; + } + } } } + c.clear(); // Regex prevents these from ever failing to parse int y = Integer.parseInt(year); int m = Integer.parseInt(month) - 1; // months are 0-based int d = Integer.parseInt(day); - if (m < 0 || m > 11) - { - error("Month must be between 1 and 12 inclusive, date: " + dateStr); + if (m < 0 || m > 11) { + throw new IllegalArgumentException("Month must be between 1 and 12 inclusive, date: " + dateStr); } - if (d < 1 || d > 31) - { - error("Day must be between 1 and 31 inclusive, date: " + dateStr); + if (d < 1 || d > 31) { + throw new IllegalArgumentException("Day must be between 1 and 31 inclusive, date: " + dateStr); } - if (matcher == null) - { // no [valid] time portion + if (matcher == null) { // no [valid] time portion c.set(y, m, d); - } - else - { + } else { // Regex prevents these from ever failing to parse. int h = Integer.parseInt(hour); int mn = Integer.parseInt(min); int s = Integer.parseInt(sec); - int ms = Integer.parseInt(prepareMillis(milli)); + int ms = Integer.parseInt(prepareMillis(milli)); // Must be between 0 and 999. - if (h > 23) - { - error("Hour must be between 0 and 23 inclusive, time: " + dateStr); + if (h > 23) { + throw new IllegalArgumentException("Hour must be between 0 and 23 inclusive, time: " + dateStr); } - if (mn > 59) - { - error("Minute must be between 0 and 59 inclusive, time: " + dateStr); + if (mn > 59) { + throw new IllegalArgumentException("Minute must be between 0 and 59 inclusive, time: " + dateStr); } - if (s > 59) - { - error("Second must be between 0 and 59 inclusive, time: " + dateStr); + if (s > 59) { + throw new IllegalArgumentException("Second must be between 0 and 59 inclusive, time: " + dateStr); } // regex enforces millis to number @@ -292,29 +299,31 @@ public static Date parseDate(String dateStr) return c.getTime(); } - private static String prepareMillis(String milli) - { - if (StringUtilities.isEmpty(milli)) - { + /** + * Calendar & Date are only accurate to milliseconds. + */ + private static String prepareMillis(String milli) { + if (StringUtilities.isEmpty(milli)) { return "000"; } final int len = milli.length(); - if (len == 1) - { + if (len == 1) { return milli + "00"; - } - else if (len == 2) - { + } else if (len == 2) { return milli + "0"; - } - else - { + } else { return milli.substring(0, 3); } } - private static void error(String msg) - { - throw new IllegalArgumentException(msg); + private static Date parseEpochString(String dateStr) { + long num = Long.parseLong(dateStr); + if (dateStr.length() < 7) { // days since epoch + return new Date(LocalDate.ofEpochDay(num).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()); + } else if (dateStr.length() < 12) { // seconds since epoch + return new Date(num * 1000); + } else { // millis since epoch + return new Date(num); + } } } \ No newline at end of file diff --git a/src/test/java/com/cedarsoftware/util/TestConverter.java b/src/test/java/com/cedarsoftware/util/TestConverter.java index 842db09c7..bb4822de0 100644 --- a/src/test/java/com/cedarsoftware/util/TestConverter.java +++ b/src/test/java/com/cedarsoftware/util/TestConverter.java @@ -12,7 +12,9 @@ import java.util.Calendar; import java.util.Date; import java.util.HashMap; +import java.util.Map; import java.util.TimeZone; +import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -40,8 +42,10 @@ import static com.cedarsoftware.util.Converter.convertToAtomicInteger; import static com.cedarsoftware.util.Converter.convertToAtomicLong; import static com.cedarsoftware.util.Converter.convertToBigDecimal; +import static com.cedarsoftware.util.Converter.convertToBigInteger; import static com.cedarsoftware.util.Converter.convertToByte; import static com.cedarsoftware.util.Converter.convertToCharacter; +import static com.cedarsoftware.util.Converter.convertToClass; import static com.cedarsoftware.util.Converter.convertToDate; import static com.cedarsoftware.util.Converter.convertToDouble; import static com.cedarsoftware.util.Converter.convertToFloat; @@ -53,12 +57,14 @@ import static com.cedarsoftware.util.Converter.convertToSqlDate; import static com.cedarsoftware.util.Converter.convertToString; import static com.cedarsoftware.util.Converter.convertToTimestamp; +import static com.cedarsoftware.util.Converter.convertToUUID; import static com.cedarsoftware.util.Converter.convertToZonedDateTime; import static com.cedarsoftware.util.Converter.localDateTimeToMillis; import static com.cedarsoftware.util.Converter.localDateToMillis; import static com.cedarsoftware.util.Converter.zonedDateTimeToMillis; import static com.cedarsoftware.util.TestConverter.fubar.bar; import static com.cedarsoftware.util.TestConverter.fubar.foo; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -1175,7 +1181,7 @@ public void testZonedDateTimeToOthers() } catch (IllegalArgumentException e) { - TestUtil.assertContainsIgnoreCase(e.getMessage(), "value", "not", "convert", "local"); + TestUtil.assertContainsIgnoreCase(e.getMessage(), "value", "not", "convert", "zoned"); } assert convertToZonedDateTime(null) == null; @@ -1678,4 +1684,121 @@ public void testLocalZonedDateTimeToBig() AtomicLong atomicLong = convertToAtomicLong(ZonedDateTime.of(2020, 9, 8, 13, 11, 1, 0, ZoneId.systemDefault())); assert atomicLong.get() == cal.getTime().getTime(); } + + @Test + public void testStringToClass() + { + Class clazz = convertToClass("java.math.BigInteger"); + assert clazz.getName().equals("java.math.BigInteger"); + + assertThatThrownBy(() -> convertToClass("foo.bar.baz.Qux")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("value [java.lang.String (foo.bar.baz.Qux)] could not be converted to a 'Class'"); + + assertThatThrownBy(() -> convertToClass(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("value [null] could not be converted to a 'Class'"); + + assertThatThrownBy(() -> convertToClass(16.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("value [java.lang.Double (16.0)] could not be converted to a 'Class'"); + } + + @Test + void testClassToClass() + { + Class clazz = convertToClass(TestConverter.class); + assert clazz.getName() == TestConverter.class.getName(); + } + + @Test + public void testStringToUUID() + { + UUID uuid = Converter.convertToUUID("00000000-0000-0000-0000-000000000064"); + BigInteger bigInt = Converter.convertToBigInteger(uuid); + assert bigInt.intValue() == 100; + + assertThatThrownBy(() -> Converter.convertToUUID("00000000")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("value [java.lang.String (00000000)] could not be converted to a 'UUID'"); + } + + @Test + public void testUUIDToUUID() + { + UUID uuid = Converter.convertToUUID("00000007-0000-0000-0000-000000000064"); + UUID uuid2 = Converter.convertToUUID(uuid); + assert uuid.equals(uuid2); + } + + @Test + public void testBogusToUUID() + { + assertThatThrownBy(() -> Converter.convertToUUID((short)77)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported value type [java.lang.Short (77)] attempting to convert to 'UUID'"); + } + + @Test + public void testBigIntegerToUUID() + { + UUID uuid = convertToUUID(new BigInteger("100")); + BigInteger hundred = convertToBigInteger(uuid); + assert hundred.intValue() == 100; + } + + @Test + public void testMapToUUID() + { + UUID uuid = convertToUUID(new BigInteger("100")); + Map map = new HashMap<>(); + map.put("mostSigBits", uuid.getMostSignificantBits()); + map.put("leastSigBits", uuid.getLeastSignificantBits()); + UUID hundred = convertToUUID(map); + assertEquals("00000000-0000-0000-0000-000000000064", hundred.toString()); + } + + @Test + public void testBadMapToUUID() + { + UUID uuid = convertToUUID(new BigInteger("100")); + Map map = new HashMap<>(); + map.put("leastSigBits", uuid.getLeastSignificantBits()); + assertThatThrownBy(() -> convertToUUID(map)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("value [java.util.HashMap ({leastSigBits=100})] could not be converted to a 'UUID'"); + } + + @Test + public void testUUIDToBigInteger() + { + BigInteger bigInt = Converter.convertToBigInteger(UUID.fromString("00000000-0000-0000-0000-000000000064")); + assert bigInt.intValue() == 100; + + bigInt = Converter.convertToBigInteger(UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff")); + assert bigInt.toString().equals("-18446744073709551617"); + + bigInt = Converter.convertToBigInteger(UUID.fromString("00000000-0000-0000-0000-000000000000")); + assert bigInt.intValue() == 0; + + assertThatThrownBy(() -> convertToClass(16.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("value [java.lang.Double (16.0)] could not be converted to a 'Class'"); + } + + @Test + public void testClassToString() + { + String str = Converter.convertToString(BigInteger.class); + assert str.equals("java.math.BigInteger"); + + str = Converter.convert2String(BigInteger.class); + assert str.equals("java.math.BigInteger"); + + str = Converter.convert2String(null); + assert "".equals(str); + + str = Converter.convertToString(null); + assert str == null; + } } diff --git a/src/test/java/com/cedarsoftware/util/TestDateUtilities.java b/src/test/java/com/cedarsoftware/util/TestDateUtilities.java index 531e22736..1d12a4777 100644 --- a/src/test/java/com/cedarsoftware/util/TestDateUtilities.java +++ b/src/test/java/com/cedarsoftware/util/TestDateUtilities.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -31,10 +32,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -public class TestDateUtilities +class TestDateUtilities { @Test - public void testXmlDates() + void testXmlDates() { Date t12 = DateUtilities.parseDate("2013-08-30T22:00Z"); Date t22 = DateUtilities.parseDate("2013-08-30T22:00+00:00"); @@ -92,10 +93,12 @@ public void testXmlDates() } @Test - public void testXmlDatesWithOffsets() + void testXmlDatesWithOffsets() { Date t1 = DateUtilities.parseDate("2013-08-30T22:00Z"); Date t2 = DateUtilities.parseDate("2013-08-30T22:00+01:00"); + assertEquals(60 * 60 * 1000, t1.getTime() - t2.getTime()); + Date t3 = DateUtilities.parseDate("2013-08-30T22:00-01:00"); Date t4 = DateUtilities.parseDate("2013-08-30T22:00+0100"); Date t5 = DateUtilities.parseDate("2013-08-30T22:00-0100"); @@ -151,7 +154,7 @@ public void testXmlDatesWithOffsets() } @Test - public void testXmlDatesWithMinuteOffsets() + void testXmlDatesWithMinuteOffsets() { Date t1 = DateUtilities.parseDate("2013-08-30T22:17:34.123456789Z"); Date t2 = DateUtilities.parseDate("2013-08-30T22:17:34.123456789+00:01"); @@ -176,12 +179,12 @@ public void testXmlDatesWithMinuteOffsets() assertEquals(-60 * 1000, t1.getTime() - t5.getTime()); } @Test - public void testConstructorIsPrivate() throws Exception + void testConstructorIsPrivate() throws Exception { Class c = DateUtilities.class; assertEquals(Modifier.FINAL, c.getModifiers() & Modifier.FINAL); - Constructor con = c.getDeclaredConstructor(); + Constructor con = c.getDeclaredConstructor(); assertEquals(Modifier.PRIVATE, con.getModifiers() & Modifier.PRIVATE); con.setAccessible(true); @@ -189,12 +192,12 @@ public void testConstructorIsPrivate() throws Exception } @Test - public void testDateAloneNumbers() + void testDateAloneNumbers() { Date d1 = DateUtilities.parseDate("2014-01-18"); Calendar c = Calendar.getInstance(); c.clear(); - c.set(2014, 0, 18, 0, 0, 0); + c.set(2014, Calendar.JANUARY, 18, 0, 0, 0); assertEquals(c.getTime(), d1); d1 = DateUtilities.parseDate("2014/01/18"); assertEquals(c.getTime(), d1); @@ -207,12 +210,12 @@ public void testDateAloneNumbers() } @Test - public void testDateAloneNames() + void testDateAloneNames() { Date d1 = DateUtilities.parseDate("2014 Jan 18"); Calendar c = Calendar.getInstance(); c.clear(); - c.set(2014, 0, 18, 0, 0, 0); + c.set(2014, Calendar.JANUARY, 18, 0, 0, 0); assertEquals(c.getTime(), d1); d1 = DateUtilities.parseDate("2014 January 18"); assertEquals(c.getTime(), d1); @@ -229,12 +232,12 @@ public void testDateAloneNames() } @Test - public void testDate24TimeParse() + void testDate24TimeParse() { Date d1 = DateUtilities.parseDate("2014-01-18 16:43"); Calendar c = Calendar.getInstance(); c.clear(); - c.set(2014, 0, 18, 16, 43, 0); + c.set(2014, Calendar.JANUARY, 18, 16, 43, 0); assertEquals(c.getTime(), d1); d1 = DateUtilities.parseDate("2014/01/18 16:43"); assertEquals(c.getTime(), d1); @@ -258,12 +261,12 @@ public void testDate24TimeParse() } @Test - public void testDate24TimeSecParse() + void testDate24TimeSecParse() { Date d1 = DateUtilities.parseDate("2014-01-18 16:43:27"); Calendar c = Calendar.getInstance(); c.clear(); - c.set(2014, 0, 18, 16, 43, 27); + c.set(2014, Calendar.JANUARY, 18, 16, 43, 27); assertEquals(c.getTime(), d1); d1 = DateUtilities.parseDate("2014/1/18 16:43:27"); assertEquals(c.getTime(), d1); @@ -274,12 +277,12 @@ public void testDate24TimeSecParse() } @Test - public void testDate24TimeSecMilliParse() + void testDate24TimeSecMilliParse() { Date d1 = DateUtilities.parseDate("2014-01-18 16:43:27.123"); Calendar c = Calendar.getInstance(); c.clear(); - c.set(2014, 0, 18, 16, 43, 27); + c.set(2014, Calendar.JANUARY, 18, 16, 43, 27); c.setTimeInMillis(c.getTime().getTime() + 123); assertEquals(c.getTime(), d1); d1 = DateUtilities.parseDate("2014/1/18 16:43:27.123"); @@ -300,7 +303,7 @@ public void testDate24TimeSecMilliParse() } @Test - public void testParseWithNull() + void testParseWithNull() { assertNull(DateUtilities.parseDate(null)); assertNull(DateUtilities.parseDate("")); @@ -308,7 +311,7 @@ public void testParseWithNull() } @Test - public void testDayOfWeek() + void testDayOfWeek() { DateUtilities.parseDate("thu, Dec 25, 2014"); DateUtilities.parseDate("thur, Dec 25, 2014"); @@ -334,25 +337,19 @@ public void testDayOfWeek() DateUtilities.parseDate(" Dec 25, 2014, thur "); DateUtilities.parseDate(" Dec 25, 2014, thursday "); - try - { + try { DateUtilities.parseDate("text Dec 25, 2014"); fail(); - } - catch (Exception ignored) - { } + } catch (Exception ignored) { } - try - { + try { DateUtilities.parseDate("Dec 25, 2014 text"); fail(); - } - catch (Exception ignored) - { } + } catch (Exception ignored) { } } @Test - public void testDaySuffixesLower() + void testDaySuffixesLower() { Date x = DateUtilities.parseDate("January 21st, 1994"); Calendar c = Calendar.getInstance(); @@ -402,7 +399,7 @@ public void testDaySuffixesLower() } @Test - public void testDaySuffixesUpper() + void testDaySuffixesUpper() { Date x = DateUtilities.parseDate("January 21ST, 1994"); Calendar c = Calendar.getInstance(); @@ -452,7 +449,7 @@ public void testDaySuffixesUpper() } @Test - public void testWeirdSpacing() + void testWeirdSpacing() { Date x = DateUtilities.parseDate("January 21ST , 1994"); Calendar c = Calendar.getInstance(); @@ -512,20 +509,16 @@ public void testWeirdSpacing() } @Test - public void test2DigitYear() + void test2DigitYear() { - try - { + try { DateUtilities.parseDate("07/04/19"); fail("should not make it here"); - } - catch (IllegalArgumentException e) - { - } + } catch (IllegalArgumentException ignored) {} } @Test - public void testDateToStringFormat() + void testDateToStringFormat() { Date x = new Date(); Date y = DateUtilities.parseDate(x.toString()); @@ -533,7 +526,7 @@ public void testDateToStringFormat() } @Test - public void testDatePrecision() + void testDatePrecision() { Date x = DateUtilities.parseDate("2021-01-13T13:01:54.6747552-05:00"); Date y = DateUtilities.parseDate("2021-01-13T13:01:55.2589242-05:00"); @@ -541,87 +534,177 @@ public void testDatePrecision() } @Test - public void testParseErrors() + void testTimeZoneValidShortNames() { + // Support for some of the oldie but goodies (when the TimeZone returned does not have a 0 offset) + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 JST"); // Japan + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 IST"); // India + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 CET"); // France + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 BST"); // British Summer Time + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 EST"); // Eastern Standard + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 CST"); // Central Standard + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 MST"); // Mountain Standard + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 PST"); // Pacific Standard + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 CAT"); // Central Africa Time + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 EAT"); // Eastern Africa Time + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 ART"); // Argentina Time + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 ECT"); // Ecuador Time + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 NST"); // Newfoundland Time + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 AST"); // Atlantic Standard Time + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 HST"); // Hawaii Standard Time + } + + @Test + void testTimeZoneLongName() + { + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 Asia/Saigon"); + DateUtilities.parseDate("2021-01-13T13:01:54.6747552 America/New_York"); + + assertThatThrownBy(() -> DateUtilities.parseDate("2021-01-13T13:01:54 Mumbo/Jumbo")) + .isInstanceOf(java.time.zone.ZoneRulesException.class) + .hasMessageContaining("Unknown time-zone ID: Mumbo/Jumbo"); + } + + @Test + void testOffsetTimezone() + { + Date london = DateUtilities.parseDate("2024-01-06T00:00:01 GMT"); + Date london_pos_short_offset = DateUtilities.parseDate("2024-01-6T00:00:01+00"); + Date london_pos_med_offset = DateUtilities.parseDate("2024-01-6T00:00:01+0000"); + Date london_pos_offset = DateUtilities.parseDate("2024-01-6T00:00:01+00:00"); + Date london_neg_short_offset = DateUtilities.parseDate("2024-01-6T00:00:01-00"); + Date london_neg_med_offset = DateUtilities.parseDate("2024-01-6T00:00:01-0000"); + Date london_neg_offset = DateUtilities.parseDate("2024-01-6T00:00:01-00:00"); + Date london_z = DateUtilities.parseDate("2024-01-6T00:00:01Z"); + Date london_utc = DateUtilities.parseDate("2024-01-06T00:00:01 UTC"); + + assertEquals(london, london_pos_short_offset); + assertEquals(london_pos_short_offset, london_pos_med_offset); + assertEquals(london_pos_med_offset, london_pos_short_offset); + assertEquals(london_pos_short_offset, london_pos_offset); + assertEquals(london_pos_offset, london_neg_short_offset); + assertEquals(london_neg_short_offset, london_neg_med_offset); + assertEquals(london_neg_med_offset, london_neg_offset); + assertEquals(london_neg_offset, london_z); + assertEquals(london_z, london_utc); + + Date ny = DateUtilities.parseDate("2024-01-06T00:00:01 America/New_York"); + assert ny.getTime() - london.getTime() == 5*60*60*1000; + + Date ny_offset = DateUtilities.parseDate("2024-01-6T00:00:01-5"); + assert ny_offset.getTime() - london.getTime() == 5*60*60*1000; + + Date la_offset = DateUtilities.parseDate("2024-01-6T00:00:01-08:00"); + assert la_offset.getTime() - london.getTime() == 8*60*60*1000; + } + + @Test + void testTimeBeforeDate() + { + Date x = DateUtilities.parseDate("13:01:54 2021-01-14"); + Calendar c = Calendar.getInstance(); + c.clear(); + c.set(2021, Calendar.JANUARY, 14, 13, 1, 54); + assertEquals(x, c.getTime()); + + x = DateUtilities.parseDate("13:01:54T2021-01-14"); + c.clear(); + c.set(2021, Calendar.JANUARY, 14, 13, 1, 54); + assertEquals(x, c.getTime()); + + x = DateUtilities.parseDate("13:01:54.1234567T2021-01-14"); + c.clear(); + c.set(2021, Calendar.JANUARY, 14, 13, 1, 54); + c.set(Calendar.MILLISECOND, 123); + assertEquals(x, c.getTime()); + + DateUtilities.parseDate("13:01:54.1234567ZT2021-01-14"); + DateUtilities.parseDate("13:01:54.1234567-10T2021-01-14"); + DateUtilities.parseDate("13:01:54.1234567-10:00T2021-01-14"); + x = DateUtilities.parseDate("13:01:54.1234567 America/New_York T2021-01-14"); + Date y = DateUtilities.parseDate("13:01:54.1234567-0500T2021-01-14"); + assertEquals(x, y); + } + + @Test + void testParseErrors() { - try - { + try { DateUtilities.parseDate("2014-11-j 16:43:27.123"); fail("should not make it here"); - } - catch (Exception ignored) - { - } + } catch (Exception ignored) {} - try - { + try { DateUtilities.parseDate("2014-6-10 24:43:27.123"); fail("should not make it here"); - } - catch (Exception ignored) - { - } + } catch (Exception ignored) {} - try - { + try { DateUtilities.parseDate("2014-6-10 23:61:27.123"); fail("should not make it here"); - } - catch (Exception ignored) - { - } + } catch (Exception ignored) {} - try - { + try { DateUtilities.parseDate("2014-6-10 23:00:75.123"); fail("should not make it here"); - } - catch (Exception igored) - { - } + } catch (Exception ignored) {} - try - { + try { DateUtilities.parseDate("27 Jume 2014"); fail("should not make it here"); - } - catch (Exception ignored) - { - } + } catch (Exception ignored) {} - try - { + try { DateUtilities.parseDate("13/01/2014"); fail("should not make it here"); - } - catch (Exception ignored) - { - } + } catch (Exception ignored) {} - try - { + try { DateUtilities.parseDate("00/01/2014"); fail("should not make it here"); - } - catch (Exception ignored) - { - } + } catch (Exception ignored) {} - try - { + try { DateUtilities.parseDate("12/32/2014"); fail("should not make it here"); - } - catch (Exception ignored) - { - } + } catch (Exception ignored) {} - try - { + try { DateUtilities.parseDate("12/00/2014"); fail("should not make it here"); - } - catch (Exception ignored) - { - } + } catch (Exception ignored) {} + } + + @Test + void testMacUnixDateFormat() + { + Date date = DateUtilities.parseDate("Sat Jan 6 20:06:58 EST 2024"); + Calendar calendar = Calendar.getInstance(); + calendar.clear(); + calendar.set(2024, Calendar.JANUARY, 6, 20, 6, 58); + assertEquals(calendar.getTime(), date); + Date date2 = DateUtilities.parseDate("Sat Jan 6 20:06:58 PST 2024"); + assertEquals(date2.getTime(), date.getTime() + 3*60*60*1000); + } + + @Test + void testUnixDateFormat() + { + Date date = DateUtilities.parseDate("Sat Jan 6 20:06:58 2024"); + Calendar calendar = Calendar.getInstance(); + calendar.clear(); + calendar.set(2024, Calendar.JANUARY, 6, 20, 6, 58); + assertEquals(calendar.getTime(), date); + } + + @Test + void testInconsistentDateSeparators() + { + assertThatThrownBy(() -> DateUtilities.parseDate("12/24-1996")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unable to parse: 12/24-1996 as a date"); + + assertThatThrownBy(() -> DateUtilities.parseDate("1996-12/24")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unable to parse: 1996-12/24 as a date"); } } \ No newline at end of file diff --git a/src/test/java/com/cedarsoftware/util/TestExceptionUtilities.java b/src/test/java/com/cedarsoftware/util/TestExceptionUtilities.java index cbd947b40..1edca9792 100644 --- a/src/test/java/com/cedarsoftware/util/TestExceptionUtilities.java +++ b/src/test/java/com/cedarsoftware/util/TestExceptionUtilities.java @@ -69,7 +69,7 @@ public void testGetDeepestException() { Throwable t = ExceptionUtilities.getDeepestException(e); assert t != e; - assert t.getMessage().equals("Unable to parse: foo"); + assert t.getMessage().contains("Unable to parse: foo"); } } }