Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import kotlin.time.Duration.Companion.seconds
* @see <a href="https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-timespan-format-strings">.NET TimeSpan format</a>
*/
internal object TimeSpanParser {
private const val MILLIS_PER_SECOND = 1000L
private const val SECONDS_PER_MINUTE = 60
private const val SECONDS_PER_HOUR = 3600
private const val SECONDS_PER_DAY = 86400
private const val FRACTION_SCALE = 10_000L
private const val FRACTION_DIGITS = 7

internal fun parseToMillis(timeSpanString: String): Long {
val regex = """(-)?(\d+\.)?([01]?\d|2[0-3]):([0-5]?\d):([0-5]?\d)(\.\d{1,7})?""".toRegex()
val matchResult = regex.matchEntire(timeSpanString)
Expand All @@ -36,7 +43,59 @@ internal object TimeSpanParser {

return if (sign == "-") duration.inWholeMilliseconds * -1 else duration.inWholeMilliseconds
}

/**
* Formats a duration in milliseconds as a .NET TimeSpan string.
*
* Output format: [-][\d.]hh:mm:ss.fffffff
* - days part ([\d.]) is included only when days > 0, separated from hours by a dot
* - hours, minutes, seconds are zero-padded to 2 digits
* - fractional seconds are always 7 digits (100-nanosecond ticks)
* - negative durations are prefixed with '-' and formatted by absolute value
*
* Examples:
* - 0 ms → "00:00:00.0000000"
* - 225 ms → "00:00:00.2250000"
* - 86_400_000 ms → "1.00:00:00.0000000"
* - -1_800_000 ms → "-00:30:00.0000000"
*
* The output is compatible with [parseToMillis]: parsing the result returns the original value.
*
* @param timeInMillis duration in milliseconds; negative values are formatted with a leading '-'
* @return string in .NET TimeSpan format
*/
internal fun formatMillisAsTimeSpan(timeInMillis: Long): String {
Comment thread
sergeysozinov marked this conversation as resolved.
val isNegative = timeInMillis < 0
val absMillis = when {
!isNegative -> timeInMillis
timeInMillis == Long.MIN_VALUE -> Long.MAX_VALUE
else -> -timeInMillis
}
val totalSeconds = absMillis / MILLIS_PER_SECOND
val remainderMillis = (absMillis % MILLIS_PER_SECOND) * FRACTION_SCALE
val fractionStr = remainderMillis.toString().padStart(FRACTION_DIGITS, '0')
Comment thread
sergeysozinov marked this conversation as resolved.
val days = totalSeconds / SECONDS_PER_DAY
val hours = (totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR
val minutes = (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE
val seconds = totalSeconds % SECONDS_PER_MINUTE
return buildString {
if (isNegative) append('-')
if (days > 0) {
append(days)
append('.')
}
append(hours.toString().padStart(2, '0'))
append(':')
append(minutes.toString().padStart(2, '0'))
append(':')
append(seconds.toString().padStart(2, '0'))
append('.')
append(fractionStr)
}
Comment thread
sergeysozinov marked this conversation as resolved.
}
}

@Throws(IllegalArgumentException::class)
public fun String.parseTimeSpanToMillis(): Long = TimeSpanParser.parseToMillis(this)

public fun Long.millisToTimeSpan(): String = TimeSpanParser.formatMillisAsTimeSpan(this)
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue

class TimeSpanParserTest {
@Test
Expand Down Expand Up @@ -106,4 +107,135 @@ class TimeSpanParserTest {
assertEquals(0, "00:00:00".parseTimeSpanToMillis())
assertEquals(-123, "-00:00:00.123".parseTimeSpanToMillis())
}

@Test
fun `parseMillisToTimeSpan zero`() {
assertEquals("00:00:00.0000000", 0L.millisToTimeSpan())
}

@Test
fun `parseMillisToTimeSpan 225ms`() {
assertEquals("00:00:00.2250000", 225L.millisToTimeSpan())
}

@Test
fun `parseMillisToTimeSpan milliseconds fraction`() {
assertEquals("00:00:00.0010000", 1L.millisToTimeSpan())
assertEquals("00:00:00.1000000", 100L.millisToTimeSpan())
assertEquals("00:00:00.9990000", 999L.millisToTimeSpan())
}

@Test
fun `parseMillisToTimeSpan whole seconds`() {
assertEquals("00:00:01.0000000", 1_000L.millisToTimeSpan())
assertEquals("00:00:59.0000000", 59_000L.millisToTimeSpan())
}

@Test
fun `parseMillisToTimeSpan minutes`() {
assertEquals("00:01:00.0000000", 60_000L.millisToTimeSpan())
assertEquals("00:59:59.0000000", 3_599_000L.millisToTimeSpan())
}

@Test
fun `parseMillisToTimeSpan hours`() {
assertEquals("01:00:00.0000000", 3_600_000L.millisToTimeSpan())
assertEquals("23:59:59.0000000", 86_399_000L.millisToTimeSpan())
}

@Test
fun `parseMillisToTimeSpan days`() {
assertEquals("1.00:00:00.0000000", 86_400_000L.millisToTimeSpan())
assertEquals("3.17:25:30.5000000", 321_930_500L.millisToTimeSpan())
assertEquals("1.01:01:01.1000000", 90_061_100L.millisToTimeSpan())
}

@Test
fun `parseMillisToTimeSpan negative values`() {
assertEquals("00:00:00.0000000", 0L.millisToTimeSpan())
assertEquals("-00:00:00.0010000", (-1L).millisToTimeSpan())
assertEquals("-00:00:00.9990000", (-999L).millisToTimeSpan())
assertEquals("-00:30:00.0000000", (-1_800_000L).millisToTimeSpan())
assertEquals("-1.00:00:00.0000000", (-86_400_000L).millisToTimeSpan())
assertEquals("-3.17:25:30.5000000", (-321_930_500L).millisToTimeSpan())
}

@Test
fun `parseMillisToTimeSpan Long MAX_VALUE`() {
assertEquals("106751991167.07:12:55.8070000", Long.MAX_VALUE.millisToTimeSpan())
}

@Test
fun `parseMillisToTimeSpan Long MIN_VALUE does not throw`() {
val result = Long.MIN_VALUE.millisToTimeSpan()
val structureRegex = Regex("""-?(\d+\.)?\d{2}:\d{2}:\d{2}\.\d{7}""")
assertTrue(structureRegex.matches(result), "Bad format for Long.MIN_VALUE: '$result'")
}

@Test
fun `parseMillisToTimeSpan output is round-trippable`() {
val cases = listOf(
0L,
1L,
225L,
1_000L,
3_600_000L,
86_400_000L,
321_930_500L,
90_061_100L,
-1L,
-999L,
-1_800_000L,
-86_400_000L,
)
for (inputMs in cases) {
val formatted = inputMs.millisToTimeSpan()
val parsed = formatted.parseTimeSpanToMillis()
assertEquals(inputMs, parsed, "Round-trip failed for ${inputMs}ms: '$formatted'")
}
}

@Test
fun `parseTimeSpanToMillis output is round-trippable`() {
val cases = listOf(
"00:00:00.0000000",
"00:00:00.2250000",
"00:00:01.0000000",
"00:30:00.0000000",
"01:00:00.0000000",
"23:59:59.0000000",
"1.00:00:00.0000000",
"3.17:25:30.5000000",
"1.01:01:01.1000000",
"-00:00:00.0010000",
"-00:30:00.0000000",
"-1.00:00:00.0000000",
"-3.17:25:30.5000000",
)
for (inputStr in cases) {
val parsed = inputStr.parseTimeSpanToMillis()
val formatted = parsed.millisToTimeSpan()
assertEquals(inputStr, formatted, "Round-trip failed for '$inputStr': got '$formatted'")
}
}

@Test
fun `parseMillisToTimeSpan output matches expected structure`() {
val structureRegex = Regex("""-?(\d+\.)?\d{2}:\d{2}:\d{2}\.\d{7}""")
val inputCases = listOf(
0L,
225L,
1_000L,
3_600_000L,
86_400_000L,
321_930_500L,
90_061_100L,
-1L,
-86_400_000L,
)
for (inputMs in inputCases) {
val result = inputMs.millisToTimeSpan()
assertTrue(structureRegex.matches(result), "Bad format for ${inputMs}ms: '$result'")
}
}
}