diff --git a/src/main/java/org/apache/commons/validator/routines/BigDecimalValidator.java b/src/main/java/org/apache/commons/validator/routines/BigDecimalValidator.java index 2efca40ae..a1a25ca32 100644 --- a/src/main/java/org/apache/commons/validator/routines/BigDecimalValidator.java +++ b/src/main/java/org/apache/commons/validator/routines/BigDecimalValidator.java @@ -88,6 +88,39 @@ public static BigDecimalValidator getInstance() { return VALIDATOR; } + /** + * Tests whether the character at the given position of a pattern sits next to the given symbol. + * + * @param pattern The pattern being stripped of the symbol. + * @param index The position to test. + * @param symbol The symbol being removed. + * @return {@code true} if the character borders the symbol. + */ + private static boolean isNextToSymbol(final String pattern, final int index, final char symbol) { + return (index > 0 && pattern.charAt(index - 1) == symbol) + || (index + 1 < pattern.length() && pattern.charAt(index + 1) == symbol); + } + + /** + * Removes the given symbol, and any space characters adjacent to it, from a {@link DecimalFormat} pattern. A locale that suffixes the symbol usually + * separates it from the number with a (non-breaking) space; that space belongs to the symbol, so removing only the symbol would leave the separator + * mandatory. + * + * @param pattern The pattern to remove the symbol from. + * @param symbol The symbol to remove. + * @return The pattern without the symbol and its separator. + */ + static String removeSymbol(final String pattern, final char symbol) { + final StringBuilder buffer = new StringBuilder(pattern.length()); + for (int i = 0; i < pattern.length(); i++) { + final char chr = pattern.charAt(i); + if (chr != symbol && !(Character.isSpaceChar(chr) && isNextToSymbol(pattern, i, symbol))) { + buffer.append(chr); + } + } + return buffer.toString(); + } + /** * Constructs a strict instance. */ diff --git a/src/main/java/org/apache/commons/validator/routines/CurrencyValidator.java b/src/main/java/org/apache/commons/validator/routines/CurrencyValidator.java index 775eb4aad..c0510400f 100644 --- a/src/main/java/org/apache/commons/validator/routines/CurrencyValidator.java +++ b/src/main/java/org/apache/commons/validator/routines/CurrencyValidator.java @@ -100,17 +100,11 @@ protected Object parse(final String value, final Format formatter) { return parsedValue; } - // Re-parse using a pattern without the currency symbol + // Re-parse using a pattern without the currency symbol and its separator final DecimalFormat decimalFormat = (DecimalFormat) formatter; final String pattern = decimalFormat.toPattern(); if (pattern.indexOf(CURRENCY_SYMBOL) >= 0) { - final StringBuilder buffer = new StringBuilder(pattern.length()); - for (int i = 0; i < pattern.length(); i++) { - if (pattern.charAt(i) != CURRENCY_SYMBOL) { - buffer.append(pattern.charAt(i)); - } - } - decimalFormat.applyPattern(buffer.toString()); + decimalFormat.applyPattern(removeSymbol(pattern, CURRENCY_SYMBOL)); parsedValue = super.parse(value, decimalFormat); } return parsedValue; diff --git a/src/main/java/org/apache/commons/validator/routines/PercentValidator.java b/src/main/java/org/apache/commons/validator/routines/PercentValidator.java index ddbd8f0a0..8461608ff 100644 --- a/src/main/java/org/apache/commons/validator/routines/PercentValidator.java +++ b/src/main/java/org/apache/commons/validator/routines/PercentValidator.java @@ -103,17 +103,11 @@ protected Object parse(final String value, final Format formatter) { return parsedValue; } - // Re-parse using a pattern without the percent symbol + // Re-parse using a pattern without the percent symbol and its separator final DecimalFormat decimalFormat = (DecimalFormat) formatter; final String pattern = decimalFormat.toPattern(); if (pattern.indexOf(PERCENT_SYMBOL) >= 0) { - final StringBuilder buffer = new StringBuilder(pattern.length()); - for (int i = 0; i < pattern.length(); i++) { - if (pattern.charAt(i) != PERCENT_SYMBOL) { - buffer.append(pattern.charAt(i)); - } - } - decimalFormat.applyPattern(buffer.toString()); + decimalFormat.applyPattern(removeSymbol(pattern, PERCENT_SYMBOL)); parsedValue = (BigDecimal) super.parse(value, decimalFormat); // If parsed OK, divide by 100 to get percent diff --git a/src/test/java/org/apache/commons/validator/routines/CurrencyValidatorTest.java b/src/test/java/org/apache/commons/validator/routines/CurrencyValidatorTest.java index 4f733b566..a74ee43a1 100644 --- a/src/test/java/org/apache/commons/validator/routines/CurrencyValidatorTest.java +++ b/src/test/java/org/apache/commons/validator/routines/CurrencyValidatorTest.java @@ -20,15 +20,20 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.math.BigDecimal; import java.math.BigInteger; +import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.util.Locale; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.junitpioneer.jupiter.DefaultLocale; /** @@ -38,9 +43,21 @@ class CurrencyValidatorTest { private static final char CURRENCY_SYMBOL = '\u00A4'; + /** The character locales such as de-DE use between the number and a trailing currency symbol. */ + private static final char NON_BREAKING_SPACE = '\u00A0'; + private String usDollar; private String ukPound; + /** + * Locales whose currency format suffixes the symbol behind a space separator, covering different concrete symbols (€ and kr). + * + * @return the locales to test. + */ + static Stream suffixSymbolLocales() { + return Stream.of(Locale.GERMANY, Locale.FRANCE, Locale.forLanguageTag("sv-SE")); + } + @BeforeEach protected void setUp() { usDollar = new DecimalFormatSymbols(Locale.US).getCurrencySymbol(); @@ -176,6 +193,47 @@ void testPattern() { assertFalse(validator.isValid(ukPound + "1,234.567", pattern, Locale.US), "invalid symbol"); } + /** + * Test currency values against the JVM's own format for locales that suffix the symbol behind a space separator. The symbol is optional, so its separator + * has to be optional too. The pattern, separator and input are all derived from the locale data at run time, so the expectations hold on any JVM; on older + * JVMs whose locale data does not use a space separated suffix symbol the test is skipped. + */ + @ParameterizedTest + @MethodSource("suffixSymbolLocales") + void testSuffixSymbolLocale(final Locale locale) { + final DecimalFormat format = (DecimalFormat) NumberFormat.getCurrencyInstance(locale); + final String pattern = format.toPattern(); + final int symbolIndex = pattern.indexOf(CURRENCY_SYMBOL); + assumeTrue(symbolIndex > 0 && Character.isSpaceChar(pattern.charAt(symbolIndex - 1)), + () -> locale + " does not use a space separated suffix symbol: " + pattern); + final char separator = pattern.charAt(symbolIndex - 1); + final String symbol = format.getDecimalFormatSymbols().getCurrencySymbol(); + final String withSymbol = format.format(1234.56); + assumeTrue(withSymbol.endsWith(separator + symbol), () -> locale + " does not format the symbol last: " + withSymbol); + final String noSymbol = withSymbol.substring(0, withSymbol.length() - symbol.length() - 1); + + final BigDecimalValidator instance = CurrencyValidator.getInstance(); + final BigDecimal expected = new BigDecimal("1234.56"); + assertEquals(expected, instance.validate(withSymbol, locale), "symbol: " + locale); + assertEquals(expected, instance.validate(noSymbol, locale), "no symbol: " + locale); + assertNull(instance.validate(noSymbol + separator, locale), "separator without symbol: " + locale); + } + + /** + * Test currency values with an explicit pattern that suffixes the symbol behind a non-breaking space, so the expectations are pinned independently of the + * JVM's locale data. The symbol is optional, so its separator has to be optional too. + */ + @Test + void testSuffixSymbolPattern() { + final BigDecimalValidator instance = CurrencyValidator.getInstance(); + final String pattern = "#,##0.00" + NON_BREAKING_SPACE + CURRENCY_SYMBOL; + final BigDecimal expected = new BigDecimal("1234.56"); + + assertEquals(expected, instance.validate("1,234.56" + NON_BREAKING_SPACE + usDollar, pattern, Locale.US), "symbol"); + assertEquals(expected, instance.validate("1,234.56", pattern, Locale.US), "no symbol"); + assertNull(instance.validate("1,234.56" + NON_BREAKING_SPACE, pattern, Locale.US), "separator without symbol"); + } + /** * Test Valid currency values */ diff --git a/src/test/java/org/apache/commons/validator/routines/PercentValidatorTest.java b/src/test/java/org/apache/commons/validator/routines/PercentValidatorTest.java index 24f1fb992..cbfe508ee 100644 --- a/src/test/java/org/apache/commons/validator/routines/PercentValidatorTest.java +++ b/src/test/java/org/apache/commons/validator/routines/PercentValidatorTest.java @@ -20,14 +20,20 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.math.BigDecimal; import java.math.BigInteger; +import java.text.DecimalFormat; +import java.text.NumberFormat; import java.util.Locale; +import java.util.stream.Stream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.junitpioneer.jupiter.DefaultLocale; /** @@ -35,9 +41,23 @@ */ class PercentValidatorTest { + private static final char PERCENT_SYMBOL = '%'; + + /** The character locales such as fr-FR use between the number and a trailing percent symbol. */ + private static final char NON_BREAKING_SPACE = '\u00A0'; + protected PercentValidator validator; private Locale originalLocale; + /** + * Locales whose percent format suffixes the symbol behind a space separator. + * + * @return the locales to test. + */ + static Stream suffixSymbolLocales() { + return Stream.of(Locale.FRANCE, Locale.GERMANY); + } + @BeforeEach protected void setUp() { originalLocale = Locale.getDefault(); @@ -100,6 +120,47 @@ void testNumberRangeExactBound() { assertFalse(instance.minValue(new BigDecimal("5"), new BigDecimal("5.5"))); } + /** + * Test percentage values against the JVM's own format for locales that suffix the symbol behind a space separator. The symbol is optional, so its separator + * has to be optional too. The pattern, separator and input are all derived from the locale data at run time, so the expectations hold on any JVM; on older + * JVMs whose locale data does not use a space separated suffix symbol the test is skipped. + */ + @ParameterizedTest + @MethodSource("suffixSymbolLocales") + void testSuffixSymbolLocale(final Locale locale) { + final DecimalFormat format = (DecimalFormat) NumberFormat.getPercentInstance(locale); + final String pattern = format.toPattern(); + final int symbolIndex = pattern.indexOf(PERCENT_SYMBOL); + assumeTrue(symbolIndex > 0 && Character.isSpaceChar(pattern.charAt(symbolIndex - 1)), + () -> locale + " does not use a space separated suffix symbol: " + pattern); + final char separator = pattern.charAt(symbolIndex - 1); + final char symbol = format.getDecimalFormatSymbols().getPercent(); + final String withSymbol = format.format(0.12); + assumeTrue(withSymbol.endsWith(separator + Character.toString(symbol)), () -> locale + " does not format the symbol last: " + withSymbol); + final String noSymbol = withSymbol.substring(0, withSymbol.length() - 2); + + final BigDecimalValidator instance = PercentValidator.getInstance(); + final BigDecimal expected = new BigDecimal("0.12"); + assertEquals(expected, instance.validate(withSymbol, locale), "symbol: " + locale); + assertEquals(expected, instance.validate(noSymbol, locale), "no symbol: " + locale); + assertNull(instance.validate(noSymbol + separator, locale), "separator without symbol: " + locale); + } + + /** + * Test percentage values with an explicit pattern that suffixes the symbol behind a non-breaking space, so the expectations are pinned independently of the + * JVM's locale data. The symbol is optional, so its separator has to be optional too. + */ + @Test + void testSuffixSymbolPattern() { + final BigDecimalValidator instance = PercentValidator.getInstance(); + final String pattern = "#,##0" + NON_BREAKING_SPACE + PERCENT_SYMBOL; + final BigDecimal expected = new BigDecimal("0.12"); + + assertEquals(expected, instance.validate("12" + NON_BREAKING_SPACE + PERCENT_SYMBOL, pattern, Locale.US), "symbol"); + assertEquals(expected, instance.validate("12", pattern, Locale.US), "no symbol"); + assertNull(instance.validate("12" + NON_BREAKING_SPACE, pattern, Locale.US), "separator without symbol"); + } + /** * Test Valid percentage values */