Skip to content

Commit bcb37a3

Browse files
committed
Add support for parsing java.time.Duration from a string
1 parent 9cf4a6c commit bcb37a3

File tree

8 files changed

+311
-4
lines changed

8 files changed

+311
-4
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 1.17
2+
- Added support for parsing `java.time.Duration` from a string. Example:
3+
4+
```
5+
@Test
6+
public void myTest(@TestParameter({"1d", "2h20min", "10.5ms"}) Duration duration){...}
7+
```
8+
19
## 1.16
210

311
- Deprecated [`TestParameter.TestParameterValuesProvider`](

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ The following examples show most of the supported types. See the `@TestParameter
182182

183183
// Bytes
184184
@TestParameter({"!!binary 'ZGF0YQ=='", "some_string"}) byte[] bytes;
185+
186+
// Durations (segments of number+unit as shown below)
187+
@TestParameter({"1d", "2h", "3min", "4s", "5ms", "6us", "7ns"}) java.time.Duration d;
188+
@TestParameter({"1h30min", "-2h10min20s", "1.5h", ".5s", "0"}) java.time.Duration d;
185189
```
186190

187191
For non-primitive types (e.g. String, enums, bytes), `"null"` is always parsed as the `null` reference.

junit4/src/main/java/com/google/testing/junit/testparameterinjector/ParameterValueParsing.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
package com.google.testing.junit.testparameterinjector;
1616

17+
import static com.google.common.base.MoreObjects.firstNonNull;
1718
import static com.google.common.base.Preconditions.checkArgument;
1819
import static com.google.common.base.Preconditions.checkNotNull;
1920
import static com.google.common.base.Preconditions.checkState;
@@ -23,6 +24,7 @@
2324
import com.google.common.base.CharMatcher;
2425
import com.google.common.base.Function;
2526
import com.google.common.base.Optional;
27+
import com.google.common.collect.ImmutableMap;
2628
import com.google.common.collect.ImmutableSet;
2729
import com.google.common.collect.Lists;
2830
import com.google.common.primitives.Primitives;
@@ -34,13 +36,17 @@
3436
import java.lang.reflect.ParameterizedType;
3537
import java.math.BigInteger;
3638
import java.nio.charset.Charset;
39+
import java.time.Duration;
3740
import java.util.Arrays;
3841
import java.util.HashSet;
3942
import java.util.LinkedHashMap;
4043
import java.util.List;
4144
import java.util.Map;
4245
import java.util.Map.Entry;
46+
import java.util.Objects;
4347
import java.util.Set;
48+
import java.util.regex.Matcher;
49+
import java.util.regex.Pattern;
4450
import javax.annotation.Nullable;
4551
import org.yaml.snakeyaml.LoaderOptions;
4652
import org.yaml.snakeyaml.Yaml;
@@ -189,6 +195,12 @@ static Object parseYamlObjectToJavaType(Object parsedYaml, TypeToken<?> javaType
189195
.supportParsedType(byte[].class, ByteStringReflection::copyFrom);
190196
}
191197

198+
yamlValueTransformer
199+
.ifJavaType(Duration.class)
200+
.supportParsedType(String.class, ParameterValueParsing::parseDuration)
201+
// Support the special case where the YAML string is "0"
202+
.supportParsedType(Integer.class, i -> parseDuration(String.valueOf(i)));
203+
192204
// Added mainly for protocol buffer parsing
193205
yamlValueTransformer
194206
.ifJavaType(List.class)
@@ -381,5 +393,68 @@ private static String valueAsString(Object value) {
381393
}
382394
}
383395

