Skip to content
This repository has been archived by the owner on Nov 5, 2024. It is now read-only.

Commit

Permalink
Propert time conversion APIs (#3396)
Browse files Browse the repository at this point in the history
* Implement `TimeConverter`

* Move time utils to `:shared:base`

* Add capabilities to get the current time

* Add unit tests for the time converter

* Add property-based tests

* Add deprecations

* Remove unnecessary test runner

* Add `DeviceTimeProviderTest`

* Improve the dev-exp for the PR template

* Improve the dev-exp for the PR template

* Fix Detekt errors

* Improve the unit testing guidelines
  • Loading branch information
ILIYANGERMANOV authored Aug 5, 2024
1 parent c06b73a commit 51d66c7
Show file tree
Hide file tree
Showing 18 changed files with 454 additions and 29 deletions.
6 changes: 5 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## Pull request (PR) checklist

Please check if your pull request fulfills the following requirements:
<!--💡 Tip: Tick checkboxes like this: [x] 💡-->
- [ ] I've read the [Contribution Guidelines](https://github.com/Ivy-Apps/ivy-wallet/blob/main/CONTRIBUTING.md) and my PR doesn't break the rules.
Expand All @@ -7,10 +8,11 @@ Please check if your pull request fulfills the following requirements:
- [ ] My PR includes only the necessary changes to fix the issue (i.e., no unnecessary files or lines of code are changed).
- [ ] 🎬 I've attached a **screen recording** of using the new code to the next paragraph (if applicable).

## Screen recording of interacting with your changes:
## Screen recording of your changes (if applicable):
<!--💡 Tip: Drag & drop the video here. 💡-->

## What's changed?

Describe with a few bullets **what's new:**
- I've fixed...

Expand All @@ -20,6 +22,7 @@ Before|After
{media}|{media}

## Risk factors

**What may go wrong if we merge your PR?**
- ...
- ...
Expand All @@ -29,6 +32,7 @@ Before|After
- ...

## Does this PR close any GitHub issues? (do not delete)

- Closes #{ISSUE_NUMBER}

<!--❗For example: - Closes #123 ❗-->
Expand Down
8 changes: 4 additions & 4 deletions docs/guidelines/Unit-Testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ in a test at a glance, then it's bad again.

Most good unit tests share a similar structure/pattern. They start with a simple
name that reads like a sentence and tells you what's being tested. Then inside
the test function's body, they're split into three parts.
the test function's body, they're split into three parts: Given-When-Then.

```kotlin
class CurrencyConverterTest {
Expand All @@ -31,19 +31,19 @@ class CurrencyConverterTest {

@Test
fun `converts BTC to USD, happy path`() = runTest {
// given
// Given
coEvery {
exchangeRatesRepo.findRate(BTC, USD)
} returns PositiveDouble.unsafe(50_000.0)
val btcHolding = value(2.0, BTC)

// when
// When
val usdMoney = converter.convert(
from = btcHolding,
to = USD
)

// then
// Then
usdMoney shouldBe value(100_000.0, USD)
}

Expand Down
9 changes: 0 additions & 9 deletions shared/base/src/main/java/com/ivy/base/TimeProvider.kt

This file was deleted.

14 changes: 12 additions & 2 deletions shared/base/src/main/java/com/ivy/base/di/BaseHiltBindings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@ package com.ivy.base.di

import com.ivy.base.threading.DispatchersProvider
import com.ivy.base.threading.IvyDispatchersProvider
import com.ivy.base.time.TimeConverter
import com.ivy.base.time.TimeProvider
import com.ivy.base.time.impl.DeviceTimeProvider
import com.ivy.base.time.impl.StandardTimeConvert
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
abstract class BaseHiltBindings {
interface BaseHiltBindings {
@Binds
abstract fun dispatchersProvider(impl: IvyDispatchersProvider): DispatchersProvider
fun dispatchersProvider(impl: IvyDispatchersProvider): DispatchersProvider

@Binds
fun bindTimezoneProvider(impl: DeviceTimeProvider): TimeProvider

@Binds
fun bindTimeConverter(impl: StandardTimeConvert): TimeConverter
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime

@Deprecated("Use the TimeConverter interface via DI")
fun Instant.convertToLocal(): ZonedDateTime {
return atZone(ZoneId.systemDefault())
}
12 changes: 12 additions & 0 deletions shared/base/src/main/java/com/ivy/base/time/TimeConverter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ivy.base.time

import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime

interface TimeConverter {
fun Instant.toLocalDateTime(): LocalDateTime
fun Instant.toLocalDate(): LocalDate

fun LocalDateTime.toUTC(): Instant
}
11 changes: 11 additions & 0 deletions shared/base/src/main/java/com/ivy/base/time/TimeProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.ivy.base.time

import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId

interface TimeProvider {
fun getZoneId(): ZoneId
fun utcNow(): Instant
fun localNow(): LocalDateTime
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.ivy.base.time.impl

import com.ivy.base.time.TimeProvider
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import javax.inject.Inject

@Suppress("UnnecessaryPassThroughClass")
class DeviceTimeProvider @Inject constructor() : TimeProvider {
override fun getZoneId(): ZoneId {
return ZoneId.systemDefault()
}

override fun utcNow(): Instant {
return Instant.now()
}

override fun localNow(): LocalDateTime {
return LocalDateTime.now()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.ivy.base.time.impl

import com.ivy.base.time.TimeConverter
import com.ivy.base.time.TimeProvider
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import javax.inject.Inject

class StandardTimeConvert @Inject constructor(
private val timeZoneProvider: TimeProvider
) : TimeConverter {

override fun Instant.toLocalDateTime(): LocalDateTime {
val zoneId = timeZoneProvider.getZoneId()
return this.atZone(zoneId).toLocalDateTime()
}

override fun Instant.toLocalDate(): LocalDate {
val zoneId = timeZoneProvider.getZoneId()
return this.atZone(zoneId).toLocalDate()
}

override fun LocalDateTime.toUTC(): Instant {
val zoneId = timeZoneProvider.getZoneId()
return this.atZone(zoneId).toInstant()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.ivy.base.time.impl

import org.junit.Before
import org.junit.Test

class DeviceTimeProviderTest {

private lateinit var timeProvider: DeviceTimeProvider

@Before
fun setup() {
timeProvider = DeviceTimeProvider()
}

@Test
fun `validate no crashes`() {
// When
timeProvider.getZoneId()
timeProvider.utcNow()
timeProvider.localNow()

// Then
// there are no crashes
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.ivy.base.time.impl

import com.ivy.base.time.TimeConverter
import com.ivy.base.time.TimeProvider
import io.kotest.common.runBlocking
import io.kotest.property.Arb
import io.kotest.property.arbitrary.instant
import io.kotest.property.arbitrary.localDateTime
import io.kotest.property.arbitrary.removeEdgecases
import io.kotest.property.arbitrary.zoneOffset
import io.kotest.property.forAll
import io.mockk.every
import io.mockk.mockk
import org.junit.Before
import org.junit.Test

class StandardTimeConvertPropertyTest {

private val timeProvider = mockk<TimeProvider>()

private lateinit var converter: TimeConverter

@Before
fun setup() {
converter = StandardTimeConvert(
timeZoneProvider = timeProvider
)
}

@Test
fun `LocalDateTime-Instant isomorphism`(): Unit = runBlocking {
forAll(Arb.zoneOffset(), Arb.localDateTime().removeEdgecases()) { zone, original ->
// Given
every { timeProvider.getZoneId() } returns zone

// When
val transformed = with(converter) { original.toUTC().toLocalDateTime() }

// Then
transformed == original
}

forAll(Arb.zoneOffset(), Arb.instant().removeEdgecases()) { zone, original ->
// Given
every { timeProvider.getZoneId() } returns zone

// When
val transformed = with(converter) { original.toLocalDateTime().toUTC() }

// Then
transformed == original
}
}

@Test
fun `Instant-LocalDate isomorphism up to the same day`(): Unit = runBlocking {
forAll(Arb.zoneOffset(), Arb.instant().removeEdgecases()) { zone, original ->
// Given
every { timeProvider.getZoneId() } returns zone

// When
val transformed = with(converter) {
val originalLocalDateTime = original.toLocalDateTime()
original.toLocalDate().atTime(originalLocalDateTime.toLocalTime()).toUTC()
}

// Then
transformed == original
}
}
}
Loading

0 comments on commit 51d66c7

Please sign in to comment.