Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update InstantWithDuration #18

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
95 changes: 59 additions & 36 deletions base/src/main/kotlin/klib/base/DateTimeKit.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -98,67 +98,90 @@ object ShortString {

@Suppress("MagicNumber")
@JvmInline
value class InstantWithDuration(private val packedValue: Long) {
value class InstantWithDuration(internal val packedValue: Long) : Comparable<InstantWithDuration> {

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)
197 changes: 197 additions & 0 deletions base/src/test/kotlin/klib/base/InstantWithDurationTest.kt
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> {
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<IllegalArgumentException>("Start") {
InstantWithDuration.fromStartAndDuration(
EPOCH_2020 + INSTANT_SECONDS_MASK + 1,
0u
)
}

assertThrows<IllegalArgumentException>("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)
}
}