diff --git a/nifi-commons/nifi-utils/src/main/java/org/apache/nifi/util/FormatUtils.java b/nifi-commons/nifi-utils/src/main/java/org/apache/nifi/util/FormatUtils.java index 1c9140b7d9..7d2992f34a 100644 --- a/nifi-commons/nifi-utils/src/main/java/org/apache/nifi/util/FormatUtils.java +++ b/nifi-commons/nifi-utils/src/main/java/org/apache/nifi/util/FormatUtils.java @@ -17,12 +17,13 @@ package org.apache.nifi.util; import java.text.NumberFormat; +import java.util.Arrays; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; public class FormatUtils { - private static final String UNION = "|"; // for Data Sizes @@ -41,8 +42,9 @@ public class FormatUtils { private static final String WEEKS = join(UNION, "w", "wk", "wks", "week", "weeks"); private static final String VALID_TIME_UNITS = join(UNION, NANOS, MILLIS, SECS, MINS, HOURS, DAYS, WEEKS); - public static final String TIME_DURATION_REGEX = "(\\d+)\\s*(" + VALID_TIME_UNITS + ")"; + public static final String TIME_DURATION_REGEX = "([\\d.]+)\\s*(" + VALID_TIME_UNITS + ")"; public static final Pattern TIME_DURATION_PATTERN = Pattern.compile(TIME_DURATION_REGEX); + private static final List TIME_UNIT_MULTIPLIERS = Arrays.asList(1000L, 1000L, 1000L, 60L, 60L, 24L); /** * Formats the specified count by adding commas. @@ -58,7 +60,7 @@ public class FormatUtils { * Formats the specified duration in 'mm:ss.SSS' format. * * @param sourceDuration the duration to format - * @param sourceUnit the unit to interpret the duration + * @param sourceUnit the unit to interpret the duration * @return representation of the given time data in minutes/seconds */ public static String formatMinutesSeconds(final long sourceDuration, final TimeUnit sourceUnit) { @@ -79,7 +81,7 @@ public class FormatUtils { * Formats the specified duration in 'HH:mm:ss.SSS' format. * * @param sourceDuration the duration to format - * @param sourceUnit the unit to interpret the duration + * @param sourceUnit the unit to interpret the duration * @return representation of the given time data in hours/minutes/seconds */ public static String formatHoursMinutesSeconds(final long sourceDuration, final TimeUnit sourceUnit) { @@ -139,65 +141,230 @@ public class FormatUtils { return format.format(dataSize) + " bytes"; } + /** + * Returns a time duration in the requested {@link TimeUnit} after parsing the {@code String} + * input. If the resulting value is a decimal (i.e. + * {@code 25 hours -> TimeUnit.DAYS = 1.04}), the value is rounded. + * + * @param value the raw String input (i.e. "28 minutes") + * @param desiredUnit the requested output {@link TimeUnit} + * @return the whole number value of this duration in the requested units + * @deprecated As of Apache NiFi 1.9.0, because this method only returns whole numbers, use {@link #getPreciseTimeDuration(String, TimeUnit)} when possible. + */ + @Deprecated public static long getTimeDuration(final String value, final TimeUnit desiredUnit) { + return Math.round(getPreciseTimeDuration(value, desiredUnit)); + } + + /** + * Returns the parsed and converted input in the requested units. + *

+ * If the value is {@code 0 <= x < 1} in the provided units, the units will first be converted to a smaller unit to get a value >= 1 (i.e. 0.5 seconds -> 500 milliseconds). + * This is because the underlying unit conversion cannot handle decimal values. + *

+ * If the value is {@code x >= 1} but x is not a whole number, the units will first be converted to a smaller unit to attempt to get a whole number value (i.e. 1.5 seconds -> 1500 milliseconds). + *

+ * If the value is {@code x < 1000} and the units are {@code TimeUnit.NANOSECONDS}, the result will be a whole number of nanoseconds, rounded (i.e. 123.4 ns -> 123 ns). + *

+ * This method handles decimal values over {@code 1 ns}, but {@code < 1 ns} will return {@code 0} in any other unit. + *

+ * Examples: + *

+ * "10 seconds", {@code TimeUnit.MILLISECONDS} -> 10_000.0 + * "0.010 s", {@code TimeUnit.MILLISECONDS} -> 10.0 + * "0.010 s", {@code TimeUnit.SECONDS} -> 0.010 + * "0.010 ns", {@code TimeUnit.NANOSECONDS} -> 1 + * "0.010 ns", {@code TimeUnit.MICROSECONDS} -> 0 + * + * @param value the {@code String} input + * @param desiredUnit the desired output {@link TimeUnit} + * @return the parsed and converted amount (without a unit) + */ + public static double getPreciseTimeDuration(final String value, final TimeUnit desiredUnit) { final Matcher matcher = TIME_DURATION_PATTERN.matcher(value.toLowerCase()); if (!matcher.matches()) { - throw new IllegalArgumentException("Value '" + value + "' is not a valid Time Duration"); + throw new IllegalArgumentException("Value '" + value + "' is not a valid time duration"); } final String duration = matcher.group(1); final String units = matcher.group(2); - TimeUnit specifiedTimeUnit = null; - switch (units.toLowerCase()) { - case "ns": - case "nano": - case "nanos": - case "nanoseconds": - specifiedTimeUnit = TimeUnit.NANOSECONDS; - break; - case "ms": - case "milli": - case "millis": - case "milliseconds": - specifiedTimeUnit = TimeUnit.MILLISECONDS; - break; - case "s": - case "sec": - case "secs": - case "second": - case "seconds": - specifiedTimeUnit = TimeUnit.SECONDS; - break; - case "m": - case "min": - case "mins": - case "minute": - case "minutes": - specifiedTimeUnit = TimeUnit.MINUTES; - break; - case "h": - case "hr": - case "hrs": - case "hour": - case "hours": - specifiedTimeUnit = TimeUnit.HOURS; - break; - case "d": - case "day": - case "days": - specifiedTimeUnit = TimeUnit.DAYS; - break; + + double durationVal = Double.parseDouble(duration); + TimeUnit specifiedTimeUnit; + + // The TimeUnit enum doesn't have a value for WEEKS, so handle this case independently + if (isWeek(units)) { + specifiedTimeUnit = TimeUnit.DAYS; + durationVal *= 7; + } else { + specifiedTimeUnit = determineTimeUnit(units); + } + + // The units are now guaranteed to be in DAYS or smaller + long durationLong; + if (durationVal == Math.rint(durationVal)) { + durationLong = Math.round(durationVal); + } else { + // Try reducing the size of the units to make the input a long + List wholeResults = makeWholeNumberTime(durationVal, specifiedTimeUnit); + durationLong = (long) wholeResults.get(0); + specifiedTimeUnit = (TimeUnit) wholeResults.get(1); + } + + return desiredUnit.convert(durationLong, specifiedTimeUnit); + } + + /** + * Converts the provided time duration value to one that can be represented as a whole number. + * Returns a {@code List} containing the new value as a {@code long} at index 0 and the + * {@link TimeUnit} at index 1. If the incoming value is already whole, it is returned as is. + * If the incoming value cannot be made whole, a whole approximation is returned. For values + * {@code >= 1 TimeUnit.NANOSECONDS}, the value is rounded (i.e. 123.4 ns -> 123 ns). + * For values {@code < 1 TimeUnit.NANOSECONDS}, the constant [1L, {@code TimeUnit.NANOSECONDS}] is returned as the smallest measurable unit of time. + *

+ * Examples: + *

+ * 1, {@code TimeUnit.SECONDS} -> [1, {@code TimeUnit.SECONDS}] + * 1.1, {@code TimeUnit.SECONDS} -> [1100, {@code TimeUnit.MILLISECONDS}] + * 0.1, {@code TimeUnit.SECONDS} -> [100, {@code TimeUnit.MILLISECONDS}] + * 0.1, {@code TimeUnit.NANOSECONDS} -> [1, {@code TimeUnit.NANOSECONDS}] + * + * @param decimal the time duration as a decimal + * @param timeUnit the current time unit + * @return the time duration as a whole number ({@code long}) and the smaller time unit used + */ + protected static List makeWholeNumberTime(double decimal, TimeUnit timeUnit) { + // If the value is already a whole number, return it and the current time unit + if (decimal == Math.rint(decimal)) { + return Arrays.asList(new Object[]{(long) decimal, timeUnit}); + } else if (TimeUnit.NANOSECONDS == timeUnit) { + // The time unit is as small as possible + if (decimal < 1.0) { + decimal = 1; + } else { + decimal = Math.rint(decimal); + } + return Arrays.asList(new Object[]{(long) decimal, timeUnit}); + } else { + // Determine the next time unit and the respective multiplier + TimeUnit smallerTimeUnit = getSmallerTimeUnit(timeUnit); + long multiplier = calculateMultiplier(timeUnit, smallerTimeUnit); + + // Recurse with the original number converted to the smaller unit + return makeWholeNumberTime(decimal * multiplier, smallerTimeUnit); + } + } + + /** + * Returns the numerical multiplier to convert a value from {@code originalTimeUnit} to + * {@code newTimeUnit} (i.e. for {@code TimeUnit.DAYS -> TimeUnit.MINUTES} would return + * 24 * 60 = 1440). If the original and new units are the same, returns 1. If the new unit + * is larger than the original (i.e. the result would be less than 1), throws an + * {@link IllegalArgumentException}. + * + * @param originalTimeUnit the source time unit + * @param newTimeUnit the destination time unit + * @return the numerical multiplier between the units + */ + protected static long calculateMultiplier(TimeUnit originalTimeUnit, TimeUnit newTimeUnit) { + if (originalTimeUnit == newTimeUnit) { + return 1; + } else if (originalTimeUnit.ordinal() < newTimeUnit.ordinal()) { + throw new IllegalArgumentException("The original time unit '" + originalTimeUnit + "' must be larger than the new time unit '" + newTimeUnit + "'"); + } else { + int originalOrd = originalTimeUnit.ordinal(); + int newOrd = newTimeUnit.ordinal(); + + List unitMultipliers = TIME_UNIT_MULTIPLIERS.subList(newOrd, originalOrd); + return unitMultipliers.stream().reduce(1L, (a, b) -> (long) a * b); + } + } + + /** + * Returns the next smallest {@link TimeUnit} (i.e. {@code TimeUnit.DAYS -> TimeUnit.HOURS}). + * If the parameter is {@code null} or {@code TimeUnit.NANOSECONDS}, an + * {@link IllegalArgumentException} is thrown because there is no valid smaller TimeUnit. + * + * @param originalUnit the TimeUnit + * @return the next smaller TimeUnit + */ + protected static TimeUnit getSmallerTimeUnit(TimeUnit originalUnit) { + if (originalUnit == null || TimeUnit.NANOSECONDS == originalUnit) { + throw new IllegalArgumentException("Cannot determine a smaller time unit than '" + originalUnit + "'"); + } else { + return TimeUnit.values()[originalUnit.ordinal() - 1]; + } + } + + /** + * Returns {@code true} if this raw unit {@code String} is parsed as representing "weeks", which does not have a value in the {@link TimeUnit} enum. + * + * @param rawUnit the String containing the desired unit + * @return true if the unit is "weeks"; false otherwise + */ + protected static boolean isWeek(final String rawUnit) { + switch (rawUnit) { case "w": case "wk": case "wks": case "week": case "weeks": - final long durationVal = Long.parseLong(duration); - return desiredUnit.convert(durationVal, TimeUnit.DAYS)*7; + return true; + default: + return false; } + } - final long durationVal = Long.parseLong(duration); - return desiredUnit.convert(durationVal, specifiedTimeUnit); + /** + * Returns the {@link TimeUnit} enum that maps to the provided raw {@code String} input. The + * highest time unit is {@code TimeUnit.DAYS}. Any input that cannot be parsed will result in + * an {@link IllegalArgumentException}. + * + * @param rawUnit the String to parse + * @return the TimeUnit + */ + protected static TimeUnit determineTimeUnit(String rawUnit) { + switch (rawUnit.toLowerCase()) { + case "ns": + case "nano": + case "nanos": + case "nanoseconds": + return TimeUnit.NANOSECONDS; + case "µs": + case "micro": + case "micros": + case "microseconds": + return TimeUnit.MICROSECONDS; + case "ms": + case "milli": + case "millis": + case "milliseconds": + return TimeUnit.MILLISECONDS; + case "s": + case "sec": + case "secs": + case "second": + case "seconds": + return TimeUnit.SECONDS; + case "m": + case "min": + case "mins": + case "minute": + case "minutes": + return TimeUnit.MINUTES; + case "h": + case "hr": + case "hrs": + case "hour": + case "hours": + return TimeUnit.HOURS; + case "d": + case "day": + case "days": + return TimeUnit.DAYS; + default: + throw new IllegalArgumentException("Could not parse '" + rawUnit + "' to TimeUnit"); + } } public static String formatUtilization(final double utilization) { @@ -225,7 +392,7 @@ public class FormatUtils { * 3 seconds, 8 millis, 3 nanos - if includeTotalNanos = false, * 3 seconds, 8 millis, 3 nanos (3008000003 nanos) - if includeTotalNanos = true * - * @param nanos the number of nanoseconds to format + * @param nanos the number of nanoseconds to format * @param includeTotalNanos whether or not to include the total number of nanoseconds in parentheses in the returned value * @return a human-readable String that is a formatted representation of the given number of nanoseconds. */ diff --git a/nifi-commons/nifi-utils/src/test/groovy/org/apache/nifi/processor/TestFormatUtilsGroovy.groovy b/nifi-commons/nifi-utils/src/test/groovy/org/apache/nifi/processor/TestFormatUtilsGroovy.groovy deleted file mode 100644 index f3e4f46402..0000000000 --- a/nifi-commons/nifi-utils/src/test/groovy/org/apache/nifi/processor/TestFormatUtilsGroovy.groovy +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You 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 org.apache.nifi.processor - -import org.apache.nifi.util.FormatUtils -import org.junit.After -import org.junit.Before -import org.junit.BeforeClass -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.slf4j.Logger -import org.slf4j.LoggerFactory - -import java.util.concurrent.TimeUnit - -@RunWith(JUnit4.class) -class TestFormatUtilsGroovy extends GroovyTestCase { - private static final Logger logger = LoggerFactory.getLogger(TestFormatUtilsGroovy.class) - - @BeforeClass - public static void setUpOnce() throws Exception { - logger.metaClass.methodMissing = { String name, args -> - logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}") - } - } - - @Before - public void setUp() throws Exception { - - } - - @After - public void tearDown() throws Exception { - - } - - /** - * New feature test - */ - @Test - void testShouldConvertWeeks() { - // Arrange - final List WEEKS = ["1 week", "1 wk", "1 w", "1 wks", "1 weeks"] - final long EXPECTED_DAYS = 7L - - // Act - List days = WEEKS.collect { String week -> - FormatUtils.getTimeDuration(week, TimeUnit.DAYS) - } - logger.converted(days) - - // Assert - assert days.every { it == EXPECTED_DAYS } - } - - - - @Test - void testShouldHandleNegativeWeeks() { - // Arrange - final List WEEKS = ["-1 week", "-1 wk", "-1 w", "-1 weeks", "- 1 week"] - - // Act - List msgs = WEEKS.collect { String week -> - shouldFail(IllegalArgumentException) { - FormatUtils.getTimeDuration(week, TimeUnit.DAYS) - } - } - - // Assert - assert msgs.every { it =~ /Value '.*' is not a valid Time Duration/ } - } - - - - /** - * Regression test - */ - @Test - void testShouldHandleInvalidAbbreviations() { - // Arrange - final List WEEKS = ["1 work", "1 wek", "1 k"] - - // Act - List msgs = WEEKS.collect { String week -> - shouldFail(IllegalArgumentException) { - FormatUtils.getTimeDuration(week, TimeUnit.DAYS) - } - } - - // Assert - assert msgs.every { it =~ /Value '.*' is not a valid Time Duration/ } - - } - - - /** - * New feature test - */ - @Test - void testShouldHandleNoSpaceInInput() { - // Arrange - final List WEEKS = ["1week", "1wk", "1w", "1wks", "1weeks"] - final long EXPECTED_DAYS = 7L - - // Act - List days = WEEKS.collect { String week -> - FormatUtils.getTimeDuration(week, TimeUnit.DAYS) - } - logger.converted(days) - - // Assert - assert days.every { it == EXPECTED_DAYS } - } -} \ No newline at end of file diff --git a/nifi-commons/nifi-utils/src/test/groovy/org/apache/nifi/util/TestFormatUtilsGroovy.groovy b/nifi-commons/nifi-utils/src/test/groovy/org/apache/nifi/util/TestFormatUtilsGroovy.groovy new file mode 100644 index 0000000000..8fc9d0c193 --- /dev/null +++ b/nifi-commons/nifi-utils/src/test/groovy/org/apache/nifi/util/TestFormatUtilsGroovy.groovy @@ -0,0 +1,488 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.util + + +import org.junit.After +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import java.util.concurrent.TimeUnit + +@RunWith(JUnit4.class) +class TestFormatUtilsGroovy extends GroovyTestCase { + private static final Logger logger = LoggerFactory.getLogger(TestFormatUtilsGroovy.class) + + @BeforeClass + static void setUpOnce() throws Exception { + logger.metaClass.methodMissing = { String name, args -> + logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}") + } + } + + @Before + void setUp() throws Exception { + + } + + @After + void tearDown() throws Exception { + + } + + /** + * New feature test + */ + @Test + void testGetTimeDurationShouldConvertWeeks() { + // Arrange + final List WEEKS = ["1 week", "1 wk", "1 w", "1 wks", "1 weeks"] + final long EXPECTED_DAYS = 7L + + // Act + List days = WEEKS.collect { String week -> + FormatUtils.getTimeDuration(week, TimeUnit.DAYS) + } + logger.converted(days) + + // Assert + assert days.every { it == EXPECTED_DAYS } + } + + + @Test + void testGetTimeDurationShouldHandleNegativeWeeks() { + // Arrange + final List WEEKS = ["-1 week", "-1 wk", "-1 w", "-1 weeks", "- 1 week"] + + // Act + List msgs = WEEKS.collect { String week -> + shouldFail(IllegalArgumentException) { + FormatUtils.getTimeDuration(week, TimeUnit.DAYS) + } + } + + // Assert + assert msgs.every { it =~ /Value '.*' is not a valid time duration/ } + } + + /** + * Regression test + */ + @Test + void testGetTimeDurationShouldHandleInvalidAbbreviations() { + // Arrange + final List WEEKS = ["1 work", "1 wek", "1 k"] + + // Act + List msgs = WEEKS.collect { String week -> + shouldFail(IllegalArgumentException) { + FormatUtils.getTimeDuration(week, TimeUnit.DAYS) + } + } + + // Assert + assert msgs.every { it =~ /Value '.*' is not a valid time duration/ } + + } + + /** + * New feature test + */ + @Test + void testGetTimeDurationShouldHandleNoSpaceInInput() { + // Arrange + final List WEEKS = ["1week", "1wk", "1w", "1wks", "1weeks"] + final long EXPECTED_DAYS = 7L + + // Act + List days = WEEKS.collect { String week -> + FormatUtils.getTimeDuration(week, TimeUnit.DAYS) + } + logger.converted(days) + + // Assert + assert days.every { it == EXPECTED_DAYS } + } + + /** + * New feature test + */ + @Test + void testGetTimeDurationShouldHandleDecimalValues() { + // Arrange + final List WHOLE_NUMBERS = ["10 ms", "10 millis", "10 milliseconds"] + final List DECIMAL_NUMBERS = ["0.010 s", "0.010 seconds"] + final long EXPECTED_MILLIS = 10 + + // Act + List parsedWholeMillis = WHOLE_NUMBERS.collect { String whole -> + FormatUtils.getTimeDuration(whole, TimeUnit.MILLISECONDS) + } + logger.converted(parsedWholeMillis) + + List parsedDecimalMillis = DECIMAL_NUMBERS.collect { String decimal -> + FormatUtils.getTimeDuration(decimal, TimeUnit.MILLISECONDS) + } + logger.converted(parsedDecimalMillis) + + // Assert + assert parsedWholeMillis.every { it == EXPECTED_MILLIS } + assert parsedDecimalMillis.every { it == EXPECTED_MILLIS } + } + + /** + * Regression test for custom week logic + */ + @Test + void testGetPreciseTimeDurationShouldHandleWeeks() { + // Arrange + final String ONE_WEEK = "1 week" + final Map ONE_WEEK_IN_OTHER_UNITS = [ + (TimeUnit.DAYS) : 7, + (TimeUnit.HOURS) : 7 * 24, + (TimeUnit.MINUTES) : 7 * 24 * 60, + (TimeUnit.SECONDS) : (long) 7 * 24 * 60 * 60, + (TimeUnit.MILLISECONDS): (long) 7 * 24 * 60 * 60 * 1000, + (TimeUnit.MICROSECONDS): (long) 7 * 24 * 60 * 60 * ((long) 1000 * 1000), + (TimeUnit.NANOSECONDS) : (long) 7 * 24 * 60 * 60 * ((long) 1000 * 1000 * 1000), + ] + + // Act + Map oneWeekInOtherUnits = TimeUnit.values()[0..<-1].collectEntries { TimeUnit destinationUnit -> + [destinationUnit, FormatUtils.getPreciseTimeDuration(ONE_WEEK, destinationUnit)] + } + logger.converted(oneWeekInOtherUnits) + + // Assert + oneWeekInOtherUnits.each { TimeUnit k, double value -> + assert value == ONE_WEEK_IN_OTHER_UNITS[k] + } + } + + /** + * Positive flow test for custom week logic with decimal value + */ + @Test + void testGetPreciseTimeDurationShouldHandleDecimalWeeks() { + // Arrange + final String ONE_AND_A_HALF_WEEKS = "1.5 week" + final Map ONE_POINT_FIVE_WEEKS_IN_OTHER_UNITS = [ + (TimeUnit.DAYS) : 7, + (TimeUnit.HOURS) : 7 * 24, + (TimeUnit.MINUTES) : 7 * 24 * 60, + (TimeUnit.SECONDS) : (long) 7 * 24 * 60 * 60, + (TimeUnit.MILLISECONDS): (long) 7 * 24 * 60 * 60 * 1000, + (TimeUnit.MICROSECONDS): (long) 7 * 24 * 60 * 60 * ((long) 1000 * 1000), + (TimeUnit.NANOSECONDS) : (long) 7 * 24 * 60 * 60 * ((long) 1000 * 1000 * 1000), + ].collectEntries { k, v -> [k, v * 1.5] } + + // Act + Map onePointFiveWeeksInOtherUnits = TimeUnit.values()[0..<-1].collectEntries { TimeUnit destinationUnit -> + [destinationUnit, FormatUtils.getPreciseTimeDuration(ONE_AND_A_HALF_WEEKS, destinationUnit)] + } + logger.converted(onePointFiveWeeksInOtherUnits) + + // Assert + onePointFiveWeeksInOtherUnits.each { TimeUnit k, double value -> + assert value == ONE_POINT_FIVE_WEEKS_IN_OTHER_UNITS[k] + } + } + + /** + * Positive flow test for decimal time inputs + */ + @Test + void testGetPreciseTimeDurationShouldHandleDecimalValues() { + // Arrange + final List WHOLE_NUMBERS = ["10 ms", "10 millis", "10 milliseconds"] + final List DECIMAL_NUMBERS = ["0.010 s", "0.010 seconds"] + final float EXPECTED_MILLIS = 10.0 + + // Act + List parsedWholeMillis = WHOLE_NUMBERS.collect { String whole -> + FormatUtils.getPreciseTimeDuration(whole, TimeUnit.MILLISECONDS) + } + logger.converted(parsedWholeMillis) + + List parsedDecimalMillis = DECIMAL_NUMBERS.collect { String decimal -> + FormatUtils.getPreciseTimeDuration(decimal, TimeUnit.MILLISECONDS) + } + logger.converted(parsedDecimalMillis) + + // Assert + assert parsedWholeMillis.every { it == EXPECTED_MILLIS } + assert parsedDecimalMillis.every { it == EXPECTED_MILLIS } + } + + /** + * Positive flow test for decimal inputs that are extremely small + */ + @Test + void testGetPreciseTimeDurationShouldHandleSmallDecimalValues() { + // Arrange + final Map SCENARIOS = [ + "decimalNanos" : [originalUnits: TimeUnit.NANOSECONDS, expectedUnits: TimeUnit.NANOSECONDS, originalValue: 123.4, expectedValue: 123.0], + "lessThanOneNano" : [originalUnits: TimeUnit.NANOSECONDS, expectedUnits: TimeUnit.NANOSECONDS, originalValue: 0.9, expectedValue: 1], + "lessThanOneNanoToMillis": [originalUnits: TimeUnit.NANOSECONDS, expectedUnits: TimeUnit.MILLISECONDS, originalValue: 0.9, expectedValue: 0], + "decimalMillisToNanos" : [originalUnits: TimeUnit.MILLISECONDS, expectedUnits: TimeUnit.NANOSECONDS, originalValue: 123.4, expectedValue: 123_400_000], + ] + + // Act + Map results = SCENARIOS.collectEntries { String k, Map values -> + logger.debug("Evaluating ${k}: ${values}") + String input = "${values.originalValue} ${values.originalUnits.name()}" + [k, FormatUtils.getPreciseTimeDuration(input, values.expectedUnits)] + } + logger.info(results) + + // Assert + results.every { String key, double value -> + assert value == SCENARIOS[key].expectedValue + } + } + + /** + * Positive flow test for decimal inputs that can be converted (all equal values) + */ + @Test + void testMakeWholeNumberTimeShouldHandleDecimals() { + // Arrange + final List DECIMAL_TIMES = [ + [0.000_000_010, TimeUnit.SECONDS], + [0.000_010, TimeUnit.MILLISECONDS], + [0.010, TimeUnit.MICROSECONDS] + ] + final long EXPECTED_NANOS = 10L + + // Act + List parsedWholeNanos = DECIMAL_TIMES.collect { List it -> + FormatUtils.makeWholeNumberTime(it[0] as float, it[1] as TimeUnit) + } + logger.converted(parsedWholeNanos) + + // Assert + assert parsedWholeNanos.every { it == [EXPECTED_NANOS, TimeUnit.NANOSECONDS] } + } + + /** + * Positive flow test for decimal inputs that can be converted (metric values) + */ + @Test + void testMakeWholeNumberTimeShouldHandleMetricConversions() { + // Arrange + final Map SCENARIOS = [ + "secondsToMillis": [originalUnits: TimeUnit.SECONDS, expectedUnits: TimeUnit.MILLISECONDS, expectedValue: 123_400, originalValue: 123.4], + "secondsToMicros": [originalUnits: TimeUnit.SECONDS, expectedUnits: TimeUnit.MICROSECONDS, originalValue: 1.000_345, expectedValue: 1_000_345], + "millisToNanos" : [originalUnits: TimeUnit.MILLISECONDS, expectedUnits: TimeUnit.NANOSECONDS, originalValue: 0.75, expectedValue: 750_000], + "nanosToNanosGE1": [originalUnits: TimeUnit.NANOSECONDS, expectedUnits: TimeUnit.NANOSECONDS, originalValue: 123.4, expectedValue: 123], + "nanosToNanosLE1": [originalUnits: TimeUnit.NANOSECONDS, expectedUnits: TimeUnit.NANOSECONDS, originalValue: 0.123, expectedValue: 1], + ] + + // Act + Map results = SCENARIOS.collectEntries { String k, Map values -> + logger.debug("Evaluating ${k}: ${values}") + [k, FormatUtils.makeWholeNumberTime(values.originalValue, values.originalUnits)] + } + logger.info(results) + + // Assert + results.every { String key, List values -> + assert values.first() == SCENARIOS[key].expectedValue + assert values.last() == SCENARIOS[key].expectedUnits + } + } + + /** + * Positive flow test for decimal inputs that can be converted (non-metric values) + */ + @Test + void testMakeWholeNumberTimeShouldHandleNonMetricConversions() { + // Arrange + final Map SCENARIOS = [ + "daysToHours" : [originalUnits: TimeUnit.DAYS, expectedUnits: TimeUnit.HOURS, expectedValue: 36, originalValue: 1.5], + "hoursToMinutes" : [originalUnits: TimeUnit.HOURS, expectedUnits: TimeUnit.MINUTES, originalValue: 1.5, expectedValue: 90], + "hoursToMinutes2": [originalUnits: TimeUnit.HOURS, expectedUnits: TimeUnit.MINUTES, originalValue: 0.75, expectedValue: 45], + ] + + // Act + Map results = SCENARIOS.collectEntries { String k, Map values -> + logger.debug("Evaluating ${k}: ${values}") + [k, FormatUtils.makeWholeNumberTime(values.originalValue, values.originalUnits)] + } + logger.info(results) + + // Assert + results.every { String key, List values -> + assert values.first() == SCENARIOS[key].expectedValue + assert values.last() == SCENARIOS[key].expectedUnits + } + } + + /** + * Positive flow test for whole inputs + */ + @Test + void testMakeWholeNumberTimeShouldHandleWholeNumbers() { + // Arrange + final List WHOLE_TIMES = [ + [10.0, TimeUnit.DAYS], + [10.0, TimeUnit.HOURS], + [10.0, TimeUnit.MINUTES], + [10.0, TimeUnit.SECONDS], + [10.0, TimeUnit.MILLISECONDS], + [10.0, TimeUnit.MICROSECONDS], + [10.0, TimeUnit.NANOSECONDS], + ] + + // Act + List parsedWholeTimes = WHOLE_TIMES.collect { List it -> + FormatUtils.makeWholeNumberTime(it[0] as float, it[1] as TimeUnit) + } + logger.converted(parsedWholeTimes) + + // Assert + parsedWholeTimes.eachWithIndex { List elements, int i -> + assert elements[0] instanceof Long + assert elements[0] == 10L + assert elements[1] == WHOLE_TIMES[i][1] + } + } + + /** + * Negative flow test for nanosecond inputs (regardless of value, the unit cannot be converted) + */ + @Test + void testMakeWholeNumberTimeShouldHandleNanoseconds() { + // Arrange + final List WHOLE_TIMES = [ + [1100.0, TimeUnit.NANOSECONDS], + [2.1, TimeUnit.NANOSECONDS], + [1.0, TimeUnit.NANOSECONDS], + [0.1, TimeUnit.NANOSECONDS], + ] + + final List EXPECTED_TIMES = [ + [1100L, TimeUnit.NANOSECONDS], + [2L, TimeUnit.NANOSECONDS], + [1L, TimeUnit.NANOSECONDS], + [1L, TimeUnit.NANOSECONDS], + ] + + // Act + List parsedWholeTimes = WHOLE_TIMES.collect { List it -> + FormatUtils.makeWholeNumberTime(it[0] as float, it[1] as TimeUnit) + } + logger.converted(parsedWholeTimes) + + // Assert + assert parsedWholeTimes == EXPECTED_TIMES + } + + /** + * Positive flow test for whole inputs + */ + @Test + void testShouldGetSmallerTimeUnit() { + // Arrange + final List UNITS = TimeUnit.values() as List + + // Act + def nullMsg = shouldFail(IllegalArgumentException) { + FormatUtils.getSmallerTimeUnit(null) + } + logger.expected(nullMsg) + + def nanosMsg = shouldFail(IllegalArgumentException) { + FormatUtils.getSmallerTimeUnit(TimeUnit.NANOSECONDS) + } + logger.expected(nanosMsg) + + List smallerTimeUnits = UNITS[1..-1].collect { TimeUnit unit -> + FormatUtils.getSmallerTimeUnit(unit) + } + logger.converted(smallerTimeUnits) + + // Assert + assert nullMsg == "Cannot determine a smaller time unit than 'null'" + assert nanosMsg == "Cannot determine a smaller time unit than 'NANOSECONDS'" + assert smallerTimeUnits == UNITS[0..<-1] + } + + /** + * Positive flow test for multipliers based on valid time units + */ + @Test + void testShouldCalculateMultiplier() { + // Arrange + final Map SCENARIOS = [ + "allUnits" : [original: TimeUnit.DAYS, destination: TimeUnit.NANOSECONDS, expectedMultiplier: (long) 24 * 60 * 60 * (long) 1_000_000_000], + "microsToNanos" : [original: TimeUnit.MICROSECONDS, destination: TimeUnit.NANOSECONDS, expectedMultiplier: 1_000], + "millisToNanos" : [original: TimeUnit.MILLISECONDS, destination: TimeUnit.NANOSECONDS, expectedMultiplier: 1_000_000], + "millisToMicros": [original: TimeUnit.MILLISECONDS, destination: TimeUnit.MICROSECONDS, expectedMultiplier: 1_000], + "daysToHours" : [original: TimeUnit.DAYS, destination: TimeUnit.HOURS, expectedMultiplier: 24], + "daysToSeconds" : [original: TimeUnit.DAYS, destination: TimeUnit.SECONDS, expectedMultiplier: 24 * 60 * 60], + ] + + // Act + Map results = SCENARIOS.collectEntries { String k, Map values -> + logger.debug("Evaluating ${k}: ${values}") + [k, FormatUtils.calculateMultiplier(values.original, values.destination)] + } + logger.converted(results) + + // Assert + results.every { String key, long value -> + assert value == SCENARIOS[key].expectedMultiplier + } + } + + /** + * Negative flow test for multipliers based on incorrectly-ordered time units + */ + @Test + void testCalculateMultiplierShouldHandleIncorrectUnits() { + // Arrange + final Map SCENARIOS = [ + "allUnits" : [original: TimeUnit.NANOSECONDS, destination: TimeUnit.DAYS], + "nanosToMicros": [original: TimeUnit.NANOSECONDS, destination: TimeUnit.MICROSECONDS], + "hoursToDays" : [original: TimeUnit.HOURS, destination: TimeUnit.DAYS], + ] + + // Act + Map results = SCENARIOS.collectEntries { String k, Map values -> + logger.debug("Evaluating ${k}: ${values}") + def msg = shouldFail(IllegalArgumentException) { + FormatUtils.calculateMultiplier(values.original, values.destination) + } + logger.expected(msg) + [k, msg] + } + + // Assert + results.every { String key, String value -> + assert value =~ "The original time unit '.*' must be larger than the new time unit '.*'" + } + } + + // TODO: Microsecond parsing +} \ No newline at end of file