You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by ah...@apache.org on 2023/08/09 15:33:37 UTC

[commons-lang] branch master updated: Syntax for optional tokens in DurationFormatUtils (#1062)

This is an automated email from the ASF dual-hosted git repository.

aherbert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git


The following commit(s) were added to refs/heads/master by this push:
     new 85959a457 Syntax for optional tokens in DurationFormatUtils (#1062)
85959a457 is described below

commit 85959a457076b740380c4d6f3d83098edd17a9e7
Author: Dan Watson <29...@users.noreply.github.com>
AuthorDate: Wed Aug 9 11:33:31 2023 -0400

    Syntax for optional tokens in DurationFormatUtils (#1062)
    
    Allow optional tokens using the [ and ] characters.
    
    Disallows nesting of optional tokens.
    
    The leading literal within an optional block depends on first non-literal within the same block.
---
 .../commons/lang3/time/DurationFormatUtils.java    | 153 ++++++++++++---
 .../lang3/time/DurationFormatUtilsTest.java        | 214 +++++++++++++++++----
 2 files changed, 302 insertions(+), 65 deletions(-)

diff --git a/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java b/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java
index 4daf47f13..00173ce98 100644
--- a/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java
+++ b/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java
@@ -49,6 +49,32 @@ import org.apache.commons.lang3.Validate;
  * Token values are printed using decimal digits.
  * A token character can be repeated to ensure that the field occupies a certain minimum
  * size. Values will be left-padded with 0 unless padding is disabled in the method invocation.
+ * <br>
+ * Tokens can be marked as optional by surrounding them with brackets [ ]. These tokens will
+ * only be printed if the token value is non-zero. Literals within optional blocks will only be
+ * printed if the preceding non-literal token is non-zero. Leading optional literals will only
+ * be printed if the following non-literal is non-zero.
+ * Multiple optional blocks can be used to group literals with the desired token.
+ * <p>
+ * Notes on Optional Tokens:<br>
+ * <b>Multiple optional tokens without literals can result in impossible to understand output.</b><br>
+ * <b>Patterns where all tokens are optional can produce empty strings.</b><br>
+ * (See examples below)
+ * </p>
+ * <br>
+ * <table border="1">
+ * <caption>Example Output</caption>
+ * <tr><th>pattern</th><th>Duration.ofDays(1)</th><th>Duration.ofHours(1)</th><th>Duration.ofMinutes(1)</th><th>Duration.ZERO</th></tr>
+ * <tr><td>d'd'H'h'm'm's's'</td><td>1d0h0m0s</td><td>0d1h0m0s</td><td>0d0h1m0s</td><td>0d0h0m0s</td></tr>
+ * <tr><td>d'd'[H'h'm'm']s's'</td><td>1d0s</td><td>0d1h0s</td><td>0d1m0s</td><td>0d0s</td></tr>
+ * <tr><td>[d'd'H'h'm'm']s's'</td><td>1d0s</td><td>1h0s</td><td>1m0s</td><td>0s</td></tr>
+ * <tr><td>[d'd'H'h'm'm's's']</td><td>1d</td><td>1h</td><td>1m</td><td></td></tr>
+ * <tr><td>['{'d'}']HH':'mm</td><td>{1}00:00</td><td>01:00</td><td>00:01</td><td>00:00</td></tr>
+ * <tr><td>['{'dd'}']['&lt;'HH'&gt;']['('mm')']</td><td>{01}</td><td>&lt;01&gt;</td><td>(00)</td><td></td></tr>
+ * <tr><td>[dHms]</td><td>1</td><td>1</td><td>1</td><td></td></tr>
+ * </table>
+ * <b>Note: Optional blocks cannot be nested.</b>
+ *
  * @since 2.1
  */
 public class DurationFormatUtils {
@@ -419,43 +445,93 @@ public class DurationFormatUtils {
      * @param padWithZeros  whether to pad
      * @return the formatted string
      */
-    static String format(final Token[] tokens, final long years, final long months, final long days, final long hours, final long minutes, final long seconds,
+    static String format(final Token[] tokens, final long years, final long months, final long days, final long hours, final long minutes,
+            final long seconds,
             final long milliseconds, final boolean padWithZeros) {
         final StringBuilder buffer = new StringBuilder();
         boolean lastOutputSeconds = false;
+        boolean lastOutputZero = false;
+        int optionalStart = -1;
+        boolean firstOptionalNonLiteral = false;
+        int optionalIndex = -1;
+        boolean inOptional = false;
         for (final Token token : tokens) {
             final Object value = token.getValue();
+            final boolean isLiteral = value instanceof StringBuilder;
             final int count = token.getCount();
-            if (value instanceof StringBuilder) {
-                buffer.append(value.toString());
+            if (optionalIndex != token.optionalIndex) {
+              optionalIndex = token.optionalIndex;
+              if (optionalIndex > -1) {
+                //entering new optional block
+                optionalStart = buffer.length();
+                lastOutputZero = false;
+                inOptional = true;
+                firstOptionalNonLiteral = false;
+              } else {
+                //leaving optional block
+                inOptional = false;
+              }
+            }
+            if (isLiteral) {
+               if (!inOptional || !lastOutputZero) {
+                     buffer.append(value.toString());
+               }
             } else if (value.equals(y)) {
-                buffer.append(paddedValue(years, padWithZeros, count));
                 lastOutputSeconds = false;
+                lastOutputZero = years == 0;
+                if (!inOptional || !lastOutputZero) {
+                    buffer.append(paddedValue(years, padWithZeros, count));
+                }
             } else if (value.equals(M)) {
-                buffer.append(paddedValue(months, padWithZeros, count));
                 lastOutputSeconds = false;
+                lastOutputZero = months == 0;
+                if (!inOptional || !lastOutputZero) {
+                    buffer.append(paddedValue(months, padWithZeros, count));
+                }
             } else if (value.equals(d)) {
-                buffer.append(paddedValue(days, padWithZeros, count));
                 lastOutputSeconds = false;
+                lastOutputZero = days == 0;
+                if (!inOptional || !lastOutputZero) {
+                    buffer.append(paddedValue(days, padWithZeros, count));
+                }
             } else if (value.equals(H)) {
-                buffer.append(paddedValue(hours, padWithZeros, count));
                 lastOutputSeconds = false;
+                lastOutputZero = hours == 0;
+                if (!inOptional || !lastOutputZero) {
+                    buffer.append(paddedValue(hours, padWithZeros, count));
+                }
             } else if (value.equals(m)) {
-                buffer.append(paddedValue(minutes, padWithZeros, count));
                 lastOutputSeconds = false;
+                lastOutputZero = minutes == 0;
+                if (!inOptional || !lastOutputZero) {
+                    buffer.append(paddedValue(minutes, padWithZeros, count));
+                }
             } else if (value.equals(s)) {
-                buffer.append(paddedValue(seconds, padWithZeros, count));
                 lastOutputSeconds = true;
+                lastOutputZero = seconds == 0;
+                if (!inOptional || !lastOutputZero) {
+                    buffer.append(paddedValue(seconds, padWithZeros, count));
+                }
             } else if (value.equals(S)) {
-                if (lastOutputSeconds) {
-                    // ensure at least 3 digits are displayed even if padding is not selected
-                    final int width = padWithZeros ? Math.max(3, count) : 3;
-                    buffer.append(paddedValue(milliseconds, true, width));
-                } else {
-                    buffer.append(paddedValue(milliseconds, padWithZeros, count));
+                lastOutputZero = milliseconds == 0;
+                if (!inOptional || !lastOutputZero) {
+                    if (lastOutputSeconds) {
+                        // ensure at least 3 digits are displayed even if padding is not selected
+                        final int width = padWithZeros ? Math.max(3, count) : 3;
+                        buffer.append(paddedValue(milliseconds, true, width));
+                    } else {
+                        buffer.append(paddedValue(milliseconds, padWithZeros, count));
+                    }
                 }
                 lastOutputSeconds = false;
             }
+            //as soon as we hit first nonliteral in optional, check for literal prefix
+            if (inOptional && !isLiteral && !firstOptionalNonLiteral){
+                firstOptionalNonLiteral = true;
+                if (lastOutputZero) {
+                    buffer.delete(optionalStart, buffer.length());
+                }
+            }
         }
         return buffer.toString();
     }
@@ -496,6 +572,8 @@ public class DurationFormatUtils {
         // used internally, so cannot be accessed by other threads
         StringBuilder buffer = null;
         Token previous = null;
+        boolean inOptional = false;
+        int optionalIndex = -1;
         for (int i = 0; i < format.length(); i++) {
             final char ch = format.charAt(i);
             if (inLiteral && ch != '\'') {
@@ -505,13 +583,26 @@ public class DurationFormatUtils {
             String value = null;
             switch (ch) {
             // TODO: Need to handle escaping of '
+            case '[':
+                if (inOptional) {
+                  throw new IllegalArgumentException("Nested optional block at index: "+i);
+                }
+                optionalIndex++;
+                inOptional = true;
+                break;
+            case ']':
+                if (!inOptional) {
+                  throw new IllegalArgumentException("Attempting to close unopened optional block at index: "+i);
+                }
+                inOptional = false;
+                break;
             case '\'':
                 if (inLiteral) {
                     buffer = null;
                     inLiteral = false;
                 } else {
                     buffer = new StringBuilder();
-                    list.add(new Token(buffer));
+                    list.add(new Token(buffer, inOptional, optionalIndex));
                     inLiteral = true;
                 }
                 break;
@@ -539,7 +630,7 @@ public class DurationFormatUtils {
             default:
                 if (buffer == null) {
                     buffer = new StringBuilder();
-                    list.add(new Token(buffer));
+                    list.add(new Token(buffer, inOptional, optionalIndex));
                 }
                 buffer.append(ch);
             }
@@ -548,7 +639,7 @@ public class DurationFormatUtils {
                 if (previous != null && previous.getValue().equals(value)) {
                     previous.increment();
                 } else {
-                    final Token token = new Token(value);
+                    final Token token = new Token(value, inOptional, optionalIndex);
                     list.add(token);
                     previous = token;
                 }
@@ -558,6 +649,9 @@ public class DurationFormatUtils {
         if (inLiteral) { // i.e. we have not found the end of the literal
             throw new IllegalArgumentException("Unmatched quote in format: " + format);
         }
+        if (inOptional) { // i.e. we have not found the end of the literal
+            throw new IllegalArgumentException("Unmatched optional in format: " + format);
+        }
         return list.toArray(Token.EMPTY_ARRAY);
     }
 
@@ -582,26 +676,21 @@ public class DurationFormatUtils {
 
         private final Object value;
         private int count;
+        private int optionalIndex = -1;
 
         /**
          * Wraps a token around a value. A value would be something like a 'Y'.
          *
-         * @param value to wrap, non-null.
+         * @param value value to wrap, non-null.
+         * @param optional whether the token is optional
+         * @param optionalIndex the index of the optional token within the pattern
          */
-        Token(final Object value) {
-            this(value, 1);
-        }
-
-        /**
-         * Wraps a token around a repeated number of a value, for example it would
-         * store 'yyyy' as a value for y and a count of 4.
-         *
-         * @param value to wrap, non-null.
-         * @param count to wrap.
-         */
-        Token(final Object value, final int count) {
+        Token(final Object value, final boolean optional, final int optionalIndex) {
             this.value = Objects.requireNonNull(value, "value");
-            this.count = count;
+            this.count = 1;
+            if (optional) {
+                this.optionalIndex = optionalIndex;
+            }
         }
 
         /**
diff --git a/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java b/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java
index 545b220fb..94e235927 100644
--- a/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java
@@ -27,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Modifier;
+import java.time.Duration;
 import java.util.Calendar;
 import java.util.TimeZone;
 
@@ -571,49 +572,49 @@ public class DurationFormatUtilsTest extends AbstractLangTest {
     public void testLexx() {
         // tests each constant
         assertArrayEquals(new DurationFormatUtils.Token[]{
-            new DurationFormatUtils.Token(DurationFormatUtils.y, 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.M, 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.d, 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.H, 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.m, 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.s, 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.S, 1)}, DurationFormatUtils.lexx("yMdHmsS"));
+            createTokenWithCount(DurationFormatUtils.y, 1),
+            createTokenWithCount(DurationFormatUtils.M, 1),
+            createTokenWithCount(DurationFormatUtils.d, 1),
+            createTokenWithCount(DurationFormatUtils.H, 1),
+            createTokenWithCount(DurationFormatUtils.m, 1),
+            createTokenWithCount(DurationFormatUtils.s, 1),
+            createTokenWithCount(DurationFormatUtils.S, 1)}, DurationFormatUtils.lexx("yMdHmsS"));
 
         // tests the ISO 8601-like
         assertArrayEquals(new DurationFormatUtils.Token[]{
-            new DurationFormatUtils.Token(DurationFormatUtils.H, 2),
-            new DurationFormatUtils.Token(new StringBuilder(":"), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.m, 2),
-            new DurationFormatUtils.Token(new StringBuilder(":"), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.s, 2),
-            new DurationFormatUtils.Token(new StringBuilder("."), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.S, 3)}, DurationFormatUtils.lexx("HH:mm:ss.SSS"));
+            createTokenWithCount(DurationFormatUtils.H, 2),
+            createTokenWithCount(new StringBuilder(":"), 1),
+            createTokenWithCount(DurationFormatUtils.m, 2),
+            createTokenWithCount(new StringBuilder(":"), 1),
+            createTokenWithCount(DurationFormatUtils.s, 2),
+            createTokenWithCount(new StringBuilder("."), 1),
+            createTokenWithCount(DurationFormatUtils.S, 3)}, DurationFormatUtils.lexx("HH:mm:ss.SSS"));
 
         // test the iso extended format
         assertArrayEquals(new DurationFormatUtils.Token[]{
-            new DurationFormatUtils.Token(new StringBuilder("P"), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.y, 4),
-            new DurationFormatUtils.Token(new StringBuilder("Y"), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.M, 1),
-            new DurationFormatUtils.Token(new StringBuilder("M"), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.d, 1),
-            new DurationFormatUtils.Token(new StringBuilder("DT"), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.H, 1),
-            new DurationFormatUtils.Token(new StringBuilder("H"), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.m, 1),
-            new DurationFormatUtils.Token(new StringBuilder("M"), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.s, 1),
-            new DurationFormatUtils.Token(new StringBuilder("."), 1),
-            new DurationFormatUtils.Token(DurationFormatUtils.S, 3),
-            new DurationFormatUtils.Token(new StringBuilder("S"), 1)}, DurationFormatUtils
+            createTokenWithCount(new StringBuilder("P"), 1),
+            createTokenWithCount(DurationFormatUtils.y, 4),
+            createTokenWithCount(new StringBuilder("Y"), 1),
+            createTokenWithCount(DurationFormatUtils.M, 1),
+            createTokenWithCount(new StringBuilder("M"), 1),
+            createTokenWithCount(DurationFormatUtils.d, 1),
+            createTokenWithCount(new StringBuilder("DT"), 1),
+            createTokenWithCount(DurationFormatUtils.H, 1),
+            createTokenWithCount(new StringBuilder("H"), 1),
+            createTokenWithCount(DurationFormatUtils.m, 1),
+            createTokenWithCount(new StringBuilder("M"), 1),
+            createTokenWithCount(DurationFormatUtils.s, 1),
+            createTokenWithCount(new StringBuilder("."), 1),
+            createTokenWithCount(DurationFormatUtils.S, 3),
+            createTokenWithCount(new StringBuilder("S"), 1)}, DurationFormatUtils
                 .lexx(DurationFormatUtils.ISO_EXTENDED_FORMAT_PATTERN));
 
         // test failures in equals
-        final DurationFormatUtils.Token token = new DurationFormatUtils.Token(DurationFormatUtils.y, 4);
+        final DurationFormatUtils.Token token = createTokenWithCount(DurationFormatUtils.y, 4);
         assertNotEquals(token, new Object(), "Token equal to non-Token class. ");
-        assertNotEquals(token, new DurationFormatUtils.Token(new Object()), "Token equal to Token with wrong value class. ");
-        assertNotEquals(token, new DurationFormatUtils.Token(DurationFormatUtils.y, 1), "Token equal to Token with different count. ");
-        final DurationFormatUtils.Token numToken = new DurationFormatUtils.Token(Integer.valueOf(1), 4);
+        assertNotEquals(token, createTokenWithCount(new Object(), 1), "Token equal to Token with wrong value class. ");
+        assertNotEquals(token, createTokenWithCount(DurationFormatUtils.y, 1), "Token equal to Token with different count. ");
+        final DurationFormatUtils.Token numToken = createTokenWithCount(Integer.valueOf(1), 4);
         assertEquals(numToken, numToken, "Token with Number value not equal to itself. ");
     }
     // Testing the under a day range in DurationFormatUtils.formatPeriod
@@ -632,4 +633,151 @@ public class DurationFormatUtilsTest extends AbstractLangTest {
         }
     }
 
+
+    @Test
+    public void testUnmatchedOptionalTokens() {
+        assertThrows(IllegalArgumentException.class, () -> DurationFormatUtils.formatDuration(1, "[s"));
+        assertThrows(IllegalArgumentException.class, () -> DurationFormatUtils.formatDuration(1, "[[s"));
+        assertThrows(IllegalArgumentException.class, () -> DurationFormatUtils.formatDuration(1, "[s]]"));
+    }
+
+    @Test
+    public void testOptionalLiteralSpecialCharacters() {
+      assertEquals(
+          DurationFormatUtils.formatDuration(10000L, "s's'"),
+          DurationFormatUtils.formatDuration(10000L, "['['m']']s's'"));
+    }
+
+    @Test
+    public void testAlternatingLiteralOptionals() {
+        String format = "['d'dH'h'][m'm']['s's]['ms'S]";
+
+        assertEquals("d1",
+            DurationFormatUtils.formatDuration(Duration.ofDays(1).toMillis(), format));
+
+        assertEquals("1h",
+            DurationFormatUtils.formatDuration(Duration.ofHours(1).toMillis(), format));
+
+        assertEquals("1m",
+            DurationFormatUtils.formatDuration(Duration.ofMinutes(1).toMillis(), format));
+
+        assertEquals("s1",
+            DurationFormatUtils.formatDuration(Duration.ofSeconds(1).toMillis(), format));
+
+        assertEquals("ms001",
+            DurationFormatUtils.formatDuration(Duration.ofMillis(1).toMillis(), format));
+
+        assertEquals("d1s1",
+            DurationFormatUtils.formatDuration(Duration.ofDays(1).plusSeconds(1).toMillis(), format));
+
+        assertEquals("d11h",
+            DurationFormatUtils.formatDuration(Duration.ofDays(1).plusHours(1).toMillis(), format));
+
+        assertEquals("d11h1m",
+            DurationFormatUtils.formatDuration(Duration.ofDays(1).plusHours(1).plusMinutes(1).toMillis(), format));
+
+        assertEquals("d11h1ms1",
+            DurationFormatUtils.formatDuration(Duration.ofDays(1).plusHours(1).plusMinutes(1).plusSeconds(1).toMillis(), format));
+
+        assertEquals("d11h1ms1ms001",
+            DurationFormatUtils.formatDuration(Duration.ofDays(1).plusHours(1).plusMinutes(1).plusSeconds(1).plusMillis(1).toMillis(), format));
+
+    }
+
+    @Test
+    public void testLiteralPrefixOptionalToken() {
+      assertEquals(
+          DurationFormatUtils.formatDuration(10000L, "s's'"),
+          DurationFormatUtils.formatDuration(10000L, "['['d']']['<'H'>']['{'m'}']s's'"));
+      assertEquals(
+          DurationFormatUtils.formatDuration(10000L, "s's'"),
+          DurationFormatUtils.formatDuration(10000L, "['{'m'}']s's'"));
+    }
+
+    @Test
+    public void testEmptyOptionals() {
+      assertEquals(
+          "",
+          DurationFormatUtils.formatDuration(0L, "[d'd'][H'h'][m'm'][s's']"));
+      assertEquals(
+          "",
+          DurationFormatUtils.formatDuration(0L, "['d''h''m''s's]"));
+    }
+
+    @Test
+    public void testMultipleOptionalBlocks() {
+
+      assertEquals(
+          DurationFormatUtils.formatDuration(Duration.ofHours(1).toMillis(), "'[['H']]'"),
+          DurationFormatUtils.formatDuration(Duration.ofHours(1).toMillis(), "['{'d'}']['[['H']]']"));
+
+      assertEquals(
+          DurationFormatUtils.formatDuration(Duration.ofDays(1).toMillis(), "['{'d'}']"),
+          DurationFormatUtils.formatDuration(Duration.ofDays(1).toMillis(), "['{'d'}']['['H']']"));
+    }
+
+    @Test
+    public void testOptionalToken() {
+
+        //make sure optional formats match corresponding adjusted non-optional formats
+        assertEquals(
+                DurationFormatUtils.formatDuration(915361000L, "d'd'H'h'm'm's's'"),
+                DurationFormatUtils.formatDuration(915361000L, "[d'd'H'h'm'm']s's'"));
+
+        assertEquals(
+                DurationFormatUtils.formatDuration(9153610L, "H'h'm'm's's'"),
+                DurationFormatUtils.formatDuration(9153610L, "[d'd'H'h'm'm']s's'"));
+
+        assertEquals(
+                DurationFormatUtils.formatDuration(915361L, "m'm's's'"),
+                DurationFormatUtils.formatDuration(915361L, "[d'd'H'h'm'm']s's'"));
+
+        assertEquals(
+                DurationFormatUtils.formatDuration(9153L, "s's'"),
+                DurationFormatUtils.formatDuration(9153L, "[d'd'H'h'm'm']s's'"));
+
+        assertEquals(
+                DurationFormatUtils.formatDuration(9153L, "s's'"),
+                DurationFormatUtils.formatDuration(9153L, "[d'd'H'h'm'm']s's'"));
+
+        assertEquals(
+                DurationFormatUtils.formatPeriod(9153610L, 915361000L, "d'd'H'h'm'm's's'"),
+                DurationFormatUtils.formatPeriod(9153610L, 915361000L, "[d'd'H'h'm'm']s's'"));
+
+        assertEquals(
+                DurationFormatUtils.formatPeriod(915361L, 9153610L, "H'h'm'm's's'"),
+                DurationFormatUtils.formatPeriod(915361L, 9153610L, "[d'd'H'h'm'm']s's'"));
+
+        assertEquals(
+                DurationFormatUtils.formatPeriod(9153L, 915361L, "m'm's's'"),
+                DurationFormatUtils.formatPeriod(9153L, 915361L, "[d'd'H'h'm'm']s's'"));
+
+        assertEquals(
+                DurationFormatUtils.formatPeriod(0L, 9153L, "s's'"),
+                DurationFormatUtils.formatPeriod(0L, 9153L, "[d'd'H'h'm'm']s's'"));
+
+        //make sure optional parts are actually omitted when zero
+
+        assertEquals("2h32m33s610ms", DurationFormatUtils.formatDuration(9153610L, "[d'd'H'h'm'm's's']S'ms'"));
+
+        assertEquals("15m15s361ms", DurationFormatUtils.formatDuration(915361L, "[d'd'H'h'm'm's's']S'ms'"));
+
+        assertEquals("9s153ms", DurationFormatUtils.formatDuration(9153L, "[d'd'H'h'm'm's's']S'ms'"));
+
+        assertEquals("915ms", DurationFormatUtils.formatDuration(915L, "[d'd'H'h'm'm's's']S'ms'"));
+
+        //make sure we can handle omitting multiple literals after a token
+
+        assertEquals(
+                DurationFormatUtils.formatPeriod(915361L, 9153610L, "H'h''h2'm'm's's'"),
+                DurationFormatUtils.formatPeriod(915361L, 9153610L, "[d'd''d2'H'h''h2'm'm']s's'"));
+    }
+
+    private DurationFormatUtils.Token createTokenWithCount(final Object value, final int count) {
+        DurationFormatUtils.Token token = new DurationFormatUtils.Token(value, false, -1);
+        for (int i = 1; i < count; i++) {
+            token.increment();
+        }
+        return token;
+    }
 }