396+
// ********** Duration parsing ********** //
397+
398+
private static final ImmutableMap<String, Duration> ABBREVIATION_TO_DURATION =
399+
new ImmutableMap.Builder<String, Duration>()
400+
.put("d", Duration.ofDays(1))
401+
.put("h", Duration.ofHours(1))
402+
.put("m", Duration.ofMinutes(1))
403+
.put("min", Duration.ofMinutes(1))
404+
.put("s", Duration.ofSeconds(1))
405+
.put("ms", Duration.ofMillis(1))
406+
.put("us", Duration.ofNanos(1000))
407+
.put("ns", Duration.ofNanos(1))
408+
.buildOrThrow();
409+
private static final Pattern UNIT_PATTERN =
410+
Pattern.compile("(?x) ([0-9]+)? (\\.[0-9]*)? (d|h|min|ms?|s|us|ns)");
411+
private static final CharMatcher ASCII_DIGIT = CharMatcher.inRange('0', '9');
412+
413+
private static Duration parseDuration(String value) {
414+
checkArgument(value != null, "input value cannot be null");
415+
checkArgument(!value.isEmpty(), "input value cannot be empty");
416+
checkArgument(!value.equals("-"), "input value cannot be '-'");
417+
checkArgument(!value.equals("+"), "input value cannot be '+'");
418+
419+
value = CharMatcher.whitespace().trimFrom(value);
420+
421+
if (Objects.equals(value, "0")) {
422+
return Duration.ZERO;
423+
}
424+
425+
Duration duration = Duration.ZERO;
426+
boolean negative = value.startsWith("-");
427+
boolean explicitlyPositive = value.startsWith("+");
428+
int index = negative || explicitlyPositive ? 1 : 0;
429+
Matcher matcher = UNIT_PATTERN.matcher(value);
430+
while (matcher.find(index) && matcher.start() == index) {
431+
// Prevent strings like ".s" or "d" by requiring at least one digit.
432+
checkArgument(ASCII_DIGIT.matchesAnyOf(matcher.group(0)));
433+
try {
434+
String unit = matcher.group(3);
435+
436+
long whole = Long.parseLong(firstNonNull(matcher.group(1), "0"));
437+
Duration singleUnit = ABBREVIATION_TO_DURATION.get(unit);
438+
checkArgument(singleUnit != null, "invalid unit (%s)", unit);
439+
// TODO(b/142748138): Consider using saturated duration math here
440+
duration = duration.plus(singleUnit.multipliedBy(whole));
441+
442+
long nanosPerUnit = singleUnit.toNanos();
443+
double frac = Double.parseDouble("0" + firstNonNull(matcher.group(2), ""));
444+
duration = duration.plus(Duration.ofNanos((long) (nanosPerUnit * frac)));
445+
} catch (ArithmeticException e) {
446+
throw new IllegalArgumentException(e);
447+
}
448+
index = matcher.end();
449+
}
450+
if (index < value.length()) {
451+
throw new IllegalArgumentException("Could not parse entire duration: " + value);
452+
}
453+
if (negative) {
454+
duration = duration.negated();
455+
}
456+
return duration;
457+
}
458+
384459
private ParameterValueParsing() {}
385460
}

