diff --git a/build.gradle.kts b/build.gradle.kts index ba4aeb28..581b817d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,6 +71,7 @@ subprojects { * The set of names of modules that required for building the `desktop` standalone project. */ val modulesRequiredForDesktop = setOf( + "clock", "github", "sessions", "mentions", diff --git a/buildSrc/src/main/kotlin/io/spine/internal/dependency/Spine.kt b/buildSrc/src/main/kotlin/io/spine/internal/dependency/Spine.kt index 6b174333..1801c345 100644 --- a/buildSrc/src/main/kotlin/io/spine/internal/dependency/Spine.kt +++ b/buildSrc/src/main/kotlin/io/spine/internal/dependency/Spine.kt @@ -33,16 +33,15 @@ public object Spine { * in `build.gradle.kts` in each module. */ private const val version = "1.9.0" + private const val group = "io.spine" - // https://github.com/SpineEventEngine/core-java - public object Server { - private const val group = "io.spine" - public const val lib: String = "$group:spine-server:$version" - } + public const val base: String = "$group:spine-base:$version" + public const val time: String = "$group:spine-time:$version" + public const val server: String = "$group:spine-server:$version" // https://github.com/SpineEventEngine/gcloud-java public object GCloud { - private const val group = "io.spine.gcloud" + private const val group = "${Spine.group}.gcloud" public const val datastore: String = "$group:spine-datastore:$version" public const val testutil: String = "$group:spine-testutil-gcloud:$version" } diff --git a/client/build.gradle.kts b/client/build.gradle.kts index 8fc72dba..09f6d644 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -56,7 +56,7 @@ dependencies { testImplementation(project(":testutil-mentions")) testImplementation(project(":testutil-sessions")) testImplementation(project(":clock")) - testImplementation(Spine.Server.lib) + testImplementation(Spine.server) testImplementation(Spine.GCloud.datastore) testImplementation(Spine.GCloud.testutil) } diff --git a/client/src/main/kotlin/io/spine/examples/pingh/client/TimestampExts.kt b/client/src/main/kotlin/io/spine/examples/pingh/client/TimestampExts.kt index 2d0ef25e..5bcee338 100644 --- a/client/src/main/kotlin/io/spine/examples/pingh/client/TimestampExts.kt +++ b/client/src/main/kotlin/io/spine/examples/pingh/client/TimestampExts.kt @@ -28,9 +28,10 @@ package io.spine.examples.pingh.client import com.google.protobuf.Timestamp import com.google.protobuf.util.Timestamps +import io.spine.time.InstantConverter import java.time.Duration import java.time.LocalDateTime -import java.time.ZoneOffset +import java.time.ZoneId import java.time.format.DateTimeFormatter /** @@ -64,15 +65,17 @@ internal fun Timestamp.add(duration: com.google.protobuf.Duration): Timestamp = * - If the difference is exactly one day, returns the string `"yesterday"`; * - In all other cases, returns the day and month. * + * @receiver The timestamp represents a time in the past. * @throws IllegalArgumentException if the `Timestamp` is not from the past. */ public fun Timestamp.howMuchTimeHasPassed(): String { - val thisDatetime = LocalDateTime.ofEpochSecond(this.seconds, this.nanos, ZoneOffset.UTC) - val difference = Duration.between( - thisDatetime, - LocalDateTime.now(ZoneOffset.UTC) - ) - require(difference > Duration.ZERO) { "" } + val thisDatetime = this.asLocalDateTime() + val now = LocalDateTime.now() + val difference = Duration.between(thisDatetime, now) + require(difference > Duration.ZERO) { + "The provided `Timestamp`, $thisDatetime, must represent a time in the past, " + + "but it does not. The current time is $now." + } return when { difference.toMinutes() < 1L -> "just now" difference.toMinutes() == 1L -> "a minute ago" @@ -83,3 +86,15 @@ public fun Timestamp.howMuchTimeHasPassed(): String { else -> dateFormat.format(thisDatetime) } } + +/** + * Converts the current UTC time in this `Timestamp` to local time, + * based on the system's time zone. + * + * The default time zone is set to the [ZoneId.systemDefault()][ZoneId.systemDefault] zone. + */ +public fun Timestamp.asLocalDateTime(timeZone: ZoneId = ZoneId.systemDefault()): LocalDateTime { + val instant = InstantConverter.reversed() + .convert(this) + return LocalDateTime.ofInstant(instant, timeZone) +} diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index c5e4ef97..3a78d120 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -45,6 +45,9 @@ plugins { // Adds and configures the Detekt for analysis code. id("detekt-code-analysis") + + // Adds dependencies for testing and configure test-running tasks. + id("tests-configuration") } /** diff --git a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/JUnit.kt b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/JUnit.kt new file mode 100644 index 00000000..918f8604 --- /dev/null +++ b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/JUnit.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.internal.dependency + +// https://github.com/junit-team/junit5/ +public object JUnit { + private const val version = "5.11.2" + private const val group = "org.junit.jupiter" + + public const val engine: String = "$group:junit-jupiter-engine:${version}" +} diff --git a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Kotest.kt b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Kotest.kt new file mode 100644 index 00000000..1188fa8e --- /dev/null +++ b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Kotest.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.internal.dependency + +// https://github.com/kotest/kotest +public object Kotest { + private const val version = "5.9.1" + private const val group = "io.kotest" + + public const val assertions: String = "$group:kotest-assertions-core:$version" +} diff --git a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt index 5c3a6843..24fa5465 100644 --- a/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt +++ b/desktop/buildSrc/src/main/kotlin/io/spine/internal/dependency/Pingh.kt @@ -28,6 +28,6 @@ package io.spine.internal.dependency // https://github.com/spine-examples/Pingh public object Pingh { - private const val version = "1.0.0-SNAPSHOT.5" + private const val version = "1.0.0-SNAPSHOT.6" public const val client: String = "io.spine.examples.pingh:client:$version" } diff --git a/desktop/buildSrc/src/main/kotlin/tests-configuration.gradle.kts b/desktop/buildSrc/src/main/kotlin/tests-configuration.gradle.kts new file mode 100644 index 00000000..1c285cb6 --- /dev/null +++ b/desktop/buildSrc/src/main/kotlin/tests-configuration.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import io.spine.internal.dependency.JUnit +import io.spine.internal.dependency.Kotest + +plugins { + java +} + +/** + * Add dependencies for testing. + */ +dependencies { + testImplementation(JUnit.engine) + testImplementation(Kotest.assertions) +} + +/** + * Explicitly instructs to discover and execute JUnit tests. + */ +tasks.withType { + useJUnitPlatform { + includeEngines("junit-jupiter") + } +} diff --git a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/TimestampExts.kt b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/TimestampExts.kt index aa467a9d..953f3d52 100644 --- a/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/TimestampExts.kt +++ b/desktop/src/main/kotlin/io/spine/examples/pingh/desktop/TimestampExts.kt @@ -27,8 +27,8 @@ package io.spine.examples.pingh.desktop import com.google.protobuf.Timestamp -import java.time.LocalDateTime -import java.time.ZoneOffset +import io.spine.examples.pingh.client.asLocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter /** @@ -44,11 +44,5 @@ private val datetimeFormat = DateTimeFormatter.ofPattern("dd MMM HH:mm") * - `10 May 14:37` * - `05 Sep 04:00` */ -internal fun Timestamp.toDatetime(): String = - datetimeFormat.format(toLocalDateTime()) - -/** - * Converts this `Timestamp` to `LocalDateTime`. - */ -private fun Timestamp.toLocalDateTime(): LocalDateTime = - LocalDateTime.ofEpochSecond(this.seconds, this.nanos, ZoneOffset.UTC) +internal fun Timestamp.toDatetime(timeZone: ZoneId = ZoneId.systemDefault()): String = + datetimeFormat.format(this.asLocalDateTime(timeZone)) diff --git a/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/TimestampExtsSpec.kt b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/TimestampExtsSpec.kt new file mode 100644 index 00000000..a9bb0809 --- /dev/null +++ b/desktop/src/test/kotlin/io/spine/examples/pingh/desktop/TimestampExtsSpec.kt @@ -0,0 +1,35 @@ +package io.spine.examples.pingh.desktop + +import com.google.protobuf.util.Timestamps +import io.kotest.matchers.shouldBe +import java.time.ZoneId +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`Timestamp` extensions should") +internal class TimestampExtsSpec { + + @Test + internal fun `consider the time difference when converting UTC time to local time`() { + val time = Timestamps.parse("2024-10-09T10:00:00Z") + val expected = "09 Oct 13:00" + val timeZone = ZoneId.of("+03:00") + time.toDatetime(timeZone) shouldBe expected + } + + @Test + internal fun `consider the date difference when converting UTC time to local time`() { + val time = Timestamps.parse("2024-10-09T03:12:43Z") + val expected = "08 Oct 23:12" + val timeZone = ZoneId.of("America/New_York") + time.toDatetime(timeZone) shouldBe expected + } + + @Test + internal fun `consider the daylight saving time when converting UTC time to local time`() { + val time = Timestamps.parse("2024-06-09T15:00:00Z") + val expected = "09 Jun 16:00" + val timeZone = ZoneId.of("Europe/London") + time.toDatetime(timeZone) shouldBe expected + } +} diff --git a/github/build.gradle.kts b/github/build.gradle.kts index 439c2ac7..e0aabf9e 100644 --- a/github/build.gradle.kts +++ b/github/build.gradle.kts @@ -24,6 +24,8 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import io.spine.internal.dependency.Spine + plugins { // Add the Gradle plugin for bootstrapping projects built with Spine. // See: https://github.com/SpineEventEngine/bootstrap @@ -36,3 +38,7 @@ spine { assembleModel() enableJava() } + +dependencies { + implementation(Spine.time) +} diff --git a/github/src/main/kotlin/io/spine/examples/pingh/github/TimestampExts.kt b/github/src/main/kotlin/io/spine/examples/pingh/github/TimestampExts.kt new file mode 100644 index 00000000..c3bb9ba3 --- /dev/null +++ b/github/src/main/kotlin/io/spine/examples/pingh/github/TimestampExts.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.examples.pingh.github + +import com.google.protobuf.Timestamp +import com.google.protobuf.util.Timestamps +import io.spine.time.InstantConverter +import java.time.Instant +import java.time.format.DateTimeParseException +import kotlin.reflect.KClass + +/** + * Parses the time from a string in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format. + * + * Implementation details: The GitHub API provides time data in `ISO 8601` format, + * while [Timestamps.parse()][Timestamps.parse] expects time data in + * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. To resolve this mismatch, + * the input string is first parsed into an [Instant], since it matches the `ISO 8601` format. + * The resulting `Instant` is then converted into a `Timestamp`. + * + * @throws DateTimeParseException if the provided string is not in `ISO 8601` format. + */ +@Suppress("UnusedReceiverParameter" /* Class extensions don't use class as a parameter. */) +internal fun KClass.parse(value: String): Timestamp { + val instant = Instant.parse(value) + return InstantConverter.instance() + .convert(instant)!! +} diff --git a/github/src/main/kotlin/io/spine/examples/pingh/github/ValuesExts.kt b/github/src/main/kotlin/io/spine/examples/pingh/github/ValuesExts.kt index 2b2d35d4..ae12c511 100644 --- a/github/src/main/kotlin/io/spine/examples/pingh/github/ValuesExts.kt +++ b/github/src/main/kotlin/io/spine/examples/pingh/github/ValuesExts.kt @@ -28,6 +28,7 @@ package io.spine.examples.pingh.github +import com.google.protobuf.Timestamp import com.google.protobuf.util.Timestamps import io.spine.base.Time.currentTime import io.spine.examples.pingh.github.rest.AccessTokenFragment @@ -114,7 +115,7 @@ public fun KClass.from(fragment: IssueOrPullRequestFragment): Mention = fragment.whoCreated.avatarUrl ) title = fragment.title - whenMentioned = Timestamps.parse(fragment.whenCreated) + whenMentioned = Timestamp::class.parse(fragment.whenCreated) url = Url::class.of(fragment.htmlUrl) vBuild() } @@ -136,7 +137,7 @@ public fun KClass.from(fragment: CommentFragment, itemTitle: String): M fragment.whoCreated.avatarUrl ) title = "Comment on $itemTitle" - whenMentioned = Timestamps.parse(fragment.whenCreated) + whenMentioned = Timestamp::class.parse(fragment.whenCreated) url = Url::class.of(fragment.htmlUrl) vBuild() } @@ -158,7 +159,7 @@ public fun KClass.from(fragment: ReviewFragment, prTitle: String): Ment fragment.whoCreated.avatarUrl ) title = "Review of $prTitle" - whenMentioned = Timestamps.parse(fragment.whenSubmitted) + whenMentioned = Timestamp::class.parse(fragment.whenSubmitted) url = Url::class.of(fragment.htmlUrl) vBuild() } diff --git a/version.gradle.kts b/version.gradle.kts index 8aa48c38..f12636e8 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of the `Pingh` to publish. */ -val pinghVersion: String by extra("1.0.0-SNAPSHOT.5") +val pinghVersion: String by extra("1.0.0-SNAPSHOT.6")