]> granicus.if.org Git - icu/commitdiff
ICU-8610 Adds basic support for number skeletons. Includes skeleton support for round...
authorShane Carr <shane@unicode.org>
Wed, 28 Feb 2018 08:06:42 +0000 (08:06 +0000)
committerShane Carr <shane@unicode.org>
Wed, 28 Feb 2018 08:06:42 +0000 (08:06 +0000)
X-SVN-Rev: 41013

icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatter.java
icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatterSettings.java
icu4j/main/classes/core/src/com/ibm/icu/number/NumberSkeletonImpl.java [new file with mode: 0644]
icu4j/main/classes/core/src/com/ibm/icu/number/Rounder.java
icu4j/main/classes/core/src/com/ibm/icu/number/SkeletonSyntaxException.java [new file with mode: 0644]
icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberFormatterApiTest.java
icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberSkeletonTest.java [new file with mode: 0644]

index dd7651bd820c2bdbcbc622d91760db7b0a82e1c0..552873fc317330796538dba20537c8962e16383f 100644 (file)
@@ -453,6 +453,20 @@ public final class NumberFormatter {
         return BASE.locale(locale);
     }
 
+    /**
+     * Call this method at the beginning of a NumberFormatter fluent chain to create an instance based
+     * on a given number skeleton string.
+     *
+     * @param skeleton
+     *            The skeleton string off of which to base this NumberFormatter.
+     * @return An {@link UnlocalizedNumberFormatter}, to be used for chaining.
+     * @draft ICU 62
+     * @provisional This API might change or be removed in a future release.
+     */
+    public static UnlocalizedNumberFormatter fromSkeleton(String skeleton) {
+        return NumberSkeletonImpl.getOrCreate(skeleton);
+    }
+
     /**
      * @internal
      * @deprecated ICU 60 This API is ICU internal only.
index 0143f2e48be904407d61d3166428efc579f720a7..22175dfd351058cdc8db950581763ac717399cf0 100644 (file)
@@ -475,6 +475,18 @@ public abstract class NumberFormatterSettings<T extends NumberFormatterSettings<
         return create(KEY_THRESHOLD, threshold);
     }
 
+    /**
+     * Creates a skeleton string representation of this number formatter. A skeleton string is a
+     * locale-agnostic serialized form of a number formatter.
+     *
+     * @return A number skeleton string with behavior corresponding to this number formatter.
+     * @draft ICU 62
+     * @provisional This API might change or be removed in a future release.
+     */
+    public String toSkeleton() {
+        return NumberSkeletonImpl.generate(resolve());
+    }
+
     /* package-protected */ abstract T create(int key, Object value);
 
     MacroProps resolve() {
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/number/NumberSkeletonImpl.java b/icu4j/main/classes/core/src/com/ibm/icu/number/NumberSkeletonImpl.java
new file mode 100644 (file)
index 0000000..3eaef09
--- /dev/null
@@ -0,0 +1,490 @@
+// © 2018 and later: Unicode, Inc. and others.
+// License & terms of use: http://www.unicode.org/copyright.html#License
+package com.ibm.icu.number;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.ibm.icu.impl.PatternProps;
+import com.ibm.icu.impl.StringSegment;
+import com.ibm.icu.impl.number.MacroProps;
+import com.ibm.icu.util.Currency.CurrencyUsage;
+
+/**
+ * @author sffc
+ *
+ */
+class NumberSkeletonImpl {
+
+    static enum StemType {
+        ROUNDER, FRACTION_ROUNDER, MAYBE_INCREMENT_ROUNDER, CURRENCY_ROUNDER
+    }
+
+    static class SkeletonDataStructure {
+        final Map<String, StemType> stemsToTypes;
+        final Map<String, Object> stemsToValues;
+        final Map<Object, String> valuesToStems;
+
+        SkeletonDataStructure() {
+            stemsToTypes = new HashMap<String, StemType>();
+            stemsToValues = new HashMap<String, Object>();
+            valuesToStems = new HashMap<Object, String>();
+        }
+
+        public void put(StemType stemType, String content, Object value) {
+            stemsToTypes.put(content, stemType);
+            stemsToValues.put(content, value);
+            valuesToStems.put(value, content);
+        }
+
+        public StemType stemToType(CharSequence content) {
+            return stemsToTypes.get(content);
+        }
+
+        public Object stemToValue(CharSequence content) {
+            return stemsToValues.get(content);
+        }
+
+        public String valueToStem(Object value) {
+            return valuesToStems.get(value);
+        }
+    }
+
+    static final SkeletonDataStructure skeletonData = new SkeletonDataStructure();
+
+    static {
+        skeletonData.put(StemType.ROUNDER, "round-integer", Rounder.integer());
+        skeletonData.put(StemType.ROUNDER, "round-unlimited", Rounder.unlimited());
+        skeletonData.put(StemType.ROUNDER,
+                "round-currency-standard",
+                Rounder.currency(CurrencyUsage.STANDARD));
+        skeletonData.put(StemType.ROUNDER, "round-currency-cash", Rounder.currency(CurrencyUsage.CASH));
+    }
+
+    private static final Map<String, UnlocalizedNumberFormatter> cache = new ConcurrentHashMap<String, UnlocalizedNumberFormatter>();
+
+    /**
+     * Gets the number formatter for the given number skeleton string from the cache, creating it if it
+     * does not exist in the cache.
+     *
+     * @param skeletonString
+     *            A number skeleton string, possibly not in its shortest form.
+     * @return An UnlocalizedNumberFormatter with behavior defined by the given skeleton string.
+     */
+    public static UnlocalizedNumberFormatter getOrCreate(String skeletonString) {
+        String unNormalized = skeletonString; // more appropriate variable name for the implementation
+
+        // First try: look up the un-normalized skeleton.
+        UnlocalizedNumberFormatter formatter = cache.get(unNormalized);
+        if (formatter != null) {
+            return formatter;
+        }
+
+        // Second try: normalize the skeleton, and then access the cache.
+        // Store the un-normalized form for a faster lookup next time.
+        // Synchronize because we need a transaction with multiple queries to the cache.
+        String normalized = normalizeSkeleton(unNormalized);
+        if (cache.containsKey(normalized)) {
+            synchronized (cache) {
+                formatter = cache.get(normalized);
+                if (formatter != null) {
+                    cache.putIfAbsent(unNormalized, formatter);
+                }
+            }
+        }
+        if (formatter != null) {
+            return formatter;
+        }
+
+        // Third try: create the formatter, store it in the cache, and return it.
+        formatter = create(normalized);
+
+        // Synchronize because we need a transaction with multiple queries to the cache.
+        synchronized (cache) {
+            if (cache.containsKey(normalized)) {
+                formatter = cache.get(normalized);
+            } else {
+                cache.put(normalized, formatter);
+            }
+            cache.putIfAbsent(unNormalized, formatter);
+        }
+        return formatter;
+    }
+
+    /**
+     * Creates a NumberFormatter corresponding to the given skeleton string.
+     *
+     * @param skeletonString
+     *            A number skeleton string, possibly not in its shortest form.
+     * @return An UnlocalizedNumberFormatter with behavior defined by the given skeleton string.
+     */
+    public static UnlocalizedNumberFormatter create(String skeletonString) {
+        MacroProps macros = parseSkeleton(skeletonString);
+        return NumberFormatter.with().macros(macros);
+    }
+
+    public static String generate(MacroProps macros) {
+        StringBuilder sb = new StringBuilder();
+        generateSkeleton(macros, sb);
+        return sb.toString();
+    }
+
+    /**
+     * Normalizes a number skeleton string to the shortest equivalent form.
+     *
+     * @param skeletonString
+     *            A number skeleton string, possibly not in its shortest form.
+     * @return An equivalent and possibly simplified skeleton string.
+     */
+    public static String normalizeSkeleton(String skeletonString) {
+        // FIXME
+        return skeletonString;
+    }
+
+    /////
+
+    private static MacroProps parseSkeleton(String skeletonString) {
+        MacroProps macros = new MacroProps();
+        StringSegment segment = new StringSegment(skeletonString + " ", false);
+        StemType stem = null;
+        int offset = 0;
+        while (offset < segment.length()) {
+            int cp = segment.codePointAt(offset);
+            boolean isWhiteSpace = PatternProps.isWhiteSpace(cp);
+            if (offset > 0 && (isWhiteSpace || cp == '/')) {
+                segment.setLength(offset);
+                if (stem == null) {
+                    stem = parseStem(segment, macros);
+                } else {
+                    stem = parseOption(stem, segment, macros);
+                }
+                segment.resetLength();
+                segment.adjustOffset(offset + 1);
+                offset = 0;
+            } else {
+                offset += Character.charCount(cp);
+            }
+            if (isWhiteSpace && stem != null) {
+                // Check for stems that require an option
+                switch (stem) {
+                case MAYBE_INCREMENT_ROUNDER:
+                    throw new SkeletonSyntaxException("Stem requires an option", segment);
+                default:
+                    break;
+                }
+                stem = null;
+            }
+        }
+        assert stem == null;
+        return macros;
+    }
+
+    private static StemType parseStem(CharSequence content, MacroProps macros) {
+        // First try: exact match with a literal stem
+        StemType stem = skeletonData.stemToType(content);
+        if (stem != null) {
+            Object value = skeletonData.stemToValue(content);
+            switch (stem) {
+            case ROUNDER:
+                checkNull(macros.rounder, content);
+                macros.rounder = (Rounder) value;
+                break;
+            default:
+                assert false;
+            }
+            return stem;
+        }
+
+        // Second try: literal stems that require an option
+        if (content.equals("round-increment")) {
+            return StemType.MAYBE_INCREMENT_ROUNDER;
+        }
+
+        // Second try: stem "blueprint" syntax
+        switch (content.charAt(0)) {
+        case '.':
+            stem = StemType.FRACTION_ROUNDER;
+            parseFractionStem(content, macros);
+            break;
+        case '@':
+            stem = StemType.ROUNDER;
+            parseDigitsStem(content, macros);
+            break;
+        }
+        if (stem != null) {
+            return stem;
+        }
+
+        // Still no hits: throw an exception
+        throw new SkeletonSyntaxException("Unknown stem", content);
+    }
+
+    private static StemType parseOption(StemType stem, CharSequence content, MacroProps macros) {
+        // Frac-sig option
+        switch (stem) {
+        case FRACTION_ROUNDER:
+            if (parseFracSigOption(content, macros)) {
+                return StemType.ROUNDER;
+            }
+        }
+
+        // Increment option
+        switch (stem) {
+        case MAYBE_INCREMENT_ROUNDER:
+            // The increment option is required.
+            parseIncrementOption(content, macros);
+            return StemType.ROUNDER;
+        }
+
+        // Rounding mode option
+        switch (stem) {
+        case ROUNDER:
+        case FRACTION_ROUNDER:
+        case CURRENCY_ROUNDER:
+            if (parseRoundingModeOption(content, macros)) {
+                break;
+            }
+        }
+
+        // Unknown option
+        throw new SkeletonSyntaxException("Unknown option", content);
+    }
+
+    /////
+
+    private static void generateSkeleton(MacroProps macros, StringBuilder sb) {
+        if (macros.rounder != null) {
+            generateRoundingValue(macros, sb);
+            sb.append(' ');
+        }
+
+        // Remove the trailing space
+        if (sb.length() > 0) {
+            sb.setLength(sb.length() - 1);
+        }
+    }
+
+    /////
+
+    private static void parseFractionStem(CharSequence content, MacroProps macros) {
+        assert content.charAt(0) == '.';
+        int offset = 1;
+        int minFrac = 0;
+        int maxFrac;
+        for (; offset < content.length(); offset++) {
+            if (content.charAt(offset) == '0') {
+                minFrac++;
+            } else {
+                break;
+            }
+        }
+        if (offset < content.length()) {
+            if (content.charAt(offset) == '+') {
+                maxFrac = -1;
+                offset++;
+            } else {
+                maxFrac = minFrac;
+                for (; offset < content.length(); offset++) {
+                    if (content.charAt(offset) == '#') {
+                        maxFrac++;
+                    } else {
+                        break;
+                    }
+                }
+            }
+        } else {
+            maxFrac = minFrac;
+        }
+        if (offset < content.length()) {
+            throw new SkeletonSyntaxException("Invalid fraction stem", content);
+        }
+        // Use the public APIs to enforce bounds checking
+        if (maxFrac == -1) {
+            macros.rounder = Rounder.minFraction(minFrac);
+        } else {
+            macros.rounder = Rounder.minMaxFraction(minFrac, maxFrac);
+        }
+    }
+
+    private static void generateFractionStem(int minFrac, int maxFrac, StringBuilder sb) {
+        if (minFrac == 0 && maxFrac == 0) {
+            sb.append("round-integer");
+            return;
+        }
+        sb.append('.');
+        appendMultiple(sb, '0', minFrac);
+        if (maxFrac == -1) {
+            sb.append('+');
+        } else {
+            appendMultiple(sb, '#', maxFrac - minFrac);
+        }
+    }
+
+    private static void parseDigitsStem(CharSequence content, MacroProps macros) {
+        assert content.charAt(0) == '@';
+        int offset = 0;
+        int minSig = 0;
+        int maxSig;
+        for (; offset < content.length(); offset++) {
+            if (content.charAt(offset) == '@') {
+                minSig++;
+            } else {
+                break;
+            }
+        }
+        if (offset < content.length()) {
+            if (content.charAt(offset) == '+') {
+                maxSig = -1;
+                offset++;
+            } else {
+                maxSig = minSig;
+                for (; offset < content.length(); offset++) {
+                    if (content.charAt(offset) == '#') {
+                        maxSig++;
+                    } else {
+                        break;
+                    }
+                }
+            }
+        } else {
+            maxSig = minSig;
+        }
+        if (offset < content.length()) {
+            throw new SkeletonSyntaxException("Invalid significant digits stem", content);
+        }
+        // Use the public APIs to enforce bounds checking
+        if (maxSig == -1) {
+            macros.rounder = Rounder.minDigits(minSig);
+        } else {
+            macros.rounder = Rounder.minMaxDigits(minSig, maxSig);
+        }
+    }
+
+    private static void generateDigitsStem(int minSig, int maxSig, StringBuilder sb) {
+        appendMultiple(sb, '@', minSig);
+        if (maxSig == -1) {
+            sb.append('+');
+        } else {
+            appendMultiple(sb, '#', maxSig - minSig);
+        }
+    }
+
+    private static boolean parseFracSigOption(CharSequence content, MacroProps macros) {
+        if (content.charAt(0) != '@') {
+            return false;
+        }
+        FractionRounder oldRounder = (FractionRounder) macros.rounder;
+        // A little bit of a hack: parse the option as a digits stem, and extract the min/max sig from
+        // the new Rounder saved into the macros
+        parseDigitsStem(content, macros);
+        Rounder.SignificantRounderImpl intermediate = (Rounder.SignificantRounderImpl) macros.rounder;
+        if (intermediate.maxSig == -1) {
+            macros.rounder = oldRounder.withMinDigits(intermediate.minSig);
+        } else {
+            macros.rounder = oldRounder.withMaxDigits(intermediate.maxSig);
+        }
+        return true;
+    }
+
+    private static void parseIncrementOption(CharSequence content, MacroProps macros) {
+        // Clunkilly convert the CharSequence to a char array for the BigDecimal constructor.
+        // We can't use content.toString() because that doesn't create a clean string.
+        char[] chars = new char[content.length()];
+        for (int i = 0; i < content.length(); i++) {
+            chars[i] = content.charAt(i);
+        }
+        BigDecimal increment;
+        try {
+            increment = new BigDecimal(chars);
+        } catch (NumberFormatException e) {
+            throw new SkeletonSyntaxException("Invalid rounding increment", content, e);
+        }
+        macros.rounder = Rounder.increment(increment);
+    }
+
+    private static void generateIncrementOption(BigDecimal increment, StringBuilder sb) {
+        sb.append(increment.toPlainString());
+    }
+
+    private static boolean parseRoundingModeOption(CharSequence content, MacroProps macros) {
+        // Iterate over int modes instead of enum modes for performance
+        for (int rm = 0; rm <= BigDecimal.ROUND_UNNECESSARY; rm++) {
+            RoundingMode mode = RoundingMode.valueOf(rm);
+            if (content.equals(mode.toString())) {
+                macros.rounder = macros.rounder.withMode(mode);
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static void generateRoundingModeOption(RoundingMode mode, StringBuilder sb) {
+        sb.append(mode.toString());
+    }
+
+    /////
+
+    private static void generateRoundingValue(MacroProps macros, StringBuilder sb) {
+        // Check for literals
+        String literal = skeletonData.valueToStem(macros.rounder);
+        if (literal != null) {
+            sb.append(literal);
+            return;
+        }
+
+        // Generate the stem
+        if (macros.rounder instanceof Rounder.InfiniteRounderImpl) {
+            sb.append("round-unlimited");
+        } else if (macros.rounder instanceof Rounder.FractionRounderImpl) {
+            Rounder.FractionRounderImpl impl = (Rounder.FractionRounderImpl) macros.rounder;
+            generateFractionStem(impl.minFrac, impl.maxFrac, sb);
+        } else if (macros.rounder instanceof Rounder.SignificantRounderImpl) {
+            Rounder.SignificantRounderImpl impl = (Rounder.SignificantRounderImpl) macros.rounder;
+            generateDigitsStem(impl.minSig, impl.maxSig, sb);
+        } else if (macros.rounder instanceof Rounder.FracSigRounderImpl) {
+            Rounder.FracSigRounderImpl impl = (Rounder.FracSigRounderImpl) macros.rounder;
+            generateFractionStem(impl.minFrac, impl.maxFrac, sb);
+            sb.append('/');
+            if (impl.minSig == -1) {
+                generateDigitsStem(1, impl.maxSig, sb);
+            } else {
+                generateDigitsStem(impl.minSig, -1, sb);
+            }
+        } else if (macros.rounder instanceof Rounder.IncrementRounderImpl) {
+            Rounder.IncrementRounderImpl impl = (Rounder.IncrementRounderImpl) macros.rounder;
+            sb.append("round-increment/");
+            generateIncrementOption(impl.increment, sb);
+        } else {
+            assert macros.rounder instanceof Rounder.CurrencyRounderImpl;
+            Rounder.CurrencyRounderImpl impl = (Rounder.CurrencyRounderImpl) macros.rounder;
+            if (impl.usage == CurrencyUsage.STANDARD) {
+                sb.append("round-currency-standard");
+            } else {
+                sb.append("round-currency-cash");
+            }
+        }
+
+        // Generate the options
+        if (macros.rounder.mathContext != Rounder.DEFAULT_MATH_CONTEXT) {
+            sb.append('/');
+            generateRoundingModeOption(macros.rounder.mathContext.getRoundingMode(), sb);
+        }
+    }
+
+    /////
+
+    private static void checkNull(Object value, CharSequence content) {
+        if (value != null) {
+            throw new SkeletonSyntaxException("Duplicated setting", content);
+        }
+    }
+
+    private static void appendMultiple(StringBuilder sb, int cp, int count) {
+        for (int i = 0; i < count; i++) {
+            sb.appendCodePoint(cp);
+        }
+    }
+}
index 783adf35c5260c6019176dbbb8ac0ef86369c177..fbae054d479358b906cce4c9fa492b541694d0cb 100644 (file)
@@ -26,8 +26,11 @@ public abstract class Rounder implements Cloneable {
 
     /* package-private final */ MathContext mathContext;
 
+    /* package-private */ static final MathContext DEFAULT_MATH_CONTEXT = RoundingUtils
+            .mathContextUnlimited(RoundingUtils.DEFAULT_ROUNDING_MODE);
+
     /* package-private */ Rounder() {
-        mathContext = RoundingUtils.mathContextUnlimited(RoundingUtils.DEFAULT_ROUNDING_MODE);
+        mathContext = DEFAULT_MATH_CONTEXT;
     }
 
     /**
@@ -245,7 +248,7 @@ public abstract class Rounder implements Cloneable {
      */
     public static Rounder maxDigits(int maxSignificantDigits) {
         if (maxSignificantDigits >= 1 && maxSignificantDigits <= RoundingUtils.MAX_INT_FRAC_SIG) {
-            return constructSignificant(0, maxSignificantDigits);
+            return constructSignificant(1, maxSignificantDigits);
         } else {
             throw new IllegalArgumentException("Significant digits must be between 1 and "
                     + RoundingUtils.MAX_INT_FRAC_SIG
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/number/SkeletonSyntaxException.java b/icu4j/main/classes/core/src/com/ibm/icu/number/SkeletonSyntaxException.java
new file mode 100644 (file)
index 0000000..92ba548
--- /dev/null
@@ -0,0 +1,20 @@
+// © 2018 and later: Unicode, Inc. and others.
+// License & terms of use: http://www.unicode.org/copyright.html#License
+package com.ibm.icu.number;
+
+/**
+ * Exception used for illegal number skeleton strings.
+ *
+ * @author sffc
+ */
+public class SkeletonSyntaxException extends IllegalArgumentException {
+    private static final long serialVersionUID = 7733971331648360554L;
+
+    public SkeletonSyntaxException(String message, CharSequence token) {
+        super("Syntax error in skeleton string: " + message + ": " + token);
+    }
+
+    public SkeletonSyntaxException(String message, CharSequence token, Throwable cause) {
+        super("Syntax error in skeleton string: " + message + ": " + token, cause);
+    }
+}
index ec4ff7e0fc78478e3834c25643939214b9263bab..d6c8a8c55249e69739a5cc28b0df3c7eef92a7ae 100644 (file)
@@ -770,7 +770,7 @@ public class NumberFormatterApiTest {
     public void roundingFraction() {
         assertFormatDescending(
                 "Integer",
-                "F0",
+                "round-integer",
                 NumberFormatter.with().rounding(Rounder.integer()),
                 ULocale.ENGLISH,
                 "87,650",
@@ -785,7 +785,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Fixed Fraction",
-                "F3",
+                ".000",
                 NumberFormatter.with().rounding(Rounder.fixedFraction(3)),
                 ULocale.ENGLISH,
                 "87,650.000",
@@ -800,7 +800,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Min Fraction",
-                "F1-",
+                ".0+",
                 NumberFormatter.with().rounding(Rounder.minFraction(1)),
                 ULocale.ENGLISH,
                 "87,650.0",
@@ -815,7 +815,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Max Fraction",
-                "F-1",
+                ".#",
                 NumberFormatter.with().rounding(Rounder.maxFraction(1)),
                 ULocale.ENGLISH,
                 "87,650",
@@ -830,7 +830,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Min/Max Fraction",
-                "F1-3",
+                ".0##",
                 NumberFormatter.with().rounding(Rounder.minMaxFraction(1, 3)),
                 ULocale.ENGLISH,
                 "87,650.0",
@@ -848,7 +848,7 @@ public class NumberFormatterApiTest {
     public void roundingFigures() {
         assertFormatSingle(
                 "Fixed Significant",
-                "S3",
+                "@@@",
                 NumberFormatter.with().rounding(Rounder.fixedDigits(3)),
                 ULocale.ENGLISH,
                 -98,
@@ -856,7 +856,7 @@ public class NumberFormatterApiTest {
 
         assertFormatSingle(
                 "Fixed Significant Rounding",
-                "S3",
+                "@@@",
                 NumberFormatter.with().rounding(Rounder.fixedDigits(3)),
                 ULocale.ENGLISH,
                 -98.7654321,
@@ -864,7 +864,7 @@ public class NumberFormatterApiTest {
 
         assertFormatSingle(
                 "Fixed Significant Zero",
-                "S3",
+                "@@@",
                 NumberFormatter.with().rounding(Rounder.fixedDigits(3)),
                 ULocale.ENGLISH,
                 0,
@@ -872,7 +872,7 @@ public class NumberFormatterApiTest {
 
         assertFormatSingle(
                 "Min Significant",
-                "S2-",
+                "@@+",
                 NumberFormatter.with().rounding(Rounder.minDigits(2)),
                 ULocale.ENGLISH,
                 -9,
@@ -880,7 +880,7 @@ public class NumberFormatterApiTest {
 
         assertFormatSingle(
                 "Max Significant",
-                "S-4",
+                "@###",
                 NumberFormatter.with().rounding(Rounder.maxDigits(4)),
                 ULocale.ENGLISH,
                 98.7654321,
@@ -888,7 +888,7 @@ public class NumberFormatterApiTest {
 
         assertFormatSingle(
                 "Min/Max Significant",
-                "S3-4",
+                "@@@#",
                 NumberFormatter.with().rounding(Rounder.minMaxDigits(3, 4)),
                 ULocale.ENGLISH,
                 9.99999,
@@ -899,7 +899,7 @@ public class NumberFormatterApiTest {
     public void roundingFractionFigures() {
         assertFormatDescending(
                 "Basic Significant", // for comparison
-                "S-2",
+                "@#",
                 NumberFormatter.with().rounding(Rounder.maxDigits(2)),
                 ULocale.ENGLISH,
                 "88,000",
@@ -914,7 +914,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "FracSig minMaxFrac minSig",
-                "F1-2>3",
+                ".0#/@@@+",
                 NumberFormatter.with().rounding(Rounder.minMaxFraction(1, 2).withMinDigits(3)),
                 ULocale.ENGLISH,
                 "87,650.0",
@@ -929,7 +929,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "FracSig minMaxFrac maxSig A",
-                "F1-3<2",
+                ".0##/@#",
                 NumberFormatter.with().rounding(Rounder.minMaxFraction(1, 3).withMaxDigits(2)),
                 ULocale.ENGLISH,
                 "88,000.0", // maxSig beats maxFrac
@@ -944,7 +944,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "FracSig minMaxFrac maxSig B",
-                "F2<2",
+                ".00/@#",
                 NumberFormatter.with().rounding(Rounder.fixedFraction(2).withMaxDigits(2)),
                 ULocale.ENGLISH,
                 "88,000.00", // maxSig beats maxFrac
@@ -959,7 +959,7 @@ public class NumberFormatterApiTest {
 
         assertFormatSingle(
                 "FracSig with trailing zeros A",
-                "",
+                ".00/@@@+",
                 NumberFormatter.with().rounding(Rounder.fixedFraction(2).withMinDigits(3)),
                 ULocale.ENGLISH,
                 0.1,
@@ -967,7 +967,7 @@ public class NumberFormatterApiTest {
 
         assertFormatSingle(
                 "FracSig with trailing zeros B",
-                "",
+                ".00/@@@+",
                 NumberFormatter.with().rounding(Rounder.fixedFraction(2).withMinDigits(3)),
                 ULocale.ENGLISH,
                 0.0999999,
@@ -978,7 +978,7 @@ public class NumberFormatterApiTest {
     public void roundingOther() {
         assertFormatDescending(
                 "Rounding None",
-                "Y",
+                "round-unlimited",
                 NumberFormatter.with().rounding(Rounder.unlimited()),
                 ULocale.ENGLISH,
                 "87,650",
@@ -993,7 +993,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Increment",
-                "M0.5",
+                "round-increment/0.5",
                 NumberFormatter.with().rounding(Rounder.increment(BigDecimal.valueOf(0.5))),
                 ULocale.ENGLISH,
                 "87,650.0",
@@ -1008,7 +1008,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Increment with Min Fraction",
-                "M0.5",
+                "round-increment/0.50",
                 NumberFormatter.with().rounding(Rounder.increment(new BigDecimal("0.50"))),
                 ULocale.ENGLISH,
                 "87,650.00",
@@ -1023,7 +1023,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Currency Standard",
-                "$CZK GSTANDARD",
+                "round-currency-standard",
                 NumberFormatter.with().rounding(Rounder.currency(CurrencyUsage.STANDARD)).unit(CZK),
                 ULocale.ENGLISH,
                 "CZK 87,650.00",
@@ -1038,7 +1038,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Currency Cash",
-                "$CZK GCASH",
+                "round-currency-cash",
                 NumberFormatter.with().rounding(Rounder.currency(CurrencyUsage.CASH)).unit(CZK),
                 ULocale.ENGLISH,
                 "CZK 87,650",
@@ -1053,7 +1053,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Currency Cash with Nickel Rounding",
-                "$CAD GCASH",
+                "round-currency-cash",
                 NumberFormatter.with().rounding(Rounder.currency(CurrencyUsage.CASH)).unit(CAD),
                 ULocale.ENGLISH,
                 "CA$87,650.00",
@@ -1068,7 +1068,7 @@ public class NumberFormatterApiTest {
 
         assertFormatDescending(
                 "Currency not in top-level fluent chain",
-                "F0",
+                "round-currency-cash/CZK",
                 NumberFormatter.with().rounding(Rounder.currency(CurrencyUsage.CASH).withCurrency(CZK)),
                 ULocale.ENGLISH,
                 "87,650",
@@ -1083,7 +1083,7 @@ public class NumberFormatterApiTest {
 
         // NOTE: Other tests cover the behavior of the other rounding modes.
         assertFormatDescending(
-                "Rounding Mode CEILING",
+                "round-integer/CEILING",
                 "",
                 NumberFormatter.with().rounding(Rounder.integer().withMode(RoundingMode.CEILING)),
                 ULocale.ENGLISH,
@@ -2036,16 +2036,18 @@ public class NumberFormatterApiTest {
             double[] inputs,
             String... expected) {
         assert expected.length == 9;
-        // TODO: Add a check for skeleton.
-        // assertEquals(message + ": Skeleton:", skeleton, f.toSkeleton());
+        assertEquals(message + ": Skeleton:", skeleton, f.toSkeleton());
         LocalizedNumberFormatter l1 = f.threshold(0L).locale(locale); // no self-regulation
         LocalizedNumberFormatter l2 = f.threshold(1L).locale(locale); // all self-regulation
+        LocalizedNumberFormatter l3 = NumberFormatter.fromSkeleton(skeleton).locale(locale);
         for (int i = 0; i < 9; i++) {
             double d = inputs[i];
             String actual1 = l1.format(d).toString();
             assertEquals(message + ": Unsafe Path: " + d, expected[i], actual1);
             String actual2 = l2.format(d).toString();
             assertEquals(message + ": Safe Path: " + d, expected[i], actual2);
+            String actual3 = l3.format(d).toString();
+            assertEquals(message + ": Skeleton Path: " + d, expected[i], actual3);
         }
     }
 
@@ -2056,14 +2058,16 @@ public class NumberFormatterApiTest {
             ULocale locale,
             Number input,
             String expected) {
-        // TODO: Add a check for skeleton.
-        // assertEquals(message + ": Skeleton:", skeleton, f.toSkeleton());
+        assertEquals(message + ": Skeleton:", skeleton, f.toSkeleton());
         LocalizedNumberFormatter l1 = f.threshold(0L).locale(locale); // no self-regulation
         LocalizedNumberFormatter l2 = f.threshold(1L).locale(locale); // all self-regulation
+        LocalizedNumberFormatter l3 = NumberFormatter.fromSkeleton(skeleton).locale(locale);
         String actual1 = l1.format(input).toString();
         assertEquals(message + ": Unsafe Path: " + input, expected, actual1);
         String actual2 = l2.format(input).toString();
         assertEquals(message + ": Safe Path: " + input, expected, actual2);
+        String actual3 = l3.format(input).toString();
+        assertEquals(message + ": Skeleton Path: " + input, expected, actual3);
     }
 
     private static void assertFormatSingleMeasure(
@@ -2073,13 +2077,15 @@ public class NumberFormatterApiTest {
             ULocale locale,
             Measure input,
             String expected) {
-        // TODO: Add a check for skeleton.
-        // assertEquals(message + ": Skeleton:", skeleton, f.toSkeleton());
+        assertEquals(message + ": Skeleton:", skeleton, f.toSkeleton());
         LocalizedNumberFormatter l1 = f.threshold(0L).locale(locale); // no self-regulation
         LocalizedNumberFormatter l2 = f.threshold(1L).locale(locale); // all self-regulation
+        LocalizedNumberFormatter l3 = NumberFormatter.fromSkeleton(skeleton).locale(locale);
         String actual1 = l1.format(input).toString();
         assertEquals(message + ": Unsafe Path: " + input, expected, actual1);
         String actual2 = l2.format(input).toString();
         assertEquals(message + ": Safe Path: " + input, expected, actual2);
+        String actual3 = l3.format(input).toString();
+        assertEquals(message + ": Skeleton Path: " + input, expected, actual3);
     }
 }
diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberSkeletonTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberSkeletonTest.java
new file mode 100644 (file)
index 0000000..6735271
--- /dev/null
@@ -0,0 +1,70 @@
+// © 2018 and later: Unicode, Inc. and others.
+// License & terms of use: http://www.unicode.org/copyright.html#License
+package com.ibm.icu.dev.test.number;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+
+import com.ibm.icu.number.NumberFormatter;
+import com.ibm.icu.number.SkeletonSyntaxException;
+
+/**
+ * @author sffc
+ *
+ */
+public class NumberSkeletonTest {
+
+    @Test
+    public void duplicateValues() {
+        try {
+            NumberFormatter.fromSkeleton("round-integer round-integer");
+            fail();
+        } catch (SkeletonSyntaxException expected) {
+            assertTrue(expected.getMessage(), expected.getMessage().contains("Duplicated setting"));
+        }
+    }
+
+    @Test
+    public void invalidTokens() {
+        String[] cases = {
+                ".00x",
+                ".00##0",
+                ".##+",
+                ".0#+",
+                "@@x",
+                "@@##0",
+                "@#+",
+                "round-increment/xxx",
+                "round-increment/0.1.2",
+        };
+
+        for (String cas : cases) {
+            try {
+                NumberFormatter.fromSkeleton(cas);
+                fail();
+            } catch (SkeletonSyntaxException expected) {
+                assertTrue(expected.getMessage(), expected.getMessage().contains("Invalid"));
+            }
+        }
+    }
+
+    @Test
+    public void stemsRequiringOption() {
+        String[] cases = {
+                "round-increment",
+                "round-increment/",
+                "round-increment scientific",
+        };
+
+        for (String cas : cases) {
+            try {
+                NumberFormatter.fromSkeleton(cas);
+                fail();
+            } catch (SkeletonSyntaxException expected) {
+                assertTrue(expected.getMessage(), expected.getMessage().contains("requires an option"));
+            }
+        }
+    }
+}