Skip to content

Commit 3f535a2

Browse files
authored
Parse negative numbers in Norwegian (and 59 other languages) (#290)
The DecimalFormatSymbols for Norwegian and 59 other languages use the minus-sign (unicode 8722) instead of the hyphen-minus sign (ascii 45). While technically correct, Gherkin is written on regular keyboards and there is no practical way to write a minus-sign. By patching the `DecimalFormatSymbols` with a regular minus sign we solve this problem. Additionally, for the same reason, the non-breaking space (ascii 160) and right single quotation mark (unicode 8217) for thousands separators are also patched with either a period or colon. Fixes: #287
1 parent b3f0892 commit 3f535a2

File tree

8 files changed

+220
-15
lines changed

8 files changed

+220
-15
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## [Unreleased]
9+
### Added
10+
- [Java] Assume numbers use either a comma or period for the thousands separator instead of non-breaking spaces. ([#290](https://github.com/cucumber/cucumber-expressions/pull/290))
11+
912
### Fixed
13+
- [Java] Parse negative numbers in Norwegian (and 59 other languages) ([#290](https://github.com/cucumber/cucumber-expressions/pull/290))
1014
- [Python] Remove support for Python 3.7 and extend support to 3.12 ([#280](https://github.com/cucumber/cucumber-expressions/pull/280))
1115
- [Python] The `ParameterType` constructor's `transformer` should be optional ([#288](https://github.com/cucumber/cucumber-expressions/pull/288))
1216

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,35 @@ the following built-in parameter types:
6565
| `{short}` | Matches the same as `{int}`, but converts to a 16 bit signed integer if the platform supports it. |
6666
| `{long}` | Matches the same as `{int}`, but converts to a 64 bit signed integer if the platform supports it. |
6767

68-
### Cucumber-JVM
68+
### Java
69+
70+
### The Anonymous Parameter
6971

7072
The *anonymous* parameter type will be converted to the parameter type of the step definition using an object mapper.
7173
Cucumber comes with a built-in object mapper that can handle all numeric types as well as. `Enum`.
7274

73-
To automatically convert to other types it is recommended to install an object mapper. See [configuration](https://cucumber.io/docs/cucumber/configuration)
75+
To automatically convert to other types it is recommended to install an object mapper. See [cucumber-java - Default Transformers](https://github.com/cucumber/cucumber-jvm/tree/main/cucumber-java#default-transformers)
7476
to learn how.
7577

78+
### Number formats
79+
80+
Java supports parsing localised numbers. I.e. in your English feature file you
81+
can format a-thousand-and-one-tenth as '1,000.1; while in French you would format it
82+
as '1.000,1'.
83+
84+
Parsing is facilitated by Javas [`DecimalFormat`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/text/DecimalFormat.html)
85+
and includes support for the scientific notation. Unfortunately the default
86+
localisation include symbols that can not be easily written on a regular
87+
keyboard. So a few substitutions are made:
88+
89+
* The minus sign is always hyphen-minus - (ascii 45).
90+
* If the decimal separator is a period (. ascii 46) the thousands separator is a comma (, ascii 44).
91+
So '1 000.1' and '1’000.1' should always be written as '1,000.1'.
92+
* If the decimal separator is a comma (, ascii 44) the thousands separator is a period (. ascii 46).
93+
So '1 000,1' or '1’000,1' should always be written as '1.000,1'.
94+
95+
If support for your preferred language could be improved, please create an issue!
96+
7697
### Custom Parameter types
7798

7899
Cucumber Expressions can be extended so they automatically convert
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package io.cucumber.cucumberexpressions;
2+
3+
import java.text.DecimalFormatSymbols;
4+
import java.util.Locale;
5+
6+
/**
7+
* A set of localized decimal symbols that can be written on a regular keyboard.
8+
* <p>
9+
* Note quite complete, feel free to make a suggestion.
10+
*/
11+
class KeyboardFriendlyDecimalFormatSymbols {
12+
13+
static DecimalFormatSymbols getInstance(Locale locale) {
14+
DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
15+
16+
// Replace the minus sign with minus-hyphen as available on most keyboards.
17+
if (symbols.getMinusSign() == '\u2212') {
18+
symbols.setMinusSign('-');
19+
}
20+
21+
if (symbols.getDecimalSeparator() == '.') {
22+
// For locales that use the period as the decimal separator
23+
// always use the comma for thousands. The alternatives are
24+
// not available on a keyboard
25+
symbols.setGroupingSeparator(',');
26+
} else if (symbols.getDecimalSeparator() == ',') {
27+
// For locales that use the comma as the decimal separator
28+
// always use the period for thousands. The alternatives are
29+
// not available on a keyboard
30+
symbols.setGroupingSeparator('.');
31+
}
32+
return symbols;
33+
}
34+
}

java/src/main/java/io/cucumber/cucumberexpressions/NumberParser.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.math.BigDecimal;
44
import java.text.DecimalFormat;
5+
import java.text.DecimalFormatSymbols;
56
import java.text.NumberFormat;
67
import java.text.ParseException;
78
import java.util.Locale;
@@ -14,6 +15,8 @@ final class NumberParser {
1415
if (numberFormat instanceof DecimalFormat) {
1516
DecimalFormat decimalFormat = (DecimalFormat) numberFormat;
1617
decimalFormat.setParseBigDecimal(true);
18+
DecimalFormatSymbols symbols = KeyboardFriendlyDecimalFormatSymbols.getInstance(locale);
19+
decimalFormat.setDecimalFormatSymbols(symbols);
1720
}
1821
}
1922

java/src/main/java/io/cucumber/cucumberexpressions/ParameterTypeRegistry.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ private ParameterTypeRegistry(ParameterByTypeTransformer defaultParameterTransfo
5858
this.internalParameterTransformer = defaultParameterTransformer;
5959
this.defaultParameterTransformer = defaultParameterTransformer;
6060

61-
DecimalFormatSymbols numberFormat = DecimalFormatSymbols.getInstance(locale);
61+
DecimalFormatSymbols numberFormat = KeyboardFriendlyDecimalFormatSymbols.getInstance(locale);
6262

6363
List<String> localizedFloatRegexp = singletonList(FLOAT_REGEXPS
6464
.replace("{decimal}", "" + numberFormat.getDecimalSeparator())
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package io.cucumber.cucumberexpressions;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.text.DecimalFormatSymbols;
6+
import java.util.AbstractMap.SimpleEntry;
7+
import java.util.Arrays;
8+
import java.util.List;
9+
import java.util.Locale;
10+
import java.util.function.Function;
11+
import java.util.stream.Stream;
12+
13+
import static java.util.Comparator.comparing;
14+
import static java.util.stream.Collectors.groupingBy;
15+
import static java.util.stream.Collectors.toList;
16+
17+
class KeyboardFriendlyDecimalFormatSymbolsTest {
18+
19+
@Test
20+
void listMinusSigns(){
21+
System.out.println("Original minus signs:");
22+
listMinusSigns(DecimalFormatSymbols::getInstance);
23+
System.out.println();
24+
System.out.println("Friendly minus signs:");
25+
listMinusSigns(KeyboardFriendlyDecimalFormatSymbols::getInstance);
26+
System.out.println();
27+
}
28+
29+
private static void listMinusSigns(Function<Locale, DecimalFormatSymbols> supplier) {
30+
getAvailableLocalesAsStream()
31+
.collect(groupingBy(locale -> supplier.apply(locale).getMinusSign()))
32+
.forEach((c, locales) -> System.out.println(render(c) + " " + render(locales)));
33+
}
34+
35+
@Test
36+
void listDecimalAndGroupingSeparators(){
37+
System.out.println("Original decimal and group separators:");
38+
listDecimalAndGroupingSeparators(DecimalFormatSymbols::getInstance);
39+
System.out.println();
40+
System.out.println("Friendly decimal and group separators:");
41+
listDecimalAndGroupingSeparators(KeyboardFriendlyDecimalFormatSymbols::getInstance);
42+
System.out.println();
43+
}
44+
45+
private static void listDecimalAndGroupingSeparators(Function<Locale, DecimalFormatSymbols> supplier) {
46+
getAvailableLocalesAsStream()
47+
.collect(groupingBy(locale -> {
48+
DecimalFormatSymbols symbols = supplier.apply(locale);
49+
return new SimpleEntry<>(symbols.getDecimalSeparator(), symbols.getGroupingSeparator());
50+
}))
51+
.entrySet()
52+
.stream()
53+
.sorted(comparing(entry -> entry.getKey().getKey()))
54+
.forEach((entry) -> {
55+
SimpleEntry<Character, Character> characters = entry.getKey();
56+
List<Locale> locales = entry.getValue();
57+
System.out.println(render(characters.getKey()) + " " + render(characters.getValue()) + " " + render(locales));
58+
});
59+
}
60+
61+
@Test
62+
void listExponentSigns(){
63+
System.out.println("Original exponent signs:");
64+
listExponentSigns(DecimalFormatSymbols::getInstance);
65+
System.out.println();
66+
System.out.println("Friendly exponent signs:");
67+
listExponentSigns(KeyboardFriendlyDecimalFormatSymbols::getInstance);
68+
System.out.println();
69+
}
70+
71+
private static void listExponentSigns(Function<Locale, DecimalFormatSymbols> supplier) {
72+
getAvailableLocalesAsStream()
73+
.collect(groupingBy(locale -> supplier.apply(locale).getExponentSeparator()))
74+
.forEach((s, locales) -> {
75+
if (s.length() == 1) {
76+
System.out.println(render(s.charAt(0)) + " " + render(locales));
77+
} else {
78+
System.out.println(s + " " + render(locales));
79+
}
80+
});
81+
}
82+
83+
private static Stream<Locale> getAvailableLocalesAsStream() {
84+
return Arrays.stream(DecimalFormatSymbols.getAvailableLocales());
85+
}
86+
87+
private static String render(Character character) {
88+
return character + " (" + (int) character + ")";
89+
}
90+
91+
private static String render(List<Locale> locales) {
92+
return locales.size() + ": " + locales.stream()
93+
.sorted(comparing(Locale::getDisplayName))
94+
.map(Locale::getDisplayName)
95+
.collect(toList());
96+
}
97+
98+
}

java/src/test/java/io/cucumber/cucumberexpressions/NumberParserTest.java

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,70 @@
55
import java.math.BigDecimal;
66
import java.util.Locale;
77

8+
import static java.util.Locale.forLanguageTag;
89
import static org.junit.jupiter.api.Assertions.assertEquals;
910

10-
public class NumberParserTest {
11+
class NumberParserTest {
1112

1213
private final NumberParser english = new NumberParser(Locale.ENGLISH);
1314
private final NumberParser german = new NumberParser(Locale.GERMAN);
1415
private final NumberParser canadianFrench = new NumberParser(Locale.CANADA_FRENCH);
16+
private final NumberParser norwegian = new NumberParser(forLanguageTag("no"));
17+
private final NumberParser canadian = new NumberParser(Locale.CANADA);
1518

1619
@Test
17-
public void can_parse_float() {
20+
void can_parse_float() {
1821
assertEquals(1042.2f, english.parseFloat("1,042.2"), 0);
19-
assertEquals(1042.2f, german.parseFloat( "1.042,2"), 0);
20-
assertEquals(1042.2f, canadianFrench.parseFloat( "1\u00A0042,2"), 0);
22+
assertEquals(1042.2f, canadian.parseFloat("1,042.2"), 0);
23+
24+
assertEquals(1042.2f, german.parseFloat("1.042,2"), 0);
25+
assertEquals(1042.2f, canadianFrench.parseFloat("1.042,2"), 0);
26+
assertEquals(1042.2f, norwegian.parseFloat("1.042,2"), 0);
2127
}
2228

2329
@Test
24-
public void can_parse_double() {
30+
void can_parse_double() {
2531
assertEquals(1042.000000000000002, english.parseDouble("1,042.000000000000002"), 0);
26-
assertEquals(1042.000000000000002, german.parseDouble( "1.042,000000000000002"), 0);
27-
assertEquals(1042.000000000000002, canadianFrench.parseDouble( "1\u00A0042,000000000000002"), 0);
32+
assertEquals(1042.000000000000002, canadian.parseDouble("1,042.000000000000002"), 0);
33+
34+
assertEquals(1042.000000000000002, german.parseDouble("1.042,000000000000002"), 0);
35+
assertEquals(1042.000000000000002, canadianFrench.parseDouble("1.042,000000000000002"), 0);
36+
assertEquals(1042.000000000000002, norwegian.parseDouble("1.042,000000000000002"), 0);
2837
}
2938

3039
@Test
31-
public void can_parse_big_decimals() {
40+
void can_parse_big_decimals() {
3241
assertEquals(new BigDecimal("1042.0000000000000000000002"), english.parseBigDecimal("1,042.0000000000000000000002"));
33-
assertEquals(new BigDecimal("1042.0000000000000000000002"), german.parseBigDecimal( "1.042,0000000000000000000002"));
34-
assertEquals(new BigDecimal("1042.0000000000000000000002"), canadianFrench.parseBigDecimal( "1\u00A0042,0000000000000000000002"));
42+
assertEquals(new BigDecimal("1042.0000000000000000000002"), canadian.parseBigDecimal("1,042.0000000000000000000002"));
43+
44+
assertEquals(new BigDecimal("1042.0000000000000000000002"), german.parseBigDecimal("1.042,0000000000000000000002"));
45+
assertEquals(new BigDecimal("1042.0000000000000000000002"), canadianFrench.parseBigDecimal("1.042,0000000000000000000002"));
46+
assertEquals(new BigDecimal("1042.0000000000000000000002"), norwegian.parseBigDecimal("1.042,0000000000000000000002"));
47+
}
48+
49+
@Test
50+
void can_parse_negative() {
51+
assertEquals(-1042.2f, english.parseFloat("-1,042.2"), 0);
52+
assertEquals(-1042.2f, canadian.parseFloat("-1,042.2"), 0);
53+
54+
assertEquals(-1042.2f, german.parseFloat("-1.042,2"), 0);
55+
assertEquals(-1042.2f, canadianFrench.parseFloat("-1.042,2"), 0);
56+
assertEquals(-1042.2f, norwegian.parseFloat("-1.042,2"), 0);
57+
}
3558

59+
@Test
60+
void can_parse_exponents() {
3661
assertEquals(new BigDecimal("100"), english.parseBigDecimal("1.00E2"));
62+
assertEquals(new BigDecimal("100"), canadian.parseBigDecimal("1.00e2"));
63+
assertEquals(new BigDecimal("100"), german.parseBigDecimal("1,00E2"));
64+
assertEquals(new BigDecimal("100"), canadianFrench.parseBigDecimal("1,00E2"));
65+
assertEquals(new BigDecimal("100"), norwegian.parseBigDecimal("1,00E2"));
66+
3767
assertEquals(new BigDecimal("0.01"), english.parseBigDecimal("1E-2"));
68+
assertEquals(new BigDecimal("0.01"), canadian.parseBigDecimal("1e-2"));
69+
assertEquals(new BigDecimal("0.01"), german.parseBigDecimal("1E-2"));
70+
assertEquals(new BigDecimal("0.01"), canadianFrench.parseBigDecimal("1E-2"));
71+
assertEquals(new BigDecimal("0.01"), norwegian.parseBigDecimal("1E-2"));
3872
}
3973

4074
}

java/src/test/java/io/cucumber/cucumberexpressions/ParameterTypeRegistryTest.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,19 @@ public void parse_decimal_numbers_in_canadian_french() {
171171
ExpressionFactory factory = new ExpressionFactory(new ParameterTypeRegistry(Locale.CANADA_FRENCH));
172172
Expression expression = factory.createExpression("{bigdecimal}");
173173

174-
assertThat(expression.match("1\u00A0000,1").get(0).getValue(), is(new BigDecimal("1000.1")));
175-
assertThat(expression.match("1\u00A0000\u00A0000,1").get(0).getValue(), is(new BigDecimal("1000000.1")));
174+
assertThat(expression.match("1.000,1").get(0).getValue(), is(new BigDecimal("1000.1")));
175+
assertThat(expression.match("1.000.000,1").get(0).getValue(), is(new BigDecimal("1000000.1")));
176+
assertThat(expression.match("-1,1").get(0).getValue(), is(new BigDecimal("-1.1")));
177+
assertThat(expression.match("-,1E1").get(0).getValue(), is(new BigDecimal("-1")));
178+
}
179+
180+
@Test
181+
public void parse_decimal_numbers_in_norwegian() {
182+
ExpressionFactory factory = new ExpressionFactory(new ParameterTypeRegistry(Locale.forLanguageTag("no")));
183+
Expression expression = factory.createExpression("{bigdecimal}");
184+
185+
assertThat(expression.match("1.000,1").get(0).getValue(), is(new BigDecimal("1000.1")));
186+
assertThat(expression.match("1.000.000,1").get(0).getValue(), is(new BigDecimal("1000000.1")));
176187
assertThat(expression.match("-1,1").get(0).getValue(), is(new BigDecimal("-1.1")));
177188
assertThat(expression.match("-,1E1").get(0).getValue(), is(new BigDecimal("-1")));
178189
}

0 commit comments

Comments
 (0)