diff --git a/base/src/main/kotlin/klib/base/DateTimeKit.kt b/base/src/main/kotlin/klib/base/DateTimeKit.kt index 37a4b67..2817b7f 100644 --- a/base/src/main/kotlin/klib/base/DateTimeKit.kt +++ b/base/src/main/kotlin/klib/base/DateTimeKit.kt @@ -80,15 +80,15 @@ object DateTimeKit { } -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonValue import klib.base.ShortString.asShortString import klib.base.ShortString.shortStringAsLong + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonValue import java.time.Duration import java.time.Instant -import kotlin.math.pow - -private val dateTimeFormatterUTC = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC) +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter @Suppress("MagicNumber") object ShortString { @@ -98,67 +98,90 @@ object ShortString { @Suppress("MagicNumber") @JvmInline -value class InstantWithDuration(private val packedValue: Long) { +value class InstantWithDuration(internal val packedValue: Long) : Comparable { init { - require(packedValue >= 0) { "PackedValue must be non-negative" } - require(packedValue shr (63 - BITS_FOR_DURATION) == 0L) { "StartEpochSeconds out of range" } - require(durationMinutes <= MAX_DURATION_MINUTES) { "Duration must be at most $MAX_DURATION_MINUTES" } + require((packedValue shr BITS_FOR_DURATION) <= INSTANT_SECONDS_MASK) { + "StartEpochSeconds out of range (${(packedValue shr BITS_FOR_DURATION) + EPOCH_2020})" + } + require(durationMinutes <= MAX_DURATION_MINUTES) { + "Max duration minutes exceeded by ${durationMinutes - MAX_DURATION_MINUTES}" + } } val startEpochSeconds: Long get() = EPOCH_2020 + (packedValue shr BITS_FOR_DURATION) - val startInstant - get() = Instant.ofEpochSecond(startEpochSeconds) - - val durationMinutes: UShort - get() = (packedValue and DURATION_MASK).toUShort() - - val duration - get() = Duration.ofMinutes(durationMinutes.toLong()) - - val endEpochSeconds: Long - get() = startEpochSeconds + durationMinutes.toLong() * 60 - - val endInstant - get() = Instant.ofEpochSecond(endEpochSeconds) + val durationMinutes: UInt + get() = (packedValue and DURATION_MINUTES_MASK).toUInt() - val startFormatted get() = dateTimeFormatterUTC.format(startInstant) - val endFormatted get() = dateTimeFormatterUTC.format(endInstant) + @get:JsonValue + val asShortString get() = packedValue.asShortString override fun toString() = "${durationMinutes}m @ $startFormatted" - @get:JsonValue - val asShortString get() = packedValue.asShortString + override fun compareTo(other: InstantWithDuration): Int = packedValue.compareTo(other.packedValue) companion object { const val EPOCH_2020 = 1577836800L - const val BITS_FOR_DURATION = 11 - val MAX_DURATION_MINUTES: UShort get() = (2.0.pow(BITS_FOR_DURATION).toUInt() - 1u).toUShort() - const val DURATION_MASK = (1L shl BITS_FOR_DURATION) - 1 - const val MAX_SECONDS = (1L shl (63 - BITS_FOR_DURATION)) - 1 + const val BITS_FOR_DURATION = 26 // 127.7 years or 67_108_863 minutes + const val DURATION_MINUTES_MASK: Long = (1L shl BITS_FOR_DURATION) - 1 + val MAX_DURATION_MINUTES: UInt = DURATION_MINUTES_MASK.toUInt() + const val INSTANT_SECONDS_MASK = (1L shl (63 - BITS_FOR_DURATION)) - 1 // 4358 future years @JsonCreator @JvmStatic fun fromShortString(value: String) = InstantWithDuration(value.shortStringAsLong) + fun fromStartAndEnd(start: String, end: String) = + fromStartAndEnd(Instant.parse(start), Instant.parse(end)) + fun fromStartAndEnd(start: Instant, end: Instant): InstantWithDuration { require(end >= start) { "End time must not be before start time" } - val durationMinutes = Duration.between(start, end).toMinutes().toUShort() + val durationMinutes = Duration.between(start, end).toMinutes().toUInt() return fromStartAndDuration(start, durationMinutes) } - fun fromStartAndDuration(start: String, durationMinutes: UShort = 0u) = + fun fromStartAndDuration(start: String, durationMinutes: UInt = 0u) = fromStartAndDuration(Instant.parse(start), durationMinutes) - fun fromStartAndDuration(start: Instant, durationMinutes: UShort = 0u) = + fun fromStartAndDuration(start: Instant, durationMinutes: UInt = 0u) = fromStartAndDuration(start.epochSecond, durationMinutes) - fun fromStartAndDuration(startEpochSeconds: Long, durationMinutes: UShort = 0u): InstantWithDuration { + fun fromStartAndDuration(startEpochSeconds: Long, durationMinutes: UInt = 0u): InstantWithDuration { + require(durationMinutes <= MAX_DURATION_MINUTES) { + "Max duration minutes exceeded by ${durationMinutes - MAX_DURATION_MINUTES}" + } + require(startEpochSeconds >= EPOCH_2020 - INSTANT_SECONDS_MASK + 1) { + "Min startEpochSeconds should be increased by ${ + EPOCH_2020 - INSTANT_SECONDS_MASK + 1 - startEpochSeconds + }" + } + require(startEpochSeconds <= EPOCH_2020 + INSTANT_SECONDS_MASK) { + "Max startEpochSeconds exceeded by ${startEpochSeconds - (EPOCH_2020 + INSTANT_SECONDS_MASK)}" + } return InstantWithDuration( - ((startEpochSeconds - EPOCH_2020).toInt().toLong() shl BITS_FOR_DURATION) or durationMinutes.toLong() + ((startEpochSeconds - EPOCH_2020) shl BITS_FOR_DURATION) or durationMinutes.toLong() ) } } } + +val InstantWithDuration.startInstant + get() = Instant.ofEpochSecond(startEpochSeconds) + +val InstantWithDuration.duration + get() = Duration.ofMinutes(durationMinutes.toLong()) + +@Suppress("MagicNumber") +val InstantWithDuration.endEpochSeconds: Long + get() = startEpochSeconds + durationMinutes.toLong() * 60 + +val InstantWithDuration.endInstant + get() = Instant.ofEpochSecond(endEpochSeconds) + +private val dateTimeFormatterUTC = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + +val InstantWithDuration.startFormatted get() = dateTimeFormatterUTC.format(startInstant) +val InstantWithDuration.endFormatted get() = dateTimeFormatterUTC.format(endInstant) diff --git a/base/src/test/kotlin/klib/base/InstantWithDurationTest.kt b/base/src/test/kotlin/klib/base/InstantWithDurationTest.kt new file mode 100644 index 0000000..9be44cd --- /dev/null +++ b/base/src/test/kotlin/klib/base/InstantWithDurationTest.kt @@ -0,0 +1,197 @@ +package klib.base + +import klib.base.InstantWithDuration.Companion.DURATION_MINUTES_MASK +import klib.base.InstantWithDuration.Companion.EPOCH_2020 +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import kotlin.test.assertEquals + +@DisplayName("InstantWithDuration") +class InstantWithDurationTest { + + @Test + fun `Constructor with startEpochSeconds and durationMinutes creates correct packedValue`() { + val instantWithDuration = InstantWithDuration + .fromStartAndDuration(EPOCH_2020, MAX_DURATION_MINUTES) + assertEquals("2020-01-01T00:00:00Z", instantWithDuration.startFormatted, "startFormatted") + assertEquals("2147-08-06T09:03:00Z", instantWithDuration.endFormatted, "endFormatted") + assertEquals( + 60 * DURATION_MINUTES_MASK, + instantWithDuration.endEpochSeconds - instantWithDuration.startEpochSeconds, + "Max duration" + ) + assertEquals(DURATION_MINUTES_MASK, instantWithDuration.packedValue and DURATION_MINUTES_MASK, "packedValue") + assertEquals(0x3ffffff, instantWithDuration.packedValue, "packedValue should be 67108863") + } + + @Test + fun `Constructor with packedValue creates correct startEpochSeconds and durationMinutes`() { + val instantWithDuration = InstantWithDuration(0x7ffL or DURATION_MINUTES_MASK) + assertEquals(EPOCH_2020, instantWithDuration.startEpochSeconds) + assertEquals(MAX_DURATION_MINUTES, instantWithDuration.durationMinutes) + } + + @ParameterizedTest + @CsvSource( + "2019-12-31T23:58:00Z, 2019-12-31T23:59:00Z, 1", + "2020-01-01T00:00:00Z, 2020-01-01T00:00:00Z, 0", + "2025-08-01T10:00:00Z, 2025-08-01T10:05:00Z, 5", + "2025-08-01T10:00:00Z, 2025-08-01T10:30:00Z, 30", + "2025-08-01T10:00:00Z, 2025-08-01T11:00:00Z, 60", + "6375-04-08T15:04:31Z, 6502-11-12T00:07:31Z, $DURATION_MINUTES_MASK" + ) + @DisplayName("Factory methods") + fun testFromStartFactory(start: String, end: String, expectedDurationMinutes: UInt) { + InstantWithDuration.fromStartAndEnd(start, end).run { + assertEquals(start, startFormatted) + assertEquals(end, endFormatted) + assertEquals(expectedDurationMinutes, durationMinutes, "fromStartAndEnd") + } + InstantWithDuration.fromStartAndDuration(start, expectedDurationMinutes).run { + assertEquals(start, startFormatted) + assertEquals(end, endFormatted) + assertEquals(expectedDurationMinutes, durationMinutes, "fromStartAndDuration") + } + } + + @Test + @DisplayName("'fromStartAndEnd' throws IllegalArgumentException when end is before start") + fun testFromStartAndEndThrowsException() { + val start = "2025-08-01T09:00:01Z" + val end = "2025-08-01T09:00:00Z" + + assertThrows { + InstantWithDuration.fromStartAndEnd(start, end) + } + } + + @ParameterizedTest + @CsvSource( + "${EPOCH_2020 - 1}, 0, -13ydj4", + "$EPOCH_2020, 0, 000000", + "$EPOCH_2020, 1, 000001", + "$EPOCH_2020, $DURATION_MINUTES_MASK, 13ydj3", + "${EPOCH_2020 + 60}, 0, 1ulajuo", + "${EPOCH_2020 + INSTANT_SECONDS_MASK}, $DURATION_MINUTES_MASK, 1y2p0ij32e8e7", + ) + @DisplayName("Roundtrip (asShortString -> fromShortString)") + fun roundTrip( + startEpochSeconds: Long, + durationMinutes: UInt, + expectedShortString: String + ) { + val encodedString = InstantWithDuration.fromStartAndDuration(startEpochSeconds, durationMinutes).asShortString + InstantWithDuration.fromShortString(encodedString).also { decoded -> + assertEquals(startEpochSeconds, decoded.startEpochSeconds) + assertEquals(durationMinutes, decoded.durationMinutes) + } + assertEquals(expectedShortString, encodedString) + } + + @Test + fun `'endEpochSeconds' for EPOCH_2020`() { + val instantWithDuration = InstantWithDuration.fromStartAndDuration(EPOCH_2020) + assertEquals( + EPOCH_2020, + instantWithDuration.endEpochSeconds, + "endEpochSeconds" + ) + } + + @Test + fun `'endEpochSeconds' for EPOCH_2020 minus 2 minutes`() { + val instantWithDuration = InstantWithDuration.fromStartAndDuration( + EPOCH_2020 - 120, 1u + ) + assertEquals( + EPOCH_2020 - 60, + instantWithDuration.endEpochSeconds, + "endEpochSeconds" + ) + } + + @Test + fun `Maximum representable values`() { + val instantWithDuration = InstantWithDuration.fromStartAndDuration( + EPOCH_2020 + INSTANT_SECONDS_MASK, MAX_DURATION_MINUTES + ) + assertEquals("6375-04-08T15:04:31Z", instantWithDuration.startFormatted, "startFormatted") + assertEquals("6502-11-12T00:07:31Z", instantWithDuration.endFormatted, "endFormatted") + assertEquals(EPOCH_2020 + INSTANT_SECONDS_MASK, instantWithDuration.startEpochSeconds, "startEpochSeconds") + assertEquals(143043322051, instantWithDuration.endEpochSeconds, "endEpochSeconds") + assertEquals(DURATION_MINUTES_MASK.toUInt(), instantWithDuration.durationMinutes, "durationMinutes") + assertEquals(DURATION_MINUTES_MASK, instantWithDuration.duration.toMinutes(), "duration") + } + + @Test + fun `Constructor throws IllegalArgumentException for values exceeding maximum`() { + assertThrows("Start") { + InstantWithDuration.fromStartAndDuration( + EPOCH_2020 + INSTANT_SECONDS_MASK + 1, + 0u + ) + } + + assertThrows("Duration") { + InstantWithDuration.fromStartAndDuration( + EPOCH_2020, + MAX_DURATION_MINUTES + 1u + ) + } + } + + @ParameterizedTest + @CsvSource( + "$EPOCH_2020, 0, '0m @ 2020-01-01T00:00:00Z'", + "$EPOCH_2020, 1, '1m @ 2020-01-01T00:00:00Z'", + "$EPOCH_2020, 2047, '2047m @ 2020-01-01T00:00:00Z'", + "$EPOCH_2020, $DURATION_MINUTES_MASK, '67108863m @ 2020-01-01T00:00:00Z'", + "${EPOCH_2020 + 60}, 0, '0m @ 2020-01-01T00:01:00Z'", + ) + @DisplayName("'toShortString()' for various inputs") + fun `toShortString()`( + startEpochSeconds: Long, + durationMinutes: UInt, + expected: String + ) { + assertEquals( + expected, + InstantWithDuration.fromStartAndDuration(startEpochSeconds, durationMinutes).toString() + ) + } + + @ParameterizedTest + @CsvSource( + "${EPOCH_2020 - 1}, 0, '2019-12-31T23:59:59Z'", + "$EPOCH_2020, 1, '2020-01-01T00:01:00Z'", + "${EPOCH_2020 + 60}, 0, '2020-01-01T00:01:00Z'", + "$EPOCH_2020, 2047, '2020-01-02T10:07:00Z'", + "$EPOCH_2020, $DURATION_MINUTES_MASK, '2147-08-06T09:03:00Z'", + "${EPOCH_2020 + DURATION_MINUTES_MASK * 60}, 0, '2147-08-06T09:03:00Z'", + ) + @DisplayName("'endFormatted' for various inputs") + fun endFormatted( + startEpochSeconds: Long, + durationMinutes: UInt, + expectedEnd: String + ) { + assertEquals( + expectedEnd, + InstantWithDuration.fromStartAndDuration(startEpochSeconds, durationMinutes).endFormatted + ) + } + + @Test + fun `'compareTo' works correctly`() { + val earlier = InstantWithDuration.fromStartAndDuration(EPOCH_2020, 30u) + val later = InstantWithDuration.fromStartAndDuration(EPOCH_2020, 60u) + val sameEndAsLater = InstantWithDuration.fromStartAndDuration(EPOCH_2020 + 1800, 30u) + + assertEquals(later.endFormatted, sameEndAsLater.endFormatted) + assert(earlier < later) + assert(later > earlier) + assert(earlier < sameEndAsLater) + assert(later < sameEndAsLater) + } +}