junit4/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValuesProvider.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ public static final class Context {
9090
*
9191
* @throws NoSuchElementException if this there is no annotation with the given type
9292
* @throws IllegalArgumentException if there are multiple annotations with the given type
93-
* @throws IllegalArgumentException if the argument it TestParameter.class because it is already
93+
* @throws IllegalArgumentException if the argument is TestParameter.class because it is already
9494
* handled by the TestParameterInjector framework.
9595
*/
9696
public <A extends Annotation> A getOtherAnnotation(Class<A> annotationType) {
@@ -123,7 +123,7 @@ public <A extends Annotation> A getOtherAnnotation(Class<A> annotationType) {
123123
*
124124
* <p>Returns an empty list if this there is no annotation with the given type.
125125
*
126-
* @throws IllegalArgumentException if the argument it TestParameter.class because it is already
126+
* @throws IllegalArgumentException if the argument is TestParameter.class because it is already
127127
* handled by the TestParameterInjector framework.
128128
*/
129129
public <A extends Annotation> ImmutableList<A> getOtherAnnotations(Class<A> annotationType) {

junit4/src/test/java/com/google/testing/junit/testparameterinjector/ParameterValueParsingTest.java

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919

2020
import com.google.common.base.CharMatcher;
2121
import com.google.common.base.Optional;
22+
import com.google.common.collect.ImmutableList;
2223
import com.google.common.primitives.UnsignedLong;
2324
import com.google.protobuf.ByteString;
25+
import com.google.testing.junit.testparameterinjector.TestParameters.TestParametersValues;
2426
import java.math.BigInteger;
27+
import java.time.Duration;
2528
import javax.annotation.Nullable;
2629
import org.junit.Test;
2730
import org.junit.runner.RunWith;
@@ -183,6 +186,136 @@ public void parseYamlStringToJavaType_success(
183186
assertThat(result).isEqualTo(parseYamlValueToJavaTypeCases.expectedResult);
184187
}
185188

189+
private static final class DurationSuccessTestCasesProvider extends TestParametersValuesProvider {
190+
@Override
191+
protected ImmutableList<TestParametersValues> provideValues(Context context) {
192+
return ImmutableList.of(
193+
// Simple
194+
testCase("7d", Duration.ofDays(7)),
195+
testCase("6h", Duration.ofHours(6)),
196+
testCase("5m", Duration.ofMinutes(5)),
197+
testCase("5min", Duration.ofMinutes(5)),
198+
testCase("4s", Duration.ofSeconds(4)),
199+
testCase("3.2s", Duration.ofMillis(3200)),
200+
testCase("0.2s", Duration.ofMillis(200)),
201+
testCase(".15s", Duration.ofMillis(150)),
202+
testCase("5.0s", Duration.ofSeconds(5)),
203+
testCase("1.0s", Duration.ofSeconds(1)),
204+
testCase("1.00s", Duration.ofSeconds(1)),
205+
testCase("1.004s", Duration.ofSeconds(1).plusMillis(4)),
206+
testCase("1.0040s", Duration.ofSeconds(1).plusMillis(4)),
207+
testCase("100.00100s", Duration.ofSeconds(100).plusMillis(1)),
208+
testCase("0.3333333333333333333h", Duration.ofMinutes(20)),
209+
testCase("1s3ms", Duration.ofSeconds(1).plusMillis(3)),
210+
testCase("1s34ms", Duration.ofSeconds(1).plusMillis(34)),
211+
testCase("1s345ms", Duration.ofSeconds(1).plusMillis(345)),
212+
testCase("345ms", Duration.ofMillis(345)),
213+
testCase(".9ms", Duration.ofNanos(900000)),
214+
testCase("5.s", Duration.ofSeconds(5)),
215+
testCase("+24h", Duration.ofHours(24)),
216+
testCase("0d", Duration.ZERO),
217+
testCase("-0d", Duration.ZERO),
218+
testCase("-1d", Duration.ofDays(-1)),
219+
testCase("1d", Duration.ofDays(1)),
220+
221+
// Zero
222+
testCase("0", Duration.ZERO),
223+
testCase("-0", Duration.ZERO),
224+
testCase("+0", Duration.ZERO),
225+
226+
// Multiple fields
227+
testCase("1h30m", Duration.ofMinutes(90)),
228+
testCase("1h30min", Duration.ofMinutes(90)),
229+
testCase("1d7m", Duration.ofDays(1).plusMinutes(7)),
230+
testCase("1m3.5s", Duration.ofMinutes(1).plusMillis(3500)),
231+
testCase("1m3s500ms", Duration.ofMinutes(1).plusMillis(3500)),
232+
testCase("5d4h3m2.1s", Duration.ofDays(5).plusHours(4).plusMinutes(3).plusMillis(2100)),
233+
testCase("3.5s250ms", Duration.ofMillis(3500 + 250)),
234+
testCase("1m2m3m", Duration.ofMinutes(6)),
235+
testCase("1m2h", Duration.ofHours(2).plusMinutes(1)),
236+
237+
// Negative duration
238+
testCase("-.5h", Duration.ofMinutes(-30)),
239+
240+
// Overflow
241+
testCase("106751d23h47m16s854ms775us807ns", Duration.ofNanos(Long.MAX_VALUE)),
242+
testCase("106751991167d7h12m55s807ms", Duration.ofMillis(Long.MAX_VALUE)),
243+
testCase("106751991167300d15h30m7s", Duration.ofSeconds(Long.MAX_VALUE)),
244+
testCase("106945d", Duration.ofDays(293 * 365)),
245+
246+
// Underflow
247+
testCase("-106751d23h47m16s854ms775us808ns", Duration.ofNanos(Long.MIN_VALUE)),
248+
testCase("-106751991167d7h12m55s808ms", Duration.ofMillis(Long.MIN_VALUE)),
249+
testCase("-106751991167300d15h30m7s", Duration.ofSeconds(Long.MIN_VALUE + 1)),
250+
testCase("-106945d", Duration.ofDays(-293 * 365)),
251+
252+
// Very large values
253+
testCase("9223372036854775807ns", Duration.ofNanos(Long.MAX_VALUE)),
254+
testCase("9223372036854775806ns", Duration.ofNanos(Long.MAX_VALUE - 1)),
255+
testCase("106751991167d7h12m55s807ms", Duration.ofMillis(Long.MAX_VALUE)),
256+
testCase("900000000000d", Duration.ofDays(900000000000L)),
257+
testCase("100000000000d100000000000d", Duration.ofDays(200000000000L)));
258+
}
259+
260+
private static TestParametersValues testCase(String yamlString, Duration expectedResult) {
261+
return TestParametersValues.builder()
262+
.name(yamlString)
263+
.addParameter("yamlString", yamlString)
264+
.addParameter("expectedResult", expectedResult)
265+
.build();
266+
}
267+
}
268+
269+
@Test
270+
@TestParameters(valuesProvider = DurationSuccessTestCasesProvider.class)
271+
public void parseYamlStringToJavaType_duration_success(String yamlString, Duration expectedResult)
272+
throws Exception {
273+
Object result = ParameterValueParsing.parseYamlStringToJavaType(yamlString, Duration.class);
274+
275+
assertThat(result).isEqualTo(expectedResult);
276+
}
277+
278+
@Test
279+
public void parseYamlStringToJavaType_duration_fails(
280+
@TestParameter({
281+
// Wrong format
282+
"1m 3s", // spaces not allowed
283+
"0x123abc",
284+
"123x456",
285+
".s",
286+
"d",
287+
"5dh",
288+
"1s500",
289+
"unparseable",
290+
"-",
291+
"+",
292+
"2",
293+
"-2",
294+
"+2",
295+
296+
// Uppercase
297+
"1D",
298+
"1H",
299+
"1M",
300+
"1S",
301+
"1MS",
302+
"1Ms",
303+
"1mS",
304+
"1NS",
305+
"1Ns",
306+
"1nS",
307+
308+
// Very large values
309+
Long.MAX_VALUE + "d",
310+
"10000000000000000000000000d"
311+
})
312+
String yamlString)
313+
throws Exception {
314+
assertThrows(
315+
IllegalArgumentException.class,
316+
() -> ParameterValueParsing.parseYamlStringToJavaType(yamlString, Duration.class));
317+
}
318+
186319
@Test
187320
public void parseYamlStringToJavaType_booleanToEnum_ambiguousValues_fails(
188321
@TestParameter({"OFF", "YES", "false", "True"}) String yamlString) throws Exception {

junit4/src/test/java/com/google/testing/junit/testparameterinjector/TestParametersMethodProcessorTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import java.lang.annotation.Annotation;
3232
import java.lang.annotation.Repeatable;
3333
import java.lang.annotation.Retention;
34+
import java.time.Duration;
3435
import java.util.Arrays;
3536
import java.util.Collection;
3637
import java.util.List;
@@ -120,6 +121,14 @@ public void test4_withCustomName(TestEnum testEnum) {
120121
storeTestParametersForThisTest(testEnum);
121122
}
122123

124+
@Test
125+
@TestParameters("{testDuration: 0}")
126+
@TestParameters("{testDuration: 1d}")
127+
@TestParameters("{testDuration: -2h}")
128+
public void test5_withDuration(Duration testDuration) {
129+
storeTestParametersForThisTest(testDuration);
130+
}
131+
123132
@Override
124133
ImmutableMap<String, String> expectedTestNameToStringifiedParameters() {
125134
return ImmutableMap.<String, String>builder()
@@ -167,6 +176,9 @@ ImmutableMap<String, String> expectedTestNameToStringifiedParameters() {
167176
.put("test4_withCustomName[custom1]", "ONE")
168177
.put("test4_withCustomName[{testEnum: TWO}]", "TWO")
169178
.put("test4_withCustomName[custom3]", "THREE")
179+
.put("test5_withDuration[{testDuration: 0}]", "PT0S")
180+
.put("test5_withDuration[{testDuration: 1d}]", "PT24H")
181+
.put("test5_withDuration[{testDuration: -2h}]", "PT-2H")
170182
.build();
171183
}
172184
}

0 commit comments

Comments
 (0)