From d5dbe07c20b59d897c0d694fa88987e67a875c6d Mon Sep 17 00:00:00 2001 From: Rollczi Date: Fri, 14 Feb 2025 00:47:48 +0100 Subject: [PATCH 1/2] Fix duration parser. Add an option to round values. --- .../litecommands/time/DurationParser.java | 19 ++- .../litecommands/time/PeriodParser.java | 22 ++- .../time/TemporalAmountParser.java | 136 +++++++++++---- .../time/TemporalAmountParserTest.java | 157 ++++++++++++++++++ 4 files changed, 292 insertions(+), 42 deletions(-) diff --git a/litecommands-core/src/dev/rollczi/litecommands/time/DurationParser.java b/litecommands-core/src/dev/rollczi/litecommands/time/DurationParser.java index be08c114a..c5959f989 100644 --- a/litecommands-core/src/dev/rollczi/litecommands/time/DurationParser.java +++ b/litecommands-core/src/dev/rollczi/litecommands/time/DurationParser.java @@ -4,6 +4,7 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.Map; +import java.util.Set; public class DurationParser extends TemporalAmountParser { @@ -26,20 +27,24 @@ public class DurationParser extends TemporalAmountParser { .withUnit("y", ChronoUnit.YEARS); public DurationParser() { - super(LocalDateTimeProvider.now()); + super(ChronoUnit.SECONDS, LocalDateTimeProvider.now()); } - public DurationParser(LocalDateTimeProvider localDateTimeProvider) { - super(localDateTimeProvider); + public DurationParser(ChronoUnit defaultZero) { + super(defaultZero, LocalDateTimeProvider.now()); } - private DurationParser(Map units, LocalDateTimeProvider baseForTimeEstimation) { - super(units, baseForTimeEstimation); + public DurationParser(ChronoUnit defaultZero, LocalDateTimeProvider localDateTimeProvider) { + super(defaultZero, localDateTimeProvider); + } + + private DurationParser(ChronoUnit defaultZero, Map units, Set modifiers, LocalDateTimeProvider baseForTimeEstimation) { + super(defaultZero, units, modifiers, baseForTimeEstimation); } @Override - protected TemporalAmountParser clone(Map units, LocalDateTimeProvider baseForTimeEstimation) { - return new DurationParser(units, baseForTimeEstimation); + protected TemporalAmountParser clone(ChronoUnit defaultZero, Map units, Set modifiers, LocalDateTimeProvider baseForTimeEstimation) { + return new DurationParser(defaultZero, units, modifiers, baseForTimeEstimation); } @Override diff --git a/litecommands-core/src/dev/rollczi/litecommands/time/PeriodParser.java b/litecommands-core/src/dev/rollczi/litecommands/time/PeriodParser.java index 82639176e..380617699 100644 --- a/litecommands-core/src/dev/rollczi/litecommands/time/PeriodParser.java +++ b/litecommands-core/src/dev/rollczi/litecommands/time/PeriodParser.java @@ -5,6 +5,7 @@ import java.time.Period; import java.time.temporal.ChronoUnit; import java.util.Map; +import java.util.Set; public class PeriodParser extends TemporalAmountParser { @@ -15,20 +16,29 @@ public class PeriodParser extends TemporalAmountParser { .withUnit("y", ChronoUnit.YEARS); public PeriodParser() { - super(LocalDateTimeProvider.now()); + super(ChronoUnit.DAYS, LocalDateTimeProvider.now()); + } + + public PeriodParser(ChronoUnit defaultZero) { + super(defaultZero, LocalDateTimeProvider.now()); } public PeriodParser(LocalDateTimeProvider baseForTimeEstimation) { - super(baseForTimeEstimation); + super(ChronoUnit.DAYS, baseForTimeEstimation); + } + + + public PeriodParser(ChronoUnit defaultZero, LocalDateTimeProvider baseForTimeEstimation) { + super(defaultZero, baseForTimeEstimation); } - private PeriodParser(Map units, LocalDateTimeProvider baseForTimeEstimation) { - super(units, baseForTimeEstimation); + private PeriodParser(ChronoUnit defaultZero, Map units, Set modifiers, LocalDateTimeProvider baseForTimeEstimation) { + super(defaultZero, units, modifiers, baseForTimeEstimation); } @Override - protected TemporalAmountParser clone(Map units, LocalDateTimeProvider baseForTimeEstimation) { - return new PeriodParser(units, baseForTimeEstimation); + protected TemporalAmountParser clone(ChronoUnit defaultZero, Map units, Set modifiers, LocalDateTimeProvider baseForTimeEstimation) { + return new PeriodParser(defaultZero, units, modifiers, baseForTimeEstimation); } @Override diff --git a/litecommands-core/src/dev/rollczi/litecommands/time/TemporalAmountParser.java b/litecommands-core/src/dev/rollczi/litecommands/time/TemporalAmountParser.java index 4e5433fb2..166947da4 100644 --- a/litecommands-core/src/dev/rollczi/litecommands/time/TemporalAmountParser.java +++ b/litecommands-core/src/dev/rollczi/litecommands/time/TemporalAmountParser.java @@ -1,6 +1,8 @@ package dev.rollczi.litecommands.time; +import dev.rollczi.litecommands.shared.Lazy; import java.math.BigInteger; +import java.math.RoundingMode; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; @@ -9,9 +11,11 @@ import java.time.temporal.TemporalAmount; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Predicate; /** @@ -38,21 +42,21 @@ */ public abstract class TemporalAmountParser { - private static final Map UNIT_TO_NANO = new LinkedHashMap<>(); + private static final Map UNIT_TO_NANO = new LinkedHashMap<>(); private static final Map PART_TIME_UNITS = new LinkedHashMap<>(); static { - UNIT_TO_NANO.put(ChronoUnit.NANOS, 1L); - UNIT_TO_NANO.put(ChronoUnit.MICROS, 1_000L); - UNIT_TO_NANO.put(ChronoUnit.MILLIS, 1_000_000L); - UNIT_TO_NANO.put(ChronoUnit.SECONDS, 1_000_000_000L); - UNIT_TO_NANO.put(ChronoUnit.MINUTES, 60 * 1_000_000_000L); - UNIT_TO_NANO.put(ChronoUnit.HOURS, 60 * 60 * 1_000_000_000L); - UNIT_TO_NANO.put(ChronoUnit.DAYS, 24 * 60 * 60 * 1_000_000_000L); - UNIT_TO_NANO.put(ChronoUnit.WEEKS, 7 * 24 * 60 * 60 * 1_000_000_000L); - UNIT_TO_NANO.put(ChronoUnit.MONTHS, 30 * 24 * 60 * 60 * 1_000_000_000L); - UNIT_TO_NANO.put(ChronoUnit.YEARS, 365 * 24 * 60 * 60 * 1_000_000_000L); - UNIT_TO_NANO.put(ChronoUnit.DECADES, 10 * 365 * 24 * 60 * 60 * 1_000_000_000L); + UNIT_TO_NANO.put(ChronoUnit.NANOS, BigInteger.valueOf(1L)); + UNIT_TO_NANO.put(ChronoUnit.MICROS, BigInteger.valueOf(1_000L)); + UNIT_TO_NANO.put(ChronoUnit.MILLIS, BigInteger.valueOf(1_000_000L)); + UNIT_TO_NANO.put(ChronoUnit.SECONDS, BigInteger.valueOf(1_000_000_000L)); + UNIT_TO_NANO.put(ChronoUnit.MINUTES, BigInteger.valueOf(60 * 1_000_000_000L)); + UNIT_TO_NANO.put(ChronoUnit.HOURS, BigInteger.valueOf(60 * 60 * 1_000_000_000L)); + UNIT_TO_NANO.put(ChronoUnit.DAYS, BigInteger.valueOf(24 * 60 * 60 * 1_000_000_000L)); + UNIT_TO_NANO.put(ChronoUnit.WEEKS, BigInteger.valueOf(7 * 24 * 60 * 60 * 1_000_000_000L)); + UNIT_TO_NANO.put(ChronoUnit.MONTHS, BigInteger.valueOf(30 * 24 * 60 * 60 * 1_000_000_000L)); + UNIT_TO_NANO.put(ChronoUnit.YEARS, BigInteger.valueOf(365 * 24 * 60 * 60 * 1_000_000_000L)); + UNIT_TO_NANO.put(ChronoUnit.DECADES, BigInteger.valueOf(10 * 365 * 24 * 60 * 60 * 1_000_000_000L)); PART_TIME_UNITS.put(ChronoUnit.NANOS, 1000); PART_TIME_UNITS.put(ChronoUnit.MICROS, 1000); @@ -66,16 +70,28 @@ public abstract class TemporalAmountParser { PART_TIME_UNITS.put(ChronoUnit.YEARS, Integer.MAX_VALUE); } + private final ChronoUnit defaultZero; + private final Lazy defaultZeroSymbol; private final Map units = new LinkedHashMap<>(); + private final Set modifiers = new HashSet<>(); private final LocalDateTimeProvider baseForTimeEstimation; - protected TemporalAmountParser(LocalDateTimeProvider baseForTimeEstimation) { - this.baseForTimeEstimation = baseForTimeEstimation; + protected TemporalAmountParser(ChronoUnit defaultZero, Map units, Set modifiers, LocalDateTimeProvider baseForTimeEstimation) { + this(defaultZero, baseForTimeEstimation); + this.units.putAll(units); + this.modifiers.addAll(modifiers); } - protected TemporalAmountParser(Map units, LocalDateTimeProvider baseForTimeEstimation) { + protected TemporalAmountParser(ChronoUnit defaultZero, LocalDateTimeProvider baseForTimeEstimation) { + this.defaultZero = defaultZero; this.baseForTimeEstimation = baseForTimeEstimation; - this.units.putAll(units); + this.defaultZeroSymbol = new Lazy<>(() -> this.units.entrySet() + .stream() + .filter(entry -> entry.getValue() == defaultZero) + .map(entry -> entry.getKey()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Can not find default zero symbol for " + defaultZero)) + ); } public TemporalAmountParser withUnit(String symbol, ChronoUnit chronoUnit) { @@ -89,14 +105,61 @@ public TemporalAmountParser withUnit(String symbol, ChronoUnit chronoUnit) { Map newUnits = new LinkedHashMap<>(this.units); newUnits.put(symbol, chronoUnit); - return clone(newUnits, this.baseForTimeEstimation); + return clone(this.defaultZero, newUnits, this.modifiers, this.baseForTimeEstimation); } public TemporalAmountParser withLocalDateTimeProvider(LocalDateTimeProvider baseForTimeEstimation) { - return clone(this.units, baseForTimeEstimation); + return clone(this.defaultZero, this.units, this.modifiers, baseForTimeEstimation); + } + + public TemporalAmountParser withDefaultZero(ChronoUnit defaultZero) { + return clone(defaultZero, this.units, this.modifiers, this.baseForTimeEstimation); + } + + public TemporalAmountParser withRounded(ChronoUnit unit, RoundingMode roundingMode) { + return withTimeModifier(duration -> { + BigInteger nanosInUnit = UNIT_TO_NANO.get(unit); + BigInteger nanos = durationToNano(duration); + BigInteger rounded = round(roundingMode, nanos, nanosInUnit); + + return Duration.ofNanos(rounded.longValue()); + }); + } + + private static BigInteger round(RoundingMode roundingMode, BigInteger nanos, BigInteger nanosInUnit) { + BigInteger remainder = nanos.remainder(nanosInUnit); + BigInteger subtract = nanos.subtract(remainder); + BigInteger add = subtract.add(nanosInUnit); + + BigInteger roundedUp = remainder.equals(BigInteger.ZERO) ? nanos : (nanos.signum() > 0 ? add : subtract.subtract(nanosInUnit)); + BigInteger roundedDown = remainder.equals(BigInteger.ZERO) ? nanos : (nanos.signum() > 0 ? subtract : add.subtract(nanosInUnit)); + + int compare = remainder.abs().multiply(BigInteger.valueOf(2)).compareTo(nanosInUnit); + switch (roundingMode) { + case UP: + return roundedUp; + case DOWN: + return roundedDown; + case CEILING: + return nanos.signum() >= 0 ? roundedUp : roundedDown; + case FLOOR: + return nanos.signum() >= 0 ? roundedDown : roundedUp; + case HALF_UP: + return compare >= 0 ? roundedUp : roundedDown; + case HALF_DOWN: + return (compare > 0) ? roundedUp : roundedDown; + default: throw new IllegalArgumentException("Unsupported rounding mode " + roundingMode); + } } - protected abstract TemporalAmountParser clone(Map units, LocalDateTimeProvider baseForTimeEstimation); + + private TemporalAmountParser withTimeModifier(TimeModifier modifier) { + Set newRoundedUnits = new HashSet<>(this.modifiers); + newRoundedUnits.add(modifier); + return clone(this.defaultZero, this.units, newRoundedUnits, this.baseForTimeEstimation); + } + + protected abstract TemporalAmountParser clone(ChronoUnit defaultZeroUnit, Map units, Set modifiers, LocalDateTimeProvider baseForTimeEstimation); private boolean validCharacters(String content, Predicate predicate) { for (int i = 0; i < content.length(); i++) { @@ -265,6 +328,9 @@ public ChronoUnit getUnit() { public String format(T temporalAmount) { StringBuilder builder = new StringBuilder(); Duration duration = this.toDuration(this.baseForTimeEstimation, temporalAmount); + for (TimeModifier modifier : this.modifiers) { + duration = modifier.modify(duration); + } if (duration.isNegative()) { builder.append('-'); @@ -275,26 +341,34 @@ public String format(T temporalAmount) { Collections.reverse(keys); for (String key : keys) { - ChronoUnit chronoUnit = this.units.get(key); - Long part = UNIT_TO_NANO.get(chronoUnit); + ChronoUnit unit = this.units.get(key); + BigInteger nanosInOneUnit = UNIT_TO_NANO.get(unit); - if (part == null) { - throw new IllegalArgumentException("Unsupported unit " + chronoUnit); + if (nanosInOneUnit == null) { + throw new IllegalArgumentException("Unsupported unit " + unit); } - BigInteger currentCount = this.durationToNano(duration).divide(BigInteger.valueOf(part)); - BigInteger maxCount = BigInteger.valueOf(PART_TIME_UNITS.get(chronoUnit)); - BigInteger count = currentCount.equals(maxCount) ? BigInteger.ONE : currentCount.mod(maxCount); + BigInteger nanosCount = this.durationToNano(duration); + BigInteger count = nanosCount.divide(nanosInOneUnit); if (count.equals(BigInteger.ZERO)) { continue; } + BigInteger nanosCountCleared = count.multiply(nanosInOneUnit); + builder.append(count).append(key); - duration = duration.minusNanos(count.longValue() * part); + duration = duration.minusNanos(nanosCountCleared.longValue()); } - return builder.toString(); + String result = builder.toString(); + + if (result.isEmpty()) { + String defaultSymbol = this.defaultZeroSymbol.get(); + return "0" + defaultSymbol; + } + + return result; } protected abstract Duration toDuration(LocalDateTimeProvider baseForTimeEstimation, T temporalAmount); @@ -325,8 +399,12 @@ static LocalDateTimeProvider of(LocalDate localDate) { } } + + protected interface TimeModifier { + Duration modify(Duration duration); + } - BigInteger durationToNano(Duration duration) { + private BigInteger durationToNano(Duration duration) { return BigInteger.valueOf(duration.getSeconds()) .multiply(BigInteger.valueOf(1_000_000_000)) .add(BigInteger.valueOf(duration.getNano())); diff --git a/litecommands-core/test/dev/rollczi/litecommands/time/TemporalAmountParserTest.java b/litecommands-core/test/dev/rollczi/litecommands/time/TemporalAmountParserTest.java index 6460a6157..ebf9a135a 100644 --- a/litecommands-core/test/dev/rollczi/litecommands/time/TemporalAmountParserTest.java +++ b/litecommands-core/test/dev/rollczi/litecommands/time/TemporalAmountParserTest.java @@ -1,6 +1,7 @@ package dev.rollczi.litecommands.time; import dev.rollczi.litecommands.time.TemporalAmountParser.LocalDateTimeProvider; +import java.math.RoundingMode; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -16,6 +17,7 @@ class TemporalAmountParserTest { @CsvSource({ + "0s,0", "1m20s,80", "1m,60", "1h,3600", @@ -169,7 +171,9 @@ void testParseInvalidToLageNumber() { } @CsvSource({ + "0,0s", "60,1m", + "61,1m1s", "3600,1h", "86400,1d", "87000,1d10m", @@ -190,6 +194,159 @@ void testFormat(int seconds, String expected) { assertEquals(expected, formatted); } + @CsvSource({ + "0,0s", + "60,1m", + "61,1m", + "3601,1h", + "86400,1d", + "87000,1d10m", + "87020,1d10m", + "1814402,3w", + "2592010,1mo", + "31536010,1y", + "-60,-1m", + "-61,-1m", + "-3661,-1h1m", + }) + @ParameterizedTest + void testRoundedDownFormat(int seconds, String expected) { + TemporalAmountParser temporalAmountParser = DurationParser.DATE_TIME_UNITS + .withRounded(ChronoUnit.MINUTES, RoundingMode.DOWN); + String formatted = temporalAmountParser.format(Duration.ofSeconds(seconds)); + + assertEquals(expected, formatted); + } + + @CsvSource({ + "0,0s", + "60,1m", + "61,2m", + "3601,1h1m", + "86400,1d", + "87000,1d10m", + "87020,1d11m", + "1814402,3w1m", + "2592010,1mo1m", + "31536010,1y1m", + "-60,-1m", + "-61,-2m", + "-3661,-1h2m", + }) + @ParameterizedTest + void testRoundedUpFormat(int seconds, String expected) { + TemporalAmountParser temporalAmountParser = DurationParser.DATE_TIME_UNITS + .withRounded(ChronoUnit.MINUTES, RoundingMode.UP); + String formatted = temporalAmountParser.format(Duration.ofSeconds(seconds)); + + assertEquals(expected, formatted); + } + + @CsvSource({ + "0,0s", + "60,1m", + "61,2m", + "3601,1h1m", + "86400,1d", + "87000,1d10m", + "87020,1d11m", + "1814402,3w1m", + "2592010,1mo1m", + "31536010,1y1m", + "-60,-1m", + "-61,-1m", + "-3661,-1h1m", + }) + @ParameterizedTest + void testRoundedCeilingFormat(int seconds, String expected) { + TemporalAmountParser temporalAmountParser = DurationParser.DATE_TIME_UNITS + .withRounded(ChronoUnit.MINUTES, RoundingMode.CEILING); + String formatted = temporalAmountParser.format(Duration.ofSeconds(seconds)); + + assertEquals(expected, formatted); + } + + @CsvSource({ + "0,0s", + "60,1m", + "61,1m", + "3601,1h", + "86400,1d", + "87000,1d10m", + "87020,1d10m", + "1814402,3w", + "2592010,1mo", + "31536010,1y", + "-60,-1m", + "-61,-2m", + "-3661,-1h2m", + }) + @ParameterizedTest + void testRoundedFloorFormat(int seconds, String expected) { + TemporalAmountParser temporalAmountParser = DurationParser.DATE_TIME_UNITS + .withRounded(ChronoUnit.MINUTES, RoundingMode.FLOOR); + String formatted = temporalAmountParser.format(Duration.ofSeconds(seconds)); + + assertEquals(expected, formatted); + } + + @CsvSource({ + "0,0s", + "60,1m", + "89,1m", + "90,2m", + "91,2m", + "3601,1h", + "86400,1d", + "87000,1d10m", + "87030,1d11m", + "1814402,3w", + "2592010,1mo", + "31536010,1y", + "-60,-1m", + "-89,-1m", + "-90,-2m", + "-91,-2m", + "-3661,-1h1m", + }) + @ParameterizedTest + void testRoundedHalfUpFormat(int seconds, String expected) { + TemporalAmountParser temporalAmountParser = DurationParser.DATE_TIME_UNITS + .withRounded(ChronoUnit.MINUTES, RoundingMode.HALF_UP); + String formatted = temporalAmountParser.format(Duration.ofSeconds(seconds)); + + assertEquals(expected, formatted); + } + + @CsvSource({ + "0,0s", + "60,1m", + "89,1m", + "90,1m", + "91,2m", + "3601,1h", + "86400,1d", + "87000,1d10m", + "87030,1d10m", + "87031,1d11m", + "1814402,3w", + "2592010,1mo", + "31536010,1y", + "-60,-1m", + "-89,-1m", + "-90,-1m", + "-91,-2m", + "-3661,-1h1m", + }) + @ParameterizedTest + void testRoundedHalfDownFormat(int seconds, String expected) { + TemporalAmountParser temporalAmountParser = DurationParser.DATE_TIME_UNITS + .withRounded(ChronoUnit.MINUTES, RoundingMode.HALF_DOWN); + String formatted = temporalAmountParser.format(Duration.ofSeconds(seconds)); + + assertEquals(expected, formatted); + } + @Test void testNotSupportedChronoUnit() { TemporalAmountParser temporalAmountParser = new PeriodParser() From 0d89262cb98a8fbb7d4d73b120f14145b20214bd Mon Sep 17 00:00:00 2001 From: Rollczi Date: Fri, 14 Feb 2025 00:49:40 +0100 Subject: [PATCH 2/2] Remove useless PART_TIME_UNITS --- .../litecommands/time/TemporalAmountParser.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/litecommands-core/src/dev/rollczi/litecommands/time/TemporalAmountParser.java b/litecommands-core/src/dev/rollczi/litecommands/time/TemporalAmountParser.java index 166947da4..d0afd0332 100644 --- a/litecommands-core/src/dev/rollczi/litecommands/time/TemporalAmountParser.java +++ b/litecommands-core/src/dev/rollczi/litecommands/time/TemporalAmountParser.java @@ -43,7 +43,6 @@ public abstract class TemporalAmountParser { private static final Map UNIT_TO_NANO = new LinkedHashMap<>(); - private static final Map PART_TIME_UNITS = new LinkedHashMap<>(); static { UNIT_TO_NANO.put(ChronoUnit.NANOS, BigInteger.valueOf(1L)); @@ -57,17 +56,6 @@ public abstract class TemporalAmountParser { UNIT_TO_NANO.put(ChronoUnit.MONTHS, BigInteger.valueOf(30 * 24 * 60 * 60 * 1_000_000_000L)); UNIT_TO_NANO.put(ChronoUnit.YEARS, BigInteger.valueOf(365 * 24 * 60 * 60 * 1_000_000_000L)); UNIT_TO_NANO.put(ChronoUnit.DECADES, BigInteger.valueOf(10 * 365 * 24 * 60 * 60 * 1_000_000_000L)); - - PART_TIME_UNITS.put(ChronoUnit.NANOS, 1000); - PART_TIME_UNITS.put(ChronoUnit.MICROS, 1000); - PART_TIME_UNITS.put(ChronoUnit.MILLIS, 1000); - PART_TIME_UNITS.put(ChronoUnit.SECONDS, 60); - PART_TIME_UNITS.put(ChronoUnit.MINUTES, 60); - PART_TIME_UNITS.put(ChronoUnit.HOURS, 24); - PART_TIME_UNITS.put(ChronoUnit.DAYS, 7); - PART_TIME_UNITS.put(ChronoUnit.WEEKS, 4); - PART_TIME_UNITS.put(ChronoUnit.MONTHS, 12); - PART_TIME_UNITS.put(ChronoUnit.YEARS, Integer.MAX_VALUE); } private final ChronoUnit defaultZero;