From 0a4458a8c75ba0b9aff4937a8b0c86029bb5dbf0 Mon Sep 17 00:00:00 2001 From: sozinov Date: Thu, 26 Feb 2026 11:28:35 +0300 Subject: [PATCH 1/3] MOBILEWEBVIEW-60: add method for format Long to TimeSpan --- .../mindbox/mobile_sdk/TimeSpanParser.kt | 31 +++++++++ .../mindbox/mobile_sdk/TimeSpanParserTest.kt | 68 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParser.kt b/mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParser.kt index 8785f74..2846ac3 100644 --- a/mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParser.kt +++ b/mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParser.kt @@ -36,7 +36,38 @@ internal object TimeSpanParser { return if (sign == "-") duration.inWholeMilliseconds * -1 else duration.inWholeMilliseconds } + + internal fun formatMillisAsTimeSpan(timeInMillis: Long): String { + val millis = timeInMillis.coerceAtLeast(0L) + val totalSeconds = millis / MILLIS_PER_SECOND + val remainderMillis = (millis % MILLIS_PER_SECOND) * FRACTION_SCALE + val fractionStr = remainderMillis.toString().padStart(FRACTION_DIGITS, '0') + 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 { + 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) + } + } + + 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 } @Throws(IllegalArgumentException::class) public fun String.parseTimeSpanToMillis(): Long = TimeSpanParser.parseToMillis(this) + +public fun Long.millisToTimeSpan(): String = TimeSpanParser.formatMillisAsTimeSpan(this) diff --git a/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt b/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt index 0bd03b3..3f402d8 100644 --- a/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt +++ b/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt @@ -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 @@ -106,4 +107,71 @@ class TimeSpanParserTest { assertEquals(0, "00:00:00".parseTimeSpanToMillis()) assertEquals(-123, "-00:00:00.123".parseTimeSpanToMillis()) } + + @Test + fun `parseMillisToTimeSpan zero`() { + assertEquals("0:00:00:00.0000000", 0L.millisToTimeSpan()) + } + + @Test + fun `parseMillisToTimeSpan adr example 225ms`() { + assertEquals("0:00:00:00.2250000", 225L.millisToTimeSpan()) + } + + @Test + fun `parseMillisToTimeSpan milliseconds fraction`() { + assertEquals("0:00:00:00.0010000", 1L.millisToTimeSpan()) + assertEquals("0:00:00:00.1000000", 100L.millisToTimeSpan()) + assertEquals("0:00:00:00.9990000", 999L.millisToTimeSpan()) + } + + @Test + fun `parseMillisToTimeSpan whole seconds`() { + assertEquals("0:00:00:01.0000000", 1_000L.millisToTimeSpan()) + assertEquals("0:00:00:59.0000000", 59_000L.millisToTimeSpan()) + } + + @Test + fun `parseMillisToTimeSpan minutes`() { + assertEquals("0:00:01:00.0000000", 60_000L.millisToTimeSpan()) + assertEquals("0:00:59:59.0000000", 3_599_000L.millisToTimeSpan()) + } + + @Test + fun `parseMillisToTimeSpan hours`() { + assertEquals("0:01:00:00.0000000", 3_600_000L.millisToTimeSpan()) + assertEquals("0: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 coerced to zero`() { + assertEquals("0:00:00:00.0000000", (-1L).millisToTimeSpan()) + assertEquals("0:00:00:00.0000000", (-999L).millisToTimeSpan()) + assertEquals("0:00:00:00.0000000", Long.MIN_VALUE.millisToTimeSpan()) + } + + @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, + ) + for (inputMs in inputCases) { + val result = inputMs.millisToTimeSpan() + assertTrue(structureRegex.matches(result), "Bad format for ${inputMs}ms: '$result'") + } + } } From 9f8abe3027723a3b4529418364523282adb1e621 Mon Sep 17 00:00:00 2001 From: sozinov Date: Thu, 26 Feb 2026 12:11:32 +0300 Subject: [PATCH 2/3] MOBILEWEBVIEW-60: change test name --- .../kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt b/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt index 3f402d8..732fa95 100644 --- a/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt +++ b/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt @@ -114,7 +114,7 @@ class TimeSpanParserTest { } @Test - fun `parseMillisToTimeSpan adr example 225ms`() { + fun `parseMillisToTimeSpan 225ms`() { assertEquals("0:00:00:00.2250000", 225L.millisToTimeSpan()) } From 42275249a93e7ad701b20fcab7e56374ab2b92a3 Mon Sep 17 00:00:00 2001 From: sozinov Date: Thu, 26 Feb 2026 13:45:00 +0300 Subject: [PATCH 3/3] MOBILEWEBVIEW-60: follow review --- .../mindbox/mobile_sdk/TimeSpanParser.kt | 52 ++++++--- .../mindbox/mobile_sdk/TimeSpanParserTest.kt | 102 ++++++++++++++---- 2 files changed, 123 insertions(+), 31 deletions(-) diff --git a/mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParser.kt b/mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParser.kt index 2846ac3..f301e78 100644 --- a/mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParser.kt +++ b/mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParser.kt @@ -18,6 +18,13 @@ import kotlin.time.Duration.Companion.seconds * @see .NET TimeSpan format */ 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) @@ -37,18 +44,46 @@ 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 { - val millis = timeInMillis.coerceAtLeast(0L) - val totalSeconds = millis / MILLIS_PER_SECOND - val remainderMillis = (millis % MILLIS_PER_SECOND) * FRACTION_SCALE + 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') 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 { - append(days) - append(':') + if (isNegative) append('-') + if (days > 0) { + append(days) + append('.') + } append(hours.toString().padStart(2, '0')) append(':') append(minutes.toString().padStart(2, '0')) @@ -58,13 +93,6 @@ internal object TimeSpanParser { append(fractionStr) } } - - 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 } @Throws(IllegalArgumentException::class) diff --git a/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt b/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt index 732fa95..00480ef 100644 --- a/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt +++ b/mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/TimeSpanParserTest.kt @@ -110,56 +110,118 @@ class TimeSpanParserTest { @Test fun `parseMillisToTimeSpan zero`() { - assertEquals("0:00:00:00.0000000", 0L.millisToTimeSpan()) + assertEquals("00:00:00.0000000", 0L.millisToTimeSpan()) } @Test fun `parseMillisToTimeSpan 225ms`() { - assertEquals("0:00:00:00.2250000", 225L.millisToTimeSpan()) + assertEquals("00:00:00.2250000", 225L.millisToTimeSpan()) } @Test fun `parseMillisToTimeSpan milliseconds fraction`() { - assertEquals("0:00:00:00.0010000", 1L.millisToTimeSpan()) - assertEquals("0:00:00:00.1000000", 100L.millisToTimeSpan()) - assertEquals("0:00:00:00.9990000", 999L.millisToTimeSpan()) + 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("0:00:00:01.0000000", 1_000L.millisToTimeSpan()) - assertEquals("0:00:00:59.0000000", 59_000L.millisToTimeSpan()) + assertEquals("00:00:01.0000000", 1_000L.millisToTimeSpan()) + assertEquals("00:00:59.0000000", 59_000L.millisToTimeSpan()) } @Test fun `parseMillisToTimeSpan minutes`() { - assertEquals("0:00:01:00.0000000", 60_000L.millisToTimeSpan()) - assertEquals("0:00:59:59.0000000", 3_599_000L.millisToTimeSpan()) + assertEquals("00:01:00.0000000", 60_000L.millisToTimeSpan()) + assertEquals("00:59:59.0000000", 3_599_000L.millisToTimeSpan()) } @Test fun `parseMillisToTimeSpan hours`() { - assertEquals("0:01:00:00.0000000", 3_600_000L.millisToTimeSpan()) - assertEquals("0:23:59:59.0000000", 86_399_000L.millisToTimeSpan()) + 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()) + 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 coerced to zero`() { - assertEquals("0:00:00:00.0000000", (-1L).millisToTimeSpan()) - assertEquals("0:00:00:00.0000000", (-999L).millisToTimeSpan()) - assertEquals("0:00:00:00.0000000", Long.MIN_VALUE.millisToTimeSpan()) + 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 structureRegex = Regex("""-?(\d+\.)?\d{2}:\d{2}:\d{2}\.\d{7}""") val inputCases = listOf( 0L, 225L, @@ -168,6 +230,8 @@ class TimeSpanParserTest { 86_400_000L, 321_930_500L, 90_061_100L, + -1L, + -86_400_000L, ) for (inputMs in inputCases) { val result = inputMs.millisToTimeSpan()