From: Shane Carr Date: Wed, 28 Feb 2018 08:06:42 +0000 (+0000) Subject: ICU-8610 Adds basic support for number skeletons. Includes skeleton support for round... X-Git-Tag: release-62-rc~200^2~112 X-Git-Url: https://granicus.if.org/sourcecode?a=commitdiff_plain;h=abb8788d23d2042aecc2ec553239abc213d94119;p=icu ICU-8610 Adds basic support for number skeletons. Includes skeleton support for rounding strategy. X-SVN-Rev: 41013 --- diff --git a/icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatter.java b/icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatter.java index dd7651bd820..552873fc317 100644 --- a/icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatter.java +++ b/icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatter.java @@ -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. diff --git a/icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatterSettings.java b/icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatterSettings.java index 0143f2e48be..22175dfd351 100644 --- a/icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatterSettings.java +++ b/icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatterSettings.java @@ -475,6 +475,18 @@ public abstract class NumberFormatterSettings stemsToTypes; + final Map stemsToValues; + final Map valuesToStems; + + SkeletonDataStructure() { + stemsToTypes = new HashMap(); + stemsToValues = new HashMap(); + valuesToStems = new HashMap(); + } + + 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 cache = new ConcurrentHashMap(); + + /** + * 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); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/number/Rounder.java b/icu4j/main/classes/core/src/com/ibm/icu/number/Rounder.java index 783adf35c52..fbae054d479 100644 --- a/icu4j/main/classes/core/src/com/ibm/icu/number/Rounder.java +++ b/icu4j/main/classes/core/src/com/ibm/icu/number/Rounder.java @@ -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 index 00000000000..92ba548eb49 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/number/SkeletonSyntaxException.java @@ -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); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberFormatterApiTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberFormatterApiTest.java index ec4ff7e0fc7..d6c8a8c5524 100644 --- a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberFormatterApiTest.java +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberFormatterApiTest.java @@ -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 index 00000000000..6735271ab4c --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberSkeletonTest.java @@ -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")); + } + } + } +}