diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/AttributeTypeTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/AttributeTypeTest.java index 6ed1155f7c..115f2e372a 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/AttributeTypeTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/AttributeTypeTest.java @@ -1,34 +1,118 @@ package name.abuchen.portfolio.model; -import static org.hamcrest.Matchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import java.text.MessageFormat; +import java.text.ParseException; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; import java.util.Locale; -import org.junit.AfterClass; -import org.junit.BeforeClass; +import org.junit.After; +import org.junit.Before; import org.junit.Test; +import name.abuchen.portfolio.Messages; import name.abuchen.portfolio.model.AttributeType.AmountConverter; +import name.abuchen.portfolio.model.AttributeType.Converter; +import name.abuchen.portfolio.model.AttributeType.LimitPriceConverter; +import name.abuchen.portfolio.model.AttributeType.LongConverter; import name.abuchen.portfolio.model.AttributeType.PercentConverter; +import name.abuchen.portfolio.model.LimitPrice.RelationalOperator; +import name.abuchen.portfolio.model.proto.v1.PAnyValue; import name.abuchen.portfolio.money.Values; +import name.abuchen.portfolio.money.ValuesBuilder; @SuppressWarnings("nls") public class AttributeTypeTest { private static Locale DEFAULT_LOCALE = Locale.getDefault(); - @BeforeClass - public static void setupLocale() + @Before + public void setupLocale() { DEFAULT_LOCALE = Locale.getDefault(); - Locale.setDefault(Locale.GERMAN); + Locale.setDefault(Locale.GERMANY); + ValuesBuilder.initQuoteValuesDecimalFormat(); + AttributeType.initPatterns(); } - @AfterClass - public static void resetLocale() + @After + public void resetLocale() { Locale.setDefault(DEFAULT_LOCALE); + ValuesBuilder.initQuoteValuesDecimalFormat(); + AttributeType.initPatterns(); + } + + @Test + public void testLongShareConverter_deDE() throws Exception + { + Locale.setDefault(Locale.GERMANY); + ValuesBuilder.initQuoteValuesDecimalFormat(); + AttributeType.initPatterns(); + performLongConverterTest( + Values.Share, + Arrays.asList(" 12.345,67890123 ", "12.345,6789012345", "12.345,678901236", "12345,67890123", + "1.2.345,678901236", "-12.345,67890123"), + Arrays.asList(1234567890123L, 1234567890123L, 1234567890123L, 1234567890123L, 1234567890123L, + -1234567890123L), + Arrays.asList("12.345,67890123", "12.345,67890123", "12.345,67890123", "12.345,67890123", + "12.345,67890123", "-12.345,67890123")); + } + + @Test + public void testLongShareConverter_deCH() throws Exception + { + Locale.setDefault(new Locale("de", "CH")); + ValuesBuilder.initQuoteValuesDecimalFormat(); + AttributeType.initPatterns(); + performLongConverterTest( + Values.Share, + Arrays.asList(" 12’345.67890123 ", "12’345.6789012345", "12’345.678901236", "12345.67890123", + "1’2’345.678901236"), + Arrays.asList(1234567890123L, 1234567890123L, 1234567890123L, 1234567890123L, 1234567890123L), + Arrays.asList("12’345.67890123", "12’345.67890123", "12’345.67890123", "12’345.67890123", + "12’345.67890123")); + } + + @Test + public void testLimitPriceConverter_deDE() + { + Locale.setDefault(Locale.GERMANY); + ValuesBuilder.initQuoteValuesDecimalFormat(); + AttributeType.initPatterns(); + + performLimitPriceTests( + Arrays.asList(" < 123,45 ", "<= 123,45", ">= 1.123,45", " > 1.234", " > 1.2.34,5678"), + Arrays.asList(new LimitPrice(RelationalOperator.SMALLER, 12345000000L), + new LimitPrice(RelationalOperator.SMALLER_OR_EQUAL, 12345000000L), + new LimitPrice(RelationalOperator.GREATER_OR_EQUAL, 112345000000L), + new LimitPrice(RelationalOperator.GREATER, 123400000000L), + new LimitPrice(RelationalOperator.GREATER, 123456780000L)), + Arrays.asList("< 123,45", "<= 123,45", ">= 1.123,45", "> 1.234,00", "> 1.234,5678")); + } + + @Test + public void testLimitPriceConverter_deCH() + { + Locale.setDefault(new Locale("de", "CH")); + ValuesBuilder.initQuoteValuesDecimalFormat(); + AttributeType.initPatterns(); + + performLimitPriceTests( + Arrays.asList(" < 123.45 ", "<= 123.45", ">= 1’123.45", " > 1’234", " > 1’2’34.5678"), + Arrays.asList(new LimitPrice(RelationalOperator.SMALLER, 12345000000L), + new LimitPrice(RelationalOperator.SMALLER_OR_EQUAL, 12345000000L), + new LimitPrice(RelationalOperator.GREATER_OR_EQUAL, 112345000000L), + new LimitPrice(RelationalOperator.GREATER, 123400000000L), + new LimitPrice(RelationalOperator.GREATER, 123456780000L)), + Arrays.asList("< 123.45", "<= 123.45", ">= 1’123.45", "> 1’234.00", "> 1’234.5678")); } @Test @@ -50,8 +134,87 @@ public void testPercentParsing() assertThat(converter.fromString("22"), is(0.22)); assertThat(converter.fromString("12,34%"), is(0.1234)); assertThat(converter.fromString("12,34567%"), is(0.1234567)); - + assertThat(converter.toString(converter.fromString("22%")), is("22,00%")); assertThat(converter.toString(converter.fromString("22")), is("22,00%")); } + + private void performLongConverterTest(Values values, List validParseTexts, List parseResults, + List toStringResult) + { + Iterator it = validParseTexts.iterator(); + Iterator valIt = parseResults.iterator(); + Iterator toStringIt = toStringResult.iterator(); + while (it.hasNext()) + { + String validParseText = it.next(); + System.out.println(validParseText); + Long val = valIt.next(); + LongConverter sc = new LongConverter(ValuesBuilder.createNumberValues(values)); + assertThat(sc.toString(null), is("")); + assertThat(sc.toString(val), is(toStringIt.next())); + assertThrows(NullPointerException.class, () -> sc.fromString(null)); + assertNull(sc.fromString(" ")); + assertThat(sc.fromString(validParseText), is(val)); + assertThat(sc.fromString(validParseText), is(values.factorize(val.doubleValue() / values.factor()))); + checkParseException(sc, "notanumber", Messages.MsgNotANumber, false); + + PAnyValue pav; + pav = sc.toProto(null); + assertNotNull(pav); + assertThat(pav.hasNull(), is(true)); + assertNull(sc.fromProto(pav)); + pav = sc.toProto(val); + assertNotNull(pav); + assertThat(pav.hasInt64(), is(true)); + assertThat(sc.fromProto(pav), is(val)); + } + } + + private void performLimitPriceTests(List validParseTexts, List parseResults, + List toStringResult) + { + Iterator it = validParseTexts.iterator(); + Iterator resultIt = parseResults.iterator(); + Iterator toStringIt = toStringResult.iterator(); + while (it.hasNext()) + { + String validParseText = it.next(); + System.out.println(validParseText); + LimitPrice val = resultIt.next(); + LimitPriceConverter sc = new LimitPriceConverter(); + assertThat(sc.toString(null), is("")); + assertThat(sc.fromString(validParseText), is(val)); + assertThat(sc.toString(val), is(toStringIt.next())); + assertThrows(NullPointerException.class, () -> sc.fromString(null)); + assertNull(sc.fromString(" ")); + checkParseException(sc, "notanumber", Messages.MsgNotAComparator, false); + + PAnyValue pav; + pav = sc.toProto(null); + assertNotNull(pav); + assertThat(pav.hasNull(), is(true)); + assertNull(sc.fromProto(pav)); + pav = sc.toProto(val); + assertNotNull(pav); + assertThat(pav.hasString(), is(true)); + assertThat(sc.fromProto(pav), is(val)); + } + } + + private void checkParseException(Converter sc, String toParse, String expectedResourceKey, boolean expectCause) + { + IllegalArgumentException iae; + iae = assertThrows(IllegalArgumentException.class, () -> sc.fromString(toParse)); + assertThat(iae.getMessage(), is(MessageFormat.format(expectedResourceKey, toParse))); + if (expectCause) + { + assertNotNull(iae.getCause()); + assertThat(iae.getCause().getClass().getName(), is(ParseException.class.getName())); + } + else + { + assertNull(iae.getCause()); + } + } } diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/money/ValuesBuilder.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/money/ValuesBuilder.java new file mode 100644 index 0000000000..755f2e12cd --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/money/ValuesBuilder.java @@ -0,0 +1,44 @@ +package name.abuchen.portfolio.money; + +import java.text.DecimalFormat; + +import name.abuchen.portfolio.money.Values.QuoteValues; + +public class ValuesBuilder +{ + public static Values createNumberValues(Values orgValues) + { + return new Values(orgValues.pattern(), orgValues.precision()) + { + private final DecimalFormat format = new DecimalFormat(pattern()); + + @Override + public String format(Number share) + { + if (DiscreetMode.isActive()) + return DiscreetMode.HIDDEN_AMOUNT; + else + return format.format(share.doubleValue() / divider()); + } + }; + } + + public static Values createNumberValues(String pattern, int precision) + { + return new Values(pattern, precision) + { + + @Override + public String format(T amount) + { + DecimalFormat df = new DecimalFormat(pattern); + return df.format(amount); + } + }; + } + + public static void initQuoteValuesDecimalFormat() + { + QuoteValues.QUOTE_FORMAT.set(new DecimalFormat(QuoteValues.QUOTE_PATTERN)); + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/editor/ClientInput.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/editor/ClientInput.java index a5a21bfbbc..f367c95bff 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/editor/ClientInput.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/editor/ClientInput.java @@ -557,30 +557,31 @@ private void scheduleOnlineUpdateJobs() if (preferences.getBoolean(UIConstants.Preferences.UPDATE_QUOTES_AFTER_FILE_OPEN, true)) { Predicate onlyActive = s -> !s.isRetired(); + Client c = getClient(); - Job initialQuoteUpdate = new UpdateQuotesJob(client, onlyActive, + Job initialQuoteUpdate = new UpdateQuotesJob(c, onlyActive, EnumSet.of(UpdateQuotesJob.Target.LATEST, UpdateQuotesJob.Target.HISTORIC)); initialQuoteUpdate.schedule(1000); - CreateInvestmentPlanTxJob checkInvestmentPlans = new CreateInvestmentPlanTxJob(client, + CreateInvestmentPlanTxJob checkInvestmentPlans = new CreateInvestmentPlanTxJob(c, exchangeRateProviderFacory); checkInvestmentPlans.startAfter(initialQuoteUpdate); checkInvestmentPlans.schedule(1100); int thirtyMinutes = 1000 * 60 * 30; - Job job = new UpdateQuotesJob(client, onlyActive, EnumSet.of(UpdateQuotesJob.Target.LATEST)) + Job job = new UpdateQuotesJob(c, onlyActive, EnumSet.of(UpdateQuotesJob.Target.LATEST)) .repeatEvery(thirtyMinutes); job.schedule(thirtyMinutes); regularJobs.add(job); int sixHours = 1000 * 60 * 60 * 6; - job = new UpdateQuotesJob(client, onlyActive, EnumSet.of(UpdateQuotesJob.Target.HISTORIC)) + job = new UpdateQuotesJob(c, onlyActive, EnumSet.of(UpdateQuotesJob.Target.HISTORIC)) .repeatEvery(sixHours); job.schedule(sixHours); regularJobs.add(job); - new SyncOnlineSecuritiesJob(client).schedule(5000); - new UpdateDividendsJob(getClient()).schedule(7000); + new SyncOnlineSecuritiesJob(c).schedule(5000); + new UpdateDividendsJob(c).schedule(7000); } } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/Colors.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/Colors.java index fb6413ead9..e42b322991 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/Colors.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/Colors.java @@ -21,8 +21,8 @@ public static class Theme private Color defaultForeground = Colors.BLACK; private Color defaultBackground = Colors.WHITE; private Color warningBackground = getColor(254, 223, 107); // FEDF6B - private Color redBackground = Colors.GREEN; - private Color greenBackground = Colors.RED; + private Color redBackground = Colors.RED; + private Color greenBackground = Colors.GREEN; private Color redForeground = Colors.DARK_RED; private Color greenForeground = Colors.DARK_GREEN; private Color grayForeground = getColor(112, 112, 112); // 707070 @@ -225,8 +225,8 @@ public static Color getTextColor(Color color) { // http://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color - double luminance = 1 - (0.299 * color.getRed() + 0.587 * color.getGreen() + 0.114 * color.getBlue()) / 255; - return luminance < 0.4 ? BLACK : WHITE; + double luminance = (0.299 * color.getRed() + 0.587 * color.getGreen() + 0.114 * color.getBlue()) / 255; + return luminance > 0.55 ? BLACK : WHITE; } public static Color brighter(Color base) diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dashboard/LimitExceededWidget.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dashboard/LimitExceededWidget.java index fffc212e99..0f818c536b 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dashboard/LimitExceededWidget.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dashboard/LimitExceededWidget.java @@ -8,6 +8,7 @@ import java.util.function.Supplier; import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; import org.eclipse.swt.layout.FormAttachment; import org.eclipse.swt.layout.FormLayout; import org.eclipse.swt.widgets.Composite; @@ -103,10 +104,11 @@ protected Composite createItemControl(Composite parent, LimitItem item) // determine colors LimitPriceSettings settings = new LimitPriceSettings(item.attributeType.getProperties()); - price.setBackdropColor(item.limit.getRelationalOperator().isGreater() + Color bgColor = item.limit.getRelationalOperator().isGreater() ? settings.getLimitExceededPositivelyColor(Colors.theme().greenBackground()) - : settings.getLimitExceededNegativelyColor(Colors.theme().redBackground())); - + : settings.getLimitExceededNegativelyColor(Colors.theme().redBackground()); + price.setBackdropColor(bgColor); + price.setForeground(Colors.getTextColor(bgColor)); price.setText(Values.Quote.format(item.getSecurity().getCurrencyCode(), item.price.getValue())); Label limit = new Label(composite, SWT.NONE); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/AttributeType.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/AttributeType.java index 0d9dae5468..8c41ca9fd9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/AttributeType.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/AttributeType.java @@ -3,6 +3,7 @@ import java.lang.reflect.InvocationTargetException; import java.math.BigDecimal; import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; import java.text.MessageFormat; import java.text.NumberFormat; import java.text.ParseException; @@ -11,6 +12,7 @@ import java.time.format.DateTimeParseException; import java.time.format.FormatStyle; import java.util.Comparator; +import java.util.Locale; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -25,8 +27,25 @@ public class AttributeType implements Named { - private static final Pattern PATTERN = Pattern.compile("^([\\d.,-]*)$"); //$NON-NLS-1$ - private static final Pattern LIMIT_PRICE_PATTERN = Pattern.compile("^\\s*(<=?|>=?)\\s*([0-9,.']+)$"); //$NON-NLS-1$ + private static Pattern PATTERN; + private static Pattern LIMIT_PRICE_PATTERN; + + static + { + initPatterns(); + } + + @SuppressWarnings("nls") + static void initPatterns() + { + DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(Locale.getDefault(Locale.Category.FORMAT)); + + // e.g. \\-?(\\d+\\.)*\\d+(\\,\\d+)? + String numberRegex = "\\" + dfs.getMinusSign() + "?(\\d+\\" + dfs.getGroupingSeparator() + ")*\\d+(\\" + + dfs.getDecimalSeparator() + "\\d+)?"; + PATTERN = Pattern.compile("^\\s*" + numberRegex + "\\s*$"); + LIMIT_PRICE_PATTERN = Pattern.compile("^\\s*(<=?|>=?)\\s*(" + numberRegex + ")\\s*$"); + } /* protobuf only */ interface ProtoConverter { @@ -90,11 +109,11 @@ public String toString(Object object) } @Override - public Object fromString(String value) + public LimitPrice fromString(String value) { try { - if (value.length() == 0) + if (value.isBlank()) return null; Matcher m = LIMIT_PRICE_PATTERN.matcher(value); @@ -156,7 +175,7 @@ public Object fromProto(PAnyValue value) } } - private static class LongConverter implements Converter, ProtoConverter + static class LongConverter implements Converter, ProtoConverter { private final DecimalFormat full; @@ -179,22 +198,22 @@ public String toString(Object object) @Override public Object fromString(String value) { + if (value.isBlank()) + return null; + + Matcher m = PATTERN.matcher(value); + if (!m.matches()) + throw new IllegalArgumentException(MessageFormat.format(Messages.MsgNotANumber, value)); try { - if (value.trim().length() == 0) - return null; - - Matcher m = PATTERN.matcher(value); - if (!m.matches()) - throw new IllegalArgumentException(MessageFormat.format(Messages.MsgNotANumber, value)); - BigDecimal v = (BigDecimal) full.parse(value); + BigDecimal v = (BigDecimal) full.parse(value.trim()); return v.multiply(BigDecimal.valueOf(values.factor())).longValue(); } catch (ParseException e) { - throw new IllegalArgumentException(e); + throw new IllegalArgumentException(MessageFormat.format(Messages.MsgNotANumber, value), e); } } @@ -246,7 +265,7 @@ public ShareConverter() } } - private static class DoubleConverter implements Converter, ProtoConverter + static class DoubleConverter implements Converter, ProtoConverter { private final NumberFormat full = new DecimalFormat("#,###.##"); //$NON-NLS-1$ @@ -266,20 +285,18 @@ public String toString(Object object) @Override public Object fromString(String value) { + if (value.isBlank()) + return null; + Matcher m = PATTERN.matcher(value); + if (!m.matches()) + throw new IllegalArgumentException(MessageFormat.format(Messages.MsgNotANumber, value)); try { - if (value.trim().length() == 0) - return null; - - Matcher m = PATTERN.matcher(value); - if (!m.matches()) - throw new IllegalArgumentException(MessageFormat.format(Messages.MsgNotANumber, value)); - - return Double.valueOf(full.parse(value).doubleValue()); + return Double.valueOf(full.parse(value.trim()).doubleValue()); } catch (ParseException e) { - throw new IllegalArgumentException(e); + throw new IllegalArgumentException(MessageFormat.format(Messages.MsgNotANumber, value), e); } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LimitPrice.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LimitPrice.java index b0c288e9e6..350446333a 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LimitPrice.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LimitPrice.java @@ -45,6 +45,7 @@ public static Optional findByOperator(String op) private RelationalOperator operator = null; private long value; + private transient Values values = Values.Quote; public LimitPrice(RelationalOperator operator, long value) { @@ -101,7 +102,7 @@ public int compareTo(LimitPrice other) @Override public String toString() { - return operator.getOperatorString() + " " + Values.Quote.format(value); //$NON-NLS-1$ + return operator.getOperatorString() + " " + values.format(value); //$NON-NLS-1$ } public boolean isExceeded(SecurityPrice price) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/money/Values.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/money/Values.java index 1262b2645f..495f03d21c 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/money/Values.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/money/Values.java @@ -64,9 +64,9 @@ public String formatNonZero(Money amount, String skipCurrencyCode) public static final class QuoteValues extends Values { - private static final String QUOTE_PATTERN = "#,##0.00######"; //$NON-NLS-1$ + static final String QUOTE_PATTERN = "#,##0.00######"; //$NON-NLS-1$ - private static final ThreadLocal QUOTE_FORMAT = ThreadLocal // NOSONAR + static final ThreadLocal QUOTE_FORMAT = ThreadLocal // NOSONAR .withInitial(() -> new DecimalFormat(QUOTE_PATTERN)); private final BigDecimal factorToMoney; @@ -454,7 +454,7 @@ public String format(Integer amount) private final int precision; private final BigDecimal bdFactor; - private Values(String pattern, int precision) + Values(String pattern, int precision) { this.pattern = pattern; this.factor = BigInteger.TEN.pow(precision).intValue();